confidence-openfeature-provider 0.1.4 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b46228f63cda87c4039d870081cc365282e6818dc0c0322e77b18b18d1c4090
4
- data.tar.gz: f18bdde4e4edbb3bae92511c240f4ae814913c118bf95330a7303a32c0e6730a
3
+ metadata.gz: 375cd3b343a316624712b6c7415665754bd356618435d9d7f455f42ac8bd797b
4
+ data.tar.gz: 3de4965a6f56a01da59102593b5f1bf88a71fb2e2da887391bdc39366a67c497
5
5
  SHA512:
6
- metadata.gz: 951566d958ebe7c8064f725663e1df91db83923afe23669ea6c5d3220e17158c49d47dabc9b8a6929700265eaa7519ef3674a2138e665c99600f449ecb5b060e
7
- data.tar.gz: f75e880c2fe2c0d36337de730879ef67f0490bf66ec89c270144c1a690bf52d515cffe286fd2c50955515fedbda9c359380446f3d407132aeafb16efe3ebe655
6
+ metadata.gz: fb95289e73c34bbce61de2e8c88ea61e688001bcddbcadc3bffec3d22daba866e22a3a2e21eecd9c26d04e28a1bc13037c80ec704a5a427144d11e733c4f3047
7
+ data.tar.gz: 03d0d7e9d6cdb1121ec77254d915a4215bc198f79283eb73ef74176a065d8ea7ea72d51884fb0b8b0b97d657a7171a4f39aac68579a437e68a2ed0295a925f0f
@@ -6,6 +6,8 @@ require "json"
6
6
  require "uri"
7
7
  require "net/http"
8
8
  require "net/https"
9
+ # Time.iso8601, used to coerce a String event_time.
10
+ require "time"
9
11
 
10
12
  module Confidence
11
13
  module OpenFeature
@@ -20,12 +22,15 @@ module Confidence
20
22
  US = new("https://resolver.us.confidence.dev/v1")
21
23
  end
22
24
 
25
+ # The events API is a single global endpoint, unlike the regional
26
+ # resolver hosts above.
27
+ EVENTS_URI = "https://events.confidence.dev/v1"
28
+
23
29
  class APIClient
24
- def initialize(client_secret:, region: Region::EU)
25
- uri = URI.parse(region.uri)
30
+ def initialize(client_secret:, region: Region::EU, events_uri: EVENTS_URI)
26
31
  @client_secret = client_secret
27
- @agent = Net::HTTP.new(uri.host, uri.port)
28
- @agent.use_ssl = uri.scheme == "https"
32
+ @uri = URI.parse(region.uri)
33
+ @events_uri = URI.parse(events_uri)
29
34
  end
30
35
 
31
36
  def resolve_one(flag:, context: {}, apply: true)
@@ -40,6 +45,41 @@ module Confidence
40
45
  result
41
46
  end
42
47
 
48
+ # Publishes a single event to the Confidence events API.
49
+ #
50
+ # +event_name+ is the event definition id, sent as
51
+ # "eventDefinitions/#{event_name}". +payload+ is an arbitrary hash.
52
+ # +event_time+ defaults to now; pass it to backdate an event.
53
+ #
54
+ # Returns nil on success. Raises APIError if the request itself fails
55
+ # and EventPublishError if the batch is accepted but the event is
56
+ # refused, so rejections are never silently discarded.
57
+ def track(event_name:, payload: {}, event_time: nil)
58
+ now = Time.now
59
+ result = post_json("/v1/events:publish", {
60
+ clientSecret: @client_secret,
61
+ sendTime: rfc3339(now),
62
+ sdk: {id: "SDK_ID_RUBY_PROVIDER", version: VERSION},
63
+ events: [{
64
+ eventDefinition: "eventDefinitions/#{event_name}",
65
+ eventTime: rfc3339(event_time || now),
66
+ payload: payload || {}
67
+ }]
68
+ }, uri: @events_uri, label: "events:publish")
69
+
70
+ rejections = (result["errors"] || []).map do |error|
71
+ Rejection.new(error["index"], error["reason"], error["message"])
72
+ end
73
+ unless rejections.empty?
74
+ raise EventPublishError.new(
75
+ "events:publish refused #{rejections.length} event(s): " +
76
+ rejections.map { |r| "[#{r.index}] #{r.reason} #{r.message}".strip }.join(", "),
77
+ rejections
78
+ )
79
+ end
80
+ nil
81
+ end
82
+
43
83
  def resolve(flags: [], context: {}, apply: true)
