billdogeng 1.0.2.pre.beta.3
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 +7 -0
- data/README.md +173 -0
- data/lib/billdogeng/analytics.rb +530 -0
- data/lib/billdogeng/client.rb +272 -0
- data/lib/billdogeng/errors.rb +20 -0
- data/lib/billdogeng/flags.rb +518 -0
- data/lib/billdogeng/llm.rb +59 -0
- data/lib/billdogeng/messaging.rb +44 -0
- data/lib/billdogeng/murmur.rb +86 -0
- data/lib/billdogeng/quota_limited.rb +52 -0
- data/lib/billdogeng/surveys.rb +117 -0
- data/lib/billdogeng/targeting.rb +278 -0
- data/lib/billdogeng/transport.rb +182 -0
- data/lib/billdogeng/version.rb +5 -0
- data/lib/billdogeng.rb +37 -0
- metadata +90 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "murmur"
|
|
4
|
+
require_relative "targeting"
|
|
5
|
+
|
|
6
|
+
module BilldogEng
|
|
7
|
+
# Feature-flag client supporting both remote and local (server-side) evaluation.
|
|
8
|
+
#
|
|
9
|
+
# Local evaluation (`local_evaluation: true`) fetches flag DEFINITIONS once,
|
|
10
|
+
# caches them with a 5-minute TTL, and evaluates each flag deterministically on
|
|
11
|
+
# this process — the correct home for flag evaluation in a server SDK and the
|
|
12
|
+
# cross-platform-identical algorithm shared with web / iOS / Android.
|
|
13
|
+
#
|
|
14
|
+
# Remote evaluation falls back to `POST /experiment-config`, which returns a
|
|
15
|
+
# pre-evaluated `feature_flags` map for the given user.
|
|
16
|
+
class Flags
|
|
17
|
+
FLAG_DEFINITIONS_TTL_MS = 5 * 60 * 1000 # 5 minutes
|
|
18
|
+
|
|
19
|
+
# Returned by #evaluate_locally when a DB-backed condition is load-bearing: the flag is neither on nor
|
|
20
|
+
# off from here, and the caller must ask the server. A sentinel — NOT `false` — because "we don't know"
|
|
21
|
+
# and "not in the audience" are different answers, and conflating them is how targeting breaks quietly.
|
|
22
|
+
COLD_VERDICT = :__billdog_requires_server_evaluation__
|
|
23
|
+
|
|
24
|
+
# Upper bound on the (distinct_id, flag, value) dedupe cache. A server SDK sees many users, so the
|
|
25
|
+
# cache is per-triple, not per-flag; when it fills, the OLDEST entry is evicted (insertion-ordered
|
|
26
|
+
# Hash) so exposures for churned users are eventually re-emitted rather than the SDK leaking memory
|
|
27
|
+
# unboundedly.
|
|
28
|
+
MAX_REPORTED_FLAG_CALLS = 50_000
|
|
29
|
+
|
|
30
|
+
# @param send_feature_flag_events [Boolean] global toggle for `$feature_flag_called` exposure events.
|
|
31
|
+
# @param disabled [Boolean] when true all flag reads are inert (return nil/{}), no network.
|
|
32
|
+
# @param polling_interval_ms [Integer] TTL (ms) for cached local flag definitions. Default 5 min.
|
|
33
|
+
# @param capture [#call] emit an analytics event, wired to the Analytics client's #capture:
|
|
34
|
+
# `->(distinct_id, event, properties) { ... }`. Optional (nil disables emission entirely).
|
|
35
|
+
def initialize(transport, api_key:, local_evaluation:, enable_logging:,
|
|
36
|
+
send_feature_flag_events: true, disabled: false,
|
|
37
|
+
polling_interval_ms: FLAG_DEFINITIONS_TTL_MS, capture: nil)
|
|
38
|
+
@transport = transport
|
|
39
|
+
@api_key = api_key
|
|
40
|
+
@local_evaluation = local_evaluation
|
|
41
|
+
@enable_logging = enable_logging
|
|
42
|
+
@send_feature_flag_events = send_feature_flag_events
|
|
43
|
+
@disabled = disabled
|
|
44
|
+
@polling_interval_ms = polling_interval_ms
|
|
45
|
+
@capture = capture
|
|
46
|
+
|
|
47
|
+
@definitions = {} # key => definition hash
|
|
48
|
+
@definitions_fetched_at = 0 # epoch ms
|
|
49
|
+
@mutex = Mutex.new
|
|
50
|
+
@fetch_mutex = Mutex.new # single-flight guard for definition fetches
|
|
51
|
+
@fetch_cond = ConditionVariable.new
|
|
52
|
+
@fetch_in_progress = false
|
|
53
|
+
|
|
54
|
+
# Dedupe set for `$feature_flag_called`, keyed "distinct_id:key:value". A Hash is used as an
|
|
55
|
+
# insertion-ordered set so #shift evicts the OLDEST entry (FIFO) when the cap is reached. Guarded by
|
|
56
|
+
# its own mutex because Ruby analytics runs on background threads.
|
|
57
|
+
@reported_flag_calls = {}
|
|
58
|
+
@report_mutex = Mutex.new
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# True when a flag's conditions need the database, so it cannot be evaluated in-process.
|
|
62
|
+
def server_only?(key)
|
|
63
|
+
def_ = @definitions[key]
|
|
64
|
+
!def_.nil? && def_["requires_server_evaluation"] == true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Get a flag's value for a user.
|
|
68
|
+
#
|
|
69
|
+
# @param groups [Hash] group memberships, e.g. `{ "organisation" => "acme" }`. Feeds the audience
|
|
70
|
+
# engine's `group` conditions, evaluated LOCALLY — the map is caller-supplied, so no DB is needed.
|
|
71
|
+
# @param person_properties [Hash] person attributes, read by `attribute` / `entitlement` /
|
|
72
|
+
# `subscription_status` conditions.
|
|
73
|
+
# @param country [String] evaluation context, read by `country` / `platform` / `app_version` /
|
|
74
|
+
# @param platform [String] `sdk_version` conditions. Each falls back to a same-named person property.
|
|
75
|
+
# @param app_version [String]
|
|
76
|
+
# @param sdk_version [String]
|
|
77
|
+
# @param experiment_variants [Hash] experiment assignments, read by `experiment_variant` conditions.
|
|
78
|
+
# @return [true, false, String, nil] boolean, variant key, or nil if unknown.
|
|
79
|
+
def get_feature_flag(key, distinct_id, groups: nil, person_properties: nil, country: nil,
|
|
80
|
+
platform: nil, app_version: nil, sdk_version: nil, experiment_variants: nil,
|
|
81
|
+
send_feature_flag_events: nil, only_evaluate_locally: false)
|
|
82
|
+
return nil if @disabled
|
|
83
|
+
|
|
84
|
+
opts = build_opts(groups, person_properties, country, platform, app_version, sdk_version,
|
|
85
|
+
experiment_variants, send_feature_flag_events, only_evaluate_locally)
|
|
86
|
+
if @local_evaluation
|
|
87
|
+
ensure_definitions
|
|
88
|
+
return nil unless @definitions.key?(key)
|
|
89
|
+
|
|
90
|
+
# A flag whose release conditions need the DATABASE (segment / cohort / survey_answer /
|
|
91
|
+
# event_fired_in_window / group_property) is emitted with requires_server_evaluation. Evaluating
|
|
92
|
+
# it here would ignore the conditions entirely and turn a TARGETED flag ON for users who do not
|
|
93
|
+
# match it.
|
|
94
|
+
unless server_only?(key)
|
|
95
|
+
verdict = eval_local(key, distinct_id, opts)
|
|
96
|
+
unless verdict == COLD_VERDICT
|
|
97
|
+
report_feature_flag_called(key, distinct_id, verdict, opts)
|
|
98
|
+
return verdict
|
|
99
|
+
end
|
|
100
|
+
# Defensive: the server said this flag was locally evaluable but a DB-backed condition turned
|
|
101
|
+
# out to be load-bearing. Ask the server rather than guess.
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# only_evaluate_locally: caller forbids a server round-trip — return nil rather than ask.
|
|
106
|
+
return nil if only_evaluate_locally
|
|
107
|
+
|
|
108
|
+
map = fetch_remote(distinct_id, opts[:person_properties], opts[:groups])
|
|
109
|
+
return nil unless map.key?(key)
|
|
110
|
+
|
|
111
|
+
report_feature_flag_called(key, distinct_id, map[key], opts)
|
|
112
|
+
map[key]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Boolean view of #get_feature_flag (a variant string counts as ON).
|
|
116
|
+
def feature_enabled?(key, distinct_id, groups: nil, person_properties: nil, country: nil,
|
|
117
|
+
platform: nil, app_version: nil, sdk_version: nil, experiment_variants: nil,
|
|
118
|
+
send_feature_flag_events: nil, only_evaluate_locally: false)
|
|
119
|
+
v = get_feature_flag(key, distinct_id, groups: groups, person_properties: person_properties,
|
|
120
|
+
country: country, platform: platform,
|
|
121
|
+
app_version: app_version, sdk_version: sdk_version,
|
|
122
|
+
experiment_variants: experiment_variants,
|
|
123
|
+
send_feature_flag_events: send_feature_flag_events,
|
|
124
|
+
only_evaluate_locally: only_evaluate_locally)
|
|
125
|
+
return false if v.nil?
|
|
126
|
+
|
|
127
|
+
v != false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Get a flag's payload (variant config). Only meaningful under local eval.
|
|
131
|
+
# Returns the matched variant's payload, else the flag-level payload, else nil.
|
|
132
|
+
def get_feature_flag_payload(key, distinct_id, groups: nil, person_properties: nil, country: nil,
|
|
133
|
+
platform: nil, app_version: nil, sdk_version: nil,
|
|
134
|
+
experiment_variants: nil, send_feature_flag_events: nil,
|
|
135
|
+
only_evaluate_locally: false)
|
|
136
|
+
return nil if @disabled
|
|
137
|
+
|
|
138
|
+
opts = build_opts(groups, person_properties, country, platform, app_version, sdk_version,
|
|
139
|
+
experiment_variants, send_feature_flag_events, only_evaluate_locally)
|
|
140
|
+
ensure_definitions
|
|
141
|
+
def_ = @definitions[key]
|
|
142
|
+
return nil unless def_
|
|
143
|
+
|
|
144
|
+
verdict = if server_only?(key)
|
|
145
|
+
# Server-only conditions: resolve remotely rather than ignoring them. only_evaluate_locally
|
|
146
|
+
# forbids the round-trip — cannot resolve, so return nil.
|
|
147
|
+
return nil if only_evaluate_locally
|
|
148
|
+
|
|
149
|
+
fetch_remote(distinct_id, opts[:person_properties], opts[:groups])[key]
|
|
150
|
+
else
|
|
151
|
+
local = eval_local(key, distinct_id, opts)
|
|
152
|
+
if local == COLD_VERDICT
|
|
153
|
+
return nil if only_evaluate_locally
|
|
154
|
+
|
|
155
|
+
fetch_remote(distinct_id, opts[:person_properties], opts[:groups])[key]
|
|
156
|
+
else
|
|
157
|
+
local
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
# Node coerces a missing remote verdict to `false` (`?? false`), so the definition-known flag still
|
|
161
|
+
# emits an exposure of `false` rather than silently skipping — mirror that before reporting.
|
|
162
|
+
verdict = false if verdict.nil?
|
|
163
|
+
report_feature_flag_called(key, distinct_id, verdict, opts)
|
|
164
|
+
return nil if verdict == false
|
|
165
|
+
|
|
166
|
+
if verdict.is_a?(String) && def_["variants"]
|
|
167
|
+
variant = Array(def_["variants"]).find { |v| v["key"] == verdict }
|
|
168
|
+
return variant["payload"] if variant && variant.key?("payload") && !variant["payload"].nil?
|
|
169
|
+
end
|
|
170
|
+
def_["payload"]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Evaluate every known flag for a user.
|
|
174
|
+
def get_all_flags(distinct_id, groups: nil, person_properties: nil, country: nil, platform: nil,
|
|
175
|
+
app_version: nil, sdk_version: nil, experiment_variants: nil,
|
|
176
|
+
only_evaluate_locally: false)
|
|
177
|
+
return {} if @disabled
|
|
178
|
+
|
|
179
|
+
opts = build_opts(groups, person_properties, country, platform, app_version, sdk_version,
|
|
180
|
+
experiment_variants, nil, only_evaluate_locally)
|
|
181
|
+
unless @local_evaluation
|
|
182
|
+
# only_evaluate_locally with no local definitions to evaluate from → nothing to return.
|
|
183
|
+
return {} if only_evaluate_locally
|
|
184
|
+
|
|
185
|
+
return fetch_remote(distinct_id, opts[:person_properties], opts[:groups])
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
ensure_definitions
|
|
189
|
+
|
|
190
|
+
# Evaluate locally FIRST, so we learn which flags actually need the server — including any that go
|
|
191
|
+
# COLD at runtime — and can then fetch them all in ONE request rather than one per flag.
|
|
192
|
+
local = {}
|
|
193
|
+
need_server = []
|
|
194
|
+
@definitions.each_key do |key|
|
|
195
|
+
if server_only?(key)
|
|
196
|
+
need_server << key
|
|
197
|
+
next
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
verdict = eval_local(key, distinct_id, opts)
|
|
201
|
+
if verdict == COLD_VERDICT
|
|
202
|
+
need_server << key
|
|
203
|
+
else
|
|
204
|
+
local[key] = verdict
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# only_evaluate_locally: skip the server round-trip; flags that needed it resolve to false.
|
|
209
|
+
remote = if need_server.empty? || only_evaluate_locally
|
|
210
|
+
{}
|
|
211
|
+
else
|
|
212
|
+
fetch_remote(distinct_id, opts[:person_properties], opts[:groups])
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
out = {}
|
|
216
|
+
@definitions.each_key do |key|
|
|
217
|
+
out[key] = local.key?(key) ? local[key] : remote.fetch(key, false)
|
|
218
|
+
end
|
|
219
|
+
out
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Evaluate every known flag AND its payload for a user. Payloads resolve in LOCAL mode (from the
|
|
223
|
+
# cached definitions); in remote mode payloads are unavailable, so payload is nil. The value side
|
|
224
|
+
# matches #get_all_flags. Returns `{ key => { "value" => v, "payload" => p } }`. Disabled → {}.
|
|
225
|
+
def get_all_flags_and_payloads(distinct_id, groups: nil, person_properties: nil, country: nil,
|
|
226
|
+
platform: nil, app_version: nil, sdk_version: nil,
|
|
227
|
+
experiment_variants: nil, only_evaluate_locally: false)
|
|
228
|
+
return {} if @disabled
|
|
229
|
+
|
|
230
|
+
values = get_all_flags(distinct_id, groups: groups, person_properties: person_properties,
|
|
231
|
+
country: country, platform: platform,
|
|
232
|
+
app_version: app_version, sdk_version: sdk_version,
|
|
233
|
+
experiment_variants: experiment_variants,
|
|
234
|
+
only_evaluate_locally: only_evaluate_locally)
|
|
235
|
+
out = {}
|
|
236
|
+
values.each do |key, value|
|
|
237
|
+
out[key] = { "value" => value, "payload" => payload_for(key, value) }
|
|
238
|
+
end
|
|
239
|
+
out
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Synchronous, LOCAL-ONLY evaluation of every cached flag — used by capture(..., send_feature_flags:)
|
|
243
|
+
# to attach `$feature/<key>` properties without blocking on a fetch. Server-only and COLD flags are
|
|
244
|
+
# skipped (they cannot be resolved here). Returns only flags whose verdict is determined locally.
|
|
245
|
+
def evaluate_all_local_sync(distinct_id, groups = nil)
|
|
246
|
+
return {} if @disabled
|
|
247
|
+
|
|
248
|
+
opts = build_opts(groups, nil, nil, nil, nil, nil, nil)
|
|
249
|
+
out = {}
|
|
250
|
+
@definitions.each_key do |key|
|
|
251
|
+
next if server_only?(key)
|
|
252
|
+
|
|
253
|
+
verdict = eval_local(key, distinct_id, opts)
|
|
254
|
+
out[key] = verdict unless verdict == COLD_VERDICT
|
|
255
|
+
end
|
|
256
|
+
out
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Force a reload of the cached flag definitions (local mode).
|
|
260
|
+
def reload_feature_flag_definitions
|
|
261
|
+
fetch_definitions
|
|
262
|
+
nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Inject flag definitions directly, bypassing the network. Primarily for
|
|
266
|
+
# tests and for hosts that distribute definitions through their own channel.
|
|
267
|
+
# Accepts an array of definition hashes (string- or symbol-keyed).
|
|
268
|
+
def set_definitions(defs)
|
|
269
|
+
normalized = Array(defs).map { |d| normalize_definition(d) }
|
|
270
|
+
@mutex.synchronize do
|
|
271
|
+
@definitions = normalized.each_with_object({}) { |d, acc| acc[d["key"]] = d }
|
|
272
|
+
@definitions_fetched_at = now_ms
|
|
273
|
+
end
|
|
274
|
+
nil
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Deterministic local flag evaluation (spec §D):
|
|
278
|
+
# 1. Missing/inactive → false.
|
|
279
|
+
# 2. The flag's release conditions (`targeting_rule`) must match, else false. Resolved by the
|
|
280
|
+
# canonical audience evaluator in BilldogEng::Targeting — AND/OR, date windows, rule status, the
|
|
281
|
+
# full operator set and group membership all resolve here, with no server round-trip.
|
|
282
|
+
# 3. bucket = murmurhash3("flag:{key}:{distinct_id}") % 100; ON iff bucket < rollout.
|
|
283
|
+
# 4. Multivariate: walk variants by cumulative rollout against an INDEPENDENT hash,
|
|
284
|
+
# murmurhash3("flag-variant:{key}:{distinct_id}") % 100 — NOT the rollout bucket.
|
|
285
|
+
#
|
|
286
|
+
# @return [false, true, String, COLD_VERDICT] false (off), true (on), the chosen variant key, or
|
|
287
|
+
# COLD_VERDICT when a DB-backed condition turned out to be load-bearing and only the server can
|
|
288
|
+
# decide. NEVER collapse COLD_VERDICT into false.
|
|
289
|
+
def evaluate_locally(key, distinct_id, person_properties = nil, groups: nil, country: nil,
|
|
290
|
+
platform: nil, app_version: nil, sdk_version: nil, experiment_variants: nil)
|
|
291
|
+
opts = build_opts(groups, person_properties, country, platform, app_version, sdk_version,
|
|
292
|
+
experiment_variants)
|
|
293
|
+
eval_local(key, distinct_id, opts)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
private
|
|
297
|
+
|
|
298
|
+
def build_opts(groups, person_properties, country, platform, app_version, sdk_version,
|
|
299
|
+
experiment_variants, send_feature_flag_events = nil, only_evaluate_locally = false)
|
|
300
|
+
{
|
|
301
|
+
groups: groups,
|
|
302
|
+
person_properties: person_properties,
|
|
303
|
+
country: country,
|
|
304
|
+
platform: platform,
|
|
305
|
+
app_version: app_version,
|
|
306
|
+
sdk_version: sdk_version,
|
|
307
|
+
experiment_variants: experiment_variants,
|
|
308
|
+
send_feature_flag_events: send_feature_flag_events,
|
|
309
|
+
only_evaluate_locally: only_evaluate_locally,
|
|
310
|
+
}
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# The payload a resolved verdict maps to, from cached definitions (variant payload → flag payload →
|
|
314
|
+
# nil). A false verdict has no payload.
|
|
315
|
+
def payload_for(key, value)
|
|
316
|
+
return nil if value == false
|
|
317
|
+
|
|
318
|
+
def_ = @definitions[key]
|
|
319
|
+
return nil unless def_
|
|
320
|
+
|
|
321
|
+
if value.is_a?(String) && def_["variants"]
|
|
322
|
+
variant = Array(def_["variants"]).find { |v| v["key"] == value }
|
|
323
|
+
return variant["payload"] if variant && variant.key?("payload") && !variant["payload"].nil?
|
|
324
|
+
end
|
|
325
|
+
def_["payload"]
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Emit the `$feature_flag_called` exposure event (PostHog parity) — the record that lets analytics and
|
|
329
|
+
# experiments attribute outcomes to the flag a user was exposed to. Deduped per (distinct_id, key,
|
|
330
|
+
# value) so a hot flag read in a request loop bills at most one exposure per user per value.
|
|
331
|
+
# Fire-and-forget: the value is handed to the analytics #capture (which queues), never awaited, so a
|
|
332
|
+
# flag read is never slowed or failed by telemetry. `nil` verdicts (unknown flag) are NOT reported —
|
|
333
|
+
# there was no exposure. Gated by a per-call `send_feature_flag_events` override (wins when present),
|
|
334
|
+
# else the global toggle.
|
|
335
|
+
def report_feature_flag_called(key, distinct_id, response, opts)
|
|
336
|
+
enabled = opts[:send_feature_flag_events]
|
|
337
|
+
enabled = @send_feature_flag_events if enabled.nil?
|
|
338
|
+
return if enabled == false
|
|
339
|
+
return if @capture.nil?
|
|
340
|
+
|
|
341
|
+
dedupe_key = "#{distinct_id}:#{key}:#{response}"
|
|
342
|
+
@report_mutex.synchronize do
|
|
343
|
+
return if @reported_flag_calls.key?(dedupe_key)
|
|
344
|
+
|
|
345
|
+
# Evict the OLDEST (insertion-ordered) entry to bound memory.
|
|
346
|
+
@reported_flag_calls.shift if @reported_flag_calls.size >= MAX_REPORTED_FLAG_CALLS
|
|
347
|
+
@reported_flag_calls[dedupe_key] = true
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
@capture.call(distinct_id, "$feature_flag_called", {
|
|
351
|
+
"$feature_flag" => key,
|
|
352
|
+
"$feature_flag_response" => response,
|
|
353
|
+
})
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def eval_local(key, distinct_id, opts)
|
|
357
|
+
def_ = @definitions[key]
|
|
358
|
+
return false if def_.nil? || !def_["active"]
|
|
359
|
+
|
|
360
|
+
rule = def_["targeting_rule"]
|
|
361
|
+
if rule
|
|
362
|
+
verdict = Targeting.evaluate_targeting_rule(rule, audience_context(opts))
|
|
363
|
+
# A DB-backed condition is load-bearing. We cannot resolve it and MUST NOT guess — treating it as
|
|
364
|
+
# "no match" would silently withhold the flag, and treating it as a match would silently hand a
|
|
365
|
+
# targeted feature to everyone. Tell the caller to ask the server.
|
|
366
|
+
return COLD_VERDICT if verdict.cold?
|
|
367
|
+
return false unless verdict.matched?
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# CANONICAL bucketing — must match the backend byte-for-byte. Conformance vectors:
|
|
371
|
+
# tests/fixtures/flag-bucketing.json (generated FROM the backend).
|
|
372
|
+
bucket = Murmur.murmurhash3("flag:#{key}:#{distinct_id}") % 100
|
|
373
|
+
return false if bucket >= def_["rollout_percentage"]
|
|
374
|
+
|
|
375
|
+
variants = def_["variants"]
|
|
376
|
+
if variants && !variants.empty?
|
|
377
|
+
# INDEPENDENT hash — reusing the rollout bucket starves later variants: at 10% rollout split 50/50
|
|
378
|
+
# every enabled user has bucket < 10, which is < 50, so variant B was NEVER assigned.
|
|
379
|
+
variant_bucket = Murmur.murmurhash3("flag-variant:#{key}:#{distinct_id}") % 100
|
|
380
|
+
cumulative = 0
|
|
381
|
+
variants.each do |variant|
|
|
382
|
+
cumulative += variant["rollout_percentage"]
|
|
383
|
+
return variant["key"] if variant_bucket < cumulative
|
|
384
|
+
end
|
|
385
|
+
return true # weights sum < 100 → on, no specific variant
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
true
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Build the audience context the canonical evaluator reads, from the SDK's flag-eval options.
|
|
392
|
+
def audience_context(opts)
|
|
393
|
+
props = opts[:person_properties] || {}
|
|
394
|
+
{
|
|
395
|
+
# Explicit context wins; otherwise fall back to a same-named person property, since most callers
|
|
396
|
+
# already carry country/platform in their property bag.
|
|
397
|
+
"country" => opts[:country] || Targeting.dig_key(props, "country"),
|
|
398
|
+
"platform" => opts[:platform] || Targeting.dig_key(props, "platform"),
|
|
399
|
+
"app_version" => opts[:app_version] || Targeting.dig_key(props, "app_version"),
|
|
400
|
+
"sdk_version" => opts[:sdk_version] || Targeting.dig_key(props, "sdk_version"),
|
|
401
|
+
"custom_attributes" => props,
|
|
402
|
+
"groups" => opts[:groups],
|
|
403
|
+
"experiment_variants" => opts[:experiment_variants],
|
|
404
|
+
}
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def log(*args)
|
|
408
|
+
warn("[BilldogEng:flags] #{args.join(" ")}") if @enable_logging
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def ensure_definitions
|
|
412
|
+
return if definitions_fresh?
|
|
413
|
+
|
|
414
|
+
# Single-flight: when the TTL expires under concurrent load, only ONE thread
|
|
415
|
+
# fetches; the rest wait for it rather than each firing a duplicate
|
|
416
|
+
# /feature-flag-definitions request (node dedups via a shared loadPromise). The
|
|
417
|
+
# HTTP call runs OUTSIDE @fetch_mutex so a slow fetch never blocks the waiters'
|
|
418
|
+
# bookkeeping and there is no lock-ordering hazard with @mutex.
|
|
419
|
+
do_fetch = false
|
|
420
|
+
@fetch_mutex.synchronize do
|
|
421
|
+
if @fetch_in_progress
|
|
422
|
+
@fetch_cond.wait(@fetch_mutex) while @fetch_in_progress
|
|
423
|
+
return # the in-flight fetch populated (or refreshed) the cache
|
|
424
|
+
end
|
|
425
|
+
return if definitions_fresh? # a fetch may have completed between the checks
|
|
426
|
+
|
|
427
|
+
@fetch_in_progress = true
|
|
428
|
+
do_fetch = true
|
|
429
|
+
end
|
|
430
|
+
return unless do_fetch
|
|
431
|
+
|
|
432
|
+
begin
|
|
433
|
+
fetch_definitions
|
|
434
|
+
ensure
|
|
435
|
+
@fetch_mutex.synchronize do
|
|
436
|
+
@fetch_in_progress = false
|
|
437
|
+
@fetch_cond.broadcast
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def definitions_fresh?
|
|
443
|
+
@mutex.synchronize do
|
|
444
|
+
!@definitions.empty? && (now_ms - @definitions_fetched_at) < @polling_interval_ms
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def fetch_definitions
|
|
449
|
+
data = @transport.request(
|
|
450
|
+
path: "/feature-flag-definitions",
|
|
451
|
+
body: { "api_key" => @api_key },
|
|
452
|
+
headers: { "X-BillDog-API-Key" => @api_key },
|
|
453
|
+
gzip: false,
|
|
454
|
+
)
|
|
455
|
+
flags = (data.is_a?(Hash) ? data["flags"] : nil) || []
|
|
456
|
+
normalized = flags.map { |d| normalize_definition(d) }
|
|
457
|
+
@mutex.synchronize do
|
|
458
|
+
@definitions = normalized.each_with_object({}) { |d, acc| acc[d["key"]] = d }
|
|
459
|
+
@definitions_fetched_at = now_ms
|
|
460
|
+
end
|
|
461
|
+
log("loaded #{@definitions.size} flag definitions")
|
|
462
|
+
rescue BilldogEng::Error => e
|
|
463
|
+
# Graceful: keep any existing cache; surface in logs only.
|
|
464
|
+
log("failed to load flag definitions: #{e.message}")
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def fetch_remote(distinct_id, attributes, groups = nil)
|
|
468
|
+
data = @transport.request(
|
|
469
|
+
path: "/experiment-config",
|
|
470
|
+
body: {
|
|
471
|
+
"api_key" => @api_key,
|
|
472
|
+
"user_id" => distinct_id,
|
|
473
|
+
"attributes" => attributes || {},
|
|
474
|
+
# GROUP TARGETING: this param existed on the flag API and was silently DROPPED (the server had
|
|
475
|
+
# nowhere to put it). It now feeds the audience engine's group / group_property conditions.
|
|
476
|
+
"groups" => groups || {},
|
|
477
|
+
},
|
|
478
|
+
headers: { "X-BillDog-API-Key" => @api_key },
|
|
479
|
+
gzip: false,
|
|
480
|
+
)
|
|
481
|
+
(data.is_a?(Hash) ? data["feature_flags"] : nil) || {}
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Normalize a definition hash to string keys (variants too). The canonical `targeting_rule` is nested
|
|
485
|
+
# (conditions → rules → condition hashes), so it is stringified DEEPLY: a shallow pass would leave
|
|
486
|
+
# symbol keys inside the rule where the evaluator looks for strings.
|
|
487
|
+
def normalize_definition(def_)
|
|
488
|
+
d = stringify(def_)
|
|
489
|
+
d["variants"] = Array(d["variants"]).map { |v| stringify(v) } if d.key?("variants")
|
|
490
|
+
d["targeting_rule"] = deep_stringify(d["targeting_rule"]) if d.key?("targeting_rule")
|
|
491
|
+
d
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def stringify(hash)
|
|
495
|
+
return {} unless hash.is_a?(Hash)
|
|
496
|
+
|
|
497
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.is_a?(Symbol) ? k.to_s : k] = v }
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Recursively string-key every nested hash, preserving arrays and scalar values verbatim.
|
|
501
|
+
def deep_stringify(obj)
|
|
502
|
+
case obj
|
|
503
|
+
when Hash
|
|
504
|
+
obj.each_with_object({}) do |(k, v), acc|
|
|
505
|
+
acc[k.is_a?(Symbol) ? k.to_s : k] = deep_stringify(v)
|
|
506
|
+
end
|
|
507
|
+
when Array
|
|
508
|
+
obj.map { |v| deep_stringify(v) }
|
|
509
|
+
else
|
|
510
|
+
obj
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def now_ms
|
|
515
|
+
(Time.now.to_f * 1000).to_i
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module BilldogEng
|
|
6
|
+
# LLM observability — capture a model invocation as a trace span.
|
|
7
|
+
#
|
|
8
|
+
# Wraps `POST /llm/trace`. The wire payload is camelCase (matching the edge
|
|
9
|
+
# function's Zod schema) and authenticates with `X-BillDog-API-Key`.
|
|
10
|
+
class Llm
|
|
11
|
+
def initialize(transport, api_key:)
|
|
12
|
+
@transport = transport
|
|
13
|
+
@api_key = api_key
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Record a single LLM trace span.
|
|
17
|
+
#
|
|
18
|
+
# @param trace_id [String] (required)
|
|
19
|
+
# @param span_id [String] (required)
|
|
20
|
+
# @param model [String] (required)
|
|
21
|
+
# @param input_text [String] (required)
|
|
22
|
+
# @param output_text [String] (required)
|
|
23
|
+
# @param parent_span_id [String, nil]
|
|
24
|
+
# @param prompt_tokens [Integer]
|
|
25
|
+
# @param completion_tokens[Integer]
|
|
26
|
+
# @param duration_ms [Integer]
|
|
27
|
+
# @param cost_usd [Numeric]
|
|
28
|
+
# @param properties [Hash]
|
|
29
|
+
# @param metadata [Hash]
|
|
30
|
+
# @param timestamp [String, nil] ISO-8601; defaults to now.
|
|
31
|
+
def capture_trace(trace_id:, span_id:, model:, input_text:, output_text:,
|
|
32
|
+
parent_span_id: nil, prompt_tokens: 0, completion_tokens: 0,
|
|
33
|
+
duration_ms: 0, cost_usd: 0, properties: {}, metadata: {}, timestamp: nil)
|
|
34
|
+
body = {
|
|
35
|
+
"api_key" => @api_key,
|
|
36
|
+
"traceId" => trace_id,
|
|
37
|
+
"spanId" => span_id,
|
|
38
|
+
"parentSpanId" => parent_span_id || "",
|
|
39
|
+
"model" => model,
|
|
40
|
+
"inputText" => input_text,
|
|
41
|
+
"outputText" => output_text,
|
|
42
|
+
"promptTokens" => prompt_tokens || 0,
|
|
43
|
+
"completionTokens" => completion_tokens || 0,
|
|
44
|
+
"durationMs" => duration_ms || 0,
|
|
45
|
+
"costUsd" => cost_usd || 0,
|
|
46
|
+
"properties" => properties || {},
|
|
47
|
+
"metadata" => metadata || {},
|
|
48
|
+
"timestamp" => timestamp || Time.now.utc.iso8601(3),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@transport.request(
|
|
52
|
+
path: "/llm/trace",
|
|
53
|
+
body: body,
|
|
54
|
+
headers: { "X-BillDog-API-Key" => @api_key },
|
|
55
|
+
gzip: false,
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BilldogEng
|
|
4
|
+
# Messaging dispatch (BillDog advantage over PostHog) — server-side delivery
|
|
5
|
+
# across push / email / sms / in-app / live-activity.
|
|
6
|
+
#
|
|
7
|
+
# Note: `messaging-dispatch` authenticates with a Supabase session **Bearer
|
|
8
|
+
# JWT** + project membership, not the `X-BillDog-API-Key` key used by the other
|
|
9
|
+
# modules. Pass the JWT as `:access_token` per call.
|
|
10
|
+
class Messaging
|
|
11
|
+
def initialize(transport)
|
|
12
|
+
@transport = transport
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Dispatch a message.
|
|
16
|
+
#
|
|
17
|
+
# @param project_id [String] project UUID (required)
|
|
18
|
+
# @param channel [String, Symbol] push|email|sms|in-app|live-activity
|
|
19
|
+
# @param content [Hash] message content
|
|
20
|
+
# @param access_token [String] Bearer JWT (required)
|
|
21
|
+
# @param targeting [Hash, nil] { type:, conditions:, segmentIds:, tags:, subscriberIds: }
|
|
22
|
+
# @param scheduling [Hash, nil] { deliveryType:, scheduledAt:, windowStart:, windowEnd: }
|
|
23
|
+
# @param template_id [String, nil]
|
|
24
|
+
# @return [Hash] e.g. { "sent" => N, "failed" => M }
|
|
25
|
+
def dispatch(project_id:, channel:, content:, access_token:, targeting: nil, scheduling: nil, template_id: nil)
|
|
26
|
+
body = {
|
|
27
|
+
"action" => "send",
|
|
28
|
+
"project_id" => project_id,
|
|
29
|
+
"channel" => channel.to_s,
|
|
30
|
+
"content" => content,
|
|
31
|
+
}
|
|
32
|
+
body["targeting"] = targeting if targeting
|
|
33
|
+
body["scheduling"] = scheduling if scheduling
|
|
34
|
+
body["template_id"] = template_id if template_id
|
|
35
|
+
|
|
36
|
+
@transport.request(
|
|
37
|
+
path: "/messaging-dispatch",
|
|
38
|
+
body: body,
|
|
39
|
+
headers: { "Authorization" => "Bearer #{access_token}" },
|
|
40
|
+
gzip: false,
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|