featurevisor 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE +1 -1
- data/README.md +9 -2
- data/bin/commands/test.rb +1 -1
- data/lib/featurevisor/bucketer.rb +62 -8
- data/lib/featurevisor/child_instance.rb +43 -1
- data/lib/featurevisor/conditions.rb +63 -34
- data/lib/featurevisor/{logger.rb → diagnostics.rb} +19 -52
- data/lib/featurevisor/evaluate.rb +51 -50
- data/lib/featurevisor/{datafile_reader.rb → evaluation_data_provider.rb} +27 -16
- data/lib/featurevisor/events.rb +2 -2
- data/lib/featurevisor/instance.rb +74 -40
- data/lib/featurevisor/version.rb +1 -1
- data/lib/featurevisor.rb +4 -4
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7745fb77b8646de51fe54b58c32274829b60b046e6a47d81872e3148107eac6
|
|
4
|
+
data.tar.gz: ef5f69f50e1af01af4b45aece70603a67dc77f3af08da98b10cf6e50dd3d77df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 745e81a4af47bc19e0a0bfe550badafb52e7541a067f461bdbc8b73be259399da5ca3fd519e7458ee0afa3ee6f810e0d7e28446add35ee13e970f25ecc7a0d2d
|
|
7
|
+
data.tar.gz: 44947e427ceb1cf6b87e2e0784e7c77dddcbd8e27677c6794dd885a3a83dec65a4a2a1cec1815198abbe3e47187399700e9ec7e4342fd729428fafb21ab2dadd
|
data/LICENSE
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
The MIT License (MIT)
|
|
2
2
|
|
|
3
|
-
Copyright (c)
|
|
3
|
+
Copyright (c) 2026 Fahad Heylaal (https://fahad19.com)
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
data/README.md
CHANGED
|
@@ -89,6 +89,8 @@ f = Featurevisor.create_featurevisor(
|
|
|
89
89
|
|
|
90
90
|
Most applications only need this factory and the returned `Featurevisor::Instance`. Public extension and observability APIs include modules, diagnostics, events, and the datafile structures accepted by the factory.
|
|
91
91
|
|
|
92
|
+
Concurrent evaluations are safe after an instance is configured. Do not mutate or close the same instance concurrently with evaluations. Serialize calls to `set_datafile`, `set_context`, `set_sticky`, `add_module`, `remove_module`, and `close`. Module, event, and diagnostic callbacks must synchronize mutable state that they capture.
|
|
93
|
+
|
|
92
94
|
## Initialization
|
|
93
95
|
|
|
94
96
|
The SDK can be initialized by passing [datafile](https://featurevisor.com/docs/building-datafiles/) content directly:
|
|
@@ -697,6 +699,8 @@ f.remove_module('my-custom-module')
|
|
|
697
699
|
|
|
698
700
|
## Child instance
|
|
699
701
|
|
|
702
|
+
A child snapshots the parent keys that exist when it is spawned. Child values win for those keys. Parent keys introduced later are still inherited. Calling `close` removes both child-owned listeners and subscriptions delegated to the parent.
|
|
703
|
+
|
|
700
704
|
When dealing with purely client-side applications, it is understandable that there is only one user involved, like in browser or mobile applications.
|
|
701
705
|
|
|
702
706
|
But when using Featurevisor SDK in server-side applications, where a single server instance can handle multiple user requests simultaneously, it is important to isolate the context for each request.
|
|
@@ -722,8 +726,11 @@ Similar to parent SDK, child instances also support several additional methods:
|
|
|
722
726
|
|
|
723
727
|
- `set_context`
|
|
724
728
|
- `set_sticky`
|
|
729
|
+
- `evaluate_flag`
|
|
725
730
|
- `is_enabled`
|
|
731
|
+
- `evaluate_variation`
|
|
726
732
|
- `get_variation`
|
|
733
|
+
- `evaluate_variable`
|
|
727
734
|
- `get_variable`
|
|
728
735
|
- `get_variable_boolean`
|
|
729
736
|
- `get_variable_string`
|
|
@@ -821,7 +828,7 @@ The provider currently requires Ruby 3.4 or newer because that is the minimum ve
|
|
|
821
828
|
Install the provider:
|
|
822
829
|
|
|
823
830
|
```ruby
|
|
824
|
-
gem "featurevisor-openfeature", "~>
|
|
831
|
+
gem "featurevisor-openfeature", "~> 2.0"
|
|
825
832
|
```
|
|
826
833
|
|
|
827
834
|
It installs the matching `featurevisor` gem and the official `openfeature-sdk` dependency. The provider and base SDK deliberately share the same version, and the provider requires that exact Featurevisor version.
|
|
@@ -901,7 +908,7 @@ The build produces `featurevisor-VERSION.gem` and `featurevisor-openfeature-VERS
|
|
|
901
908
|
- Run `bundle install`
|
|
902
909
|
- Push commit to `main` branch
|
|
903
910
|
- Wait for CI to complete
|
|
904
|
-
- Tag the release with the same version number, for example `
|
|
911
|
+
- Tag the release with the same version number, for example `v2.0.0`
|
|
905
912
|
- The workflow verifies that the tag matches the shared version
|
|
906
913
|
- The workflow publishes `featurevisor` first, followed by `featurevisor-openfeature`
|
|
907
914
|
|
data/bin/commands/test.rb
CHANGED
|
@@ -671,7 +671,7 @@ module FeaturevisorCLI
|
|
|
671
671
|
|
|
672
672
|
if assertion.key?(:expectedToMatch)
|
|
673
673
|
expected_to_match = assertion[:expectedToMatch]
|
|
674
|
-
actual = instance.instance_variable_get(:@
|
|
674
|
+
actual = instance.instance_variable_get(:@datafile).all_conditions_are_matched(conditions, context)
|
|
675
675
|
|
|
676
676
|
if actual != expected_to_match
|
|
677
677
|
has_error = true
|
|
@@ -30,16 +30,16 @@ module Featurevisor
|
|
|
30
30
|
# - feature_key [String] The feature key
|
|
31
31
|
# - bucket_by [String, Array<String>, Hash] Bucketing strategy
|
|
32
32
|
# - context [Hash] User context
|
|
33
|
-
# -
|
|
33
|
+
# - diagnostics [DiagnosticReporter] Diagnostic reporter
|
|
34
34
|
# @return [String] The bucket key
|
|
35
35
|
# @raise [StandardError] If bucket_by is invalid
|
|
36
36
|
def self.get_bucket_key(options)
|
|
37
37
|
feature_key = options[:feature_key]
|
|
38
38
|
bucket_by = options[:bucket_by]
|
|
39
39
|
context = options[:context]
|
|
40
|
-
|
|
40
|
+
diagnostics = options[:diagnostics]
|
|
41
41
|
|
|
42
|
-
type, attribute_keys = parse_bucket_by(bucket_by,
|
|
42
|
+
type, attribute_keys = parse_bucket_by(bucket_by, diagnostics, feature_key)
|
|
43
43
|
|
|
44
44
|
bucket_key = build_bucket_key(attribute_keys, context, type, feature_key)
|
|
45
45
|
|
|
@@ -50,10 +50,10 @@ module Featurevisor
|
|
|
50
50
|
|
|
51
51
|
# Parse bucket_by configuration to determine type and attribute keys
|
|
52
52
|
# @param bucket_by [String, Array<String>, Hash] Bucketing strategy
|
|
53
|
-
# @param
|
|
53
|
+
# @param diagnostics [DiagnosticReporter] Diagnostic reporter
|
|
54
54
|
# @param feature_key [String] Feature key for error logging
|
|
55
55
|
# @return [Array] Tuple of [type, attribute_keys]
|
|
56
|
-
def self.parse_bucket_by(bucket_by,
|
|
56
|
+
def self.parse_bucket_by(bucket_by, diagnostics, feature_key)
|
|
57
57
|
if bucket_by.is_a?(String)
|
|
58
58
|
["plain", [bucket_by]]
|
|
59
59
|
elsif bucket_by.is_a?(Array)
|
|
@@ -61,7 +61,7 @@ module Featurevisor
|
|
|
61
61
|
elsif bucket_by.is_a?(Hash) && bucket_by[:or].is_a?(Array)
|
|
62
62
|
["or", bucket_by[:or]]
|
|
63
63
|
else
|
|
64
|
-
|
|
64
|
+
diagnostics.error("invalid bucketBy", { feature_key: feature_key, bucket_by: bucket_by })
|
|
65
65
|
raise StandardError, "invalid bucketBy"
|
|
66
66
|
end
|
|
67
67
|
end
|
|
@@ -78,7 +78,7 @@ module Featurevisor
|
|
|
78
78
|
attribute_keys.each do |attribute_key|
|
|
79
79
|
attribute_value = Featurevisor::Conditions.get_value_from_context(context, attribute_key)
|
|
80
80
|
|
|
81
|
-
next if attribute_value.nil?
|
|
81
|
+
next if attribute_value.nil? && !context_path_exists?(context, attribute_key)
|
|
82
82
|
|
|
83
83
|
if type == "plain" || type == "and"
|
|
84
84
|
bucket_key << attribute_value
|
|
@@ -89,7 +89,61 @@ module Featurevisor
|
|
|
89
89
|
end
|
|
90
90
|
|
|
91
91
|
bucket_key << feature_key
|
|
92
|
-
bucket_key
|
|
92
|
+
bucket_key.map { |value| javascript_string(value) }
|
|
93
93
|
end
|
|
94
|
+
|
|
95
|
+
def self.context_path_exists?(context, path)
|
|
96
|
+
path.split(".").reduce(context) do |current, key|
|
|
97
|
+
return false unless current.is_a?(Hash)
|
|
98
|
+
return false unless current.key?(key.to_sym) || current.key?(key)
|
|
99
|
+
|
|
100
|
+
current.key?(key.to_sym) ? current[key.to_sym] : current[key]
|
|
101
|
+
end
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.javascript_string(value)
|
|
106
|
+
return "" if value.nil?
|
|
107
|
+
return value ? "true" : "false" if value == true || value == false
|
|
108
|
+
if value.is_a?(Float)
|
|
109
|
+
return "NaN" if value.nan?
|
|
110
|
+
return value.positive? ? "Infinity" : "-Infinity" if value.infinite?
|
|
111
|
+
return "0" if value.zero?
|
|
112
|
+
return value.to_i.to_s if value == value.to_i && value.abs < 1e21
|
|
113
|
+
if value.abs >= 1e-6 && value.abs < 1e21
|
|
114
|
+
return scientific_to_fixed(value.to_s)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
coefficient, exponent = value.to_s.downcase.split("e")
|
|
118
|
+
return "#{coefficient.delete_suffix('.0')}e#{Integer(exponent) >= 0 ? '+' : ''}#{Integer(exponent)}"
|
|
119
|
+
end
|
|
120
|
+
return value.map { |item| javascript_string(item) }.join(",") if value.is_a?(Array)
|
|
121
|
+
return "[object Object]" if value.is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
value.to_s
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.scientific_to_fixed(value)
|
|
127
|
+
return value unless value.downcase.include?("e")
|
|
128
|
+
|
|
129
|
+
coefficient, exponent_text = value.downcase.split("e")
|
|
130
|
+
exponent = Integer(exponent_text)
|
|
131
|
+
negative = coefficient.start_with?("-")
|
|
132
|
+
digits = coefficient.delete_prefix("-").delete(".")
|
|
133
|
+
decimal_position = coefficient.delete_prefix("-").index(".") || coefficient.delete_prefix("-").length
|
|
134
|
+
decimal_position += exponent
|
|
135
|
+
|
|
136
|
+
result = if decimal_position <= 0
|
|
137
|
+
"0.#{"0" * -decimal_position}#{digits}"
|
|
138
|
+
elsif decimal_position >= digits.length
|
|
139
|
+
"#{digits}#{"0" * (decimal_position - digits.length)}"
|
|
140
|
+
else
|
|
141
|
+
"#{digits[0...decimal_position]}.#{digits[decimal_position..]}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
result = result.sub(/(\.\d*?)0+\z/, '\1').delete_suffix(".")
|
|
145
|
+
negative ? "-#{result}" : result
|
|
146
|
+
end
|
|
147
|
+
private_class_method :scientific_to_fixed
|
|
94
148
|
end
|
|
95
149
|
end
|
|
@@ -13,6 +13,7 @@ module Featurevisor
|
|
|
13
13
|
@context = options[:context] || {}
|
|
14
14
|
@sticky = options[:sticky] || {}
|
|
15
15
|
@emitter = Featurevisor::Emitter.new
|
|
16
|
+
@parent_unsubscribers = []
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
# Subscribe to an event
|
|
@@ -25,12 +26,25 @@ module Featurevisor
|
|
|
25
26
|
if event_name == "context_set" || event_name == "sticky_set"
|
|
26
27
|
@emitter.on(event_name, callback)
|
|
27
28
|
else
|
|
28
|
-
@parent.on(event_name, callback)
|
|
29
|
+
parent_unsubscribe = @parent.on(event_name, callback)
|
|
30
|
+
active = true
|
|
31
|
+
unsubscribe = nil
|
|
32
|
+
unsubscribe = proc do
|
|
33
|
+
next unless active
|
|
34
|
+
|
|
35
|
+
active = false
|
|
36
|
+
parent_unsubscribe.call
|
|
37
|
+
@parent_unsubscribers.delete(unsubscribe)
|
|
38
|
+
end
|
|
39
|
+
@parent_unsubscribers << unsubscribe
|
|
40
|
+
unsubscribe
|
|
29
41
|
end
|
|
30
42
|
end
|
|
31
43
|
|
|
32
44
|
# Close the child instance
|
|
33
45
|
def close
|
|
46
|
+
@parent_unsubscribers.dup.each(&:call)
|
|
47
|
+
@parent_unsubscribers.clear
|
|
34
48
|
@emitter.clear_all
|
|
35
49
|
end
|
|
36
50
|
|
|
@@ -98,6 +112,15 @@ module Featurevisor
|
|
|
98
112
|
)
|
|
99
113
|
end
|
|
100
114
|
|
|
115
|
+
# Evaluate a feature flag and return its full evaluation details.
|
|
116
|
+
def evaluate_flag(feature_key, context = {}, options = {})
|
|
117
|
+
@parent.evaluate_flag(
|
|
118
|
+
feature_key,
|
|
119
|
+
{ **@context, **context },
|
|
120
|
+
{ **options, __featurevisor_child_sticky: @sticky }
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
101
124
|
# Get variation value
|
|
102
125
|
# @param feature_key [String] Feature key
|
|
103
126
|
# @param context [Hash] Context
|
|
@@ -117,6 +140,15 @@ module Featurevisor
|
|
|
117
140
|
)
|
|
118
141
|
end
|
|
119
142
|
|
|
143
|
+
# Evaluate a variation and return its full evaluation details.
|
|
144
|
+
def evaluate_variation(feature_key, context = {}, options = {})
|
|
145
|
+
@parent.evaluate_variation(
|
|
146
|
+
feature_key,
|
|
147
|
+
{ **@context, **context },
|
|
148
|
+
{ **options, __featurevisor_child_sticky: @sticky }
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
120
152
|
# Get variable value
|
|
121
153
|
# @param feature_key [String] Feature key
|
|
122
154
|
# @param variable_key [String] Variable key
|
|
@@ -138,6 +170,16 @@ module Featurevisor
|
|
|
138
170
|
)
|
|
139
171
|
end
|
|
140
172
|
|
|
173
|
+
# Evaluate a variable and return its full evaluation details.
|
|
174
|
+
def evaluate_variable(feature_key, variable_key, context = {}, options = {})
|
|
175
|
+
@parent.evaluate_variable(
|
|
176
|
+
feature_key,
|
|
177
|
+
variable_key,
|
|
178
|
+
{ **@context, **context },
|
|
179
|
+
{ **options, __featurevisor_child_sticky: @sticky }
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
|
|
141
183
|
# Get variable as boolean
|
|
142
184
|
# @param feature_key [String] Feature key
|
|
143
185
|
# @param variable_key [String] Variable key
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "date"
|
|
4
|
+
require "time"
|
|
4
5
|
|
|
5
6
|
module Featurevisor
|
|
6
7
|
# Conditions module for evaluating feature flags and segments
|
|
7
8
|
module Conditions
|
|
9
|
+
MISSING = Object.new.freeze
|
|
10
|
+
private_constant :MISSING
|
|
11
|
+
|
|
8
12
|
# Get value from context object using dot notation path
|
|
9
13
|
# @param obj [Hash] Context object
|
|
10
14
|
# @param path [String] Dot-separated path to the value
|
|
@@ -12,12 +16,36 @@ module Featurevisor
|
|
|
12
16
|
def self.get_value_from_context(obj, path)
|
|
13
17
|
return nil if obj.nil? || path.nil?
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
value = get_value_with_presence(obj, path)
|
|
20
|
+
value.equal?(MISSING) ? nil : value
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.get_value_with_presence(obj, path)
|
|
24
|
+
return MISSING unless obj.is_a?(Hash) && path.is_a?(String)
|
|
25
|
+
|
|
26
|
+
path.split(".").reduce(obj) do |current, key|
|
|
27
|
+
break MISSING unless current.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
if current.key?(key.to_sym)
|
|
30
|
+
current[key.to_sym]
|
|
31
|
+
elsif current.key?(key)
|
|
32
|
+
current[key]
|
|
33
|
+
else
|
|
34
|
+
break MISSING
|
|
35
|
+
end
|
|
17
36
|
end
|
|
37
|
+
end
|
|
38
|
+
private_class_method :get_value_with_presence
|
|
39
|
+
|
|
40
|
+
def self.strict_equal?(left, right)
|
|
41
|
+
return true if left.nil? && right.nil?
|
|
42
|
+
return left.to_f == right.to_f if left.is_a?(Numeric) && right.is_a?(Numeric)
|
|
43
|
+
return left == right if left.is_a?(String) && right.is_a?(String)
|
|
44
|
+
return left == right if (left == true || left == false) && (right == true || right == false)
|
|
18
45
|
|
|
19
|
-
|
|
46
|
+
false
|
|
20
47
|
end
|
|
48
|
+
private_class_method :strict_equal?
|
|
21
49
|
|
|
22
50
|
# Check if a condition is matched against context
|
|
23
51
|
# @param condition [Hash] Condition to evaluate
|
|
@@ -30,18 +58,19 @@ module Featurevisor
|
|
|
30
58
|
value = condition["value"] || condition[:value]
|
|
31
59
|
regex_flags = condition["regexFlags"] || condition[:regexFlags]
|
|
32
60
|
|
|
33
|
-
|
|
61
|
+
raw_context_value = get_value_with_presence(context, attribute)
|
|
62
|
+
context_value_from_path = raw_context_value.equal?(MISSING) ? nil : raw_context_value
|
|
63
|
+
attribute_exists = !raw_context_value.equal?(MISSING)
|
|
34
64
|
|
|
35
65
|
case operator
|
|
36
66
|
when "equals"
|
|
37
|
-
context_value_from_path
|
|
67
|
+
attribute_exists && strict_equal?(context_value_from_path, value)
|
|
38
68
|
when "notEquals"
|
|
39
|
-
context_value_from_path
|
|
69
|
+
!attribute_exists || !strict_equal?(context_value_from_path, value)
|
|
40
70
|
when "before", "after"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
date_in_condition = value.is_a?(Date) ? value : Date.parse(value.to_s)
|
|
71
|
+
date_in_context = portable_date(context_value_from_path)
|
|
72
|
+
date_in_condition = portable_date(value)
|
|
73
|
+
return false unless date_in_context && date_in_condition
|
|
45
74
|
|
|
46
75
|
if operator == "before"
|
|
47
76
|
date_in_context < date_in_condition
|
|
@@ -50,21 +79,13 @@ module Featurevisor
|
|
|
50
79
|
end
|
|
51
80
|
when "in", "notIn"
|
|
52
81
|
# in / notIn (where condition value is an array)
|
|
53
|
-
if value.is_a?(Array) &&
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
# If key doesn't exist, notIn should fail (return false), in should also fail
|
|
58
|
-
if !key_exists
|
|
59
|
-
return false
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
value_in_context = context_value_from_path.to_s
|
|
63
|
-
|
|
82
|
+
if attribute_exists && value.is_a?(Array) &&
|
|
83
|
+
(context_value_from_path.is_a?(String) || context_value_from_path.is_a?(Numeric) || context_value_from_path.nil?)
|
|
84
|
+
matched = value.any? { |candidate| strict_equal?(candidate, context_value_from_path) }
|
|
64
85
|
if operator == "in"
|
|
65
|
-
|
|
86
|
+
matched
|
|
66
87
|
else # notIn
|
|
67
|
-
!
|
|
88
|
+
!matched
|
|
68
89
|
end
|
|
69
90
|
else
|
|
70
91
|
false
|
|
@@ -124,18 +145,18 @@ module Featurevisor
|
|
|
124
145
|
false
|
|
125
146
|
end
|
|
126
147
|
when "exists"
|
|
127
|
-
|
|
148
|
+
attribute_exists
|
|
128
149
|
when "notExists"
|
|
129
|
-
|
|
150
|
+
!attribute_exists
|
|
130
151
|
when "includes", "notIncludes"
|
|
131
152
|
# includes / notIncludes (where context value is an array)
|
|
132
|
-
if context_value_from_path.is_a?(Array) &&
|
|
133
|
-
|
|
134
|
-
|
|
153
|
+
if context_value_from_path.is_a?(Array) &&
|
|
154
|
+
(value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.nil?)
|
|
155
|
+
matched = context_value_from_path.any? { |candidate| strict_equal?(candidate, value) }
|
|
135
156
|
if operator == "includes"
|
|
136
|
-
|
|
157
|
+
matched
|
|
137
158
|
else # notIncludes
|
|
138
|
-
!
|
|
159
|
+
!matched
|
|
139
160
|
end
|
|
140
161
|
else
|
|
141
162
|
false
|
|
@@ -143,10 +164,18 @@ module Featurevisor
|
|
|
143
164
|
else
|
|
144
165
|
false
|
|
145
166
|
end
|
|
146
|
-
rescue => e
|
|
147
|
-
# Log error but don't stop execution
|
|
148
|
-
warn "Error in condition evaluation: #{e.message}"
|
|
149
|
-
false
|
|
150
167
|
end
|
|
168
|
+
|
|
169
|
+
def self.portable_date(value)
|
|
170
|
+
return value if value.is_a?(Time) || value.is_a?(DateTime)
|
|
171
|
+
return value.to_time if value.is_a?(Date)
|
|
172
|
+
return nil unless value.is_a?(String)
|
|
173
|
+
return nil unless value.match?(/T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+\-]\d{2}:\d{2})\z/)
|
|
174
|
+
|
|
175
|
+
Time.iso8601(value)
|
|
176
|
+
rescue ArgumentError
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
private_class_method :portable_date
|
|
151
180
|
end
|
|
152
181
|
end
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Featurevisor
|
|
4
|
-
#
|
|
4
|
+
# Diagnostic severity levels
|
|
5
5
|
LOG_LEVELS = %w[fatal error warn info debug].freeze
|
|
6
6
|
DEFAULT_LOG_LEVEL = "info".freeze
|
|
7
|
-
|
|
7
|
+
DIAGNOSTIC_PREFIX = "[Featurevisor]".freeze
|
|
8
8
|
|
|
9
|
-
#
|
|
10
|
-
class
|
|
9
|
+
# Private evaluator adapter for structured diagnostics.
|
|
10
|
+
class DiagnosticReporter
|
|
11
11
|
attr_reader :level, :handler
|
|
12
12
|
|
|
13
|
-
# Initialize a
|
|
14
|
-
# @param options [Hash]
|
|
13
|
+
# Initialize a diagnostic reporter.
|
|
14
|
+
# @param options [Hash] Reporter options
|
|
15
15
|
# @option options [String] :level Log level (default: "info")
|
|
16
|
-
# @option options [Proc] :handler
|
|
16
|
+
# @option options [Proc] :handler Internal structured diagnostic sink
|
|
17
17
|
def initialize(options = {})
|
|
18
18
|
@level = options[:level] || DEFAULT_LOG_LEVEL
|
|
19
|
+
@filter = !options.key?(:handler)
|
|
19
20
|
@handler = options[:handler] || method(:default_log_handler)
|
|
20
21
|
end
|
|
21
22
|
|
|
@@ -25,12 +26,13 @@ module Featurevisor
|
|
|
25
26
|
@level = level
|
|
26
27
|
end
|
|
27
28
|
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
# @param
|
|
29
|
+
# Forward an evaluator diagnostic. Filtering is performed centrally by
|
|
30
|
+
# Instance so module subscriptions and error events remain independent.
|
|
31
|
+
# @param level [String] Diagnostic level
|
|
32
|
+
# @param message [String] Diagnostic message
|
|
31
33
|
# @param details [Hash, nil] Additional details
|
|
32
34
|
def log(level, message, details = nil)
|
|
33
|
-
return
|
|
35
|
+
return if @filter && !should_handle?(level)
|
|
34
36
|
|
|
35
37
|
@handler.call(level, message, details)
|
|
36
38
|
end
|
|
@@ -72,16 +74,8 @@ module Featurevisor
|
|
|
72
74
|
|
|
73
75
|
private
|
|
74
76
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
# @return [Boolean] True if should handle
|
|
78
|
-
def should_handle?(log_level)
|
|
79
|
-
current_index = LOG_LEVELS.index(@level)
|
|
80
|
-
target_index = LOG_LEVELS.index(log_level)
|
|
81
|
-
|
|
82
|
-
return false if current_index.nil? || target_index.nil?
|
|
83
|
-
|
|
84
|
-
current_index >= target_index
|
|
77
|
+
def should_handle?(level)
|
|
78
|
+
LOG_LEVELS.index(level).to_i <= LOG_LEVELS.index(@level).to_i
|
|
85
79
|
end
|
|
86
80
|
|
|
87
81
|
# Default log handler that outputs to console
|
|
@@ -99,45 +93,18 @@ module Featurevisor
|
|
|
99
93
|
case method_name
|
|
100
94
|
when "puts"
|
|
101
95
|
if details && !details.empty?
|
|
102
|
-
Kernel.puts("#{
|
|
96
|
+
Kernel.puts("#{DIAGNOSTIC_PREFIX} #{message} #{details.inspect}")
|
|
103
97
|
else
|
|
104
|
-
Kernel.puts("#{
|
|
98
|
+
Kernel.puts("#{DIAGNOSTIC_PREFIX} #{message}")
|
|
105
99
|
end
|
|
106
100
|
when "warn"
|
|
107
101
|
if details && !details.empty?
|
|
108
|
-
Kernel.warn("#{
|
|
102
|
+
Kernel.warn("#{DIAGNOSTIC_PREFIX} #{message} #{details.inspect}")
|
|
109
103
|
else
|
|
110
|
-
Kernel.warn("#{
|
|
104
|
+
Kernel.warn("#{DIAGNOSTIC_PREFIX} #{message}")
|
|
111
105
|
end
|
|
112
106
|
end
|
|
113
107
|
end
|
|
114
108
|
end
|
|
115
109
|
|
|
116
|
-
# Default log handler function
|
|
117
|
-
# @param level [String] Log level
|
|
118
|
-
# @param message [String] Log message
|
|
119
|
-
# @param details [Hash, nil] Additional details
|
|
120
|
-
def self.default_log_handler(level, message, details = nil)
|
|
121
|
-
method_name = case level
|
|
122
|
-
when "info" then "puts"
|
|
123
|
-
when "warn" then "warn"
|
|
124
|
-
when "error", "fatal" then "warn"
|
|
125
|
-
else "puts"
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
case method_name
|
|
129
|
-
when "puts"
|
|
130
|
-
if details && !details.empty?
|
|
131
|
-
Kernel.puts("#{LOGGER_PREFIX} #{message} #{details.inspect}")
|
|
132
|
-
else
|
|
133
|
-
Kernel.puts("#{LOGGER_PREFIX} #{message}")
|
|
134
|
-
end
|
|
135
|
-
when "warn"
|
|
136
|
-
if details && !details.empty?
|
|
137
|
-
Kernel.warn("#{LOGGER_PREFIX} #{message} #{details.inspect}")
|
|
138
|
-
else
|
|
139
|
-
Kernel.warn("#{LOGGER_PREFIX} #{message}")
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
end
|
|
143
110
|
end
|