44
84
  result = post_json("/v1/flags:resolve", {
45
85
  clientSecret: @client_secret,
@@ -47,7 +87,7 @@ module Confidence
47
87
  apply: apply,
48
88
  flags: flags,
49
89
  sdk: {id: "SDK_ID_RUBY_PROVIDER", version: VERSION}
50
- })
90
+ }, uri: @uri, label: "flags:resolve")
51
91
 
52
92
  resolved_flags = result["resolvedFlags"] || []
53
93
  resolved_flags.map do |flag|
@@ -61,21 +101,79 @@ module Confidence
61
101
 
62
102
  private
63
103
 
64
- def post_json(path, body)
104
+ # A fresh Net::HTTP per request, because one instance cannot be shared
105
+ # across threads. Net::HTTP#request auto-starts the connection when the
106
+ # receiver is not already started, mutating its @started and @socket:
107
+ #
108
+ # unless started?
109
+ # start { req['connection'] ||= 'close'; return request(req, ...) }
110
+ # end
111
+ #
112
+ # Two threads entering that on the same object both open a connection and
113
+ # both assign @socket, so one clobbers the other and the loser reads or
114
+ # writes a socket the winner may already have closed.
115
+ #
116
+ # Per-request instantiation costs nothing here: the instance was never
117
+ # explicitly started, so every request already opened a connection, sent
118
+ # "Connection: close" and closed it again. There was no reuse to lose.
119
+ # Takes a URI so an additional endpoint can share it.
120
+ def build_agent(uri)
121
+ agent = Net::HTTP.new(uri.host, uri.port)
122
+ agent.use_ssl = uri.scheme == "https"
123
+ agent
124
+ end
125
+
126
+ # getutc rather than utc: the latter mutates its receiver, which would
127
+ # convert a caller-supplied event_time to UTC in place.
128
+ #
129
+ # Accepts a String as well as a Time. Spec 6.2.2 permits string custom
130
+ # fields, and an "event_time" entry in tracking event details is the only
131
+ # route to set the event time through the spec-conformant +track+, so a
132
+ # caller passing an ISO-8601 string is expected rather than exceptional.
133
+ # An unparseable value raises TypeMismatchError rather than falling back
134
+ # to "now": a silently wrong timestamp is harder to diagnose than a
135
+ # logged failure, and +track+ turns the raise into a warning.
136
+ def rfc3339(time)
137
+ coerce_time(time).getutc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
138
+ end
139
+
140
+ def coerce_time(time)
141
+ return time if time.is_a?(Time)
142
+
143
+ if time.is_a?(String)
144
+ begin
145
+ return Time.iso8601(time)
146
+ rescue ArgumentError => ex
147
+ raise TypeMismatchError.new(
148
+ "event_time #{time.inspect} is not a valid ISO-8601 timestamp: #{ex.message}"
149
+ )
150
+ end
151
+ end
152
+
153
+ raise TypeMismatchError.new(
154
+ "event_time must be a Time or an ISO-8601 String, got #{time.class}"
155
+ )
156
+ end
157
+
158
+ # Takes the target URI rather than a prepared agent so that every request
159
+ # still builds its own, per build_agent above. +label+ names the endpoint
160
+ # in errors; both callers pass it explicitly, which keeps "which host does
161
+ # this go to" a decision at the call site.
162
+ def post_json(path, body, uri:, label:)
65
163
  headers = {"Content-Type" => "application/json"}
66
164
  request = Net::HTTP::Post.new(path, headers)
67
165
  request.body = JSON.dump(body)
68
- response = @agent.request(request)
166
+ response = build_agent(uri).request(request)
69
167
 
70
168
  code = response.code.to_i
71
169
  if code != 200
