diff --git a/packages/core/proto/README.md b/packages/core/proto/README.md index 7f26f684..0cf2a777 100644 --- a/packages/core/proto/README.md +++ b/packages/core/proto/README.md @@ -1,8 +1,8 @@ # UFC protobuf schema -`ufc.proto` is copied byte-for-byte from -[`ddoghq/dd-source#93065`](https://github.com/ddoghq/dd-source/pull/93065) at commit -`391bcbf7be5c6f2b1137543c01b4575d2b6d73d5`. +`ufc.proto` is copied byte-for-byte from the `dd-source` +`dependent-flags-variant-key-ufc` branch. Keep both copies synchronized when +the schema changes. After updating the schema, regenerate the TypeScript definitions from the repository root: diff --git a/packages/core/proto/ufc.proto b/packages/core/proto/ufc.proto index 420bacb4..9638c365 100644 --- a/packages/core/proto/ufc.proto +++ b/packages/core/proto/ufc.proto @@ -31,6 +31,17 @@ message FlagsConfiguration { // Opt-in flag for emitting full flag-evaluation data from SDKs to // Datadog. May contain PII; defaults to false for privacy. bool observe_full_evaluation_data = 10; + + // Optional evaluator behavior overrides. Evaluators use their hard-coded + // defaults when this message or one of its fields is absent. + EvaluatorParams evaluator_params = 11; +} + +message EvaluatorParams { + // Maximum dependency-edge depth, counted from a root flag at depth zero. + // The maximum is inclusive, and zero rejects every dependency edge. Values + // must not exceed 255. + optional uint64 max_dependency_depth = 1; } message Version { @@ -135,9 +146,22 @@ message Condition { VersionCondition version = 8; StringComparisonCondition string_comparison = 9; Sha256StringComparisonCondition sha256_string_comparison = 10; + FlagEvaluationStringMembershipCondition flag_evaluation_string_membership = 11; } } +// Evaluates another flag and tests its variant key for membership. This is a +// dedicated condition kind so flag evaluations cannot be used as attribute +// sources for other comparators or as partition keys. +message FlagEvaluationStringMembershipCondition { + // Index into FlagsConfiguration.strings for the flag key. + uint32 flag_key_string_index = 1; + // Indices into FlagsConfiguration.strings for expected variant keys. + repeated uint32 string_indexes = 2; + // True negates membership. + bool negate = 3; +} + message ConditionOperands { // Zero-based indexes into FlagsConfiguration.conditions. repeated uint32 condition_indexes = 1; diff --git a/packages/core/src/configuration/generated/ufc_pb.ts b/packages/core/src/configuration/generated/ufc_pb.ts index d854a561..f4860391 100644 --- a/packages/core/src/configuration/generated/ufc_pb.ts +++ b/packages/core/src/configuration/generated/ufc_pb.ts @@ -12,7 +12,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file ufc.proto. */ export const file_ufc: GenFile = /*@__PURE__*/ - fileDesc("Cgl1ZmMucHJvdG8SG2RhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MSKRBAoSRmxhZ3NDb25maWd1cmF0aW9uEi4KCmNyZWF0ZWRfYXQYASABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhgKEGVudmlyb25tZW50X25hbWUYAiABKAkSSQoFZmxhZ3MYAyADKAsyOi5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuRmxhZ3NDb25maWd1cmF0aW9uLkZsYWdzRW50cnkSQwoKYXR0cmlidXRlcxgEIAMoCzIvLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5BdHRyaWJ1dGVSZWZlcmVuY2USDwoHc3RyaW5ncxgFIAMoCRIPCgdyZWdleGVzGAYgAygJEjYKCHZlcnNpb25zGAcgAygLMiQuZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlZlcnNpb24SFAoManNvbl9zdHJpbmdzGAggAygJEjoKCmNvbmRpdGlvbnMYCSADKAsyJi5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuQ29uZGl0aW9uEiQKHG9ic2VydmVfZnVsbF9ldmFsdWF0aW9uX2RhdGEYCiABKAgaTwoKRmxhZ3NFbnRyeRILCgNrZXkYASABKAkSMAoFdmFsdWUYAiABKAsyIS5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuRmxhZzoCOAEiMQoHVmVyc2lvbhISCgpjb21wb25lbnRzGAEgAygJEhIKCnByZXJlbGVhc2UYAiADKAki4wEKBEZsYWcSHQoVbWluaW11bV9mZWF0dXJlX2xldmVsGAEgASgNEkIKDnZhcmlhdGlvbl90eXBlGAIgASgOMiouZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlZhcmlhdGlvblR5cGUSOgoKdmFyaWF0aW9ucxgDIAMoCzImLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5WYXJpYXRpb24SPAoLYWxsb2NhdGlvbnMYBCADKAsyJy5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuQWxsb2NhdGlvbiK0AQoJVmFyaWF0aW9uEhgKEGtleV9zdHJpbmdfaW5kZXgYASABKA0SHAoSc3RyaW5nX3ZhbHVlX2luZGV4GAIgASgNSAASFwoNaW50ZWdlcl92YWx1ZRgDIAEoA0gAEhcKDW51bWVyaWNfdmFsdWUYBCABKAFIABIXCg1ib29sZWFuX3ZhbHVlGAUgASgISAASGwoRanNvbl9zdHJpbmdfaW5kZXgYBiABKA1IAEIHCgV2YWx1ZSLxAQoKQWxsb2NhdGlvbhILCgNrZXkYASABKAkSJgoZdGFyZ2V0aW5nX2NvbmRpdGlvbl9pbmRleBgCIAEoDUgAiAEBEkAKDXBhcnRpdGlvbl9rZXkYAyADKAsyKS5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuUGFydGl0aW9uS2V5EjIKBnNwbGl0cxgEIAMoCzIiLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5TcGxpdBIaChJsb2dfZXhwb3N1cmVfZXZlbnQYBSABKAhCHAoaX3RhcmdldGluZ19jb25kaXRpb25faW5kZXgikwEKEkF0dHJpYnV0ZVJlZmVyZW5jZRIvCg10YXJnZXRpbmdfa2V5GAEgASgLMhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5SAASRAoOYXR0cmlidXRlX3BhdGgYAiABKAsyKi5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuQXR0cmlidXRlUGF0aEgAQgYKBGtpbmQiVAoNQXR0cmlidXRlUGF0aBJDCghzZWdtZW50cxgBIAMoCzIxLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5BdHRyaWJ1dGVQYXRoU2VnbWVudCJYChRBdHRyaWJ1dGVQYXRoU2VnbWVudBIhChdvYmplY3Rfa2V5X3N0cmluZ19pbmRleBgBIAEoDUgAEhUKC2FycmF5X2luZGV4GAIgASgNSABCBgoEa2luZCKLBgoJQ29uZGl0aW9uEj0KA2FsbBgBIAEoCzIuLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5Db25kaXRpb25PcGVyYW5kc0gAEj0KA2FueRgCIAEoCzIuLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5Db25kaXRpb25PcGVyYW5kc0gAEkAKB251bWVyaWMYAyABKAsyLS5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuTnVtZXJpY0NvbmRpdGlvbkgAEjwKBXJlZ2V4GAQgASgLMisuZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlJlZ2V4Q29uZGl0aW9uSAASUwoRc3RyaW5nX21lbWJlcnNoaXAYBSABKAsyNi5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuU3RyaW5nTWVtYmVyc2hpcENvbmRpdGlvbkgAElMKEXNoYTI1Nl9tZW1iZXJzaGlwGAYgASgLMjYuZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlNoYTI1Nk1lbWJlcnNoaXBDb25kaXRpb25IABJVChJhdHRyaWJ1dGVfcHJlc2VuY2UYByABKAsyNy5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuQXR0cmlidXRlUHJlc2VuY2VDb25kaXRpb25IABJACgd2ZXJzaW9uGAggASgLMi0uZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlZlcnNpb25Db25kaXRpb25IABJTChFzdHJpbmdfY29tcGFyaXNvbhgJIAEoCzI2LmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5TdHJpbmdDb21wYXJpc29uQ29uZGl0aW9uSAASYAoYc2hhMjU2X3N0cmluZ19jb21wYXJpc29uGAogASgLMjwuZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlNoYTI1NlN0cmluZ0NvbXBhcmlzb25Db25kaXRpb25IAEIGCgRraW5kIi4KEUNvbmRpdGlvbk9wZXJhbmRzEhkKEWNvbmRpdGlvbl9pbmRleGVzGAEgAygNIoIBChBOdW1lcmljQ29uZGl0aW9uEhcKD2F0dHJpYnV0ZV9pbmRleBgBIAEoDRJCCgpjb21wYXJhdG9yGAIgASgOMi4uZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLk51bWVyaWNDb21wYXJhdG9yEhEKCWNvbXBhcmFuZBgDIAEoASJOCg5SZWdleENvbmRpdGlvbhIXCg9hdHRyaWJ1dGVfaW5kZXgYASABKA0SEwoLcmVnZXhfaW5kZXgYAiABKA0SDgoGbmVnYXRlGAMgASgIIlwKGVN0cmluZ01lbWJlcnNoaXBDb25kaXRpb24SFwoPYXR0cmlidXRlX2luZGV4GAEgASgNEhYKDnN0cmluZ19pbmRleGVzGAIgAygNEg4KBm5lZ2F0ZRgDIAEoCCKdAQoZU3RyaW5nQ29tcGFyaXNvbkNvbmRpdGlvbhIXCg9hdHRyaWJ1dGVfaW5kZXgYASABKA0SQQoKY29tcGFyYXRvchgCIAEoDjItLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5TdHJpbmdDb21wYXJhdG9yEhQKDHN0cmluZ19pbmRleBgDIAEoDRIOCgZuZWdhdGUYBCABKAgiwQEKH1NoYTI1NlN0cmluZ0NvbXBhcmlzb25Db25kaXRpb24SFwoPYXR0cmlidXRlX2luZGV4GAEgASgNEgwKBHNhbHQYAiABKAwSRwoKY29tcGFyYXRvchgDIAEoDjIzLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5TaGEyNTZTdHJpbmdDb21wYXJhdG9yEg4KBmxlbmd0aBgEIAEoDRIOCgZzaGEyNTYYBSABKAwSDgoGbmVnYXRlGAYgASgIImIKGVNoYTI1Nk1lbWJlcnNoaXBDb25kaXRpb24SFwoPYXR0cmlidXRlX2luZGV4GAEgASgNEgwKBHNhbHQYAiABKAwSDgoGc2hhMjU2GAMgAygMEg4KBm5lZ2F0ZRgEIAEoCCJKChpBdHRyaWJ1dGVQcmVzZW5jZUNvbmRpdGlvbhIXCg9hdHRyaWJ1dGVfaW5kZXgYASABKA0SEwoLZXhwZWN0X251bGwYAiABKAgihgEKEFZlcnNpb25Db25kaXRpb24SFwoPYXR0cmlidXRlX2luZGV4GAEgASgNEkIKCmNvbXBhcmF0b3IYAiABKA4yLi5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuVmVyc2lvbkNvbXBhcmF0b3ISFQoNdmVyc2lvbl9pbmRleBgDIAEoDSKdAQoMUGFydGl0aW9uS2V5Ej0KBHRpbWUYASABKAsyLS5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuVGltZVBhcnRpdGlvbktleUgAEkYKCXNoYXJkX21kNRgCIAEoCzIxLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5NZDVTaGFyZFBhcnRpdGlvbktleUgAQgYKBGtpbmQiEgoQVGltZVBhcnRpdGlvbktleSJTChRNZDVTaGFyZFBhcnRpdGlvbktleRIMCgRzYWx0GAEgASgJEhcKD2F0dHJpYnV0ZV9pbmRleBgCIAEoDRIUCgx0b3RhbF9zaGFyZHMYAyABKAQiuAEKBVNwbGl0EjsKBnJhbmdlcxgBIAMoCzIrLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5QYXJ0aXRpb25SYW5nZRIXCg92YXJpYXRpb25faW5kZXgYAiABKA0SFgoJc2VyaWFsX2lkGAMgASgFSACIAQESMwoGcmVhc29uGAQgASgOMiMuZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlJlYXNvbkIMCgpfc2VyaWFsX2lkIkQKDlBhcnRpdGlvblJhbmdlEhEKBGZyb20YASABKARIAIgBARIPCgJ0bxgCIAEoBEgBiAEBQgcKBV9mcm9tQgUKA190byq3AQoNVmFyaWF0aW9uVHlwZRIeChpWQVJJQVRJT05fVFlQRV9VTlNQRUNJRklFRBAAEhkKFVZBUklBVElPTl9UWVBFX1NUUklORxABEhoKFlZBUklBVElPTl9UWVBFX0lOVEVHRVIQAhIaChZWQVJJQVRJT05fVFlQRV9OVU1FUklDEAMSGgoWVkFSSUFUSU9OX1RZUEVfQk9PTEVBThAEEhcKE1ZBUklBVElPTl9UWVBFX0pTT04QBSrXAQoRTnVtZXJpY0NvbXBhcmF0b3ISIgoeTlVNRVJJQ19DT01QQVJBVE9SX1VOU1BFQ0lGSUVEEAASIAocTlVNRVJJQ19DT01QQVJBVE9SX0xFU1NfVEhBThABEikKJU5VTUVSSUNfQ09NUEFSQVRPUl9MRVNTX1RIQU5fT1JfRVFVQUwQAhIjCh9OVU1FUklDX0NPTVBBUkFUT1JfR1JFQVRFUl9USEFOEAMSLAooTlVNRVJJQ19DT01QQVJBVE9SX0dSRUFURVJfVEhBTl9PUl9FUVVBTBAEKpkBChBTdHJpbmdDb21wYXJhdG9yEiEKHVNUUklOR19DT01QQVJBVE9SX1VOU1BFQ0lGSUVEEAASIQodU1RSSU5HX0NPTVBBUkFUT1JfU1RBUlRTX1dJVEgQARIfChtTVFJJTkdfQ09NUEFSQVRPUl9FTkRTX1dJVEgQAhIeChpTVFJJTkdfQ09NUEFSQVRPUl9DT05UQUlOUxADKpQBChZTaGEyNTZTdHJpbmdDb21wYXJhdG9yEigKJFNIQTI1Nl9TVFJJTkdfQ09NUEFSQVRPUl9VTlNQRUNJRklFRBAAEigKJFNIQTI1Nl9TVFJJTkdfQ09NUEFSQVRPUl9TVEFSVFNfV0lUSBABEiYKIlNIQTI1Nl9TVFJJTkdfQ09NUEFSQVRPUl9FTkRTX1dJVEgQAiqXAgoRVmVyc2lvbkNvbXBhcmF0b3ISIgoeVkVSU0lPTl9DT01QQVJBVE9SX1VOU1BFQ0lGSUVEEAASHAoYVkVSU0lPTl9DT01QQVJBVE9SX0VRVUFMEAESIAocVkVSU0lPTl9DT01QQVJBVE9SX05PVF9FUVVBTBACEiAKHFZFUlNJT05fQ09NUEFSQVRPUl9MRVNTX1RIQU4QAxIpCiVWRVJTSU9OX0NPTVBBUkFUT1JfTEVTU19USEFOX09SX0VRVUFMEAQSIwofVkVSU0lPTl9DT01QQVJBVE9SX0dSRUFURVJfVEhBThAFEiwKKFZFUlNJT05fQ09NUEFSQVRPUl9HUkVBVEVSX1RIQU5fT1JfRVFVQUwQBip1CgZSZWFzb24SFgoSUkVBU09OX1VOU1BFQ0lGSUVEEAASGgoWUkVBU09OX1RBUkdFVElOR19NQVRDSBABEhAKDFJFQVNPTl9TUExJVBACEhEKDVJFQVNPTl9TVEFUSUMQAxISCg5SRUFTT05fREVGQVVMVBAEQkJaQGdpdGh1Yi5jb20vRGF0YURvZy9kZC1zb3VyY2UvZG9tYWlucy9mZmUvbGlicy9mbGFnZ2luZy91ZmMvdWZjcGJiBnByb3RvMw", [file_google_protobuf_empty, file_google_protobuf_timestamp]); + fileDesc("Cgl1ZmMucHJvdG8SG2RhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MSLZBAoSRmxhZ3NDb25maWd1cmF0aW9uEi4KCmNyZWF0ZWRfYXQYASABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhgKEGVudmlyb25tZW50X25hbWUYAiABKAkSSQoFZmxhZ3MYAyADKAsyOi5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuRmxhZ3NDb25maWd1cmF0aW9uLkZsYWdzRW50cnkSQwoKYXR0cmlidXRlcxgEIAMoCzIvLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5BdHRyaWJ1dGVSZWZlcmVuY2USDwoHc3RyaW5ncxgFIAMoCRIPCgdyZWdleGVzGAYgAygJEjYKCHZlcnNpb25zGAcgAygLMiQuZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlZlcnNpb24SFAoManNvbl9zdHJpbmdzGAggAygJEjoKCmNvbmRpdGlvbnMYCSADKAsyJi5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuQ29uZGl0aW9uEiQKHG9ic2VydmVfZnVsbF9ldmFsdWF0aW9uX2RhdGEYCiABKAgSRgoQZXZhbHVhdG9yX3BhcmFtcxgLIAEoCzIsLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5FdmFsdWF0b3JQYXJhbXMaTwoKRmxhZ3NFbnRyeRILCgNrZXkYASABKAkSMAoFdmFsdWUYAiABKAsyIS5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuRmxhZzoCOAEiTQoPRXZhbHVhdG9yUGFyYW1zEiEKFG1heF9kZXBlbmRlbmN5X2RlcHRoGAEgASgESACIAQFCFwoVX21heF9kZXBlbmRlbmN5X2RlcHRoIjEKB1ZlcnNpb24SEgoKY29tcG9uZW50cxgBIAMoCRISCgpwcmVyZWxlYXNlGAIgAygJIuMBCgRGbGFnEh0KFW1pbmltdW1fZmVhdHVyZV9sZXZlbBgBIAEoDRJCCg52YXJpYXRpb25fdHlwZRgCIAEoDjIqLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5WYXJpYXRpb25UeXBlEjoKCnZhcmlhdGlvbnMYAyADKAsyJi5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuVmFyaWF0aW9uEjwKC2FsbG9jYXRpb25zGAQgAygLMicuZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLkFsbG9jYXRpb24itAEKCVZhcmlhdGlvbhIYChBrZXlfc3RyaW5nX2luZGV4GAEgASgNEhwKEnN0cmluZ192YWx1ZV9pbmRleBgCIAEoDUgAEhcKDWludGVnZXJfdmFsdWUYAyABKANIABIXCg1udW1lcmljX3ZhbHVlGAQgASgBSAASFwoNYm9vbGVhbl92YWx1ZRgFIAEoCEgAEhsKEWpzb25fc3RyaW5nX2luZGV4GAYgASgNSABCBwoFdmFsdWUi8QEKCkFsbG9jYXRpb24SCwoDa2V5GAEgASgJEiYKGXRhcmdldGluZ19jb25kaXRpb25faW5kZXgYAiABKA1IAIgBARJACg1wYXJ0aXRpb25fa2V5GAMgAygLMikuZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlBhcnRpdGlvbktleRIyCgZzcGxpdHMYBCADKAsyIi5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuU3BsaXQSGgoSbG9nX2V4cG9zdXJlX2V2ZW50GAUgASgIQhwKGl90YXJnZXRpbmdfY29uZGl0aW9uX2luZGV4IpMBChJBdHRyaWJ1dGVSZWZlcmVuY2USLwoNdGFyZ2V0aW5nX2tleRgBIAEoCzIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eUgAEkQKDmF0dHJpYnV0ZV9wYXRoGAIgASgLMiouZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLkF0dHJpYnV0ZVBhdGhIAEIGCgRraW5kIlQKDUF0dHJpYnV0ZVBhdGgSQwoIc2VnbWVudHMYASADKAsyMS5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuQXR0cmlidXRlUGF0aFNlZ21lbnQiWAoUQXR0cmlidXRlUGF0aFNlZ21lbnQSIQoXb2JqZWN0X2tleV9zdHJpbmdfaW5kZXgYASABKA1IABIVCgthcnJheV9pbmRleBgCIAEoDUgAQgYKBGtpbmQi/gYKCUNvbmRpdGlvbhI9CgNhbGwYASABKAsyLi5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuQ29uZGl0aW9uT3BlcmFuZHNIABI9CgNhbnkYAiABKAsyLi5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuQ29uZGl0aW9uT3BlcmFuZHNIABJACgdudW1lcmljGAMgASgLMi0uZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLk51bWVyaWNDb25kaXRpb25IABI8CgVyZWdleBgEIAEoCzIrLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5SZWdleENvbmRpdGlvbkgAElMKEXN0cmluZ19tZW1iZXJzaGlwGAUgASgLMjYuZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlN0cmluZ01lbWJlcnNoaXBDb25kaXRpb25IABJTChFzaGEyNTZfbWVtYmVyc2hpcBgGIAEoCzI2LmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5TaGEyNTZNZW1iZXJzaGlwQ29uZGl0aW9uSAASVQoSYXR0cmlidXRlX3ByZXNlbmNlGAcgASgLMjcuZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLkF0dHJpYnV0ZVByZXNlbmNlQ29uZGl0aW9uSAASQAoHdmVyc2lvbhgIIAEoCzItLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5WZXJzaW9uQ29uZGl0aW9uSAASUwoRc3RyaW5nX2NvbXBhcmlzb24YCSABKAsyNi5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuU3RyaW5nQ29tcGFyaXNvbkNvbmRpdGlvbkgAEmAKGHNoYTI1Nl9zdHJpbmdfY29tcGFyaXNvbhgKIAEoCzI8LmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5TaGEyNTZTdHJpbmdDb21wYXJpc29uQ29uZGl0aW9uSAAScQohZmxhZ19ldmFsdWF0aW9uX3N0cmluZ19tZW1iZXJzaGlwGAsgASgLMkQuZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLkZsYWdFdmFsdWF0aW9uU3RyaW5nTWVtYmVyc2hpcENvbmRpdGlvbkgAQgYKBGtpbmQicAonRmxhZ0V2YWx1YXRpb25TdHJpbmdNZW1iZXJzaGlwQ29uZGl0aW9uEh0KFWZsYWdfa2V5X3N0cmluZ19pbmRleBgBIAEoDRIWCg5zdHJpbmdfaW5kZXhlcxgCIAMoDRIOCgZuZWdhdGUYAyABKAgiLgoRQ29uZGl0aW9uT3BlcmFuZHMSGQoRY29uZGl0aW9uX2luZGV4ZXMYASADKA0iggEKEE51bWVyaWNDb25kaXRpb24SFwoPYXR0cmlidXRlX2luZGV4GAEgASgNEkIKCmNvbXBhcmF0b3IYAiABKA4yLi5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuTnVtZXJpY0NvbXBhcmF0b3ISEQoJY29tcGFyYW5kGAMgASgBIk4KDlJlZ2V4Q29uZGl0aW9uEhcKD2F0dHJpYnV0ZV9pbmRleBgBIAEoDRITCgtyZWdleF9pbmRleBgCIAEoDRIOCgZuZWdhdGUYAyABKAgiXAoZU3RyaW5nTWVtYmVyc2hpcENvbmRpdGlvbhIXCg9hdHRyaWJ1dGVfaW5kZXgYASABKA0SFgoOc3RyaW5nX2luZGV4ZXMYAiADKA0SDgoGbmVnYXRlGAMgASgIIp0BChlTdHJpbmdDb21wYXJpc29uQ29uZGl0aW9uEhcKD2F0dHJpYnV0ZV9pbmRleBgBIAEoDRJBCgpjb21wYXJhdG9yGAIgASgOMi0uZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlN0cmluZ0NvbXBhcmF0b3ISFAoMc3RyaW5nX2luZGV4GAMgASgNEg4KBm5lZ2F0ZRgEIAEoCCLBAQofU2hhMjU2U3RyaW5nQ29tcGFyaXNvbkNvbmRpdGlvbhIXCg9hdHRyaWJ1dGVfaW5kZXgYASABKA0SDAoEc2FsdBgCIAEoDBJHCgpjb21wYXJhdG9yGAMgASgOMjMuZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlNoYTI1NlN0cmluZ0NvbXBhcmF0b3ISDgoGbGVuZ3RoGAQgASgNEg4KBnNoYTI1NhgFIAEoDBIOCgZuZWdhdGUYBiABKAgiYgoZU2hhMjU2TWVtYmVyc2hpcENvbmRpdGlvbhIXCg9hdHRyaWJ1dGVfaW5kZXgYASABKA0SDAoEc2FsdBgCIAEoDBIOCgZzaGEyNTYYAyADKAwSDgoGbmVnYXRlGAQgASgIIkoKGkF0dHJpYnV0ZVByZXNlbmNlQ29uZGl0aW9uEhcKD2F0dHJpYnV0ZV9pbmRleBgBIAEoDRITCgtleHBlY3RfbnVsbBgCIAEoCCKGAQoQVmVyc2lvbkNvbmRpdGlvbhIXCg9hdHRyaWJ1dGVfaW5kZXgYASABKA0SQgoKY29tcGFyYXRvchgCIAEoDjIuLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5WZXJzaW9uQ29tcGFyYXRvchIVCg12ZXJzaW9uX2luZGV4GAMgASgNIp0BCgxQYXJ0aXRpb25LZXkSPQoEdGltZRgBIAEoCzItLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5UaW1lUGFydGl0aW9uS2V5SAASRgoJc2hhcmRfbWQ1GAIgASgLMjEuZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLk1kNVNoYXJkUGFydGl0aW9uS2V5SABCBgoEa2luZCISChBUaW1lUGFydGl0aW9uS2V5IlMKFE1kNVNoYXJkUGFydGl0aW9uS2V5EgwKBHNhbHQYASABKAkSFwoPYXR0cmlidXRlX2luZGV4GAIgASgNEhQKDHRvdGFsX3NoYXJkcxgDIAEoBCK4AQoFU3BsaXQSOwoGcmFuZ2VzGAEgAygLMisuZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlBhcnRpdGlvblJhbmdlEhcKD3ZhcmlhdGlvbl9pbmRleBgCIAEoDRIWCglzZXJpYWxfaWQYAyABKAVIAIgBARIzCgZyZWFzb24YBCABKA4yIy5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuUmVhc29uQgwKCl9zZXJpYWxfaWQiRAoOUGFydGl0aW9uUmFuZ2USEQoEZnJvbRgBIAEoBEgAiAEBEg8KAnRvGAIgASgESAGIAQFCBwoFX2Zyb21CBQoDX3RvKrcBCg1WYXJpYXRpb25UeXBlEh4KGlZBUklBVElPTl9UWVBFX1VOU1BFQ0lGSUVEEAASGQoVVkFSSUFUSU9OX1RZUEVfU1RSSU5HEAESGgoWVkFSSUFUSU9OX1RZUEVfSU5URUdFUhACEhoKFlZBUklBVElPTl9UWVBFX05VTUVSSUMQAxIaChZWQVJJQVRJT05fVFlQRV9CT09MRUFOEAQSFwoTVkFSSUFUSU9OX1RZUEVfSlNPThAFKtcBChFOdW1lcmljQ29tcGFyYXRvchIiCh5OVU1FUklDX0NPTVBBUkFUT1JfVU5TUEVDSUZJRUQQABIgChxOVU1FUklDX0NPTVBBUkFUT1JfTEVTU19USEFOEAESKQolTlVNRVJJQ19DT01QQVJBVE9SX0xFU1NfVEhBTl9PUl9FUVVBTBACEiMKH05VTUVSSUNfQ09NUEFSQVRPUl9HUkVBVEVSX1RIQU4QAxIsCihOVU1FUklDX0NPTVBBUkFUT1JfR1JFQVRFUl9USEFOX09SX0VRVUFMEAQqmQEKEFN0cmluZ0NvbXBhcmF0b3ISIQodU1RSSU5HX0NPTVBBUkFUT1JfVU5TUEVDSUZJRUQQABIhCh1TVFJJTkdfQ09NUEFSQVRPUl9TVEFSVFNfV0lUSBABEh8KG1NUUklOR19DT01QQVJBVE9SX0VORFNfV0lUSBACEh4KGlNUUklOR19DT01QQVJBVE9SX0NPTlRBSU5TEAMqlAEKFlNoYTI1NlN0cmluZ0NvbXBhcmF0b3ISKAokU0hBMjU2X1NUUklOR19DT01QQVJBVE9SX1VOU1BFQ0lGSUVEEAASKAokU0hBMjU2X1NUUklOR19DT01QQVJBVE9SX1NUQVJUU19XSVRIEAESJgoiU0hBMjU2X1NUUklOR19DT01QQVJBVE9SX0VORFNfV0lUSBACKpcCChFWZXJzaW9uQ29tcGFyYXRvchIiCh5WRVJTSU9OX0NPTVBBUkFUT1JfVU5TUEVDSUZJRUQQABIcChhWRVJTSU9OX0NPTVBBUkFUT1JfRVFVQUwQARIgChxWRVJTSU9OX0NPTVBBUkFUT1JfTk9UX0VRVUFMEAISIAocVkVSU0lPTl9DT01QQVJBVE9SX0xFU1NfVEhBThADEikKJVZFUlNJT05fQ09NUEFSQVRPUl9MRVNTX1RIQU5fT1JfRVFVQUwQBBIjCh9WRVJTSU9OX0NPTVBBUkFUT1JfR1JFQVRFUl9USEFOEAUSLAooVkVSU0lPTl9DT01QQVJBVE9SX0dSRUFURVJfVEhBTl9PUl9FUVVBTBAGKnUKBlJlYXNvbhIWChJSRUFTT05fVU5TUEVDSUZJRUQQABIaChZSRUFTT05fVEFSR0VUSU5HX01BVENIEAESEAoMUkVBU09OX1NQTElUEAISEQoNUkVBU09OX1NUQVRJQxADEhIKDlJFQVNPTl9ERUZBVUxUEARCQlpAZ2l0aHViLmNvbS9EYXRhRG9nL2RkLXNvdXJjZS9kb21haW5zL2ZmZS9saWJzL2ZsYWdnaW5nL3VmYy91ZmNwYmIGcHJvdG8z", [file_google_protobuf_empty, file_google_protobuf_timestamp]); /** * FlagsConfiguration is a compiled rules-based flag configuration. @@ -82,6 +82,14 @@ export type FlagsConfiguration = Message<"datadog.ffe.flagging.ufc.v1.FlagsConfi * @generated from field: bool observe_full_evaluation_data = 10; */ observeFullEvaluationData: boolean; + + /** + * Optional evaluator behavior overrides. Evaluators use their hard-coded + * defaults when this message or one of its fields is absent. + * + * @generated from field: datadog.ffe.flagging.ufc.v1.EvaluatorParams evaluator_params = 11; + */ + evaluatorParams?: EvaluatorParams | undefined; }; /** @@ -91,6 +99,27 @@ export type FlagsConfiguration = Message<"datadog.ffe.flagging.ufc.v1.FlagsConfi export const FlagsConfigurationSchema: GenMessage = /*@__PURE__*/ messageDesc(file_ufc, 0); +/** + * @generated from message datadog.ffe.flagging.ufc.v1.EvaluatorParams + */ +export type EvaluatorParams = Message<"datadog.ffe.flagging.ufc.v1.EvaluatorParams"> & { + /** + * Maximum dependency-edge depth, counted from a root flag at depth zero. + * The maximum is inclusive, and zero rejects every dependency edge. Values + * must not exceed 255. + * + * @generated from field: optional uint64 max_dependency_depth = 1; + */ + maxDependencyDepth?: bigint | undefined; +}; + +/** + * Describes the message datadog.ffe.flagging.ufc.v1.EvaluatorParams. + * Use `create(EvaluatorParamsSchema)` to create a new message. + */ +export const EvaluatorParamsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_ufc, 1); + /** * @generated from message datadog.ffe.flagging.ufc.v1.Version */ @@ -115,7 +144,7 @@ export type Version = Message<"datadog.ffe.flagging.ufc.v1.Version"> & { * Use `create(VersionSchema)` to create a new message. */ export const VersionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 1); + messageDesc(file_ufc, 2); /** * @generated from message datadog.ffe.flagging.ufc.v1.Flag @@ -153,7 +182,7 @@ export type Flag = Message<"datadog.ffe.flagging.ufc.v1.Flag"> & { * Use `create(FlagSchema)` to create a new message. */ export const FlagSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 2); + messageDesc(file_ufc, 3); /** * @generated from message datadog.ffe.flagging.ufc.v1.Variation @@ -214,7 +243,7 @@ export type Variation = Message<"datadog.ffe.flagging.ufc.v1.Variation"> & { * Use `create(VariationSchema)` to create a new message. */ export const VariationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 3); + messageDesc(file_ufc, 4); /** * An allocation first selects subjects matching its targeting expression. It @@ -261,7 +290,7 @@ export type Allocation = Message<"datadog.ffe.flagging.ufc.v1.Allocation"> & { * Use `create(AllocationSchema)` to create a new message. */ export const AllocationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 4); + messageDesc(file_ufc, 5); /** * AttributeReference identifies either the targeting key or a value reached by @@ -294,7 +323,7 @@ export type AttributeReference = Message<"datadog.ffe.flagging.ufc.v1.AttributeR * Use `create(AttributeReferenceSchema)` to create a new message. */ export const AttributeReferenceSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 5); + messageDesc(file_ufc, 6); /** * @generated from message datadog.ffe.flagging.ufc.v1.AttributePath @@ -313,7 +342,7 @@ export type AttributePath = Message<"datadog.ffe.flagging.ufc.v1.AttributePath"> * Use `create(AttributePathSchema)` to create a new message. */ export const AttributePathSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 6); + messageDesc(file_ufc, 7); /** * @generated from message datadog.ffe.flagging.ufc.v1.AttributePathSegment @@ -344,7 +373,7 @@ export type AttributePathSegment = Message<"datadog.ffe.flagging.ufc.v1.Attribut * Use `create(AttributePathSegmentSchema)` to create a new message. */ export const AttributePathSegmentSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 7); + messageDesc(file_ufc, 8); /** * Leaf conditions are grouped by the attribute value type they operate on, @@ -423,6 +452,12 @@ export type Condition = Message<"datadog.ffe.flagging.ufc.v1.Condition"> & { */ value: Sha256StringComparisonCondition; case: "sha256StringComparison"; + } | { + /** + * @generated from field: datadog.ffe.flagging.ufc.v1.FlagEvaluationStringMembershipCondition flag_evaluation_string_membership = 11; + */ + value: FlagEvaluationStringMembershipCondition; + case: "flagEvaluationStringMembership"; } | { case: undefined; value?: undefined }; }; @@ -431,7 +466,44 @@ export type Condition = Message<"datadog.ffe.flagging.ufc.v1.Condition"> & { * Use `create(ConditionSchema)` to create a new message. */ export const ConditionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 8); + messageDesc(file_ufc, 9); + +/** + * Evaluates another flag and tests its variant key for membership. This is a + * dedicated condition kind so flag evaluations cannot be used as attribute + * sources for other comparators or as partition keys. + * + * @generated from message datadog.ffe.flagging.ufc.v1.FlagEvaluationStringMembershipCondition + */ +export type FlagEvaluationStringMembershipCondition = Message<"datadog.ffe.flagging.ufc.v1.FlagEvaluationStringMembershipCondition"> & { + /** + * Index into FlagsConfiguration.strings for the flag key. + * + * @generated from field: uint32 flag_key_string_index = 1; + */ + flagKeyStringIndex: number; + + /** + * Indices into FlagsConfiguration.strings for expected variant keys. + * + * @generated from field: repeated uint32 string_indexes = 2; + */ + stringIndexes: number[]; + + /** + * True negates membership. + * + * @generated from field: bool negate = 3; + */ + negate: boolean; +}; + +/** + * Describes the message datadog.ffe.flagging.ufc.v1.FlagEvaluationStringMembershipCondition. + * Use `create(FlagEvaluationStringMembershipConditionSchema)` to create a new message. + */ +export const FlagEvaluationStringMembershipConditionSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_ufc, 10); /** * @generated from message datadog.ffe.flagging.ufc.v1.ConditionOperands @@ -450,7 +522,7 @@ export type ConditionOperands = Message<"datadog.ffe.flagging.ufc.v1.ConditionOp * Use `create(ConditionOperandsSchema)` to create a new message. */ export const ConditionOperandsSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 9); + messageDesc(file_ufc, 11); /** * @generated from message datadog.ffe.flagging.ufc.v1.NumericCondition @@ -477,7 +549,7 @@ export type NumericCondition = Message<"datadog.ffe.flagging.ufc.v1.NumericCondi * Use `create(NumericConditionSchema)` to create a new message. */ export const NumericConditionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 10); + messageDesc(file_ufc, 12); /** * @generated from message datadog.ffe.flagging.ufc.v1.RegexCondition @@ -508,7 +580,7 @@ export type RegexCondition = Message<"datadog.ffe.flagging.ufc.v1.RegexCondition * Use `create(RegexConditionSchema)` to create a new message. */ export const RegexConditionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 11); + messageDesc(file_ufc, 13); /** * @generated from message datadog.ffe.flagging.ufc.v1.StringMembershipCondition @@ -539,7 +611,7 @@ export type StringMembershipCondition = Message<"datadog.ffe.flagging.ufc.v1.Str * Use `create(StringMembershipConditionSchema)` to create a new message. */ export const StringMembershipConditionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 12); + messageDesc(file_ufc, 14); /** * @generated from message datadog.ffe.flagging.ufc.v1.StringComparisonCondition @@ -575,7 +647,7 @@ export type StringComparisonCondition = Message<"datadog.ffe.flagging.ufc.v1.Str * Use `create(StringComparisonConditionSchema)` to create a new message. */ export const StringComparisonConditionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 13); + messageDesc(file_ufc, 15); /** * Compares a prefix or suffix without exposing its plaintext value. For @@ -634,7 +706,7 @@ export type Sha256StringComparisonCondition = Message<"datadog.ffe.flagging.ufc. * Use `create(Sha256StringComparisonConditionSchema)` to create a new message. */ export const Sha256StringComparisonConditionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 14); + messageDesc(file_ufc, 16); /** * True when SHA-256(salt || attribute_value) matches one of sha256 values. @@ -672,7 +744,7 @@ export type Sha256MembershipCondition = Message<"datadog.ffe.flagging.ufc.v1.Sha * Use `create(Sha256MembershipConditionSchema)` to create a new message. */ export const Sha256MembershipConditionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 15); + messageDesc(file_ufc, 17); /** * @generated from message datadog.ffe.flagging.ufc.v1.AttributePresenceCondition @@ -697,7 +769,7 @@ export type AttributePresenceCondition = Message<"datadog.ffe.flagging.ufc.v1.At * Use `create(AttributePresenceConditionSchema)` to create a new message. */ export const AttributePresenceConditionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 16); + messageDesc(file_ufc, 18); /** * @generated from message datadog.ffe.flagging.ufc.v1.VersionCondition @@ -726,7 +798,7 @@ export type VersionCondition = Message<"datadog.ffe.flagging.ufc.v1.VersionCondi * Use `create(VersionConditionSchema)` to create a new message. */ export const VersionConditionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 17); + messageDesc(file_ufc, 19); /** * PartitionKey computes one coordinate of the composite key used to select a @@ -758,7 +830,7 @@ export type PartitionKey = Message<"datadog.ffe.flagging.ufc.v1.PartitionKey"> & * Use `create(PartitionKeySchema)` to create a new message. */ export const PartitionKeySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 18); + messageDesc(file_ufc, 20); /** * Time is the current Unix timestamp in milliseconds. @@ -773,7 +845,7 @@ export type TimePartitionKey = Message<"datadog.ffe.flagging.ufc.v1.TimePartitio * Use `create(TimePartitionKeySchema)` to create a new message. */ export const TimePartitionKeySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 19); + messageDesc(file_ufc, 21); /** * Partition value is computed as follows: @@ -809,7 +881,7 @@ export type Md5ShardPartitionKey = Message<"datadog.ffe.flagging.ufc.v1.Md5Shard * Use `create(Md5ShardPartitionKeySchema)` to create a new message. */ export const Md5ShardPartitionKeySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 20); + messageDesc(file_ufc, 22); /** * Split.ranges is a Cartesian-product range over Allocation.partition_key. @@ -846,7 +918,7 @@ export type Split = Message<"datadog.ffe.flagging.ufc.v1.Split"> & { * Use `create(SplitSchema)` to create a new message. */ export const SplitSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 21); + messageDesc(file_ufc, 23); /** * A half-open range: from <= partition coordinate < to. Time coordinates are @@ -871,7 +943,7 @@ export type PartitionRange = Message<"datadog.ffe.flagging.ufc.v1.PartitionRange * Use `create(PartitionRangeSchema)` to create a new message. */ export const PartitionRangeSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_ufc, 22); + messageDesc(file_ufc, 24); /** * @generated from enum datadog.ffe.flagging.ufc.v1.VariationType diff --git a/packages/core/src/evaluation/errors.ts b/packages/core/src/evaluation/errors.ts index bf08eb9c..df893430 100644 --- a/packages/core/src/evaluation/errors.ts +++ b/packages/core/src/evaluation/errors.ts @@ -1,3 +1,5 @@ +import type { ErrorCode } from '@openfeature/core' + export class TargetingKeyMissingError extends Error { constructor() { super('Targeting key is required for split evaluation') @@ -18,3 +20,14 @@ export class InvalidContextError extends Error { this.name = 'InvalidContextError' } } + +export class DependencyGraphError extends Error { + constructor( + readonly flagKey: string, + readonly errorCode: ErrorCode, + message: string + ) { + super(message) + this.name = 'DependencyGraphError' + } +} diff --git a/packages/core/src/evaluation/evaluateForSubject.ts b/packages/core/src/evaluation/evaluateForSubject.ts index 9f783f22..d0893285 100644 --- a/packages/core/src/evaluation/evaluateForSubject.ts +++ b/packages/core/src/evaluation/evaluateForSubject.ts @@ -4,7 +4,7 @@ import { type TimeStamp, timeStampNow } from '../time' import { TargetingKeyMissingError } from './errors' import { createEvaluationTimestampMetadata } from './evaluationMetadata' import { matchesShard } from './matchesShard' -import { isValidRule, matchesRule, type Rule } from './rules' +import { isValidRule, matchesRule, type ResolveFlagEvaluation, type Rule } from './rules' import { type Flag, type Split, type VariantType, variantTypeToFlagValueType } from './ufc-v1' export function evaluateForSubject( @@ -14,7 +14,8 @@ export function evaluateForSubject( subjectAttributes: EvaluationContext, defaultValue: FlagTypeToValue, logger: Logger, - evaluationTimestampMs: TimeStamp = timeStampNow() + evaluationTimestampMs: TimeStamp = timeStampNow(), + resolveFlagEvaluation?: ResolveFlagEvaluation ): ResolutionDetails> { if (!isValidFlag(flag)) { logger.debug(`returning default assignment because flag configuration is invalid`, { @@ -79,7 +80,7 @@ export function evaluateForSubject( continue } - const matched = containsMatchingRule(allocation.rules, subjectAttributes, logger) + const matched = containsMatchingRule(allocation.rules, subjectAttributes, logger, resolveFlagEvaluation) if (!matched) { continue } @@ -241,7 +242,8 @@ function isValidShard(shard: unknown): boolean { export function containsMatchingRule( rules: Rule[] | undefined, subjectAttributes: EvaluationContext, - logger: Logger + logger: Logger, + resolveFlagEvaluation?: ResolveFlagEvaluation ): boolean { if (!rules?.length) { return true @@ -250,7 +252,7 @@ export function containsMatchingRule( rules: JSON.stringify(rules), subjectAttributes, }) - return rules.some((rule) => matchesRule(rule, subjectAttributes)) + return rules.some((rule) => matchesRule(rule, subjectAttributes, resolveFlagEvaluation)) } function selectSplitUsingSharding( diff --git a/packages/core/src/evaluation/evaluateProtobufConfiguration.ts b/packages/core/src/evaluation/evaluateProtobufConfiguration.ts index bd5a3f25..145c3d12 100644 --- a/packages/core/src/evaluation/evaluateProtobufConfiguration.ts +++ b/packages/core/src/evaluation/evaluateProtobufConfiguration.ts @@ -14,7 +14,7 @@ import type { PreparedRulesResponse } from '../configuration/prepared-rules-resp import { type TimeStamp, timeStampNow } from '../time' import { encodeUtf8 } from '../utf8' import { coerceToNumber, coerceToString, compileRegex } from './condition-helpers' -import { FlagConfigurationError, InvalidContextError, TargetingKeyMissingError } from './errors' +import { DependencyGraphError, FlagConfigurationError, InvalidContextError, TargetingKeyMissingError } from './errors' import { createEvaluationTimestampMetadata } from './evaluationMetadata' import { getOwnProperty } from './getOwnProperty' import { compareVersions, isParsedVersion, parseVersion } from './semver' @@ -31,6 +31,8 @@ import { const SUPPORTED_FEATURE_LEVEL = 0 +export type ResolveProtobufFlagEvaluation = (flagKey: string) => string | undefined + export function evaluateProtobufConfiguration( configuration: PreparedRulesResponse, type: T, @@ -38,7 +40,8 @@ export function evaluateProtobufConfiguration( defaultValue: FlagTypeToValue, context: EvaluationContext, logger: Logger, - evaluationTimestampMs: TimeStamp = timeStampNow() + evaluationTimestampMs: TimeStamp = timeStampNow(), + resolveFlagEvaluation?: ResolveProtobufFlagEvaluation ): ResolutionDetails> { const { targetingKey } = context const flag = getOwnProperty(configuration.flags, flagKey) @@ -56,7 +59,7 @@ export function evaluateProtobufConfiguration( if (flag.minimumFeatureLevel > SUPPORTED_FEATURE_LEVEL) { throw new FlagConfigurationError('Flag requires an unsupported feature level') } - const flagValueType = variationTypeToFlagValueType(flag.variationType) + const flagValueType = protobufVariationTypeToFlagValueType(flag.variationType) if (type !== flagValueType) { logger.debug('variant value type mismatch, returning default value', { flagKey, @@ -74,7 +77,15 @@ export function evaluateProtobufConfiguration( const conditionResults = new Map() for (const allocation of flag.allocations) { - if (!matchesCondition(allocation.targetingConditionIndex, configuration, context, conditionResults)) { + if ( + !matchesCondition( + allocation.targetingConditionIndex, + configuration, + context, + conditionResults, + resolveFlagEvaluation + ) + ) { continue } const partitionKey = computePartitionKey(allocation, configuration, context, evaluationTimestampMs) @@ -95,12 +106,13 @@ export function evaluateProtobufConfiguration( __dd_do_log: allocation.logExposureEvent, __dd_split_serial_id: split.serialId, allocationKey: allocation.key, - variationType: variationTypeToFlagValueType(flag.variationType), + variationType: protobufVariationTypeToFlagValueType(flag.variationType), doLog: allocation.logExposureEvent, } as PrecomputedFlagMetadata, } } } catch (error) { + if (error instanceof DependencyGraphError) throw error if (error instanceof FlagConfigurationError) { logger.error('returning default value because flag configuration is invalid', { flagKey, @@ -152,7 +164,8 @@ function matchesCondition( index: number | undefined, configuration: PreparedRulesResponse, attributes: EvaluationContext, - results: Map + results: Map, + resolveFlagEvaluation?: ResolveProtobufFlagEvaluation ): boolean { if (index === undefined) return true const cached = results.get(index) @@ -162,15 +175,15 @@ function matchesCondition( if (condition.kind.case === 'all') { result = condition.kind.value.conditionIndexes.every((child) => { if (child >= index) throw new FlagConfigurationError('Condition must only reference preceding conditions') - return matchesCondition(child, configuration, attributes, results) + return matchesCondition(child, configuration, attributes, results, resolveFlagEvaluation) }) } else if (condition.kind.case === 'any') { result = condition.kind.value.conditionIndexes.some((child) => { if (child >= index) throw new FlagConfigurationError('Condition must only reference preceding conditions') - return matchesCondition(child, configuration, attributes, results) + return matchesCondition(child, configuration, attributes, results, resolveFlagEvaluation) }) } else { - result = matchesLeafCondition(condition, configuration, attributes) + result = matchesLeafCondition(condition, configuration, attributes, resolveFlagEvaluation) } results.set(index, result) return result @@ -179,13 +192,23 @@ function matchesCondition( function matchesLeafCondition( condition: Condition, configuration: PreparedRulesResponse, - context: EvaluationContext + context: EvaluationContext, + resolveFlagEvaluation?: ResolveProtobufFlagEvaluation ): boolean { const kind = condition.kind if (kind.case === undefined || kind.case === 'all' || kind.case === 'any') { throw new FlagConfigurationError('Unsupported condition') } - const value = attributeValueAt(configuration, kind.value.attributeIndex, context) + if (kind.case === 'flagEvaluationStringMembership') { + if (resolveFlagEvaluation === undefined) throw new FlagConfigurationError('Flag evaluation is not supported') + const flagKey = atIndex(configuration.strings, kind.value.flagKeyStringIndex, 'flag key') + const variantKey = resolveFlagEvaluation(flagKey) + if (variantKey === undefined) return false + const included = containsInternedString(kind.value.stringIndexes, variantKey, configuration.strings) + return kind.value.negate ? !included : included + } + const attribute = atIndex(configuration.attributes, kind.value.attributeIndex, 'condition attribute') + const value = attributeValue(attribute, configuration, context) if (kind.case === 'attributePresence') return kind.value.expectNull ? value == null : value != null if (value == null) return false @@ -332,14 +355,6 @@ function computePartitionKey( return partitionKey } -function attributeValueAt( - configuration: PreparedRulesResponse, - index: number, - context: EvaluationContext -): EvaluationContextValue | undefined { - return attributeValue(atIndex(configuration.attributes, index, 'condition attribute'), configuration, context) -} - function attributeValue( attribute: PreparedRulesResponse['attributes'][number], configuration: PreparedRulesResponse, @@ -414,7 +429,7 @@ function resolutionReason(split: Split): ResolutionReason { return 'UNKNOWN' } -function variationTypeToFlagValueType(variationType: VariationType): FlagValueType { +export function protobufVariationTypeToFlagValueType(variationType: VariationType): FlagValueType { const ufcVariationType = variationType as number if (ufcVariationType === UFC_VARIATION_TYPE.BOOLEAN) return 'boolean' if (ufcVariationType === UFC_VARIATION_TYPE.STRING) return 'string' diff --git a/packages/core/src/evaluation/evaluation.ts b/packages/core/src/evaluation/evaluation.ts index 4d9ad133..e3fab6f3 100644 --- a/packages/core/src/evaluation/evaluation.ts +++ b/packages/core/src/evaluation/evaluation.ts @@ -1,4 +1,11 @@ -import type { ErrorCode, EvaluationContext, FlagValueType, Logger, ResolutionDetails } from '@openfeature/core' +import type { + ErrorCode, + EvaluationContext, + FlagValue, + FlagValueType, + Logger, + ResolutionDetails, +} from '@openfeature/core' import { configMatchesContext, type FlagsConfiguration, @@ -9,12 +16,15 @@ import { import type { FlagsConfiguration as ProtobufFlagsConfiguration } from '../configuration/generated/ufc_pb' import { prepareRulesResponse } from '../configuration/prepared-rules-response' import { type TimeStamp, timeStampNow } from '../time' -import { TargetingKeyMissingError } from './errors' +import { DependencyGraphError, TargetingKeyMissingError } from './errors' import { evaluateForSubject } from './evaluateForSubject' -import { evaluateProtobufConfiguration } from './evaluateProtobufConfiguration' +import { evaluateProtobufConfiguration, protobufVariationTypeToFlagValueType } from './evaluateProtobufConfiguration' import { createEvaluationMetadata, createEvaluationTimestampMetadata } from './evaluationMetadata' import { evaluatePrecomputedConfiguration } from './precomputed-evaluation' -import type { UniversalFlagConfigurationV1 } from './ufc-v1' +import type { FlagEvaluation } from './rules' +import { type Flag, type UniversalFlagConfigurationV1, variantTypeToFlagValueType } from './ufc-v1' + +const DEFAULT_MAX_DEPENDENCY_DEPTH = 10 const NOOP_LOGGER: Logger = { debug: () => {}, @@ -29,7 +39,9 @@ export function evaluate( flagKey: string, defaultValue: FlagTypeToValue, context: EvaluationContext, - logger: Logger = NOOP_LOGGER + logger: Logger = NOOP_LOGGER, + onDependencyEvaluation?: DependencyEvaluationHandler, + onDependencyExposures?: DependencyExposureHandler ): ResolutionDetails> { const selection = selectFlagsConfiguration(flagsConfiguration, context) if (selection.kind === 'precomputed') { @@ -43,7 +55,9 @@ export function evaluate( flagKey, defaultValue, context, - logger + logger, + onDependencyEvaluation, + onDependencyExposures ) } @@ -120,7 +134,9 @@ export function evaluateRulesBasedConfiguration( flagKey: string, defaultValue: FlagTypeToValue, context: EvaluationContext, - logger: Logger + logger: Logger, + onDependencyEvaluation?: DependencyEvaluationHandler, + onDependencyExposures?: DependencyExposureHandler ): ResolutionDetails> { const evaluationTimestampMs = timeStampNow() // Snapshot consent once at the start of this evaluation. The provider can receive a new @@ -129,7 +145,17 @@ export function evaluateRulesBasedConfiguration( const metadata = createEvaluationMetadata(evaluationTimestampMs, observeFullEvaluationData) let details: ResolutionDetails> try { - details = evaluateRules(config, type, flagKey, defaultValue, context, logger, evaluationTimestampMs) + details = evaluateRules( + config, + type, + flagKey, + defaultValue, + context, + logger, + evaluationTimestampMs, + onDependencyEvaluation, + onDependencyExposures + ) } catch (error) { logger.error('Error evaluating flag', { error }) details = { value: defaultValue, reason: 'ERROR', errorCode: 'GENERAL' as ErrorCode } @@ -144,7 +170,9 @@ function evaluateRules( defaultValue: FlagTypeToValue, context: EvaluationContext, logger: Logger, - evaluationTimestampMs: TimeStamp + evaluationTimestampMs: TimeStamp, + onDependencyEvaluation?: DependencyEvaluationHandler, + onDependencyExposures?: DependencyExposureHandler ): ResolutionDetails> { if (!config) { return { @@ -156,15 +184,57 @@ function evaluateRules( } if (isProtobufConfiguration(config)) { - return evaluateProtobufConfiguration( - prepareRulesResponse(config), - type, - flagKey, - defaultValue, - context, - logger, - evaluationTimestampMs - ) + const prepared = prepareRulesResponse(config) + try { + const maxDependencyDepth = getMaxDependencyDepth(config) + let dependencySession: DependencyEvaluationSession | undefined + const result = evaluateProtobufConfiguration( + prepared, + type, + flagKey, + defaultValue, + context, + logger, + evaluationTimestampMs, + (dependencyFlagKey) => { + dependencySession ??= new DependencyEvaluationSession( + flagKey, + maxDependencyDepth, + config.observeFullEvaluationData, + (key) => { + const flag = Object.prototype.hasOwnProperty.call(config.flags, key) ? config.flags[key] : undefined + if (flag === undefined) return undefined + const dependencyType = protobufVariationTypeToFlagValueType(flag.variationType) + return { type: dependencyType, defaultValue: defaultValueForType(dependencyType) } + }, + (key, dependencyType, dependencyDefault, resolveFlagEvaluation) => + evaluateProtobufConfiguration( + prepared, + dependencyType, + key, + dependencyDefault, + context, + logger, + evaluationTimestampMs, + resolveFlagEvaluation + ), + logger, + evaluationTimestampMs, + onDependencyEvaluation + ) + return dependencySession.resolveFlagEvaluation(dependencyFlagKey, 1) + } + ) + if (dependencySession !== undefined && result.reason !== 'ERROR' && result.errorCode === undefined) { + onDependencyExposures?.(dependencySession.exposureCandidates()) + } + return result + } catch (error) { + if (error instanceof DependencyGraphError) { + return dependencyGraphFailure(error, defaultValue, logger, evaluationTimestampMs) + } + throw error + } } const { targetingKey: subjectKey, ...remainingContext } = context @@ -184,9 +254,59 @@ function evaluateRules( } } - const flag = config.flags[flagKey] try { - return evaluateForSubject(flag, type, subjectKey, subjectAttributes, defaultValue, logger, evaluationTimestampMs) + const maxDependencyDepth = getMaxDependencyDepth(config) + let dependencySession: DependencyEvaluationSession | undefined + const evaluated = evaluateForSubject( + config.flags[flagKey], + type, + subjectKey, + subjectAttributes, + defaultValue, + logger, + evaluationTimestampMs, + (flagEvaluation) => { + dependencySession ??= new DependencyEvaluationSession( + flagKey, + maxDependencyDepth, + config.observeFullEvaluationData === true, + (key) => { + const flag = Object.prototype.hasOwnProperty.call(config.flags, key) ? config.flags[key] : undefined + if (flag === undefined) return undefined + return { + type: variantTypeToFlagValueType(flag.variationType), + defaultValue: defaultValueForFlag(flag), + } + }, + (key, dependencyType, dependencyDefault, resolveFlagEvaluation) => + evaluateForSubject( + config.flags[key], + dependencyType, + subjectKey, + subjectAttributes, + dependencyDefault, + logger, + evaluationTimestampMs, + (flagEvaluation) => resolveFlagEvaluation(flagEvaluation.key) + ) as ResolutionDetails, + logger, + evaluationTimestampMs, + onDependencyEvaluation + ) + return dependencySession.resolveFlagEvaluation(flagEvaluation, 1) + } + ) + const result: ResolutionDetails> = { + ...evaluated, + flagMetadata: { + ...evaluated.flagMetadata, + ...createEvaluationMetadata(evaluationTimestampMs, config.observeFullEvaluationData), + }, + } + if (dependencySession !== undefined && result.reason !== 'ERROR' && result.errorCode === undefined) { + onDependencyExposures?.(dependencySession.exposureCandidates()) + } + return result } catch (error) { if (error instanceof TargetingKeyMissingError) { return { @@ -196,6 +316,9 @@ function evaluateRules( flagMetadata: createEvaluationTimestampMetadata(evaluationTimestampMs), } } + if (error instanceof DependencyGraphError) { + return dependencyGraphFailure(error, defaultValue, logger, evaluationTimestampMs) + } logger.error('Error evaluating flag', { error }) return { value: defaultValue, @@ -206,6 +329,230 @@ function evaluateRules( } } +function dependencyGraphFailure( + error: DependencyGraphError, + defaultValue: T, + logger: Logger, + evaluationTimestampMs: TimeStamp +): ResolutionDetails { + logger.warn('flag dependency graph evaluation failed', { + flagKey: error.flagKey, + errorCode: error.errorCode, + errorMessage: error.message, + }) + return { + value: defaultValue, + reason: 'ERROR', + errorCode: error.errorCode, + errorMessage: error.message, + flagMetadata: createEvaluationTimestampMetadata(evaluationTimestampMs), + } +} + +export type DependencyEvaluation = { + flagKey: string + details: ResolutionDetails +} + +export type DependencyExposureCandidate = { + flagKey: string + details: ResolutionDetails +} + +export type DependencyEvaluationHandler = (evaluation: DependencyEvaluation) => void +export type DependencyExposureHandler = (evaluations: readonly DependencyExposureCandidate[]) => void + +class DependencyEvaluationSession { + private readonly memo = new Map>() + private readonly active: Set + private readonly exposures: DependencyExposureCandidate[] = [] + + constructor( + rootFlagKey: string, + private readonly maxDependencyDepth: number, + private readonly observeFullEvaluationData: boolean, + private readonly getFlag: (flagKey: string) => DependencyFlag | undefined, + private readonly evaluateFlag: DependencyFlagEvaluator, + private readonly logger: Logger, + private readonly evaluationTimestampMs: TimeStamp, + private readonly onDependencyEvaluation?: DependencyEvaluationHandler + ) { + this.active = new Set([rootFlagKey]) + } + + exposureCandidates(): readonly DependencyExposureCandidate[] { + return this.exposures + } + + private evaluate( + flagKey: string, + type: FlagValueType, + defaultValue: FlagValue, + depth: number + ): ResolutionDetails { + const memoized = this.memo.get(flagKey) + if (memoized !== undefined) { + return memoized + } + + const flag = this.getFlag(flagKey) + if (flag === undefined) { + return { + value: defaultValue, + reason: 'ERROR', + errorCode: 'FLAG_NOT_FOUND' as ErrorCode, + flagMetadata: createEvaluationTimestampMetadata(this.evaluationTimestampMs), + } + } + + this.active.add(flagKey) + try { + const evaluated = this.evaluateFlag(flagKey, type, defaultValue, (dependencyFlagKey) => + this.resolveFlagEvaluation(dependencyFlagKey, depth + 1) + ) + const result: ResolutionDetails = { + ...evaluated, + flagMetadata: { + ...evaluated.flagMetadata, + ...createEvaluationMetadata(this.evaluationTimestampMs, this.observeFullEvaluationData), + }, + } + if (result.reason === 'ERROR' || result.errorCode !== undefined) { + throw new DependencyGraphError( + flagKey, + result.errorCode ?? ('GENERAL' as ErrorCode), + result.errorMessage ?? 'prerequisite flag evaluation failed' + ) + } + if (result.variant === undefined) { + throw new DependencyGraphError( + flagKey, + 'GENERAL' as ErrorCode, + 'prerequisite flag evaluation returned no variant' + ) + } + this.memo.set(flagKey, result) + const evaluation = { flagKey, details: result } + this.exposures.push(evaluation) + this.emitEvaluation(evaluation) + return result + } catch (error) { + const graphError = this.toDependencyGraphError(flagKey, error) + this.emitEvaluation({ + flagKey, + details: { + value: defaultValue, + reason: 'ERROR', + errorCode: graphError.errorCode, + errorMessage: graphError.message, + flagMetadata: createEvaluationMetadata(this.evaluationTimestampMs, this.observeFullEvaluationData), + }, + }) + throw graphError + } finally { + this.active.delete(flagKey) + } + } + + resolveFlagEvaluation(flagEvaluation: FlagEvaluation | string, depth: number): string | undefined { + const flagKey = typeof flagEvaluation === 'string' ? flagEvaluation : flagEvaluation.key + if (depth > this.maxDependencyDepth) { + this.logger.warn('maximum flag dependency depth exceeded', { + flagKey, + maxDependencyDepth: this.maxDependencyDepth, + }) + throw new DependencyGraphError(flagKey, 'GENERAL' as ErrorCode, 'maximum flag dependency depth exceeded') + } + if (this.active.has(flagKey)) { + this.logger.warn('flag dependency cycle detected', { flagKey }) + throw new DependencyGraphError(flagKey, 'GENERAL' as ErrorCode, 'flag dependency cycle detected') + } + + const flag = this.getFlag(flagKey) + if (flag === undefined) { + this.logger.warn('prerequisite flag is missing', { flagKey }) + throw new DependencyGraphError(flagKey, 'FLAG_NOT_FOUND' as ErrorCode, 'prerequisite flag is missing') + } + + const result = this.evaluate(flagKey, flag.type, flag.defaultValue, depth) + return result.variant + } + + private toDependencyGraphError(flagKey: string, error: unknown): DependencyGraphError { + if (error instanceof DependencyGraphError) { + return error + } + if (error instanceof TargetingKeyMissingError) { + return new DependencyGraphError(flagKey, 'TARGETING_KEY_MISSING' as ErrorCode, error.message) + } + this.logger.error('Error evaluating prerequisite flag', { flagKey, error }) + return new DependencyGraphError(flagKey, 'GENERAL' as ErrorCode, 'prerequisite flag evaluation failed') + } + + private emitEvaluation(evaluation: DependencyEvaluation): void { + try { + this.onDependencyEvaluation?.(evaluation) + } catch { + // Evaluation telemetry is best effort and must not affect flag evaluation semantics. + } + } +} + +type DependencyFlag = { + type: FlagValueType + defaultValue: FlagValue +} + +type DependencyFlagEvaluator = ( + flagKey: string, + type: FlagValueType, + defaultValue: FlagValue, + resolveFlagEvaluation: (flagKey: string) => string | undefined +) => ResolutionDetails + +function getMaxDependencyDepth(config: UniversalFlagConfigurationV1 | ProtobufFlagsConfiguration): number { + const configuredDepth = config.evaluatorParams?.maxDependencyDepth + if (configuredDepth === undefined) { + return DEFAULT_MAX_DEPENDENCY_DEPTH + } + const numericDepth = typeof configuredDepth === 'bigint' ? Number(configuredDepth) : configuredDepth + if (Number.isInteger(numericDepth) && numericDepth >= 0 && numericDepth <= 255) { + return numericDepth + } + throw new DependencyGraphError( + '', + 'PARSE_ERROR' as ErrorCode, + 'maxDependencyDepth must be an integer from 0 through 255' + ) +} + +function defaultValueForType(type: FlagValueType): FlagValue { + switch (type) { + case 'boolean': + return false + case 'number': + return 0 + case 'string': + return '' + case 'object': + return {} + } +} + +function defaultValueForFlag(flag: Flag): FlagValue { + switch (flag.variationType) { + case 'BOOLEAN': + return false + case 'INTEGER': + case 'NUMERIC': + return 0 + case 'STRING': + return '' + case 'JSON': + return {} + } +} + function isProtobufConfiguration( configuration: UniversalFlagConfigurationV1 | ProtobufFlagsConfiguration ): configuration is ProtobufFlagsConfiguration { diff --git a/packages/core/src/evaluation/rules.test.ts b/packages/core/src/evaluation/rules.test.ts index ac25b2ec..9b896aed 100644 --- a/packages/core/src/evaluation/rules.test.ts +++ b/packages/core/src/evaluation/rules.test.ts @@ -1,5 +1,5 @@ import type { EvaluationContext } from '@openfeature/core' -import { matchesRule, OperatorType, type Rule } from './rules' +import { isValidRule, matchesRule, OperatorType, type Rule } from './rules' describe('condition attribute coercion', () => { it.each([ @@ -39,3 +39,23 @@ describe('condition attribute coercion', () => { expect(matchesRule(rule, { value } as EvaluationContext)).toBe(expected) }) }) + +describe('dependent condition validation', () => { + it('accepts key-only variant membership', () => { + expect( + isValidRule({ + conditions: [{ flagEvaluation: { key: 'prerequisite' }, operator: OperatorType.ONE_OF, value: ['on'] }], + }) + ).toBe(true) + }) + + it.each([ + [ + 'a malformed flag-evaluation reference', + { flagEvaluation: { key: 'prerequisite', unexpected: true }, operator: OperatorType.ONE_OF, value: ['on'] }, + ], + ['a regex operator', { flagEvaluation: { key: 'prerequisite' }, operator: OperatorType.MATCHES, value: '^on$' }], + ])('rejects %s', (_description, condition) => { + expect(isValidRule({ conditions: [condition] })).toBe(false) + }) +}) diff --git a/packages/core/src/evaluation/rules.ts b/packages/core/src/evaluation/rules.ts index fd320f2d..67d45cdd 100644 --- a/packages/core/src/evaluation/rules.ts +++ b/packages/core/src/evaluation/rules.ts @@ -58,6 +58,18 @@ type NotOneOfCondition = { value: string[] } +export type FlagEvaluation = { + key: string +} + +type FlagEvaluationCondition = { + operator: OperatorType.ONE_OF | OperatorType.NOT_ONE_OF + flagEvaluation: FlagEvaluation + value: string[] +} + +export type ResolveFlagEvaluation = (flagEvaluation: FlagEvaluation) => string | undefined + type NumericCondition = { operator: NumericOperator attribute: string @@ -81,6 +93,7 @@ export type Condition = | NotMatchesCondition | OneOfCondition | NotOneOfCondition + | FlagEvaluationCondition | NumericCondition | NullCondition | SemVerCondition @@ -95,47 +108,73 @@ export function isValidRule(rule: unknown): rule is Rule { } return rule.conditions.every((condition) => { - if (!isRecord(condition) || typeof condition.attribute !== 'string' || typeof condition.operator !== 'string') { + if (!isRecord(condition) || typeof condition.operator !== 'string') { return false } if (!supportedOperators.has(condition.operator)) { return false } - if (isNumericOperator(condition.operator)) { - return typeof condition.value === 'number' && Number.isFinite(condition.value) - } - if (condition.operator === OperatorType.ONE_OF || condition.operator === OperatorType.NOT_ONE_OF) { - return Array.isArray(condition.value) && condition.value.every((value) => typeof value === 'string') - } - if (condition.operator === OperatorType.IS_NULL) { - return typeof condition.value === 'boolean' - } - if (isSemVerOperator(condition.operator)) { - return parseSemver(condition.value) !== null - } - if (typeof condition.value !== 'string') { + const hasAttribute = Object.prototype.hasOwnProperty.call(condition, 'attribute') + const hasFlagEvaluation = Object.prototype.hasOwnProperty.call(condition, 'flagEvaluation') + if (hasAttribute === hasFlagEvaluation) { return false } - try { - compileRegex(condition.value) - return true - } catch { + if (hasFlagEvaluation) { + if (!isValidFlagEvaluation(condition.flagEvaluation) || !isFlagEvaluationOperator(condition.operator)) { + return false + } + } else if (typeof condition.attribute !== 'string') { return false } + return isValidOperatorOperand(condition.operator, condition.value) }) } -export function matchesRule(rule: Rule, subjectAttributes: EvaluationContext): boolean { - const conditionEvaluations = evaluateRuleConditions(subjectAttributes, rule.conditions) - // TODO: short-circuit return when false condition is found - return !conditionEvaluations.includes(false) +function isValidOperatorOperand(operator: string, value: unknown): boolean { + if (isNumericOperator(operator)) { + return typeof value === 'number' && Number.isFinite(value) + } + if (operator === OperatorType.ONE_OF || operator === OperatorType.NOT_ONE_OF) { + return Array.isArray(value) && value.every((item) => typeof item === 'string') + } + if (operator === OperatorType.IS_NULL) { + return typeof value === 'boolean' + } + if (isSemVerOperator(operator)) { + return parseSemver(value) !== null + } + if (typeof value !== 'string') { + return false + } + try { + compileRegex(value) + return true + } catch { + return false + } } -function evaluateRuleConditions(subjectAttributes: EvaluationContext, conditions: Condition[]): boolean[] { - return conditions.map((condition) => evaluateCondition(subjectAttributes, condition)) +export function matchesRule( + rule: Rule, + subjectAttributes: EvaluationContext, + resolveFlagEvaluation?: ResolveFlagEvaluation +): boolean { + return rule.conditions.every((condition) => evaluateCondition(subjectAttributes, condition, resolveFlagEvaluation)) } -function evaluateCondition(subjectAttributes: EvaluationContext, condition: Condition): boolean { +function evaluateCondition( + subjectAttributes: EvaluationContext, + condition: Condition, + resolveFlagEvaluation?: ResolveFlagEvaluation +): boolean { + if ('flagEvaluation' in condition) { + const flagValue = resolveFlagEvaluation?.(condition.flagEvaluation) + if (flagValue === undefined) { + return false + } + return evaluateStringCondition(flagValue, condition.operator, condition.value) + } + const value = subjectAttributes[condition.attribute] if (condition.operator === OperatorType.IS_NULL) { if (condition.value) { @@ -192,10 +231,36 @@ function evaluateCondition(subjectAttributes: EvaluationContext, condition: Cond return false } +function evaluateStringCondition( + value: string, + operator: OperatorType.ONE_OF | OperatorType.NOT_ONE_OF, + comparand: string[] +): boolean { + switch (operator) { + case OperatorType.ONE_OF: + return isOneOf(value, comparand) + case OperatorType.NOT_ONE_OF: + return isNotOneOf(value, comparand) + } +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } +function isValidFlagEvaluation(value: unknown): value is FlagEvaluation { + return ( + isRecord(value) && + typeof value.key === 'string' && + value.key.length > 0 && + Object.keys(value).every((key) => key === 'key') + ) +} + +function isFlagEvaluationOperator(operator: string): boolean { + return operator === OperatorType.ONE_OF || operator === OperatorType.NOT_ONE_OF +} + function isNumericOperator(operator: string): operator is NumericOperator { return ( operator === OperatorType.GTE || diff --git a/packages/core/src/evaluation/ufc-v1.ts b/packages/core/src/evaluation/ufc-v1.ts index 417151bb..057ddf6d 100644 --- a/packages/core/src/evaluation/ufc-v1.ts +++ b/packages/core/src/evaluation/ufc-v1.ts @@ -47,6 +47,9 @@ export interface UniversalFlagConfigurationV1 { createdAt: string format: string observeFullEvaluationData?: boolean + evaluatorParams?: { + maxDependencyDepth?: number + } environment: { name: string } diff --git a/packages/core/test/evaluation/dependent-flags.spec.ts b/packages/core/test/evaluation/dependent-flags.spec.ts new file mode 100644 index 00000000..386ee2f6 --- /dev/null +++ b/packages/core/test/evaluation/dependent-flags.spec.ts @@ -0,0 +1,399 @@ +import { create } from '@bufbuild/protobuf' +import type { Logger } from '@openfeature/core' +import { + ConditionSchema, + EvaluatorParamsSchema, + FlagsConfigurationSchema, + Reason, + VariationType, +} from '../../src/configuration/generated/ufc_pb' +import { decodeFlagsConfiguration, encodeFlagsConfiguration } from '../../src/configuration/ufc-protobuf' +import { + type DependencyEvaluation, + evaluateRulesBasedConfiguration, + type Flag, + type UniversalFlagConfigurationV1, +} from '../../src/evaluation' +import { OperatorType } from '../../src/evaluation/rules' + +describe('dependent flag evaluation', () => { + let logger: Logger + + beforeEach(() => { + logger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() } + }) + + it('evaluates varying depths with one timestamp', () => { + const config = configuration({ + root: dependentBooleanFlag('root', [flagCondition('middle', OperatorType.ONE_OF, ['on'])]), + middle: dependentBooleanFlag('middle', [flagCondition('leaf', OperatorType.ONE_OF, ['on'])]), + leaf: booleanFlag('leaf', 'on'), + }) + config.observeFullEvaluationData = true + const dependencies: DependencyEvaluation[] = [] + + const result = evaluate(config, 'root', logger, (evaluation) => dependencies.push(evaluation)) + + expect(result).toMatchObject({ value: true, variant: 'on' }) + expect(dependencies.map(({ flagKey }) => flagKey)).toEqual(['leaf', 'middle']) + expect(dependencies.map(({ details }) => details.flagMetadata?.__dd_eval_timestamp_ms)).toEqual([ + result.flagMetadata?.__dd_eval_timestamp_ms, + result.flagMetadata?.__dd_eval_timestamp_ms, + ]) + expect(dependencies.map(({ details }) => details.flagMetadata?.__dd_observe_full_evaluation_data)).toEqual([ + true, + true, + ]) + }) + + it('uses the shared dependency session for protobuf UFC', () => { + const config = protobufDependentConfiguration(true) + const dependencies: DependencyEvaluation[] = [] + const onExposures = jest.fn() + + expect( + evaluateRulesBasedConfiguration( + config, + 'boolean', + 'root', + false, + { targetingKey: 'subject' }, + logger, + (evaluation) => dependencies.push(evaluation), + onExposures + ) + ).toMatchObject({ value: true, variant: 'on' }) + expect(dependencies).toMatchObject([{ flagKey: 'prerequisite', details: { value: true, variant: 'on' } }]) + expect(onExposures).toHaveBeenCalledWith([ + expect.objectContaining({ flagKey: 'prerequisite', details: expect.objectContaining({ variant: 'on' }) }), + ]) + }) + + it('fails a protobuf graph when a reached prerequisite returns no variant', () => { + const config = protobufDependentConfiguration(false) + const dependencies: DependencyEvaluation[] = [] + const onExposures = jest.fn() + + expect( + evaluateRulesBasedConfiguration( + config, + 'boolean', + 'root', + false, + { targetingKey: 'subject' }, + logger, + (evaluation) => dependencies.push(evaluation), + onExposures + ) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'GENERAL', + errorMessage: 'prerequisite flag evaluation returned no variant', + }) + expect(dependencies).toMatchObject([ + { + flagKey: 'prerequisite', + details: { reason: 'ERROR', errorCode: 'GENERAL' }, + }, + ]) + expect(onExposures).not.toHaveBeenCalled() + }) + + it('supports protobuf NOT_ONE_OF dependencies', () => { + const config = protobufDependentConfiguration(true) + config.strings.push('off') + const membership = config.conditions[0].kind + if (membership.case !== 'flagEvaluationStringMembership') throw new Error('unexpected test condition') + membership.value.stringIndexes = [2] + membership.value.negate = true + + expect(evaluateProtobuf(roundTrip(config), logger).result).toMatchObject({ value: true, variant: 'on' }) + }) + + it('applies the protobuf maximum dependency depth override', () => { + const config = protobufDependentConfiguration(true) + config.evaluatorParams = create(EvaluatorParamsSchema, { maxDependencyDepth: BigInt(0) }) + + expect(evaluateProtobuf(roundTrip(config), logger).result).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'GENERAL', + errorMessage: 'maximum flag dependency depth exceeded', + }) + }) + + it('propagates a nested missing protobuf dependency and records its ancestor', () => { + const config = protobufDependentConfiguration(true) + config.strings.push('missing') + config.conditions.push( + create(ConditionSchema, { + kind: { + case: 'flagEvaluationStringMembership', + value: { flagKeyStringIndex: 2, stringIndexes: [0], negate: false }, + }, + }) + ) + config.flags.prerequisite.allocations[0].targetingConditionIndex = 1 + + const { result, dependencies, onExposures } = evaluateProtobuf(roundTrip(config), logger) + expect(result).toMatchObject({ value: false, reason: 'ERROR', errorCode: 'FLAG_NOT_FOUND' }) + expect(dependencies).toMatchObject([ + { flagKey: 'prerequisite', details: { reason: 'ERROR', errorCode: 'FLAG_NOT_FOUND' } }, + ]) + expect(onExposures).not.toHaveBeenCalled() + }) + + it('detects a protobuf dependency cycle', () => { + const config = protobufDependentConfiguration(true) + config.strings.push('root') + config.conditions.push( + create(ConditionSchema, { + kind: { + case: 'flagEvaluationStringMembership', + value: { flagKeyStringIndex: 2, stringIndexes: [0], negate: false }, + }, + }) + ) + config.flags.prerequisite.allocations[0].targetingConditionIndex = 1 + + const { result, dependencies, onExposures } = evaluateProtobuf(roundTrip(config), logger) + expect(result).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: 'GENERAL', + errorMessage: 'flag dependency cycle detected', + }) + expect(dependencies).toMatchObject([ + { flagKey: 'prerequisite', details: { reason: 'ERROR', errorCode: 'GENERAL' } }, + ]) + expect(onExposures).not.toHaveBeenCalled() + }) + + it('memoizes protobuf dependencies and rolls back exposures after a later missing sibling', () => { + const config = protobufDependentConfiguration(true) + config.strings.push('missing') + config.conditions.push( + create(ConditionSchema, { + kind: { + case: 'flagEvaluationStringMembership', + value: { flagKeyStringIndex: 1, stringIndexes: [0], negate: false }, + }, + }), + create(ConditionSchema, { + kind: { + case: 'flagEvaluationStringMembership', + value: { flagKeyStringIndex: 2, stringIndexes: [0], negate: false }, + }, + }), + create(ConditionSchema, { + kind: { case: 'all', value: { conditionIndexes: [0, 1, 2] } }, + }) + ) + config.flags.root.allocations[0].targetingConditionIndex = 3 + + const { result, dependencies, onExposures } = evaluateProtobuf(roundTrip(config), logger) + expect(result).toMatchObject({ value: false, reason: 'ERROR', errorCode: 'FLAG_NOT_FOUND' }) + expect(dependencies.map(({ flagKey }) => flagKey)).toEqual(['prerequisite']) + expect(onExposures).not.toHaveBeenCalled() + }) + + it('encodes protobuf dependencies as condition-native values, not partition attributes', () => { + const config = roundTrip(protobufDependentConfiguration(true)) + + expect(config.attributes).toEqual([]) + expect(config.conditions[0].kind).toEqual({ + case: 'flagEvaluationStringMembership', + value: expect.objectContaining({ flagKeyStringIndex: 1, stringIndexes: [0], negate: false }), + }) + }) + + it('uses the hard-coded maximum when the override is omitted', () => { + const flags: Record = { leaf: booleanFlag('leaf', 'on') } + for (let depth = 9; depth >= 0; depth -= 1) { + const key = depth === 0 ? 'root' : `depth-${depth}` + const child = depth === 9 ? 'leaf' : `depth-${depth + 1}` + flags[key] = dependentBooleanFlag(key, [flagCondition(child, OperatorType.ONE_OF, ['on'])]) + } + + expect(evaluate(configuration(flags), 'root', logger)).toMatchObject({ value: true, variant: 'on' }) + }) + + it('does not enter dependency handling for an ordinary rule', () => { + const config = configuration({ + root: dependentBooleanFlag('root', [{ attribute: 'country', operator: OperatorType.ONE_OF, value: ['US'] }]), + }) + const onDependency = jest.fn() + const onExposures = jest.fn() + + expect(evaluate(config, 'root', logger, onDependency, { country: 'US' }, onExposures)).toMatchObject({ + value: true, + variant: 'on', + }) + expect(onDependency).not.toHaveBeenCalled() + expect(onExposures).not.toHaveBeenCalled() + }) + + it('isolates dependency telemetry sink failures from evaluation', () => { + const config = configuration({ + prerequisite: booleanFlag('prerequisite', 'on'), + root: dependentBooleanFlag('root', [flagCondition('prerequisite', OperatorType.ONE_OF, ['on'])]), + }) + + expect( + evaluate(config, 'root', logger, () => { + throw new Error('telemetry unavailable') + }) + ).toMatchObject({ value: true, variant: 'on' }) + }) +}) + +function evaluate( + config: UniversalFlagConfigurationV1, + flagKey: string, + logger: Logger, + onDependency?: (evaluation: DependencyEvaluation) => void, + attributes: Record = {}, + onExposures?: (evaluations: readonly DependencyEvaluation[]) => void +) { + return evaluateRulesBasedConfiguration( + config, + 'boolean', + flagKey, + false, + { targetingKey: 'subject', ...attributes }, + logger, + onDependency, + onExposures + ) +} + +function configuration(flags: Record, maxDependencyDepth?: number): UniversalFlagConfigurationV1 { + return { + createdAt: '2026-10-07T12:00:00Z', + format: 'SERVER', + ...(maxDependencyDepth === undefined ? {} : { evaluatorParams: { maxDependencyDepth } }), + environment: { name: 'test' }, + flags, + } +} + +function booleanFlag(key: string, variant: 'on' | 'off'): Flag { + return { + key, + enabled: true, + variationType: 'BOOLEAN', + variations: { on: { key: 'on', value: true }, off: { key: 'off', value: false } }, + allocations: [{ key: 'default', doLog: true, rules: [], splits: [{ variationKey: variant, shards: [] }] }], + } +} + +function dependentBooleanFlag( + key: string, + conditions: NonNullable[number]['conditions'] +): Flag { + return { + key, + enabled: true, + variationType: 'BOOLEAN', + variations: { on: { key: 'on', value: true }, off: { key: 'off', value: false } }, + allocations: [ + { + key: 'targeted', + doLog: true, + rules: [{ conditions }], + splits: [{ variationKey: 'on', shards: [] }], + }, + { key: 'default', doLog: true, rules: [], splits: [{ variationKey: 'off', shards: [] }] }, + ], + } +} + +function flagCondition( + key: string, + operator: OperatorType.ONE_OF | OperatorType.NOT_ONE_OF, + value: readonly string[] +): { + flagEvaluation: { key: string } + operator: OperatorType.ONE_OF | OperatorType.NOT_ONE_OF + value: string[] +} +function flagCondition(key: string, operator: OperatorType.ONE_OF | OperatorType.NOT_ONE_OF, value: readonly string[]) { + return { + flagEvaluation: { key }, + operator, + value: [...value], + } +} + +function protobufDependentConfiguration(prerequisiteHasAllocation: boolean) { + const variation = { + keyStringIndex: 0, + value: { case: 'booleanValue' as const, value: true }, + } + const split = { + ranges: [], + variationIndex: 0, + serialId: 1, + reason: Reason.STATIC, + } + const prerequisiteAllocation = { + key: 'prerequisite-static', + partitionKey: [], + splits: [split], + logExposureEvent: true, + } + const rootAllocation = { + key: 'root-targeted', + targetingConditionIndex: 0, + partitionKey: [], + splits: [{ ...split, reason: Reason.TARGETING_MATCH }], + logExposureEvent: true, + } + + const configuration = create(FlagsConfigurationSchema, { + environmentName: 'test', + strings: ['on', 'prerequisite'], + conditions: [ + { + kind: { + case: 'flagEvaluationStringMembership', + value: { flagKeyStringIndex: 1, stringIndexes: [0], negate: false }, + }, + }, + ], + flags: { + root: { + variationType: VariationType.BOOLEAN, + variations: [variation], + allocations: [rootAllocation], + }, + prerequisite: { + variationType: VariationType.BOOLEAN, + variations: [variation], + allocations: prerequisiteHasAllocation ? [prerequisiteAllocation] : [], + }, + }, + }) + return decodeFlagsConfiguration(encodeFlagsConfiguration(configuration)) +} + +function roundTrip(configuration: Parameters[0]) { + return decodeFlagsConfiguration(encodeFlagsConfiguration(configuration)) +} + +function evaluateProtobuf(configuration: ReturnType, logger: Logger) { + const dependencies: DependencyEvaluation[] = [] + const onExposures = jest.fn() + const result = evaluateRulesBasedConfiguration( + configuration, + 'boolean', + 'root', + false, + { targetingKey: 'subject' }, + logger, + (evaluation) => dependencies.push(evaluation), + onExposures + ) + return { result, dependencies, onExposures } +} diff --git a/packages/core/test/ffe-system-test-data b/packages/core/test/ffe-system-test-data index b469cb91..9f21a7b1 160000 --- a/packages/core/test/ffe-system-test-data +++ b/packages/core/test/ffe-system-test-data @@ -1 +1 @@ -Subproject commit b469cb917d7f5df1cdc35723ba7da54c3ee53208 +Subproject commit 9f21a7b1f634e99a68862c17acb2837a9f2e6127 diff --git a/packages/node-server/index.d.ts b/packages/node-server/index.d.ts index 4081b2cc..ff178c40 100644 --- a/packages/node-server/index.d.ts +++ b/packages/node-server/index.d.ts @@ -1,5 +1,7 @@ import { Channel } from 'node:diagnostics_channel'; +type Metadata = Record; +type Paradigm = "server" | "client"; type PrimitiveValue = null | boolean | string | number; type JsonObject = { [key: string]: JsonValue; @@ -17,155 +19,8 @@ type EvaluationContext = { */ targetingKey?: string; } & Record; -type FlagValue = boolean | string | number | JsonValue; -interface ExposureEvent { - allocation: { - key: string; - }; - flag: { - key: string; - }; - variant: { - key: string; - }; - serial_id?: number; - subject: { - id: string; - attributes: EvaluationContext; - }; - service?: string; - rum?: { - application?: { - id?: string; - }; - view?: { - url?: string; - }; - }; -} -declare enum OperatorType { - MATCHES = "MATCHES", - NOT_MATCHES = "NOT_MATCHES", - GTE = "GTE", - GT = "GT", - LTE = "LTE", - LT = "LT", - ONE_OF = "ONE_OF", - NOT_ONE_OF = "NOT_ONE_OF", - IS_NULL = "IS_NULL", - SEMVER_EQ = "SEMVER_EQ", - SEMVER_NEQ = "SEMVER_NEQ", - SEMVER_LT = "SEMVER_LT", - SEMVER_LTE = "SEMVER_LTE", - SEMVER_GT = "SEMVER_GT", - SEMVER_GTE = "SEMVER_GTE" -} -type NumericOperator = OperatorType.GTE | OperatorType.GT | OperatorType.LTE | OperatorType.LT; -type SemVerOperator = OperatorType.SEMVER_EQ | OperatorType.SEMVER_NEQ | OperatorType.SEMVER_GTE | OperatorType.SEMVER_GT | OperatorType.SEMVER_LTE | OperatorType.SEMVER_LT; -type MatchesCondition = { - operator: OperatorType.MATCHES; - attribute: string; - value: string; -}; -type NotMatchesCondition = { - operator: OperatorType.NOT_MATCHES; - attribute: string; - value: string; -}; -type OneOfCondition = { - operator: OperatorType.ONE_OF; - attribute: string; - value: string[]; -}; -type NotOneOfCondition = { - operator: OperatorType.NOT_ONE_OF; - attribute: string; - value: string[]; -}; -type NumericCondition = { - operator: NumericOperator; - attribute: string; - value: number; -}; -type NullCondition = { - operator: OperatorType.IS_NULL; - attribute: string; - value: boolean; -}; -type SemVerCondition = { - operator: SemVerOperator; - attribute: string; - value: string; -}; -type Condition = MatchesCondition | NotMatchesCondition | OneOfCondition | NotOneOfCondition | NumericCondition | NullCondition | SemVerCondition; -interface Rule { - conditions: Condition[]; -} -type VariantType = "BOOLEAN" | "INTEGER" | "NUMERIC" | "STRING" | "JSON"; -interface VariantConfiguration { - key: string; - value: FlagValue; -} -interface ShardRange { - start: number; - end: number; -} -interface Shard { - salt: string; - ranges: ShardRange[]; - totalShards: number; -} -interface Split { - variationKey: string; - shards: Shard[]; - extraLogging?: Record; - serialId?: number; -} -interface Allocation { - key: string; - rules?: Rule[]; - startAt?: Date; - endAt?: Date; - splits: Split[]; - doLog?: boolean; -} -interface Flag { - key: string; - enabled: boolean; - variationType: VariantType; - variations: Record; - allocations: Allocation[]; -} -export interface UniversalFlagConfigurationV1 { - createdAt: string; - format: string; - observeFullEvaluationData?: boolean; - environment: { - name: string; - }; - flags: Record; -} -type Metadata = Record; -type Paradigm = "server" | "client"; -type PrimitiveValue$1 = null | boolean | string | number; -type JsonObject$1 = { - [key: string]: JsonValue$1; -}; -type JsonArray$1 = JsonValue$1[]; -type JsonValue$1 = PrimitiveValue$1 | JsonObject$1 | JsonArray$1; -type EvaluationContextValue$1 = PrimitiveValue$1 | Date | { - [key: string]: EvaluationContextValue$1; -} | EvaluationContextValue$1[]; -type EvaluationContext$1 = { - /** - * A string uniquely identifying the subject (end-user, or client service) of a flag evaluation. - * Providers may require this field for fractional flag evaluation, rules, or overrides targeting specific users. - * Such providers may behave unpredictably if a targeting key is not specified at flag resolution. - */ - targetingKey?: string; -} & Record; type FlagValueType = "boolean" | "string" | "number" | "object"; -type FlagValue$1 = boolean | string | number | JsonValue$1; +type FlagValue = boolean | string | number | JsonValue; type ResolutionReason = keyof typeof StandardResolutionReasons | (string & Record); type FlagMetadata = Record; type ResolutionDetails = { @@ -176,7 +31,7 @@ type ResolutionDetails = { errorCode?: ErrorCode; errorMessage?: string; }; -type EvaluationDetails = { +type EvaluationDetails = { flagKey: string; flagMetadata: Readonly; } & ResolutionDetails; @@ -362,7 +217,7 @@ interface ProviderEventEmitter * Use this function to perform any context-dependent setup within the provider. * @param context */ - initialize?(context?: EvaluationContext$1): Promise; + initialize?(context?: EvaluationContext): Promise; /** * Track a user action or application state, usually representing a business objective or outcome. * @param trackingEventName * @param context * @param trackingEventDetails */ - track?(trackingEventName: string, context: EvaluationContext$1, trackingEventDetails: TrackingEventDetails): void; + track?(trackingEventName: string, context: EvaluationContext, trackingEventDetails: TrackingEventDetails): void; } interface ClientMetadata { /** @@ -521,20 +376,20 @@ interface HookData> { clear(): void; } type HookHints = Readonly>; -interface HookContext> { +interface HookContext> { readonly flagKey: string; readonly defaultValue: T; readonly flagValueType: FlagValueType; - readonly context: Readonly; + readonly context: Readonly; readonly clientMetadata: ClientMetadata; readonly providerMetadata: ProviderMetadata; readonly logger: Logger; readonly hookData: HookData; } -interface BeforeHookContext> extends HookContext { - context: EvaluationContext$1; +interface BeforeHookContext> extends HookContext { + context: EvaluationContext; } -interface BaseHook, BeforeHookReturn = unknown, HooksReturn = unknown> { +interface BaseHook, BeforeHookReturn = unknown, HooksReturn = unknown> { /** * Runs before flag values are resolved from the provider. * If an EvaluationContext is returned, it will be merged with the pre-existing EvaluationContext. @@ -564,7 +419,145 @@ interface BaseHook>, evaluationDetails: EvaluationDetails, hookHints?: HookHints): HooksReturn; } -type Hook> = BaseHook | EvaluationContext$1 | void, Promise | void>; +interface ExposureEvent { + allocation: { + key: string; + }; + flag: { + key: string; + }; + variant: { + key: string; + }; + serial_id?: number; + subject: { + id: string; + attributes: EvaluationContext; + }; + service?: string; + rum?: { + application?: { + id?: string; + }; + view?: { + url?: string; + }; + }; +} +declare enum OperatorType { + MATCHES = "MATCHES", + NOT_MATCHES = "NOT_MATCHES", + GTE = "GTE", + GT = "GT", + LTE = "LTE", + LT = "LT", + ONE_OF = "ONE_OF", + NOT_ONE_OF = "NOT_ONE_OF", + IS_NULL = "IS_NULL", + SEMVER_EQ = "SEMVER_EQ", + SEMVER_NEQ = "SEMVER_NEQ", + SEMVER_LT = "SEMVER_LT", + SEMVER_LTE = "SEMVER_LTE", + SEMVER_GT = "SEMVER_GT", + SEMVER_GTE = "SEMVER_GTE" +} +type NumericOperator = OperatorType.GTE | OperatorType.GT | OperatorType.LTE | OperatorType.LT; +type SemVerOperator = OperatorType.SEMVER_EQ | OperatorType.SEMVER_NEQ | OperatorType.SEMVER_GTE | OperatorType.SEMVER_GT | OperatorType.SEMVER_LTE | OperatorType.SEMVER_LT; +type MatchesCondition = { + operator: OperatorType.MATCHES; + attribute: string; + value: string; +}; +type NotMatchesCondition = { + operator: OperatorType.NOT_MATCHES; + attribute: string; + value: string; +}; +type OneOfCondition = { + operator: OperatorType.ONE_OF; + attribute: string; + value: string[]; +}; +type NotOneOfCondition = { + operator: OperatorType.NOT_ONE_OF; + attribute: string; + value: string[]; +}; +type FlagEvaluation = { + key: string; +}; +type FlagEvaluationCondition = { + operator: OperatorType.ONE_OF | OperatorType.NOT_ONE_OF; + flagEvaluation: FlagEvaluation; + value: string[]; +}; +type NumericCondition = { + operator: NumericOperator; + attribute: string; + value: number; +}; +type NullCondition = { + operator: OperatorType.IS_NULL; + attribute: string; + value: boolean; +}; +type SemVerCondition = { + operator: SemVerOperator; + attribute: string; + value: string; +}; +type Condition = MatchesCondition | NotMatchesCondition | OneOfCondition | NotOneOfCondition | FlagEvaluationCondition | NumericCondition | NullCondition | SemVerCondition; +interface Rule { + conditions: Condition[]; +} +type VariantType = "BOOLEAN" | "INTEGER" | "NUMERIC" | "STRING" | "JSON"; +interface VariantConfiguration { + key: string; + value: FlagValue; +} +interface ShardRange { + start: number; + end: number; +} +interface Shard { + salt: string; + ranges: ShardRange[]; + totalShards: number; +} +interface Split { + variationKey: string; + shards: Shard[]; + extraLogging?: Record; + serialId?: number; +} +interface Allocation { + key: string; + rules?: Rule[]; + startAt?: Date; + endAt?: Date; + splits: Split[]; + doLog?: boolean; +} +interface Flag { + key: string; + enabled: boolean; + variationType: VariantType; + variations: Record; + allocations: Allocation[]; +} +export interface UniversalFlagConfigurationV1 { + createdAt: string; + format: string; + observeFullEvaluationData?: boolean; + evaluatorParams?: { + maxDependencyDepth?: number; + }; + environment: { + name: string; + }; + flags: Record; +} +type Hook> = BaseHook | EvaluationContext | void, Promise | void>; interface Provider extends CommonProvider { /** * A provider hook exposes a mechanism for provider authors to register hooks @@ -576,25 +569,29 @@ interface Provider extends CommonProvider { /** * Resolve a boolean flag and its evaluation details. */ - resolveBooleanEvaluation(flagKey: string, defaultValue: boolean, context: EvaluationContext$1, logger: Logger): Promise>; + resolveBooleanEvaluation(flagKey: string, defaultValue: boolean, context: EvaluationContext, logger: Logger): Promise>; /** * Resolve a string flag and its evaluation details. */ - resolveStringEvaluation(flagKey: string, defaultValue: string, context: EvaluationContext$1, logger: Logger): Promise>; + resolveStringEvaluation(flagKey: string, defaultValue: string, context: EvaluationContext, logger: Logger): Promise>; /** * Resolve a numeric flag and its evaluation details. */ - resolveNumberEvaluation(flagKey: string, defaultValue: number, context: EvaluationContext$1, logger: Logger): Promise>; + resolveNumberEvaluation(flagKey: string, defaultValue: number, context: EvaluationContext, logger: Logger): Promise>; /** * Resolve and parse an object flag and its evaluation details. */ - resolveObjectEvaluation(flagKey: string, defaultValue: T, context: EvaluationContext$1, logger: Logger): Promise>; + resolveObjectEvaluation(flagKey: string, defaultValue: T, context: EvaluationContext, logger: Logger): Promise>; } export interface DatadogNodeServerProviderOptions { /** * Log experiment exposures */ exposureChannel: Channel; + /** + * Report prerequisite evaluations, including errors propagated through dependency ancestors. + */ + dependencyEvaluationChannel?: Channel; /** * Timeout in milliseconds for provider initialization. * If the configuration is not set within this time, initialization will fail. @@ -602,6 +599,10 @@ export interface DatadogNodeServerProviderOptions { */ initializationTimeoutMs?: number; } +export type DependencyEvaluationEvent = { + context: EvaluationContext; + details: EvaluationDetails; +}; export declare class DatadogNodeServerProvider implements Provider { private readonly options; readonly metadata: ProviderMetadata; @@ -634,10 +635,12 @@ export declare class DatadogNodeServerProvider implements Provider { * See setConfiguration and setError for more details. */ initialize(): Promise; - resolveBooleanEvaluation(flagKey: string, defaultValue: boolean, context: EvaluationContext$1, _logger: Logger): Promise>; - resolveStringEvaluation(flagKey: string, defaultValue: string, context: EvaluationContext$1, _logger: Logger): Promise>; - resolveNumberEvaluation(flagKey: string, defaultValue: number, context: EvaluationContext$1, _logger: Logger): Promise>; - resolveObjectEvaluation(flagKey: string, defaultValue: T, context: EvaluationContext$1, _logger: Logger): Promise>; + resolveBooleanEvaluation(flagKey: string, defaultValue: boolean, context: EvaluationContext, _logger: Logger): Promise>; + resolveStringEvaluation(flagKey: string, defaultValue: string, context: EvaluationContext, _logger: Logger): Promise>; + resolveNumberEvaluation(flagKey: string, defaultValue: number, context: EvaluationContext, _logger: Logger): Promise>; + resolveObjectEvaluation(flagKey: string, defaultValue: T, context: EvaluationContext, _logger: Logger): Promise>; + private handleDependencyEvaluation; + private handleDependencyExposures; private handleExposure; } diff --git a/packages/node-server/rules-based.d.ts b/packages/node-server/rules-based.d.ts index 4770b48a..f2d32c1d 100644 --- a/packages/node-server/rules-based.d.ts +++ b/packages/node-server/rules-based.d.ts @@ -191,6 +191,23 @@ type FlagsConfiguration = Message<"datadog.ffe.flagging.ufc.v1.FlagsConfiguratio * @generated from field: bool observe_full_evaluation_data = 10; */ observeFullEvaluationData: boolean; + /** + * Optional evaluator behavior overrides. Evaluators use their hard-coded + * defaults when this message or one of its fields is absent. + * + * @generated from field: datadog.ffe.flagging.ufc.v1.EvaluatorParams evaluator_params = 11; + */ + evaluatorParams?: EvaluatorParams | undefined; +}; +type EvaluatorParams = Message<"datadog.ffe.flagging.ufc.v1.EvaluatorParams"> & { + /** + * Maximum dependency-edge depth, counted from a root flag at depth zero. + * The maximum is inclusive, and zero rejects every dependency edge. Values + * must not exceed 255. + * + * @generated from field: optional uint64 max_dependency_depth = 1; + */ + maxDependencyDepth?: bigint | undefined; }; type Version = Message<"datadog.ffe.flagging.ufc.v1.Version"> & { /** @@ -432,11 +449,37 @@ type Condition = Message<"datadog.ffe.flagging.ufc.v1.Condition"> & { */ value: Sha256StringComparisonCondition; case: "sha256StringComparison"; + } | { + /** + * @generated from field: datadog.ffe.flagging.ufc.v1.FlagEvaluationStringMembershipCondition flag_evaluation_string_membership = 11; + */ + value: FlagEvaluationStringMembershipCondition; + case: "flagEvaluationStringMembership"; } | { case: undefined; value?: undefined; }; }; +type FlagEvaluationStringMembershipCondition = Message<"datadog.ffe.flagging.ufc.v1.FlagEvaluationStringMembershipCondition"> & { + /** + * Index into FlagsConfiguration.strings for the flag key. + * + * @generated from field: uint32 flag_key_string_index = 1; + */ + flagKeyStringIndex: number; + /** + * Indices into FlagsConfiguration.strings for expected variant keys. + * + * @generated from field: repeated uint32 string_indexes = 2; + */ + stringIndexes: number[]; + /** + * True negates membership. + * + * @generated from field: bool negate = 3; + */ + negate: boolean; +}; type ConditionOperands = Message<"datadog.ffe.flagging.ufc.v1.ConditionOperands"> & { /** * Zero-based indexes into FlagsConfiguration.conditions. diff --git a/packages/node-server/src/provider.ts b/packages/node-server/src/provider.ts index d20a0c90..3ec2b213 100644 --- a/packages/node-server/src/provider.ts +++ b/packages/node-server/src/provider.ts @@ -2,6 +2,8 @@ import type { Channel } from 'node:diagnostics_channel' import { type AssignmentCache, createExposureEvent, + type DependencyEvaluation, + type DependencyExposureCandidate, type ExposureEvent, LRUInMemoryAssignmentCache, timeStampNow, @@ -40,6 +42,10 @@ export interface DatadogNodeServerProviderOptions { * Log experiment exposures */ exposureChannel: Channel + /** + * Report prerequisite evaluations, including errors propagated through dependency ancestors. + */ + dependencyEvaluationChannel?: Channel /** * Timeout in milliseconds for provider initialization. * If the configuration is not set within this time, initialization will fail. @@ -48,6 +54,11 @@ export interface DatadogNodeServerProviderOptions { initializationTimeoutMs?: number } +export type DependencyEvaluationEvent = { + context: EvaluationContext + details: EvaluationDetails +} + export class DatadogNodeServerProvider implements Provider { readonly metadata: ProviderMetadata = { name: 'datadog-node-server', @@ -144,7 +155,18 @@ export class DatadogNodeServerProvider implements Provider { context: EvaluationContext, _logger: Logger ): Promise> { - const resolutionDetails = evaluate(this.configuration, 'boolean', flagKey, defaultValue, context, _logger) + const dependencyExposures: DependencyExposureCandidate[] = [] + const resolutionDetails = evaluate( + this.configuration, + 'boolean', + flagKey, + defaultValue, + context, + _logger, + (evaluation) => this.handleDependencyEvaluation(context, evaluation), + (evaluations) => dependencyExposures.push(...evaluations) + ) + this.handleDependencyExposures(context, dependencyExposures) this.handleExposure(flagKey, context, resolutionDetails) return resolutionDetails } @@ -155,7 +177,18 @@ export class DatadogNodeServerProvider implements Provider { context: EvaluationContext, _logger: Logger ): Promise> { - const resolutionDetails = evaluate(this.configuration, 'string', flagKey, defaultValue, context, _logger) + const dependencyExposures: DependencyExposureCandidate[] = [] + const resolutionDetails = evaluate( + this.configuration, + 'string', + flagKey, + defaultValue, + context, + _logger, + (evaluation) => this.handleDependencyEvaluation(context, evaluation), + (evaluations) => dependencyExposures.push(...evaluations) + ) + this.handleDependencyExposures(context, dependencyExposures) this.handleExposure(flagKey, context, resolutionDetails) return resolutionDetails } @@ -166,7 +199,18 @@ export class DatadogNodeServerProvider implements Provider { context: EvaluationContext, _logger: Logger ): Promise> { - const resolutionDetails = evaluate(this.configuration, 'number', flagKey, defaultValue, context, _logger) + const dependencyExposures: DependencyExposureCandidate[] = [] + const resolutionDetails = evaluate( + this.configuration, + 'number', + flagKey, + defaultValue, + context, + _logger, + (evaluation) => this.handleDependencyEvaluation(context, evaluation), + (evaluations) => dependencyExposures.push(...evaluations) + ) + this.handleDependencyExposures(context, dependencyExposures) this.handleExposure(flagKey, context, resolutionDetails) return resolutionDetails } @@ -183,18 +227,45 @@ export class DatadogNodeServerProvider implements Provider { // type-sound way because there's no runtime information passed to // learn what type the user expects. So it's up to the user to // make sure they pass the appropriate type. + const dependencyExposures: DependencyExposureCandidate[] = [] const resolutionDetails = evaluate( this.configuration, 'object', flagKey, defaultValue, context, - _logger + _logger, + (evaluation) => this.handleDependencyEvaluation(context, evaluation), + (evaluations) => dependencyExposures.push(...evaluations) ) as ResolutionDetails + this.handleDependencyExposures(context, dependencyExposures) this.handleExposure(flagKey, context, resolutionDetails) return resolutionDetails } + private handleDependencyEvaluation(context: EvaluationContext, evaluation: DependencyEvaluation): void { + if (!this.options.dependencyEvaluationChannel?.hasSubscribers) { + return + } + this.options.dependencyEvaluationChannel.publish({ + context, + details: { + ...evaluation.details, + flagKey: evaluation.flagKey, + flagMetadata: evaluation.details.flagMetadata ?? {}, + }, + }) + } + + private handleDependencyExposures( + context: EvaluationContext, + evaluations: readonly DependencyExposureCandidate[] + ): void { + for (const evaluation of evaluations) { + this.handleExposure(evaluation.flagKey, context, evaluation.details) + } + } + private handleExposure( flagKey: string, context: EvaluationContext, diff --git a/packages/node-server/test/dependent-flags.spec.ts b/packages/node-server/test/dependent-flags.spec.ts new file mode 100644 index 00000000..63699ee4 --- /dev/null +++ b/packages/node-server/test/dependent-flags.spec.ts @@ -0,0 +1,102 @@ +import type { Channel } from 'node:diagnostics_channel' +import { type ExposureEvent, type Flag, OperatorType, type UniversalFlagConfigurationV1 } from '@datadog/flagging-core' +import type { Logger } from '@openfeature/server-sdk' +import { DatadogNodeServerProvider, type DependencyEvaluationEvent } from '../src/provider' + +const logger: Logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +} + +describe('dependent flag exposures', () => { + it('publishes successful dependency exposures depth-first before the root', async () => { + const exposureChannel = channel() + const dependencyEvaluationChannel = channel() + const provider = new DatadogNodeServerProvider({ exposureChannel, dependencyEvaluationChannel }) + provider.setConfiguration( + configuration({ + root: dependentFlag('root', ['middle']), + middle: dependentFlag('middle', ['leaf']), + leaf: booleanFlag('leaf'), + }) + ) + + await expect( + provider.resolveBooleanEvaluation('root', false, { targetingKey: 'subject' }, logger) + ).resolves.toMatchObject({ value: true, variant: 'on' }) + expect(exposureChannel.publish.mock.calls.map(([event]) => (event as ExposureEvent).flag.key)).toEqual([ + 'leaf', + 'middle', + 'root', + ]) + expect( + dependencyEvaluationChannel.publish.mock.calls.map( + ([event]) => (event as DependencyEvaluationEvent).details.flagKey + ) + ).toEqual(['leaf', 'middle']) + + await provider.resolveBooleanEvaluation('root', false, { targetingKey: 'subject' }, logger) + expect(exposureChannel.publish).toHaveBeenCalledTimes(3) + expect(dependencyEvaluationChannel.publish).toHaveBeenCalledTimes(4) + }) +}) + +function channel(): jest.Mocked> { + return { + hasSubscribers: true, + publish: jest.fn(), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + bindStore: jest.fn(), + unbindStore: jest.fn(), + runStores: jest.fn(), + name: 'test-channel', + } as jest.Mocked> +} + +function configuration(flags: Record): UniversalFlagConfigurationV1 { + return { + createdAt: '2026-10-07T12:00:00Z', + format: 'SERVER', + environment: { name: 'test' }, + flags, + } +} + +function booleanFlag(key: string): Flag { + return { + key, + enabled: true, + variationType: 'BOOLEAN', + variations: { on: { key: 'on', value: true }, off: { key: 'off', value: false } }, + allocations: [{ key: `${key}-allocation`, doLog: true, rules: [], splits: [{ variationKey: 'on', shards: [] }] }], + } +} + +function dependentFlag(key: string, dependencies: string[]): Flag { + return { + key, + enabled: true, + variationType: 'BOOLEAN', + variations: { on: { key: 'on', value: true }, off: { key: 'off', value: false } }, + allocations: [ + { + key: `${key}-targeted`, + doLog: true, + rules: [ + { + conditions: dependencies.map((dependency) => ({ + flagEvaluation: { key: dependency }, + operator: OperatorType.ONE_OF, + value: ['on'], + })), + }, + ], + splits: [{ variationKey: 'on', shards: [] }], + }, + { key: `${key}-default`, doLog: true, rules: [], splits: [{ variationKey: 'off', shards: [] }] }, + ], + } +}