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.
@@ -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