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,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "transport"
|
|
4
|
+
require_relative "analytics"
|
|
5
|
+
require_relative "flags"
|
|
6
|
+
require_relative "surveys"
|
|
7
|
+
require_relative "messaging"
|
|
8
|
+
require_relative "llm"
|
|
9
|
+
|
|
10
|
+
module BilldogEng
|
|
11
|
+
# The BilldogEng server SDK client.
|
|
12
|
+
#
|
|
13
|
+
# Engagement suite for server-side use: Analytics, Feature Flags (remote +
|
|
14
|
+
# local evaluation), Surveys (data API), Messaging dispatch, and LLM tracing.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# bd = BilldogEng::Client.new("bd_test_xxx", local_evaluation: true)
|
|
18
|
+
# bd.capture("user-123", "order_completed", { revenue: 49.99 })
|
|
19
|
+
# on = bd.feature_enabled?("new_checkout", "user-123")
|
|
20
|
+
# bd.shutdown
|
|
21
|
+
class Client
|
|
22
|
+
DEFAULTS = {
|
|
23
|
+
host: "https://api.billdog.io/v1",
|
|
24
|
+
flush_at: 20,
|
|
25
|
+
flush_interval: 10_000,
|
|
26
|
+
max_queue_size: 1000,
|
|
27
|
+
gzip: true,
|
|
28
|
+
local_evaluation: false,
|
|
29
|
+
request_timeout: 10_000,
|
|
30
|
+
max_retries: 3,
|
|
31
|
+
enable_logging: false,
|
|
32
|
+
# Person-profile processing mode — PostHog `$process_person_profile`
|
|
33
|
+
# parity. One of "always", "identified_only" (default), or "never".
|
|
34
|
+
#
|
|
35
|
+
# "identified_only" (DEFAULT) — anonymous until you call #identify,
|
|
36
|
+
# then identified. Matches historical behaviour; cheapest.
|
|
37
|
+
# Attaches nothing to the wire event.
|
|
38
|
+
# "never" — every event stays ANONYMOUS, even after #identify. The
|
|
39
|
+
# SDK sends $process_person_profile:false; ingest keeps customer_id
|
|
40
|
+
# empty and never re-stitches the row to a person.
|
|
41
|
+
# "always" — every event is IDENTIFIED, even before #identify: ingest
|
|
42
|
+
# promotes the anonymous id to a customer. ⚠️ Billing impact —
|
|
43
|
+
# every distinct device then counts toward billable MAU.
|
|
44
|
+
#
|
|
45
|
+
# A per-event "$process_person_profile" boolean in an event's properties
|
|
46
|
+
# overrides this mode for that single event.
|
|
47
|
+
person_profiles: "identified_only",
|
|
48
|
+
# Global toggle for `$feature_flag_called` exposure events (PostHog parity).
|
|
49
|
+
# A per-call `send_feature_flag_events:` on a flag read overrides this.
|
|
50
|
+
send_feature_flag_events: true,
|
|
51
|
+
# GeoIP resolution level applied to the /ingest-events flush via the
|
|
52
|
+
# `x-geoip-resolution-level` header. One of "full", "country_region",
|
|
53
|
+
# "country_only", "none". Unset (nil) → no header, backend default applies.
|
|
54
|
+
geoip_resolution_level: nil,
|
|
55
|
+
# Kill switch. When true the SDK is INERT: capture/identify/group_identify/alias/
|
|
56
|
+
# set_person_properties are no-ops, flag reads return nil/{}, NO background flush
|
|
57
|
+
# timer starts and NO network happens. Scoped to analytics + flags (like node);
|
|
58
|
+
# explicit surveys/messaging/llm calls are NOT gated. Default false.
|
|
59
|
+
disabled: false,
|
|
60
|
+
# TTL (ms) for cached local flag definitions — how stale a definition set may get
|
|
61
|
+
# before the next flag read refetches it. Replaces the old hardcoded 5-min TTL.
|
|
62
|
+
# Default 300000 (5 min).
|
|
63
|
+
feature_flags_polling_interval: 5 * 60 * 1000,
|
|
64
|
+
# Hook run on every event just before it is queued: `->(event) { event | nil }`.
|
|
65
|
+
# Return the (possibly mutated) event to send it, or nil to DROP it. Runs at the
|
|
66
|
+
# single enqueue choke point, so it sees capture/identify/group/alias AND the
|
|
67
|
+
# `$feature_flag_called` exposure. Default nil (no hook).
|
|
68
|
+
before_send: nil,
|
|
69
|
+
}.freeze
|
|
70
|
+
|
|
71
|
+
attr_reader :options
|
|
72
|
+
# Surveys data API.
|
|
73
|
+
attr_reader :surveys
|
|
74
|
+
# Messaging dispatch.
|
|
75
|
+
attr_reader :messaging
|
|
76
|
+
# LLM observability.
|
|
77
|
+
attr_reader :llm
|
|
78
|
+
# Direct access to the flags client (advanced: set_definitions, evaluate_locally).
|
|
79
|
+
attr_reader :feature_flags
|
|
80
|
+
|
|
81
|
+
# @param api_key [String]
|
|
82
|
+
# @param opts [Hash] see DEFAULTS for accepted keys. May also pass
|
|
83
|
+
# :http_adapter (an injectable `->(method,url,headers,body){[status,body]}`
|
|
84
|
+
# used by the transport, primarily for tests).
|
|
85
|
+
def initialize(api_key, opts = {})
|
|
86
|
+
raise ArgumentError, "BilldogEng: api_key is required" if api_key.nil? || api_key.empty?
|
|
87
|
+
|
|
88
|
+
@api_key = api_key
|
|
89
|
+
http_adapter = opts.delete(:http_adapter)
|
|
90
|
+
@options = DEFAULTS.merge(strip_nil(opts))
|
|
91
|
+
|
|
92
|
+
@transport = Transport.new(
|
|
93
|
+
host: @options[:host],
|
|
94
|
+
request_timeout: @options[:request_timeout],
|
|
95
|
+
gzip: @options[:gzip],
|
|
96
|
+
max_retries: @options[:max_retries],
|
|
97
|
+
enable_logging: @options[:enable_logging],
|
|
98
|
+
http_adapter: http_adapter,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
@analytics = Analytics.new(@transport, api_key: api_key, options: @options)
|
|
102
|
+
@flags = Flags.new(
|
|
103
|
+
@transport,
|
|
104
|
+
api_key: api_key,
|
|
105
|
+
local_evaluation: @options[:local_evaluation],
|
|
106
|
+
enable_logging: @options[:enable_logging],
|
|
107
|
+
send_feature_flag_events: @options[:send_feature_flag_events],
|
|
108
|
+
disabled: @options[:disabled],
|
|
109
|
+
polling_interval_ms: @options[:feature_flags_polling_interval],
|
|
110
|
+
# Exposure events ride the normal analytics pipeline (batched, billed).
|
|
111
|
+
capture: ->(distinct_id, event, properties) { @analytics.capture(distinct_id, event, properties) },
|
|
112
|
+
)
|
|
113
|
+
@feature_flags = @flags
|
|
114
|
+
# Late-bind so capture(..., send_feature_flags: true) can inject the user's active flags without a
|
|
115
|
+
# construction-order cycle (analytics is built before flags, but flags depends on analytics.capture).
|
|
116
|
+
@analytics.set_flag_resolver(->(distinct_id, groups) { @flags.evaluate_all_local_sync(distinct_id, groups) })
|
|
117
|
+
@surveys = Surveys.new(@transport, api_key: api_key)
|
|
118
|
+
@messaging = Messaging.new(@transport)
|
|
119
|
+
@llm = Llm.new(@transport, api_key: api_key)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# ─── Analytics ────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
# Capture an event for a user. Batched; flushed per the configured policy.
|
|
125
|
+
#
|
|
126
|
+
# @param timestamp [Integer, Time, nil] override the event timestamp — epoch ms or a native Time
|
|
127
|
+
# (for backfills / historical import). A Time is normalized to epoch ms; nil → now.
|
|
128
|
+
# @param send_feature_flags [Boolean] attach the user's active flags to the event as
|
|
129
|
+
# `$feature/<key>` properties (plus `$active_feature_flags`). Best-effort, LOCAL-only, sync — uses
|
|
130
|
+
# already-cached definitions and never blocks the capture on a fetch; server-only/cold flags skipped.
|
|
131
|
+
def capture(distinct_id, event, properties = {}, groups = {}, timestamp: nil, send_feature_flags: false)
|
|
132
|
+
@analytics.capture(distinct_id, event, properties, groups,
|
|
133
|
+
timestamp: timestamp, send_feature_flags: send_feature_flags)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Capture an exception as a canonical `$exception` event (PostHog error-tracking parity). Parses the
|
|
137
|
+
# error's class / message / backtrace into the `$exception_list` shape. A server SDK requires the
|
|
138
|
+
# distinct_id. Disabled => no-op; a malformed/absent backtrace never raises out.
|
|
139
|
+
def capture_exception(error, distinct_id, properties = {})
|
|
140
|
+
@analytics.capture_exception(error, distinct_id, properties)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Set person properties. Emits `$identify`. When `set_once` is supplied, ALSO persists
|
|
144
|
+
# `$set_once` properties to the profile store via #set_person_properties.
|
|
145
|
+
def identify(distinct_id, properties = {}, set_once: nil)
|
|
146
|
+
@analytics.identify(distinct_id, properties, set_once: set_once)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Apply person-property operations to the profile store (POST `/user-properties`).
|
|
150
|
+
# Ops build in the order set -> set_once -> add -> append -> unset; empty ops => no request.
|
|
151
|
+
# Fire-and-forget (never blocks or raises out of the caller).
|
|
152
|
+
def set_person_properties(distinct_id, set: nil, set_once: nil, add: nil, append: nil, unset: nil)
|
|
153
|
+
@analytics.set_person_properties(distinct_id, set: set, set_once: set_once, add: add,
|
|
154
|
+
append: append, unset: unset)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Set group properties. Emits `$groupidentify`.
|
|
158
|
+
def group_identify(group_type, group_key, properties = {})
|
|
159
|
+
@analytics.group_identify(group_type, group_key, properties)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Alias one distinct id to another. Emits `$create_alias`.
|
|
163
|
+
def alias(distinct_id, alias_id)
|
|
164
|
+
@analytics.alias(distinct_id, alias_id)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Flush queued analytics events now.
|
|
168
|
+
def flush
|
|
169
|
+
@analytics.flush
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Number of events currently queued (test/observability aid).
|
|
173
|
+
def queue_length
|
|
174
|
+
@analytics.queue_length
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# ─── Feature flags ──────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
# `country` / `platform` / `app_version` / `sdk_version` / `experiment_variants` are the evaluation
|
|
180
|
+
# context read by the canonical audience conditions of the same name; `groups` feeds `group`
|
|
181
|
+
# membership conditions. All are resolved LOCALLY under local evaluation.
|
|
182
|
+
def get_feature_flag(key, distinct_id, groups: nil, person_properties: nil, country: nil,
|
|
183
|
+
platform: nil, app_version: nil, sdk_version: nil, experiment_variants: nil,
|
|
184
|
+
send_feature_flag_events: nil, only_evaluate_locally: false)
|
|
185
|
+
@flags.get_feature_flag(key, distinct_id, groups: groups, person_properties: person_properties,
|
|
186
|
+
country: country, platform: platform,
|
|
187
|
+
app_version: app_version, sdk_version: sdk_version,
|
|
188
|
+
experiment_variants: experiment_variants,
|
|
189
|
+
send_feature_flag_events: send_feature_flag_events,
|
|
190
|
+
only_evaluate_locally: only_evaluate_locally)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def feature_enabled?(key, distinct_id, groups: nil, person_properties: nil, country: nil,
|
|
194
|
+
platform: nil, app_version: nil, sdk_version: nil, experiment_variants: nil,
|
|
195
|
+
send_feature_flag_events: nil, only_evaluate_locally: false)
|
|
196
|
+
@flags.feature_enabled?(key, distinct_id, groups: groups, person_properties: person_properties,
|
|
197
|
+
country: country, platform: platform,
|
|
198
|
+
app_version: app_version, sdk_version: sdk_version,
|
|
199
|
+
experiment_variants: experiment_variants,
|
|
200
|
+
send_feature_flag_events: send_feature_flag_events,
|
|
201
|
+
only_evaluate_locally: only_evaluate_locally)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def get_feature_flag_payload(key, distinct_id, groups: nil, person_properties: nil, country: nil,
|
|
205
|
+
platform: nil, app_version: nil, sdk_version: nil,
|
|
206
|
+
experiment_variants: nil, send_feature_flag_events: nil,
|
|
207
|
+
only_evaluate_locally: false)
|
|
208
|
+
@flags.get_feature_flag_payload(key, distinct_id, groups: groups,
|
|
209
|
+
person_properties: person_properties,
|
|
210
|
+
country: country, platform: platform,
|
|
211
|
+
app_version: app_version,
|
|
212
|
+
sdk_version: sdk_version,
|
|
213
|
+
experiment_variants: experiment_variants,
|
|
214
|
+
send_feature_flag_events: send_feature_flag_events,
|
|
215
|
+
only_evaluate_locally: only_evaluate_locally)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def get_all_flags(distinct_id, groups: nil, person_properties: nil, country: nil, platform: nil,
|
|
219
|
+
app_version: nil, sdk_version: nil, experiment_variants: nil,
|
|
220
|
+
only_evaluate_locally: false)
|
|
221
|
+
@flags.get_all_flags(distinct_id, groups: groups, person_properties: person_properties,
|
|
222
|
+
country: country, platform: platform,
|
|
223
|
+
app_version: app_version, sdk_version: sdk_version,
|
|
224
|
+
experiment_variants: experiment_variants,
|
|
225
|
+
only_evaluate_locally: only_evaluate_locally)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Evaluate every known flag AND its payload for a user. Payloads resolve in LOCAL mode (from the
|
|
229
|
+
# cached definitions); in remote mode payloads are unavailable, so payload is nil. Returns a hash of
|
|
230
|
+
# `{ "value" => <bool|variant>, "payload" => <config|nil> }` per flag.
|
|
231
|
+
def get_all_flags_and_payloads(distinct_id, groups: nil, person_properties: nil, country: nil,
|
|
232
|
+
platform: nil, app_version: nil, sdk_version: nil,
|
|
233
|
+
experiment_variants: nil, only_evaluate_locally: false)
|
|
234
|
+
@flags.get_all_flags_and_payloads(distinct_id, groups: groups, person_properties: person_properties,
|
|
235
|
+
country: country, platform: platform,
|
|
236
|
+
app_version: app_version, sdk_version: sdk_version,
|
|
237
|
+
experiment_variants: experiment_variants,
|
|
238
|
+
only_evaluate_locally: only_evaluate_locally)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def reload_feature_flag_definitions
|
|
242
|
+
@flags.reload_feature_flag_definitions
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# ─── Messaging convenience ──────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
def dispatch_message(**params)
|
|
248
|
+
@messaging.dispatch(**params)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# ─── LLM convenience ────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
def capture_trace(**params)
|
|
254
|
+
@llm.capture_trace(**params)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# ─── Lifecycle ────────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
# Flush remaining events and stop the background flush timer.
|
|
260
|
+
def shutdown
|
|
261
|
+
@analytics.shutdown
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
private
|
|
265
|
+
|
|
266
|
+
def strip_nil(opts)
|
|
267
|
+
opts.each_with_object({}) do |(k, v), acc|
|
|
268
|
+
acc[k] = v unless v.nil?
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BilldogEng
|
|
4
|
+
# Raised when an API request ultimately fails (after retries).
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
# HTTP status code, or 0 for network/timeout failures.
|
|
7
|
+
attr_reader :status
|
|
8
|
+
# Machine-readable error code from the API envelope, if any.
|
|
9
|
+
attr_reader :code
|
|
10
|
+
# Raw response body (parsed if JSON, else text), if any.
|
|
11
|
+
attr_reader :body
|
|
12
|
+
|
|
13
|
+
def initialize(message, status: 0, code: nil, body: nil)
|
|
14
|
+
super(message)
|
|
15
|
+
@status = status
|
|
16
|
+
@code = code
|
|
17
|
+
@body = body
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|