72
- raise APIError.new("flags:resolve HTTP #{response.code} #{response.message}")
170
+ raise APIError.new("#{label} HTTP #{response.code} #{response.message}")
73
171
  end
74
172
 
75
173
  begin
76
174
  JSON.parse(response.body)
77
175
  rescue JSON::ParserError => ex
78
- raise APIError.new("flags:resolve malformed JSON: #{ex}")
176
+ raise APIError.new("#{label} malformed JSON: #{ex}")
79
177
  end
80
178
  end
81
179
 
@@ -89,6 +187,9 @@ module Confidence
89
187
  variant.nil? || value.nil?
90
188
  end
91
189
  end
190
+
191
+ # A single event the events API refused within an accepted batch.
192
+ Rejection = Struct.new(:index, :reason, :message)
92
193
  end
93
194
  end
94
195
 
@@ -13,5 +13,31 @@ module Confidence
13
13
 
14
14
  class TypeMismatchError < BaseError
15
15
  end
16
+
17
+ # Raised when tracking event details already carry a "context" key.
18
+ #
19
+ # The evaluation context is merged into the event payload under the
20
+ # reserved "context" key, so a details key of the same name would be
21
+ # ambiguous. Rejecting it matches the other Confidence SDKs, which raise
22
+ # rather than silently overwrite one with the other.
23
+ class InvalidContextInPayloadError < BaseError
24
+ end
25
+
26
+ # Raised when the events API accepts the batch but refuses individual
27
+ # events. The batch call still returns HTTP 200 in that case, so without
28
+ # this the rejections would be silently dropped.
29
+ #
30
+ # +rejections+ holds one Rejection per refused event, each carrying the
31
+ # index of the event in the published batch, the reason (for example
32
+ # EVENT_DEFINITION_NOT_FOUND or EVENT_SCHEMA_VALIDATION_FAILED) and an
33
+ # optional message.
34
+ class EventPublishError < BaseError
35
+ attr_reader :rejections
36
+
37
+ def initialize(message, rejections = [])
38
+ super(message)
39
+ @rejections = rejections
40
+ end
41
+ end
16
42
  end
17
43
  end
@@ -11,9 +11,27 @@ module Confidence
11
11
 
12
12
  # Error_code and error_message seemingly not used by OpenFeature SDK.
13
13
  # Including here for compatibility.
14
+ #
15
+ # flag_metadata became part of the contract in openfeature-sdk 0.6.1:
16
+ # EvaluationDetails delegates it to whatever the provider returns, so
17
+ # omitting the member raises NoMethodError on the fetch_*_details path.
18
+ # Defaulting and immutability mirror Provider::ResolutionDetails.
19
+ EMPTY_FLAG_METADATA = {}.freeze
20
+
14
21
  ResolutionDetails = Struct.new(
15
- :value, :reason, :variant, :error_code, :error_message
16
- )
22
+ :value, :reason, :variant, :error_code, :error_message, :flag_metadata
23
+ ) do
24
+ def flag_metadata
25
+ raw = self[:flag_metadata]
26
+ if raw.nil?
27
+ EMPTY_FLAG_METADATA
28
+ elsif raw.frozen?
29
+ raw
30
+ else
31
+ raw.dup.freeze
32
+ end
33
+ end
34
+ end
17
35
 
18
36
  def initialize(api_client:, apply_on_resolve: true)
19
37
  @api_client = api_client
@@ -56,8 +74,96 @@ module Confidence
56
74
  )
57
75
  end
58
76
 
