mixpanel-ruby 3.3.0 → 3.4.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: 1be60e96a1c17bd58aa1e6a1b9c17228a2b4934966c95bd893efa66d6a4a4f54
4
- data.tar.gz: 8081213f4003791117e588c7afa479d249dd79ec219e16f109dda5ef26a0224c
3
+ metadata.gz: c087578749eaaa76591b16c382aef85e38e276501c39c8a82e5ff155e2572747
4
+ data.tar.gz: edc867253e2a32dafa92dc32cc318d6aab740de9a776955715eef42972810b90
5
5
  SHA512:
6
- metadata.gz: f11a900846a56ab4d2ff879c23af112a086bee115e0b12aae4f5fb65ba9f37f6bb65959554e48da90d632e2e5a24070ed4e1867ef213db970d990caa2adc25c5
7
- data.tar.gz: c7f2e19753de2dc9d55dcf93a798074ff1dc4cc82a5b2b33ce78dd765a33e98f6fc357d994580b38ac1ea2a92a9b942287f92ed3b34a4262f9bc75f476adc496
6
+ metadata.gz: a635485dfad21d6c20c7beafb831bd27af2333ca59b2cf3e04e3be6fd09dbc506c4ac67b160415a6d70646e9068290ffdcd6160453dee0b88ff4a1ec799e7253
7
+ data.tar.gz: 173b6b1e59c299c5ddbf3c36f02ef64893388d55ea94671fb4da15a8e636e7bc2c943cf4b5f9c81a941f20a73e3c97cd508fd681d21dca85b25b94b3f612514f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [v3.4.0](https://github.com/mixpanel/mixpanel-ruby/tree/v3.4.0) (2026-09-01)
4
+
5
+ ### Features
6
+ - Implement semver and date custom ops for flags runtime props ([#171](https://github.com/mixpanel/mixpanel-ruby/pull/171))
7
+
8
+ [Full Changelog](https://github.com/mixpanel/mixpanel-ruby/compare/v3.3.0...v3.4.0)
9
+
3
10
  ## [v3.3.0](https://github.com/mixpanel/mixpanel-ruby/tree/v3.3.0) (2026-07-24)
4
11
 
5
12
  ### Fixes
data/Readme.rdoc CHANGED
@@ -1,6 +1,6 @@
1
1
  = mixpanel-ruby: The official Mixpanel Ruby library
2
2
 
3
- ##### _July 24, 2026_ - [v3.3.0](https://github.com/mixpanel/mixpanel-ruby/releases/tag/v3.3.0)
3
+ ##### _September 01, 2026_ - [v3.4.0](https://github.com/mixpanel/mixpanel-ruby/releases/tag/v3.4.0)
4
4
 
5
5
  mixpanel-ruby is a library for tracking events and sending \Mixpanel profile
6
6
  updates to \Mixpanel from your ruby applications.
@@ -0,0 +1,207 @@
1
+ require 'date'
2
+ require 'time'
3
+ require 'json_logic'
4
+
5
+ module Mixpanel
6
+ module Flags
7
+ module CustomOperators
8
+ # Using the official semantic versioning 2.0.0 regular expression to handle cross-platform validation
9
+ # differences on other SDK's. For example, some platforms allow leading zeros even though it is not valid
10
+ # as part of the Semver 2.0.0 spec. See https://semver.org/
11
+ SEMVER_STRICT = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?\z/
12
+
13
+ # Strict RFC3339 guard for datetime strings. The date and hour fields are captured so the
14
+ # calendar can be validated separately; the pattern only constrains their shape.
15
+ RFC3339_STRICT = /\A(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})\z/
16
+
17
+ # SemVer 2.0.0 requires major.minor.patch; partial versions are zero-padded to this.
18
+ SEMVER_PARTS = 3
19
+
20
+ # Longest operand the semver regex is allowed to see. A real version never approaches this; the
21
+ # bound matches MAX_LENGTH in node-semver, and keeps an arbitrarily long property value off the
22
+ # regex regardless of how the engine schedules backtracking.
23
+ MAX_SEMVER_LENGTH = 256
24
+
25
+ # Epoch milliseconds are compared as int64 elsewhere, so anything at or beyond this is out of range.
26
+ MAX_EPOCH_MS = 2**63
27
+
28
+ module_function
29
+
30
+ # Implements a custom operation for semantic versioning comparison that conforms to the
31
+ # semver 2.0.0 standard. Prior to comparison, any leading version prefix is stripped.
32
+ def semver_compare(values)
33
+ unpacked = operands(values)
34
+ return false unless unpacked
35
+
36
+ actual, symbol, target = unpacked
37
+ return false unless actual.is_a?(String) && target.is_a?(String)
38
+ return false if actual.length > MAX_SEMVER_LENGTH || target.length > MAX_SEMVER_LENGTH
39
+
40
+ actual_version = normalize_semver(actual)
41
+ target_version = normalize_semver(target)
42
+ return false unless actual_version.match?(SEMVER_STRICT) && target_version.match?(SEMVER_STRICT)
43
+
44
+ cmp = compare_semver(actual_version, target_version)
45
+ comparator_matches?(cmp, symbol)
46
+ end
47
+
48
+ # Strip optional build metadata and separate the core version from pre-release identifiers
49
+ def split_semver(version)
50
+ plus = version.index('+')
51
+ version = version[0, plus] if plus
52
+ dash = version.index('-')
53
+ return [version.split('.'), []] unless dash
54
+
55
+ [version[0, dash].split('.'), version[(dash + 1)..-1].split('.')]
56
+ end
57
+
58
+ def numeric_identifier?(identifier)
59
+ identifier.match?(/\A[0-9]+\z/)
60
+ end
61
+
62
+ # Numeric identifiers carry no leading zeros, so the longer run of digits is the larger number.
63
+ # Comparing them as digits rather than parsing to a fixed-width integer keeps versions that
64
+ # overflow a 64-bit integer ordered correctly.
65
+ def compare_numeric(a, b)
66
+ return a.length <=> b.length unless a.length == b.length
67
+
68
+ a <=> b
69
+ end
70
+
71
+ # SemVer 2.0.0 section 11.4: digits compare numerically, a numeric identifier ranks below an
72
+ # alphanumeric one, and anything else compares by ASCII order.
73
+ def compare_prerelease_identifier(a, b)
74
+ a_numeric = numeric_identifier?(a)
75
+ b_numeric = numeric_identifier?(b)
76
+ return compare_numeric(a, b) if a_numeric && b_numeric
77
+ return -1 if a_numeric
78
+ return 1 if b_numeric
79
+
80
+ a <=> b
81
+ end
82
+
83
+ # Ordering per SemVer 2.0.0 section 11. Both operands have already been normalized and matched
84
+ # against the official regex, so the core holds exactly three numeric identifiers and every
85
+ # prerelease field is well-formed; the split needs no error path.
86
+ def compare_semver(actual, target)
87
+ actual_core, actual_prerelease = split_semver(actual)
88
+ target_core, target_prerelease = split_semver(target)
89
+
90
+ actual_core.each_with_index do |part, index|
91
+ result = compare_numeric(part, target_core[index])
92
+ return result unless result.zero?
93
+ end
94
+
95
+ # A prerelease ranks below the release it belongs to (section 11.3).
96
+ return 0 if actual_prerelease.empty? && target_prerelease.empty?
97
+ return 1 if actual_prerelease.empty?
98
+ return -1 if target_prerelease.empty?
99
+
100
+ [actual_prerelease.length, target_prerelease.length].min.times do |index|
101
+ result = compare_prerelease_identifier(actual_prerelease[index], target_prerelease[index])
102
+ return result unless result.zero?
103
+ end
104
+ # Every field so far is equal, so the longer list wins (section 11.4.4).
105
+ actual_prerelease.length <=> target_prerelease.length
106
+ end
107
+
108
+ # Implements a custom operation for datetime comparison. The target value stored on the
109
+ # feature flag is the millisecond epoch, whereas the actual value provided at evaluation
110
+ # time must be RFC-3339 formatted.
111
+ def datetime_compare(values)
112
+ unpacked = operands(values)
113
+ return false unless unpacked
114
+
115
+ actual, symbol, target = unpacked
116
+ actual_sec = convert_rfc3339_to_unix_seconds(actual)
117
+ target_sec = convert_unix_milliseconds_to_seconds(target)
118
+ return false unless actual_sec && target_sec
119
+
120
+ cmp = actual_sec - target_sec
121
+ comparator_matches?(cmp, symbol)
122
+ end
123
+
124
+ def operands(values)
125
+ return nil unless values.length == 3
126
+
127
+ actual, symbol, target = values
128
+ return nil unless symbol.is_a?(String)
129
+
130
+ [actual, symbol, target]
131
+ end
132
+
133
+ def comparator_matches?(cmp, symbol)
134
+ case symbol
135
+ when '===' then cmp.zero?
136
+ when '!==' then !cmp.zero?
137
+ when '<' then cmp < 0
138
+ when '<=' then cmp <= 0
139
+ when '>' then cmp > 0
140
+ when '>=' then cmp >= 0
141
+ else false
142
+ end
143
+ end
144
+
145
+ def normalize_semver(str)
146
+ stripped = str.strip
147
+ stripped = stripped[1..] if stripped =~ /\Av/i
148
+
149
+ suffix_start = stripped.length
150
+ ['-', '+'].each do |separator|
151
+ index = stripped.index(separator)
152
+ suffix_start = index if index && index < suffix_start
153
+ end
154
+
155
+ core = stripped[0, suffix_start]
156
+ suffix = stripped[suffix_start..] || ''
157
+
158
+ # split(-1) keeps trailing empty fields, so "1." and "1.2.3." stay malformed instead of
159
+ # silently padding to a valid version. Returning the input unchanged lets the validator reject it.
160
+ segments = core.split('.', -1)
161
+ return stripped unless segments.length.between?(1, SEMVER_PARTS) && segments.all? { |seg| seg.match?(/\A\d+\z/) }
162
+
163
+ segments += ['0'] * (SEMVER_PARTS - segments.length)
164
+ segments.join('.') + suffix
165
+ end
166
+
167
+ # The pattern constrains each field to two digits, which still admits a date that cannot exist,
168
+ # such as 2026-02-30 or 29 February in a common year. Time.iso8601 rolls those forward into a
169
+ # real instant instead of raising, and hour 24 likewise becomes the following midnight, so the
170
+ # calendar is checked here. RFC 3339 section 5.6 allows hours 00 through 23.
171
+ def real_calendar_date?(year, month, day, hour)
172
+ hour <= 23 && Date.valid_date?(year, month, day)
173
+ end
174
+
175
+ def convert_rfc3339_to_unix_seconds(value)
176
+ return nil unless value.is_a?(String)
177
+
178
+ normalized = value.strip.upcase
179
+ fields = RFC3339_STRICT.match(normalized)
180
+ return nil unless fields
181
+ return nil unless real_calendar_date?(fields[1].to_i, fields[2].to_i, fields[3].to_i, fields[4].to_i)
182
+
183
+ parsed = Time.iso8601(normalized)
184
+ parsed.to_i
185
+ rescue ArgumentError
186
+ nil
187
+ end
188
+
189
+ def convert_unix_milliseconds_to_seconds(value)
190
+ return nil unless value.is_a?(Numeric)
191
+ # A value int64 cannot represent is not a real timestamp; treating one as a bound would let a
192
+ # nonsense target define a rollout window. NaN fails this comparison too.
193
+ return nil unless value.abs < MAX_EPOCH_MS
194
+
195
+ value.to_i.fdiv(1000).truncate
196
+ end
197
+ end
198
+ end
199
+ end
200
+
201
+ JsonLogic.add_operation('semver_compare') do |values, _data|
202
+ Mixpanel::Flags::CustomOperators.semver_compare(values)
203
+ end
204
+
205
+ JsonLogic.add_operation('datetime_compare') do |values, _data|
206
+ Mixpanel::Flags::CustomOperators.datetime_compare(values)
207
+ end
@@ -1,6 +1,7 @@
1
1
  require 'thread'
2
2
  require 'json_logic'
3
3
  require 'mixpanel-ruby/flags/flags_provider'
4
+ require 'mixpanel-ruby/flags/custom_operators'
4
5
 
5
6
  module Mixpanel
6
7
  module Flags
@@ -377,7 +378,10 @@ module Mixpanel
377
378
  begin
378
379
  rule = lowercase_only_leaf_nodes(runtime_rule)
379
380
  result = JsonLogic.apply(rule, parameters)
380
- !!result
381
+ # A well-formed runtime rule evaluates to a boolean. Anything else —
382
+ # notably an unrecognized operator, which the engine echoes back as a
383
+ # (truthy) hash rather than raising — fails closed.
384
+ result == true
381
385
  rescue StandardError => e
382
386
  @error_handler.handle(e) if @error_handler
383
387
  false
@@ -1,3 +1,3 @@
1
1
  module Mixpanel
2
- VERSION = '3.3.0'
2
+ VERSION = '3.4.0'
3
3
  end
@@ -15,7 +15,7 @@ spec = Gem::Specification.new do |spec|
15
15
  spec.required_ruby_version = '>= 3.0.0'
16
16
  spec.add_runtime_dependency 'mutex_m'
17
17
  spec.add_runtime_dependency "base64"
18
- spec.add_runtime_dependency 'json-logic-rb', '~> 0.1.5'
18
+ spec.add_runtime_dependency 'json-logic-rb', '~> 0.2'
19
19
 
20
20
  spec.add_development_dependency 'activesupport', '~> 4.0'
21
21
  spec.add_development_dependency 'rake', '~> 13'
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [openfeature/v0.2.0](https://github.com/mixpanel/mixpanel-ruby/tree/openfeature/v0.2.0) (2026-07-28)
4
+
5
+ ### Features
6
+ - add service account support ([#152](https://github.com/mixpanel/mixpanel-ruby/pull/152))
7
+
8
+ ### Chores
9
+ - pin mixpanel-ruby ~> 3.3 for fallback_reason (SDK-126) ([#166](https://github.com/mixpanel/mixpanel-ruby/pull/166))
10
+
11
+ [Full Changelog](https://github.com/mixpanel/mixpanel-ruby/compare/openfeature/v0.1.0...openfeature/v0.2.0)
12
+
3
13
  ## [openfeature/v0.1.0](https://github.com/mixpanel/mixpanel-ruby/tree/openfeature/v0.1.0) (2026-05-13)
4
14
 
5
15
  Initial release of the Mixpanel OpenFeature provider for Ruby.
@@ -1,6 +1,6 @@
1
1
  # mixpanel-ruby-openfeature
2
2
 
3
- ##### _May 13, 2026_ - [openfeature/v0.1.0](https://github.com/mixpanel/mixpanel-ruby/releases/tag/openfeature/v0.1.0)
3
+ ##### _July 28, 2026_ - [openfeature/v0.2.0](https://github.com/mixpanel/mixpanel-ruby/releases/tag/openfeature/v0.2.0)
4
4
 
5
5
  [![Gem Version](https://img.shields.io/gem/v/mixpanel-ruby-openfeature.svg)](https://rubygems.org/gems/mixpanel-ruby-openfeature)
6
6
  [![OpenFeature](https://img.shields.io/badge/OpenFeature-compatible-green)](https://openfeature.dev/)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Mixpanel
4
4
  module OpenFeature
5
- VERSION = '0.1.0'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
@@ -17,7 +17,7 @@ Gem::Specification.new do |spec|
17
17
  spec.require_paths = ['lib']
18
18
 
19
19
  spec.add_runtime_dependency 'openfeature-sdk', '~> 0.5'
20
- spec.add_runtime_dependency 'mixpanel-ruby', '~> 3.1'
20
+ spec.add_runtime_dependency 'mixpanel-ruby', '~> 3.3'
21
21
 
22
22
  spec.add_development_dependency 'rspec', '~> 3.0'
23
23
  spec.add_development_dependency 'simplecov'
@@ -0,0 +1,100 @@
1
+ [
2
+ "A list of golden vectors for custom operators, to ensure logic parity across platforms",
3
+
4
+ "# Ordering and the six symbols",
5
+ ["2026-07-15T00:00:00Z", "<", 1784160000000, true],
6
+ ["2026-07-16T00:00:00Z", "<", 1784160000000, false],
7
+ ["2026-07-16T00:00:00Z", "===", 1784160000000, true],
8
+ ["2026-07-17T00:00:00Z", "!==", 1784160000000, true],
9
+ ["2026-07-16T00:00:00Z", ">=", 1784160000000, true],
10
+ ["2026-07-17T00:00:00Z", ">", 1784160000000, true],
11
+ ["2026-07-15T00:00:00Z", ">", 1784160000000, false],
12
+ ["2026-07-16T00:00:00Z", "<=", 1784160000000, true],
13
+ ["2026-07-17T00:00:00Z", "<=", 1784160000000, false],
14
+ ["2026-07-17T00:00:00Z", "===", 1784160000000, false],
15
+ ["2026-07-16T00:00:00Z", "!==", 1784160000000, false],
16
+ ["2026-07-15T00:00:00Z", ">=", 1784160000000, false],
17
+
18
+ "# Leap day",
19
+ ["2024-02-29T00:00:00Z", "===", 1709164800000, true],
20
+
21
+ "# Time-zone offsets change the instant",
22
+ ["2026-07-16T00:00:00+05:30", "===", 1784140200000, true],
23
+ ["2026-07-16T02:00:00+02:00", "===", 1784160000000, true],
24
+ ["2026-07-16T00:00:00+05:30", "<", 1784160000000, true],
25
+ ["2026-07-16T00:00:00-08:00", "===", 1784188800000, true],
26
+ ["2026-07-16T00:00:00-08:00", ">", 1784160000000, true],
27
+ ["2026-07-16T00:00:00+00:00", "===", 1784160000000, true],
28
+
29
+ "# Sub-second precision is dropped",
30
+ ["2026-07-16T00:00:00.5Z", "===", 1784160000000, true],
31
+ ["2026-07-16T00:00:00.500Z", "===", 1784160000000, true],
32
+ ["2026-07-16T00:00:00.123456Z", "===", 1784160000000, true],
33
+ ["2026-07-16T00:00:00.999999999Z", "===", 1784160000000, true],
34
+ ["2026-07-16T00:00:00.0Z", "===", 1784160000000, true],
35
+ ["2026-07-16T00:00:00.500Z", ">=", 1784160000000, true],
36
+ ["2026-07-16T23:59:59Z", "===", 1784246399000, true],
37
+ ["2026-07-16T23:59:59Z", "<=", 1784246399000, true],
38
+ ["2026-07-16T23:59:59.999Z", "===", 1784246399000, true],
39
+ ["2026-07-16T23:59:59.999Z", "<=", 1784246399000, true],
40
+
41
+ "# Trimming and lowercasing",
42
+ ["2026-07-16t00:00:00.500z", "===", 1784160000000, true],
43
+ ["2026-07-16t02:00:00+02:00", "===", 1784160000000, true],
44
+ [" 2026-07-16T00:00:00Z ", "===", 1784160000000, true],
45
+ ["2026-07-16t00:00:00z", "===", 1784160000000, true],
46
+
47
+ "# Wrong shapes, checked under both symbols",
48
+ ["2026-7-16T00:00:00Z", "===", 1784160000000, false],
49
+ ["2026-7-16T00:00:00Z", "!==", 1784160000000, false],
50
+ ["2026-07-16 00:00:00Z", "===", 1784160000000, false],
51
+ ["2026-07-16 00:00:00Z", "!==", 1784160000000, false],
52
+ ["2026-07-16T00:00:00", "===", 1784160000000, false],
53
+ ["2026-07-16T00:00:00", "!==", 1784160000000, false],
54
+ ["2026-07-16T00:00:00.Z", "===", 1784160000000, false],
55
+ ["2026-07-16T00:00:00.Z", "!==", 1784160000000, false],
56
+ ["2026-07-16T00:00:00+0200", "===", 1784160000000, false],
57
+ ["2026-07-16T00:00:00+0200", "!==", 1784160000000, false],
58
+ ["2026-07-16T00:00:00+02", "===", 1784160000000, false],
59
+ ["2026-07-16T00:00:00+02", "!==", 1784160000000, false],
60
+ ["2026-07-16T00:00:00Zextra", "===", 1784160000000, false],
61
+ ["2026-07-16T00:00:00Zextra", "!==", 1784160000000, false],
62
+ ["2026-07-16", "===", 1784160000000, false],
63
+ ["2026-07-16", "!==", 1784160000000, false],
64
+ ["20260716T000000Z", "===", 1784160000000, false],
65
+ ["20260716T000000Z", "!==", 1784160000000, false],
66
+ ["2026-07-16T00:00:00z00:00", "===", 1784160000000, false],
67
+ ["2026-07-16T00:00:00z00:00", "!==", 1784160000000, false],
68
+ ["2026-07-16T00:00:00,5Z", "===", 1784160000000, false],
69
+ ["2026-07-16T00:00:00,5Z", "!==", 1784160000000, false],
70
+
71
+ "# Missing or wrong-typed values",
72
+ [1784160000000, "===", 1784160000000, false],
73
+ ["2026-07-16T00:00:00Z", "===", 1e19, false],
74
+ ["2026-07-16T00:00:00Z", ">", 1e19, false],
75
+ ["2026-07-16T00:00:00Z", "<", 1e19, false],
76
+ ["2026-07-16", "===", 1784160000000, false],
77
+ ["2026-07-16T00:00:00", "===", 1784160000000, false],
78
+ ["yesterday", "===", 1784160000000, false],
79
+ [null, "===", 1784160000000, false],
80
+
81
+ "# Targets before 1970",
82
+ ["1969-12-31T23:59:59Z", "===", -1000, true],
83
+ ["1969-12-31T23:59:59Z", "!==", -1000, false],
84
+ ["1969-12-31T23:59:59Z", ">=", -1000, true],
85
+ ["1969-12-31T23:59:58Z", "<", -1000, true],
86
+ ["1969-12-31T23:59:59Z", ">", -2000, true],
87
+ ["1969-12-31T23:59:58.500Z", "===", -2000, true],
88
+ ["1969-12-31T23:59:58.500Z", "!==", -1000, true],
89
+
90
+ "# Impossible dates and out-of-range fields",
91
+ ["2026-02-30T00:00:00Z", "===", 1784160000000, false],
92
+ ["2026-02-30T00:00:00Z", "!==", 1784160000000, false],
93
+ ["2026-02-29T00:00:00Z", "!==", 1784160000000, false],
94
+ ["2025-02-29T00:00:00Z", "!==", 1784160000000, false],
95
+ ["2026-04-31T00:00:00Z", "!==", 1784160000000, false],
96
+ ["2026-06-31T00:00:00Z", "!==", 1784160000000, false],
97
+ ["2026-07-16T24:00:00Z", "!==", 1784160000000, false],
98
+ ["2026-13-01T00:00:00Z", "!==", 1784160000000, false],
99
+ ["2026-01-32T00:00:00Z", "!==", 1784160000000, false]
100
+ ]
@@ -0,0 +1,161 @@
1
+ [
2
+ "A list of golden vectors for custom operators, to ensure logic parity across platforms",
3
+
4
+ "# Ordering and the six symbols",
5
+ ["1.2.3", "===", "1.2.3", true],
6
+ ["1.2.4", "===", "1.2.3", false],
7
+ ["1.2.4", "!==", "1.2.3", true],
8
+ ["1.2.2", "<", "1.2.3", true],
9
+ ["1.2.3", "<", "1.2.3", false],
10
+ ["1.2.3", "<=", "1.2.3", true],
11
+ ["1.3.0", ">", "1.2.3", true],
12
+ ["1.2.3", ">=", "1.2.3", true],
13
+ ["1.10.0", ">", "1.9.0", true],
14
+ ["10.0.0", ">", "9.0.0", true],
15
+ ["1.0.10", ">", "1.0.9", true],
16
+ ["2.0.0", ">", "1.9.9", true],
17
+ ["1.0.0-alpha", "<", "1.0.0", true],
18
+ ["v1.2.3", "===", "1.2.3", true],
19
+ ["1.2.0", "===", "1.2", true],
20
+ [" 1.2.3 ", "===", "1.2.3", true],
21
+ ["1.2.3", "!==", "1.2.3", false],
22
+ ["1.2.4", "<=", "1.2.3", false],
23
+ ["1.2.2", ">", "1.2.3", false],
24
+ ["1.2.2", ">=", "1.2.3", false],
25
+
26
+ "# Pre-release ordering",
27
+ ["1.0.0-alpha", "<", "1.0.0-beta", true],
28
+ ["1.0.0-beta", "<", "1.0.0-rc1", true],
29
+ ["1.0.0-rc1", "<", "1.0.0-rc2", true],
30
+ ["1.0.0-alpha", "<", "1.0.0-alpha.1", true],
31
+ ["1.0.0-alpha.1", "<", "1.0.0-alpha.beta", true],
32
+ ["1.0.0-alpha", "<", "1.0.0-alpha.beta", true],
33
+ ["1.0.0-beta.2", "<", "1.0.0-beta.11", true],
34
+ ["1.0.0-a.1", "<", "1.0.0-b.1", true],
35
+ ["1.0.0-a.1", "<", "1.0.0-a.2", true],
36
+ ["1.0.0-rc1", "===", "1.0.0-rc1", true],
37
+ ["1.0.0-rc1", ">", "1.0.0-rc.1", true],
38
+ ["2.0.0-alpha", ">", "1.9.9", true],
39
+
40
+ "# A pre-release and a plain release, compared directly",
41
+ ["1.0.0", ">", "1.0.0-alpha", true],
42
+ ["1.0.0", ">=", "1.0.0-rc1", true],
43
+ ["1.0.0", "!==", "1.0.0-alpha", true],
44
+ ["1.0.0-alpha", "!==", "1.0.0", true],
45
+ ["1.0.0-alpha", "<=", "1.0.0", true],
46
+ ["1.0.0-alpha", ">", "0.9.9", true],
47
+ ["1.0.0-rc1", "<", "1.0.1", true],
48
+
49
+ "# How pre-release identifiers compare, SemVer 2.0.0 item 11",
50
+ ["1.0.0-2", "<", "1.0.0-10", true],
51
+ ["1.0.0-1", "<", "1.0.0-alpha", true],
52
+ ["1.0.0-alpha", "<", "1.0.0-alpha-1", true],
53
+ ["1.0.0-beta.11", "<", "1.0.0-rc.1", true],
54
+ ["1.0.0-rc.1", "<", "1.0.0", true],
55
+ ["1.0.0-alpha.1.2.3", "<", "1.0.0-beta", true],
56
+ ["1.0.0-beta", ">", "1.0.0-alpha.1", true],
57
+
58
+ "# Build metadata is ignored",
59
+ ["1.0.0+build1", "===", "1.0.0+build2", true],
60
+ ["1.0.0-alpha+build", "===", "1.0.0-alpha", true],
61
+ ["1.2.3+build.1-2", "===", "1.2.3", true],
62
+ ["1.0.0+build1", "!==", "1.0.0+build2", false],
63
+ ["1.0.0+build1", "<", "1.0.0+build2", false],
64
+ ["1.0.0+build1", ">", "1.0.0+build2", false],
65
+ ["1.0.0+build1", "<=", "1.0.0+build2", true],
66
+ ["1.0.0+build1", ">=", "1.0.0+build2", true],
67
+ ["1.0.0+build9", "<", "1.0.1+build1", true],
68
+ ["1.0.1+build1", ">", "1.0.0+build9", true],
69
+
70
+ "# Partial versions",
71
+ ["1.2-alpha", "===", "1.2.0-alpha", true],
72
+ ["1.2-alpha", "<", "1.3.1", true],
73
+ ["1.2-alpha", "<", "1.2.0", true],
74
+ ["1-rc1", "<", "1.0.0", true],
75
+ ["1.2+build", "===", "1.2.0", true],
76
+
77
+ "# Zero versions",
78
+ ["0.0.0", "===", "0.0.0", true],
79
+ ["0.0.0", "<", "0.0.1", true],
80
+ ["0", "===", "0.0.0", true],
81
+
82
+ "# A version ending in a bare hyphen is rejected",
83
+ ["1.0.0-", "===", "1.0.0", false],
84
+ ["1.0.0-", "!==", "1.0.0", false],
85
+ ["1.2-", "===", "1.2.0", false],
86
+ ["1.2-", "!==", "1.2.0", false],
87
+
88
+ "# Hyphens inside a pre-release are fine, since they are part of the pre-release identifier",
89
+ ["1.0.0-alpha-", "<", "1.0.0", true],
90
+
91
+ "# Leading zeros are rejected",
92
+ ["01.2.3", "===", "1.2.3", false],
93
+ ["01.2.3", "!==", "1.2.3", false],
94
+ ["1.02.3", "===", "1.2.3", false],
95
+ ["1.02.3", "!==", "1.2.3", false],
96
+ ["1.2.03", "===", "1.2.3", false],
97
+ ["1.2.03", "!==", "1.2.3", false],
98
+ ["01.02.03", "===", "1.2.3", false],
99
+ ["01.02.03", "!==", "1.2.3", false],
100
+
101
+ "# Leading zeros in a numeric pre-release are rejected too, SemVer 2.0.0 item 9",
102
+ ["1.2.3-01", "===", "1.2.3", false],
103
+ ["1.2.3-01", "!==", "1.2.3", false],
104
+ ["1.2.3-rc.01", "===", "1.2.3", false],
105
+ ["1.2.3-rc.01", "!==", "1.2.3", false],
106
+
107
+ "# A lone zero is a legal pre-release identifier",
108
+ ["1.2.3-0", "<", "1.2.3", true],
109
+
110
+ "# Digits inside a word are still valid",
111
+ ["1.2.3-rc01", "<", "1.2.3", true],
112
+
113
+ "# A leading v is accepted, in either case",
114
+ ["V1.2.3", "===", "1.2.3", true],
115
+ ["v1.0.0-alpha", "<", "1.0.0", true],
116
+ ["v1.2.4", "!==", "1.2.3", true],
117
+ ["v1.2.3", "<=", "1.2.3", true],
118
+ ["v1.2.4", ">", "1.2.3", true],
119
+ ["v1.2.3", ">=", "1.2.3", true],
120
+
121
+ "# Missing or wrong-typed values",
122
+ ["not-a-version", "===", "1.2.3", false],
123
+ [123, "===", "1.2.3", false],
124
+ [null, "===", "1.2.3", false],
125
+
126
+ "# Malformed versions, checked under both symbols",
127
+ ["", "===", "1.2.3", false],
128
+ ["", "!==", "1.2.3", false],
129
+ ["v", "===", "1.2.3", false],
130
+ ["v", "!==", "1.2.3", false],
131
+ ["-1.2.3", "===", "1.2.3", false],
132
+ ["-1.2.3", "!==", "1.2.3", false],
133
+ ["1.", "===", "1.2.3", false],
134
+ ["1.", "!==", "1.2.3", false],
135
+ ["1.2.3.", "===", "1.2.3", false],
136
+ ["1.2.3.", "!==", "1.2.3", false],
137
+ ["1..2", "===", "1.2.3", false],
138
+ ["1..2", "!==", "1.2.3", false],
139
+ ["1.2.3.4", "===", "1.2.3", false],
140
+ ["1.2.3.4", "!==", "1.2.3", false],
141
+ ["^1.2.3", "===", "1.2.3", false],
142
+ ["^1.2.3", "!==", "1.2.3", false],
143
+ ["abc1.2.3", "===", "1.2.3", false],
144
+ ["abc1.2.3", "!==", "1.2.3", false],
145
+ ["1.2.3+", "===", "1.2.3", false],
146
+ ["1.2.3+", "!==", "1.2.3", false],
147
+ ["1.2.3-alpha..1", "===", "1.2.3", false],
148
+ ["1.2.3-alpha..1", "!==", "1.2.3", false],
149
+ ["1.2.3-.", "===", "1.2.3", false],
150
+ ["1.2.3-.", "!==", "1.2.3", false],
151
+ ["1.2.3-ALPHA_BETA", "===", "1.2.3", false],
152
+ ["1.2.3-ALPHA_BETA", "!==", "1.2.3", false],
153
+ ["vv1.2.3", "===", "1.2.3", false],
154
+ ["vv1.2.3", "!==", "1.2.3", false],
155
+
156
+ "# Operand length cap",
157
+ ["1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "===", "1.2.3", false],
158
+ ["1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "!==", "1.2.3", false],
159
+ ["1.2.3", "!==", "1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false],
160
+ ["1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "!==", "1.2.3", true]
161
+ ]
@@ -0,0 +1,74 @@
1
+ require 'json'
2
+ require 'rspec'
3
+ require 'json_logic'
4
+ require 'mixpanel-ruby/flags/custom_operators'
5
+
6
+ # The golden vectors are the cross-SDK contract for the custom operators; the canonical copy and its
7
+ # README live in the analytics monorepo. Cases run through JsonLogic.apply so that operator
8
+ # registration is covered alongside the comparison itself.
9
+ #
10
+ # Defined at the top level so the case tables can be built while the describe blocks are collected.
11
+ FIXTURES = File.expand_path('../../fixtures', __dir__)
12
+
13
+ # The property key the vectors are evaluated against. It is plumbing the spec supplies, so any name
14
+ # works as long as the rule and the data agree on it.
15
+ VECTOR_KEY = 'value'.freeze
16
+
17
+ def rule_for(operator, symbol, target)
18
+ { "#{operator}_compare" => [{ 'var' => VECTOR_KEY }, symbol, target] }
19
+ end
20
+
21
+ # Build the event the rule reads from, omitting the key entirely for an unset property.
22
+ def data_for(subject)
23
+ subject.nil? ? {} : { VECTOR_KEY => subject }
24
+ end
25
+
26
+ # Read a golden-vector file. String entries are headings, array entries are cases.
27
+ def load_vectors(operator)
28
+ entries = JSON.parse(File.read(File.join(FIXTURES, "#{operator}_compare_tests.json")))
29
+
30
+ section = ''
31
+ cases = []
32
+ entries.each_with_index do |entry, index|
33
+ if entry.is_a?(String)
34
+ section = entry
35
+ next
36
+ end
37
+ subject, symbol, target, want = entry
38
+ name = "#{index} #{section}: #{subject.to_json} #{symbol} #{target.to_json}"
39
+ cases << [name, rule_for(operator, symbol, target), data_for(subject), want]
40
+ end
41
+ cases
42
+ end
43
+
44
+ SEMVER_CASES = load_vectors('semver')
45
+ DATETIME_CASES = load_vectors('datetime')
46
+
47
+ describe Mixpanel::Flags::CustomOperators do
48
+ def apply(rule, data)
49
+ JsonLogic.apply(rule, data)
50
+ end
51
+
52
+ describe 'semver_compare' do
53
+ SEMVER_CASES.each do |name, rule, data, want|
54
+ it name do
55
+ expect(apply(rule, data)).to eq(want)
56
+ end
57
+ end
58
+ end
59
+
60
+ describe 'datetime_compare' do
61
+ DATETIME_CASES.each do |name, rule, data, want|
62
+ it name do
63
+ expect(apply(rule, data)).to eq(want)
64
+ end
65
+ end
66
+ end
67
+
68
+ # An unset property must produce an event with no key at all, rather than a key holding a nil.
69
+ # Both spellings fail closed, so the vectors alone cannot tell them apart.
70
+ it 'omits the property for an unset subject' do
71
+ expect(data_for(nil)).to eq({})
72
+ expect(data_for('1.2.3')).to eq({ VECTOR_KEY => '1.2.3' })
73
+ end
74
+ end
@@ -486,6 +486,68 @@ describe Mixpanel::Flags::LocalFlagsProvider do
486
486
  expect(result).to eq('fallback')
487
487
  end
488
488
 
489
+ it 'respects runtime evaluation rule with semver_compare operator when satisfied' do
490
+ runtime_eval = {
491
+ 'semver_compare' => [{'var' => 'app_version'}, '>=', '1.2.0']
492
+ }
493
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
494
+
495
+ stub_flag_definitions([flag])
496
+ provider.start_polling_for_definitions!
497
+
498
+ context = user_context_with_properties({'app_version' => '1.5.0'})
499
+ result = provider.get_variant_value('test_flag', 'fallback', context)
500
+
501
+ expect(result).not_to eq('fallback')
502
+ expect(['control', 'treatment']).to include(result)
503
+ end
504
+
505
+ it 'respects runtime evaluation rule with semver_compare operator when not satisfied' do
506
+ runtime_eval = {
507
+ 'semver_compare' => [{'var' => 'app_version'}, '>=', '1.2.0']
508
+ }
509
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
510
+
511
+ stub_flag_definitions([flag])
512
+ provider.start_polling_for_definitions!
513
+
514
+ context = user_context_with_properties({'app_version' => '1.0.0'})
515
+ result = provider.get_variant_value('test_flag', 'fallback', context)
516
+
517
+ expect(result).to eq('fallback')
518
+ end
519
+
520
+ it 'respects runtime evaluation rule with datetime_compare operator when satisfied' do
521
+ runtime_eval = {
522
+ 'datetime_compare' => [{'var' => 'signup'}, '>=', 1_784_160_000_000]
523
+ }
524
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
525
+
526
+ stub_flag_definitions([flag])
527
+ provider.start_polling_for_definitions!
528
+
529
+ context = user_context_with_properties({'signup' => '2026-07-17T00:00:00Z'})
530
+ result = provider.get_variant_value('test_flag', 'fallback', context)
531
+
532
+ expect(result).not_to eq('fallback')
533
+ expect(['control', 'treatment']).to include(result)
534
+ end
535
+
536
+ it 'respects runtime evaluation rule with datetime_compare operator when not satisfied' do
537
+ runtime_eval = {
538
+ 'datetime_compare' => [{'var' => 'signup'}, '>=', 1_784_160_000_000]
539
+ }
540
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
541
+
542
+ stub_flag_definitions([flag])
543
+ provider.start_polling_for_definitions!
544
+
545
+ context = user_context_with_properties({'signup' => '2026-07-15T00:00:00Z'})
546
+ result = provider.get_variant_value('test_flag', 'fallback', context)
547
+
548
+ expect(result).to eq('fallback')
549
+ end
550
+
489
551
  it 'picks correct variant with hundred percent split' do
490
552
  variants = [
491
553
  { 'key' => 'A', 'value' => 'variant_a', 'is_control' => false, 'split' => 100.0 },
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mixpanel-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.0
4
+ version: 3.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mixpanel
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-27 00:00:00.000000000 Z
11
+ date: 2026-09-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mutex_m
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: 0.1.5
47
+ version: '0.2'
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: 0.1.5
54
+ version: '0.2'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: activesupport
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -194,6 +194,7 @@ files:
194
194
  - lib/mixpanel-ruby/credentials.rb
195
195
  - lib/mixpanel-ruby/error.rb
196
196
  - lib/mixpanel-ruby/events.rb
197
+ - lib/mixpanel-ruby/flags/custom_operators.rb
197
198
  - lib/mixpanel-ruby/flags/flags_provider.rb
198
199
  - lib/mixpanel-ruby/flags/local_flags_provider.rb
199
200
  - lib/mixpanel-ruby/flags/remote_flags_provider.rb
@@ -214,11 +215,14 @@ files:
214
215
  - openfeature-provider/mixpanel-ruby-openfeature.gemspec
215
216
  - openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb
216
217
  - openfeature-provider/spec/spec_helper.rb
218
+ - spec/fixtures/datetime_compare_tests.json
219
+ - spec/fixtures/semver_compare_tests.json
217
220
  - spec/mixpanel-ruby/consumer_spec.rb
218
221
  - spec/mixpanel-ruby/credentials_security_spec.rb
219
222
  - spec/mixpanel-ruby/credentials_spec.rb
220
223
  - spec/mixpanel-ruby/error_spec.rb
221
224
  - spec/mixpanel-ruby/events_spec.rb
225
+ - spec/mixpanel-ruby/flags/custom_operators_spec.rb
222
226
  - spec/mixpanel-ruby/flags/local_flags_spec.rb
223
227
  - spec/mixpanel-ruby/flags/remote_flags_spec.rb
224
228
  - spec/mixpanel-ruby/flags/types_spec.rb