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,530 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "quota_limited"
4
+
5
+ module BilldogEng
6
+ # Analytics client: capture/identify/group_identify/alias + batching.
7
+ #
8
+ # Events accumulate in an in-memory queue and are flushed as a single batched
9
+ # POST to `/ingest-events` when:
10
+ # - the queue reaches `flush_at`,
11
+ # - the background timer fires every `flush_interval`, or
12
+ # - `flush` / `shutdown` is called.
13
+ #
14
+ # A failed flush re-queues its batch (front of the queue) so events are never
15
+ # lost on a single transient failure; the Transport also retries with backoff
16
+ # before a flush is considered failed.
17
+ #
18
+ # A 200 carrying `quota_limited` is NOT a failure and NOT a success: the batch was
19
+ # refused, not stored (see QuotaLimited). It SUSPENDS the flush loop — capture becomes
20
+ # a no-op, because data that cannot be stored is a memory leak rather than a rescue —
21
+ # and one probe batch is retried every QUOTA_SUSPEND_COOLDOWN_MS until the allowance
22
+ # returns.
23
+ class Analytics
24
+ # How long a quota suspension holds the flush loop closed before ONE probe batch is
25
+ # let through. A server process runs for months: the allowance resets monthly and the
26
+ # account can be upgraded at any moment, so — unlike the mobile SDKs, which suspend
27
+ # for the session and recover on next launch — this one has to heal itself.
28
+ QUOTA_SUSPEND_COOLDOWN_MS = 15 * 60 * 1000
29
+
30
+ # @param transport [Transport]
31
+ # @param api_key [String]
32
+ # @param options [Hash] resolved options
33
+ def initialize(transport, api_key:, options:)
34
+ @transport = transport
35
+ @api_key = api_key
36
+ @options = options
37
+
38
+ @queue = []
39
+ @mutex = Mutex.new # guards @queue, @async_flush_running and the quota state
40
+ @flush_mutex = Mutex.new # serializes flushes so re-queued batches don't interleave
41
+ @timer = nil
42
+ @running = true
43
+ @async_flush_running = false # at-most-one background threshold flush in flight
44
+ # Resolves a user's active flags for capture(..., send_feature_flags: true). Late-bound by the
45
+ # client to avoid a construction-order cycle (analytics is built before flags).
46
+ @flag_resolver = nil
47
+ # Quota suspension (see QUOTA_SUSPEND_COOLDOWN_MS). Monotonic ms, so a wall-clock
48
+ # step on a long-lived host cannot skew — or cancel — the cooldown.
49
+ @quota_suspended = false
50
+ @quota_suspended_at = 0
51
+
52
+ # Disabled SDK: stay fully inert — no background timer, no network.
53
+ start_timer unless @options[:disabled]
54
+ end
55
+
56
+ # Late-bind the flag resolver used by `send_feature_flags` (avoids a construction-order cycle).
57
+ # @param callable [#call] `->(distinct_id, groups) { { key => value, ... } }`
58
+ def set_flag_resolver(callable)
59
+ @flag_resolver = callable
60
+ end
61
+
62
+ # Capture an arbitrary event for a user. Batched; flushed per policy.
63
+ #
64
+ # @param timestamp [Integer, Time, nil] override the event timestamp — epoch ms or a native Time
65
+ # (backfill). A Time is normalized to epoch ms; nil → now.
66
+ # @param send_feature_flags [Boolean] attach the user's active flags as `$feature/<key>` properties
67
+ # (plus `$active_feature_flags`). Best-effort, LOCAL-only, SYNC via the late-bound resolver — never
68
+ # blocks capture on a fetch; server-only/cold flags are skipped.
69
+ def capture(distinct_id, event, properties = {}, groups = {}, timestamp: nil, send_feature_flags: false)
70
+ return if @options[:disabled]
71
+
72
+ props = with_groups(properties, groups)
73
+ if send_feature_flags && @flag_resolver
74
+ active = @flag_resolver.call(distinct_id, groups) || {}
75
+ enabled = []
76
+ active.each do |key, value|
77
+ props["$feature/#{key}"] = value
78
+ enabled << key unless value == false
79
+ end
80
+ props["$active_feature_flags"] = enabled
81
+ end
82
+ enqueue(distinct_id, event, props, resolve_timestamp(timestamp))
83
+ end
84
+
85
+ # Capture an exception as a canonical `$exception` event (PostHog error-tracking parity). Parses the
86
+ # error's class / message / backtrace into the `$exception_list` shape so downstream error-tracking can
87
+ # group and symbolicate it — do NOT hand-build `$exception` via #capture. A server SDK requires the
88
+ # distinct_id (there is no ambient user). Disabled => no-op. Parses defensively: a malformed/absent
89
+ # backtrace never raises out of here.
90
+ #
91
+ # @param error [Exception, Object] a raised exception (preferred) or any value (its string form
92
+ # becomes the message with an empty stacktrace).
93
+ # @param distinct_id [String]
94
+ # @param properties [Hash] extra properties merged into the event.
95
+ def capture_exception(error, distinct_id, properties = {})
96
+ return if @options[:disabled]
97
+
98
+ exc = build_exception_entry(error)
99
+ capture(distinct_id, "$exception", stringify_keys(properties).merge(
100
+ "$exception_list" => [exc],
101
+ "$exception_type" => exc["type"],
102
+ "$exception_message" => exc["value"],
103
+ "$exception_level" => "error",
104
+ ))
105
+ end
106
+
107
+ # Normalize a caller-supplied timestamp (Time or epoch ms) to epoch ms; nil → now.
108
+ def resolve_timestamp(ts)
109
+ return (ts.to_f * 1000).to_i if ts.is_a?(Time)
110
+ return ts if ts.is_a?(Integer)
111
+
112
+ now_ms
113
+ end
114
+
115
+ # Set person properties. Emits the `$identify` event (identity/analytics) and, when `set_once`
116
+ # is supplied, ALSO persists `$set_once` properties to the profile store via
117
+ # #set_person_properties ("only if absent" — e.g. signup_date). Default behaviour (no set_once)
118
+ # is unchanged — it does NOT hit /user-properties.
119
+ def identify(distinct_id, properties = {}, set_once: nil)
120
+ enqueue(distinct_id, "$identify", { "$set" => properties }.merge(stringify_keys(properties)))
121
+ set_person_properties(distinct_id, set_once: set_once) if set_once && !set_once.empty?
122
+ end
123
+
124
+ # Apply person-property operations to the profile store (POST `/user-properties`), the
125
+ # PostHog-parity property-op vocabulary the `$identify` event alone cannot express:
126
+ # `$set` (overwrite), `$set_once` (write only if absent), `$add` (numeric increment),
127
+ # `$append` (push to a list), `$unset` (delete). Operations are built in the order
128
+ # set -> set_once -> add -> append -> unset. Empty operations => no request.
129
+ #
130
+ # Fire-and-forget on a background thread (same discipline as the threshold flush): the
131
+ # Transport retries transient failures; a final error is swallowed so this never blocks
132
+ # or raises out of the caller (best-effort telemetry contract).
133
+ def set_person_properties(distinct_id, set: nil, set_once: nil, add: nil, append: nil, unset: nil)
134
+ return if @options[:disabled]
135
+
136
+ operations = []
137
+ stringify_keys(set).each { |k, v| operations << { "operation" => "$set", "key" => k, "value" => v } }
138
+ stringify_keys(set_once).each { |k, v| operations << { "operation" => "$set_once", "key" => k, "value" => v } }
139
+ stringify_keys(add).each { |k, v| operations << { "operation" => "$add", "key" => k, "value" => v } }
140
+ stringify_keys(append).each { |k, v| operations << { "operation" => "$append", "key" => k, "value" => v } }
141
+ (unset || []).each { |k| operations << { "operation" => "$unset", "key" => k.to_s } }
142
+ return if operations.empty?
143
+
144
+ body = { "api_key" => @api_key, "distinct_id" => distinct_id, "operations" => operations }
145
+
146
+ Thread.new do
147
+ begin
148
+ @transport.request(
149
+ path: "/user-properties",
150
+ body: body,
151
+ headers: { "X-BillDog-API-Key" => @api_key },
152
+ gzip: false,
153
+ )
154
+ rescue BilldogEng::Error => e
155
+ log("set_person_properties failed: #{e.message}")
156
+ end
157
+ end
158
+ end
159
+
160
+ # Set properties on a group. Emits `$groupidentify`.
161
+ def group_identify(group_type, group_key, properties = {})
162
+ enqueue(group_key, "$groupidentify", {
163
+ "$group_type" => group_type,
164
+ "$group_key" => group_key,
165
+ "$group_set" => properties,
166
+ })
167
+ end
168
+
169
+ # Alias one distinct id to another. Emits `$create_alias`.
170
+ def alias(distinct_id, alias_id)
171
+ enqueue(distinct_id, "$create_alias", { "alias" => alias_id, "distinct_id" => distinct_id })
172
+ end
173
+
174
+ # Force-flush the queue immediately. Raises on failure (after re-queue). A quota
175
+ # refusal is neither: it suspends quietly, so this never raises or hangs once the
176
+ # allowance is gone.
177
+ def flush
178
+ @flush_mutex.synchronize { do_flush }
179
+ end
180
+
181
+ # Stop the background timer. Use #shutdown to also drain the queue.
182
+ def stop_timer
183
+ @running = false
184
+ timer = nil
185
+ @mutex.synchronize do
186
+ timer = @timer
187
+ @timer = nil
188
+ end
189
+ if timer
190
+ timer.kill
191
+ timer.join
192
+ end
193
+ end
194
+
195
+ # Flush remaining events and stop the background timer.
196
+ def shutdown
197
+ stop_timer
198
+ flush
199
+ end
200
+
201
+ # Current number of queued (not-yet-flushed) events.
202
+ def queue_length
203
+ @mutex.synchronize { @queue.length }
204
+ end
205
+
206
+ private
207
+
208
+ def log(*args)
209
+ warn("[BilldogEng:analytics] #{args.join(" ")}") if @options[:enable_logging]
210
+ end
211
+
212
+ def start_timer
213
+ interval_s = @options[:flush_interval] / 1000.0
214
+ @timer = Thread.new do
215
+ while @running
216
+ sleep(interval_s)
217
+ break unless @running
218
+
219
+ begin
220
+ flush
221
+ rescue BilldogEng::Error => e
222
+ log("background flush failed: #{e.message}")
223
+ end
224
+ end
225
+ end
226
+ @timer.abort_on_exception = false
227
+ end
228
+
229
+ def with_groups(properties, groups)
230
+ props = stringify_keys(properties)
231
+ return props if groups.nil? || groups.empty?
232
+
233
+ # `$groups` ({type => key}) is the only group wire shape.
234
+ props.merge("$groups" => stringify_keys(groups))
235
+ end
236
+
237
+ # PostHog-parity person-profile control (choke point). Resolves the
238
+ # `person_profiles` option into the reserved `$process_person_profile` wire
239
+ # flag the ingest pipeline honors:
240
+ #
241
+ # "never" => false (event stays anonymous even after identify)
242
+ # "always" => true (ingest promotes even a pre-identify anon id to a
243
+ # person — makes every device a billable MAU)
244
+ # "identified_only" (default) / unset => attach nothing
245
+ #
246
+ # A caller-supplied "$process_person_profile" in properties always wins; the
247
+ # flag is only injected when absent.
248
+ def apply_person_profile_flag(properties)
249
+ return if properties.key?("$process_person_profile")
250
+
251
+ case @options[:person_profiles]
252
+ when "never"
253
+ properties["$process_person_profile"] = false
254
+ when "always"
255
+ properties["$process_person_profile"] = true
256
+ end
257
+ end
258
+
259
+ def enqueue(distinct_id, event_name, properties, timestamp = nil)
260
+ return if @options[:disabled]
261
+
262
+ # Quota-suspended: the allowance is gone, so this event CANNOT be stored however
263
+ # long we hold it — queueing it would only grow a long-lived process's memory.
264
+ return if quota_suspended?
265
+
266
+ apply_person_profile_flag(properties)
267
+ wire_event = {
268
+ "event_name" => event_name,
269
+ "event_timestamp" => timestamp || now_ms,
270
+ "properties" => properties,
271
+ "user_id" => distinct_id,
272
+ }
273
+
274
+ # before_send hook: mutate or DROP (nil) the event before it is queued. Runs at this single choke
275
+ # point, so it sees capture/identify/group/alias AND the `$feature_flag_called` exposure.
276
+ hook = @options[:before_send]
277
+ if hook
278
+ wire_event = hook.call(wire_event)
279
+ return if wire_event.nil?
280
+ end
281
+
282
+ should_flush = false
283
+ @mutex.synchronize do
284
+ @queue.push(wire_event)
285
+
286
+ max = @options[:max_queue_size]
287
+ if @queue.length > max
288
+ dropped = @queue.length - max
289
+ @queue.shift(dropped)
290
+ log("max queue size exceeded, dropped #{dropped} oldest event(s)")
291
+ end
292
+
293
+ should_flush = @queue.length >= @options[:flush_at]
294
+ end
295
+
296
+ trigger_async_flush if should_flush
297
+ end
298
+
299
+ # Fire-and-forget the threshold flush on a background thread so capture never
300
+ # blocks the caller through the transport's retry backoff and never lets a flush
301
+ # error propagate out of capture(). Node fire-and-forgets `void this.flush()`;
302
+ # the synchronous port used to block AND raise into host request code. Bounded to
303
+ # at most one in-flight async flush (further threshold crossings coalesce onto the
304
+ # next one), so this can't spawn a thread per event.
305
+ def trigger_async_flush
306
+ start = false
307
+ @mutex.synchronize do
308
+ unless @async_flush_running
309
+ @async_flush_running = true
310
+ start = true
311
+ end
312
+ end
313
+ return unless start
314
+
315
+ Thread.new do
316
+ begin
317
+ flush
318
+ rescue BilldogEng::Error => e
319
+ log("async flush failed: #{e.message}")
320
+ ensure
321
+ @mutex.synchronize { @async_flush_running = false }
322
+ end
323
+ end
324
+ end
325
+
326
+ def do_flush
327
+ # A quota suspension stops the flush loop dead until the cooldown expires, at which
328
+ # point exactly one probe batch is let through to test whether the allowance is back.
329
+ return if quota_flush_blocked?
330
+
331
+ batch = nil
332
+ @mutex.synchronize do
333
+ return if @queue.empty?
334
+
335
+ batch = @queue
336
+ @queue = []
337
+ end
338
+
339
+ body = { "api_key" => @api_key, "events" => batch }
340
+
341
+ # GeoIP control (PostHog `disable_geoip` parity): a server SDK's request IP is the customer's
342
+ # backend, so tell ingest how precisely to resolve it — or not to (`"none"`). Only sent when
343
+ # configured; otherwise the project default applies. Header rides ONLY the /ingest-events flush.
344
+ headers = { "X-BillDog-API-Key" => @api_key }
345
+ level = @options[:geoip_resolution_level]
346
+ headers["x-geoip-resolution-level"] = level if level
347
+
348
+ begin
349
+ result = @transport.request(
350
+ path: "/ingest-events",
351
+ body: body,
352
+ headers: headers,
353
+ )
354
+ # A 2xx is no longer proof of storage: `quota_limited` means this batch was
355
+ # refused and dropped. Keep it as the probe batch and go quiet — never report
356
+ # success for data the server just discarded.
357
+ if QuotaLimited.limited?(result)
358
+ @mutex.synchronize { @queue = batch.concat(@queue) }
359
+ enter_quota_suspension(result)
360
+ return
361
+ end
362
+
363
+ leave_quota_suspension
364
+ log("flushed #{batch.length} event(s)")
365
+ rescue BilldogEng::Error => e
366
+ # Re-queue the batch at the front so it is retried on the next flush;
367
+ # never lose events on a single failure.
368
+ @mutex.synchronize { @queue = batch.concat(@queue) }
369
+ log("flush failed, re-queued #{batch.length} event(s): #{e.message}")
370
+ raise e
371
+ end
372
+ end
373
+
374
+ # True while a quota suspension holds. Stays true across the whole cooldown so the
375
+ # queue cannot grow while the pipeline is dark.
376
+ def quota_suspended?
377
+ @mutex.synchronize { @quota_suspended }
378
+ end
379
+
380
+ # True while suspended AND still inside the cooldown, i.e. no probe flush is due yet.
381
+ def quota_flush_blocked?
382
+ @mutex.synchronize do
383
+ @quota_suspended && (monotonic_ms - @quota_suspended_at) < QUOTA_SUSPEND_COOLDOWN_MS
384
+ end
385
+ end
386
+
387
+ # Enter — or re-arm — the quota suspension. The reason is logged EXACTLY ONCE, on
388
+ # entry: a probe that finds the allowance still gone only resets the cooldown, so a
389
+ # dark pipeline does not also fill the host's log every QUOTA_SUSPEND_COOLDOWN_MS.
390
+ def enter_quota_suspension(result)
391
+ first = false
392
+ @mutex.synchronize do
393
+ first = !@quota_suspended
394
+ @quota_suspended = true
395
+ @quota_suspended_at = monotonic_ms
396
+ end
397
+ quota_warn(QuotaLimited.reason(result)) if first
398
+ end
399
+
400
+ # Leave the suspension after a probe batch was accepted, logging the recovery once so
401
+ # the silence has a visible end as well as a visible start.
402
+ def leave_quota_suspension
403
+ return unless @quota_suspended
404
+
405
+ resumed = false
406
+ @mutex.synchronize do
407
+ resumed = @quota_suspended
408
+ @quota_suspended = false
409
+ @quota_suspended_at = 0
410
+ end
411
+ quota_warn("allowance restored — resuming event flushes") if resumed
412
+ end
413
+
414
+ # Unconditional stderr warning, NOT gated on :enable_logging like #log: a quota
415
+ # suspension is the only signal a developer gets that their pipeline went dark.
416
+ def quota_warn(message)
417
+ warn("[BilldogEng:analytics] #{message}")
418
+ end
419
+
420
+ # Monotonic clock for the cooldown only; #now_ms stays wall-clock because event
421
+ # timestamps have to mean something to the server.
422
+ def monotonic_ms
423
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
424
+ end
425
+
426
+ def now_ms
427
+ (Time.now.to_f * 1000).to_i
428
+ end
429
+
430
+ # Build one `$exception_list` entry (PostHog canonical shape) from a raised exception or any value.
431
+ # A real Exception contributes its class name / message / backtrace frames; any other value yields
432
+ # type "Error", its string form as the message, and no frames. Never raises.
433
+ def build_exception_entry(error)
434
+ is_exception = error.is_a?(Exception)
435
+ type = is_exception ? (error.class.name || "Error") : "Error"
436
+ value = is_exception ? error.message.to_s : error.to_s
437
+ frames = is_exception ? parse_backtrace(error) : []
438
+ {
439
+ "type" => type,
440
+ "value" => value,
441
+ "mechanism" => { "handled" => true, "synthetic" => false },
442
+ "stacktrace" => { "type" => "raw", "frames" => frames },
443
+ }
444
+ rescue StandardError
445
+ # Defensive: a pathological error object (e.g. #message raises) must not break capture_exception.
446
+ { "type" => "Error", "value" => "", "mechanism" => { "handled" => true, "synthetic" => false },
447
+ "stacktrace" => { "type" => "raw", "frames" => [] } }
448
+ end
449
+
450
+ # Ruby / bundled-gem path markers → in_app = false (stdlib + vendored/dependency frames).
451
+ IN_APP_EXCLUDE = %r{
452
+ /gems/ | /ruby/ | /rubygems/ | /vendor/ | /bundle/ |
453
+ /\.rbenv/ | /\.rvm/ | /\.gem/ | /ruby[0-9]
454
+ }x
455
+ private_constant :IN_APP_EXCLUDE
456
+
457
+ # Parse an exception's backtrace into frames, OLDEST-FIRST. Prefers structured
458
+ # `backtrace_locations` (Thread::Backtrace::Location: #path/#label/#lineno); falls back to parsing the
459
+ # `backtrace` strings ("path:lineno:in 'method'"). A nil/absent backtrace yields []. Never raises.
460
+ def parse_backtrace(error)
461
+ locations = safe_backtrace_locations(error)
462
+ frames =
463
+ if locations && !locations.empty?
464
+ locations.map { |loc| frame_from_location(loc) }.compact
465
+ else
466
+ (safe_backtrace(error) || []).map { |line| frame_from_line(line) }.compact
467
+ end
468
+ # Ruby backtraces are newest-first (call site first); PostHog orders oldest-first.
469
+ frames.reverse
470
+ rescue StandardError
471
+ []
472
+ end
473
+
474
+ def safe_backtrace_locations(error)
475
+ error.backtrace_locations
476
+ rescue StandardError
477
+ nil
478
+ end
479
+
480
+ def safe_backtrace(error)
481
+ error.backtrace
482
+ rescue StandardError
483
+ nil
484
+ end
485
+
486
+ def frame_from_location(loc)
487
+ filename = (loc.path || "").to_s
488
+ {
489
+ "filename" => filename,
490
+ "function" => (loc.label || "<unknown>").to_s,
491
+ "lineno" => loc.lineno.to_i,
492
+ "in_app" => in_app?(filename),
493
+ }
494
+ rescue StandardError
495
+ nil
496
+ end
497
+
498
+ # Parse a backtrace string: "path/to/file.rb:123:in 'method'" (Ruby 3.4+ single-quote) or the older
499
+ # backtick form "...:123:in `method'". Trailing method chunk is optional.
500
+ def frame_from_line(line)
501
+ s = line.to_s
502
+ m = /\A(.+):(\d+)(?::in [`']?(.*?)'?)?\z/.match(s)
503
+ return nil unless m
504
+
505
+ filename = m[1]
506
+ {
507
+ "filename" => filename,
508
+ "function" => (m[3] && !m[3].empty? ? m[3] : "<unknown>"),
509
+ "lineno" => m[2].to_i,
510
+ "in_app" => in_app?(filename),
511
+ }
512
+ rescue StandardError
513
+ nil
514
+ end
515
+
516
+ def in_app?(filename)
517
+ return false if filename.nil? || filename.empty?
518
+
519
+ !IN_APP_EXCLUDE.match?(filename)
520
+ end
521
+
522
+ def stringify_keys(hash)
523
+ return {} if hash.nil?
524
+
525
+ hash.each_with_object({}) do |(k, v), acc|
526
+ acc[k.is_a?(Symbol) ? k.to_s : k] = v
527
+ end
528
+ end
529
+ end
530
+ end