77
+ # Publishes an event to Confidence.
78
+ #
79
+ # Signature matches OpenFeature requirement 6.1.1.1 and the shape the
80
+ # OpenFeature Ruby SDK client invokes providers with:
81
+ #
82
+ # @provider.track(name, evaluation_context:, tracking_event_details:)
83
+ #
84
+ # Returns nothing, and never raises: the SDK client does not rescue, so
85
+ # an exception here would surface in application code from a
86
+ # fire-and-forget tracking call. Failures are written to stderr. Use
87
+ # +track!+ when you want them raised instead.
88
+ def track(tracking_event_name, evaluation_context: nil, tracking_event_details: nil)
89
+ track!(
90
+ tracking_event_name,
91
+ evaluation_context: evaluation_context,
92
+ tracking_event_details: tracking_event_details
93
+ )
94
+ nil
95
+ rescue => ex
96
+ # Bare rescue is StandardError; anything narrower would let a caller
97
+ # mistake (a non-Hash, say) escape a call that must not raise.
98
+ warn("Confidence: track(#{tracking_event_name.inspect}) failed: #{ex.message}")
99
+ nil
100
+ end
101
+
102
+ # Same as +track+ but raises on failure.
103
+ #
104
+ # Raises APIError if the request fails, EventPublishError if the batch is
105
+ # accepted but the event is refused, InvalidContextInPayloadError on a
106
+ # reserved-key collision and TypeMismatchError if +value+ is not numeric
107
+ # or +event_time+ is not a valid timestamp.
108
+ #
109
+ # +event_time+ backdates the event and accepts a Time or an ISO-8601
110
+ # String. It can also be supplied as an "event_time" entry in
111
+ # +tracking_event_details+, which is the only route available through the
112
+ # spec-conformant +track+; it is removed from the payload rather than
113
+ # published as a custom field.
114
+ #
115
+ # "event_time" is therefore reserved in +tracking_event_details+. A value
116
+ # that is neither a Time nor a parseable ISO-8601 String raises
117
+ # TypeMismatchError rather than being published as an ordinary string
118
+ # field. Failing loudly is deliberate: publishing the event stamped
119
+ # "now" instead of the intended time, or dropping it silently, is far
120
+ # harder to diagnose than a logged failure.
121
+ def track!(tracking_event_name, evaluation_context: nil, tracking_event_details: nil, event_time: nil)
122
+ details = normalize_details(tracking_event_details)
123
+ at = details.delete("event_time") || event_time
124
+
125
+ @api_client.track(
126
+ event_name: tracking_event_name,
127
+ payload: event_payload(details, evaluation_context),
128
+ event_time: at
129
+ )
130
+ end
131
+
59
132
  private
60
133
 
134
+ # Mirrors PayloadMerger in the other Confidence SDKs: the tracking event
135
+ # details sit at the top level of the payload and the evaluation context
136
+ # is nested under the reserved "context" key.
137
+ def event_payload(details, evaluation_context)
138
+ if details.key?("context")
139
+ raise InvalidContextInPayloadError.new(
140
+ 'tracking event details may not contain a "context" key; it is ' \
141
+ "reserved for the evaluation context"
142
+ )
143
+ end
144
+ details.merge("context" => context_hash(evaluation_context))
145
+ end
146
+
147
+ # Requirement 6.2.1: tracking event details define an optional numeric
148
+ # +value+. Requirement 6.2.2: custom fields keyed by string.
149
+ def normalize_details(tracking_event_details)
150
+ return {} if tracking_event_details.nil?
151
+ unless tracking_event_details.is_a?(Hash)
152
+ raise TypeMismatchError.new(
153
+ "tracking event details must be a Hash, got #{tracking_event_details.class}"
154
+ )
155
+ end
156
+
157
+ details = tracking_event_details.transform_keys(&:to_s)
158
+ value = details["value"]
159
+ if !value.nil? && !value.is_a?(Numeric)
160
+ raise TypeMismatchError.new(
161
+ "tracking event details 'value' must be numeric, got #{value.class}"
162
+ )
163
+ end
164
+ details
165
+ end
166
+
61
167
  def evaluate(flag_key:, default_value:, evaluation_context: nil, validator: nil)
62
168
  parts = flag_key.split(".")
63
169
  flag_id = parts.shift
@@ -104,10 +210,15 @@ module Confidence
104
210
 
105
211
  def context_hash(evaluation_context)
106
212
  return {} if evaluation_context.nil?
213
+ # Direct callers may pass a plain Hash; the SDK client always passes an
214
+ # EvaluationContext.
215
+ return evaluation_context.dup if evaluation_context.is_a?(Hash)
107
216
 
108
217
  # In SDK 0.4+, EvaluationContext stores all fields in a hash
