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