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,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