109
218
  # targeting_key is a special field that can be accessed via .targeting_key
110
- evaluation_context.fields.dup
219
+ return evaluation_context.fields.dup if evaluation_context.respond_to?(:fields)
220
+
221
+ {}
111
222
  end
112
223
  end
113
224
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Confidence
4
4
  module OpenFeature
5
- VERSION = "0.1.4" # x-release-please-version
5
+ VERSION = "0.2.0" # x-release-please-version
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: confidence-openfeature-provider
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Confidence Team
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-06-18 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: openfeature-sdk
@@ -16,28 +15,34 @@ dependencies:
16
15
  requirements:
17
16
  - - "~>"
18
17
  - !ruby/object:Gem::Version
19
- version: 0.4.1
18
+ version: 0.6.1
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - "~>"
25
24
  - !ruby/object:Gem::Version
26
- version: 0.4.1
25
+ version: 0.6.1
27
26
  - !ruby/object:Gem::Dependency
28
27
  name: openssl
29
28
  requirement: !ruby/object:Gem::Requirement
30
29
  requirements:
31
- - - "~>"
30
+ - - ">="
32
31
  - !ruby/object:Gem::Version
33
32
  version: '3.3'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '5.0'
34
36
  type: :runtime
35
37
  prerelease: false
36
38
  version_requirements: !ruby/object:Gem::Requirement
37
39
  requirements:
38
- - - "~>"
40
+ - - ">="
39
41
  - !ruby/object:Gem::Version
40
42
  version: '3.3'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '5.0'
41
46
  - !ruby/object:Gem::Dependency
42
47
  name: rake
43
48
  requirement: !ruby/object:Gem::Requirement
@@ -58,14 +63,14 @@ dependencies:
58
63
  requirements:
59
64
  - - "~>"
60
65
  - !ruby/object:Gem::Version
61
- version: 3.12.0
66
+ version: 3.13.2
62
67
  type: :development
63
68
  prerelease: false
64
69
  version_requirements: !ruby/object:Gem::Requirement
65
70
  requirements:
66
71
  - - "~>"
67
72
  - !ruby/object:Gem::Version
68
- version: 3.12.0
73
+ version: 3.13.2
69
74
  - !ruby/object:Gem::Dependency
70
75
  name: standard
71
76
  requirement: !ruby/object:Gem::Requirement
@@ -114,15 +119,14 @@ dependencies:
114
119
  requirements:
115
120
  - - "~>"
116
121
  - !ruby/object:Gem::Version
117
- version: 2.1.0
122
+ version: 3.2.0
118
123
  type: :development
119
124
  prerelease: false
120
125
  version_requirements: !ruby/object:Gem::Requirement
121
126
  requirements:
122
127
  - - "~>"
123
128
  - !ruby/object:Gem::Version
124
- version: 2.1.0
125
- description:
129
+ version: 3.2.0
126
130
  email:
127
131
  - TBD@TBD.com
128
132
  executables: []
@@ -143,7 +147,6 @@ metadata:
143
147
  changelog_uri: https://github.com/spotify/confidence-resolver/blob/main/openfeature-provider/ruby/CHANGELOG.md
144
148
  bug_tracker_uri: https://github.com/spotify/confidence-resolver/issues
145
149
  documentation_uri: https://github.com/spotify/confidence-resolver/blob/main/openfeature-provider/ruby/README.md
146
- post_install_message:
147
150
  rdoc_options: []
148
151
  require_paths:
149
152
  - lib
@@ -151,15 +154,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
151
154
  requirements:
152
155
  - - ">="
153
156
  - !ruby/object:Gem::Version
154
- version: '3.1'
157
+ version: '3.4'
155
158
  required_rubygems_version: !ruby/object:Gem::Requirement
156
159
  requirements:
157
160
  - - ">="
158
161
  - !ruby/object:Gem::Version
159
162
  version: '0'
160
163
  requirements: []
161
- rubygems_version: 3.5.22
162
- signing_key:
164
+ rubygems_version: 3.6.9
163
165
  specification_version: 4
164
166
  summary: Confidence provider for the OpenFeature SDK
165
167
  test_files: []