beacon-client 0.6.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3e95a63ccca5c5cfcad6a8b6f725109ff817a747b099f5800cadfdc214a53b96
4
+ data.tar.gz: 847ba604959ff63d15ef4f654a42935bc8404d5443e562ad9f1676eaeef37fd3
5
+ SHA512:
6
+ metadata.gz: 68563de7a536e9149ea4ffcea3c307a61ff3fb1bfb2dd3ca064716ca3ba12bccf924aabb538d8236d02d5c9daa75ef8786f5b460d57ffea79ed3cb55ccf5f423
7
+ data.tar.gz: d8f5ab65210df3b35f55127a1ab6b57e5c349d6fcc84c9798ca785c00be95dbef5462d1590d74da1f7705663e7df0a2fb86ea1e315f433084f291e3ee958ae8f
data/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # beacon-client
2
+
3
+ The Ruby client for [Beacon](https://github.com/luuuc/beacon) — the small
4
+ observability accessory for self-hosted apps.
5
+
6
+ One initializer wires up three pillars:
7
+
8
+ - **Performance** — every Rack request is auto-instrumented
9
+ - **Errors** — every unhandled exception is fingerprinted and shipped
10
+ - **Outcomes** — `Beacon.track("signup.completed", user: current_user)`
11
+
12
+ ## Install
13
+
14
+ ```ruby
15
+ gem "beacon-client"
16
+ ```
17
+
18
+ > **Do not add `require: "beacon/testing"` in your Gemfile.** The `beacon/testing` file contains test helpers (`NullSink`, `FakeTransport`, `Beacon::Testing.reset_config!`) that should only be loaded from `spec/test_helper.rb` — loading them into production Rails boot is a footgun that leaks test-only classes into your host namespace. `beacon-client` itself is safe to auto-require; only `beacon/testing` is not.
19
+
20
+ ## Configure
21
+
22
+ ```ruby
23
+ # config/initializers/beacon.rb
24
+ Beacon.configure do |c|
25
+ c.endpoint = "http://beacon:4680"
26
+ c.environment = Rails.env
27
+ c.deploy_sha = ENV["GIT_SHA"] # optional
28
+ c.auth_token = Rails.application.credentials.beacon_token # optional
29
+ end
30
+ ```
31
+
32
+ In a Rails app, that's **all** you write. The gem ships a Railtie that:
33
+
34
+ - Inserts `Beacon::Middleware` into the stack, right after `ActionDispatch::DebugExceptions` (so host errors flow through Beacon before Rails renders them).
35
+ - Auto-installs the ActiveJob and ActionMailer integrations — no `require "beacon/integrations/..."` needed.
36
+ - Installs a `Process._fork` hook that runs `Beacon.client.after_fork` in every fork child, so clustered Puma / Unicorn / Passenger workers get their own flusher thread automatically. **No manual `on_worker_boot` needed.**
37
+
38
+ In a plain Rack app (no Rails), mount the middleware manually:
39
+
40
+ ```ruby
41
+ # config.ru
42
+ require "beacon"
43
+ require "beacon/middleware"
44
+ use Beacon::Middleware
45
+ ```
46
+
47
+ ### Ambient mode + enrichment
48
+
49
+ Enable ambient mode to passively capture operational telemetry (HTTP requests, jobs, mailers) alongside the standard three pillars. Add an `enrich_context` block to attach dimensions (country, plan, locale) to every event:
50
+
51
+ ```ruby
52
+ Beacon.configure do |c|
53
+ c.endpoint = "http://beacon:4680"
54
+ c.ambient = true
55
+
56
+ c.enrich_context do |request|
57
+ user = request.env["warden"]&.user
58
+ {
59
+ country: user&.country || Beacon::Enrichment.country_from_cdn(request),
60
+ plan: user&.plan_name
61
+ }
62
+ end
63
+ end
64
+ ```
65
+
66
+ The enrichment block runs on every request. Keep it fast — use data already loaded by the app, don't make database queries. If the block raises, the event sends without dimensions and a warning is logged once.
67
+
68
+ #### Enrichment examples
69
+
70
+ **Devise/Warden (most Rails apps):**
71
+ ```ruby
72
+ c.enrich_context do |request|
73
+ user = request.env["warden"]&.user
74
+ { country: user&.country, plan: user&.plan_name }
75
+ end
76
+ ```
77
+
78
+ **CDN geo headers (Cloudflare, Fastly, or CloudFront):**
79
+ ```ruby
80
+ c.enrich_context do |request|
81
+ { country: Beacon::Enrichment.country_from_cdn(request) }
82
+ end
83
+ ```
84
+ The helper checks all three CDNs in priority order — no CDN-specific code needed.
85
+
86
+ **No CDN, no auth — just browser locale:**
87
+ ```ruby
88
+ c.enrich_context do |request|
89
+ { locale: request.env["HTTP_ACCEPT_LANGUAGE"]&.split(",")&.first }
90
+ end
91
+ ```
92
+
93
+ `Beacon::Enrichment.country_from_cdn` checks Cloudflare, Fastly, and CloudFront headers in priority order. Returns a two-letter ISO code or `nil`.
94
+
95
+ ### Kill switch
96
+
97
+ To silence Beacon entirely without removing the gem:
98
+
99
+ ```ruby
100
+ # config/initializers/beacon.rb
101
+ Beacon.configure { |c| c.enabled = false }
102
+ ```
103
+
104
+ Or at the operating-system level:
105
+
106
+ ```bash
107
+ BEACON_DISABLED=1 bin/rails server
108
+ ```
109
+
110
+ A disabled Beacon is a pure passthrough: the middleware adds one
111
+ boolean check per request, nothing is captured, no flusher thread
112
+ is started, no network connection is opened.
113
+
114
+ **`BEACON_DISABLED` is read once at process start.** Setting it after
115
+ the Ruby process has already booted has no effect — you must restart
116
+ the worker. Accepted truthy values: `1`, `true`, `yes`, `on`
117
+ (case-insensitive). Everything else (including `0`, `false`, `no`,
118
+ `off`, and the empty string) leaves Beacon enabled.
119
+
120
+ If `c.endpoint` is nil or unparseable, Beacon prints one boot warning
121
+ to stderr and then behaves the same as `c.enabled = false` — no crash,
122
+ no spam, no network traffic.
123
+
124
+ ### A note on the fork hook
125
+
126
+ Because the Railtie prepends `Process._fork`, Beacon's `after_fork` runs in
127
+ **every** forked child in the process — not just Puma workers. Short-lived
128
+ forks like `rails runner`, `system`, and `Open3` subshells will briefly
129
+ initialize Beacon in the child. The reinit is idempotent and the flusher is
130
+ bounded, but it's a global behavior worth knowing about when you see
131
+ `beacon-flusher` threads show up in unexpected places.
132
+
133
+ ## Usage
134
+
135
+ ```ruby
136
+ Beacon.track("signup.completed", user: current_user, plan: "pro")
137
+ Beacon.track("checkout.failed", user: current_user, reason: "card_declined")
138
+ Beacon.flush # synchronous, drains the queue (rake tasks, shutdown)
139
+ ```
140
+
141
+ ## Hot-path guarantees
142
+
143
+ - **<50µs added P95** on a reference Rack endpoint (enforced by
144
+ `spec/bench/rack_overhead_bench.rb` in CI — the bench fails the build if
145
+ the middleware regresses)
146
+ - **Bounded queue** with oldest-drop semantics (default 10,000 events)
147
+ - **Rescue-all** — Beacon never raises into the host application
148
+ - **Fork-safe** — re-spawns the flusher in clustered Puma/Unicorn workers
149
+ - **Idempotency keys** on every retry so safe retries never double-count
150
+
151
+ See `.doc/definition/05-clients.md` and `.doc/definition/07-writing-a-client.md`
152
+ in the Beacon repo for the full contract.
153
+
154
+ ## Development
155
+
156
+ ```bash
157
+ gem install minitest rack
158
+ rake test # 32 tests, 102 assertions
159
+ rake bench # Rack overhead bench, fails if added P95 > 50µs
160
+ rake # both
161
+ ```
162
+
163
+ The conformance fixtures live at `../../../spec/fixtures.json` (shared with
164
+ the Go reference server). Fingerprint and path-normalization tests load
165
+ those fixtures directly so client and server can never drift.
@@ -0,0 +1,132 @@
1
+ require "beacon/queue"
2
+ require "beacon/flusher"
3
+ require "beacon/transport"
4
+
5
+ module Beacon
6
+ # The top-level client. Owns the queue and the flusher, exposes track,
7
+ # implements fork safety. Beacon.track / Beacon.flush / Beacon.shutdown
8
+ # all delegate here.
9
+ class Client
10
+ LANGUAGE = "ruby".freeze
11
+
12
+ attr_reader :config, :queue, :flusher
13
+
14
+ def initialize(config:, transport: nil, autostart: true)
15
+ @config = config
16
+ @enabled = config.enabled?
17
+ # When disabled, build no transport and no flusher — the no-op
18
+ # path does not touch the network and does not spawn threads.
19
+ @transport = transport || (@enabled ? Transport::Http.new(config) : nil)
20
+ @queue = Beacon::Queue.new(max: config.queue_size, flush_threshold: config.flush_threshold)
21
+ @pid = Process.pid
22
+ @mutex = Mutex.new
23
+ start_flusher if autostart && @enabled && config.async
24
+ end
25
+
26
+ # Outcomes API. The :user shorthand is the only magic — everything else
27
+ # in the properties hash flows through unchanged.
28
+ def track(name, properties = {})
29
+ return nil unless @enabled
30
+ return nil unless @config.pillar?(:outcomes)
31
+ props = properties.dup
32
+ actor_type, actor_id = extract_actor(props)
33
+
34
+ push({
35
+ kind: :outcome,
36
+ name: name.to_s,
37
+ created_at_ns: realtime_ns,
38
+ actor_type: actor_type,
39
+ actor_id: actor_id,
40
+ properties: props,
41
+ context: base_context,
42
+ })
43
+ rescue => e
44
+ warn "[beacon] track failed: #{e.class}: #{e.message}"
45
+ nil
46
+ end
47
+
48
+ # Sink interface — what middleware and integrations push into.
49
+ def push(event)
50
+ return nil unless @enabled
51
+ ensure_forked!
52
+ @queue.push(event)
53
+ end
54
+ alias << push
55
+
56
+ def enabled?
57
+ @enabled
58
+ end
59
+
60
+ # Reconnect counter passthrough for Beacon.stats. Returns 0 when
61
+ # no transport is held (disabled client) or when the transport
62
+ # doesn't implement the counter (test doubles).
63
+ def transport_reconnects
64
+ return 0 unless @transport && @transport.respond_to?(:reconnects)
65
+ @transport.reconnects
66
+ end
67
+
68
+ def flush
69
+ @flusher&.flush_now
70
+ end
71
+
72
+ def shutdown
73
+ @flusher&.stop
74
+ @flusher = nil
75
+ end
76
+
77
+ # Re-spawn flusher in a forked child. Hosted servers (Puma clustered,
78
+ # Unicorn, Passenger) MUST call this in their on_worker_boot hook.
79
+ # Beacon detects forks lazily on the next push too — but explicit is
80
+ # cheaper than waiting for the first event.
81
+ def after_fork
82
+ @mutex.synchronize do
83
+ @pid = Process.pid
84
+ @queue = Beacon::Queue.new(max: @config.queue_size, flush_threshold: @config.flush_threshold)
85
+ @flusher = nil
86
+ # Drop any socket FD inherited from the parent — sharing one
87
+ # across parent and child is undefined. The transport will
88
+ # re-open lazily on the child's first flush.
89
+ @transport.after_fork if @transport.respond_to?(:after_fork)
90
+ start_flusher if @enabled && @config.async
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def ensure_forked!
97
+ return if @pid == Process.pid
98
+ after_fork
99
+ end
100
+
101
+ def start_flusher
102
+ @flusher = Flusher.new(self, transport: @transport)
103
+ @flusher.start
104
+ end
105
+
106
+ # Extract actor_type / actor_id from the :user shorthand. user.id is
107
+ # stringified so UUIDs (Rails 7.1+), ULIDs, Snowflakes, and legacy
108
+ # integer IDs all land in the server's TEXT actor_id column without
109
+ # the caller having to know the difference. Stringifying an integer
110
+ # is free; skipping it for integers and stringifying for UUIDs would
111
+ # just be a branch with no upside.
112
+ def extract_actor(props)
113
+ user = props.delete(:user)
114
+ return [nil, nil] unless user
115
+ type = user.class.name
116
+ id = user.respond_to?(:id) ? user.id : nil
117
+ [type, id.nil? ? nil : id.to_s]
118
+ end
119
+
120
+ def base_context
121
+ @base_context ||= {
122
+ environment: @config.environment,
123
+ deploy_sha: @config.deploy_sha,
124
+ language: LANGUAGE,
125
+ }.compact.freeze
126
+ end
127
+
128
+ def realtime_ns
129
+ Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,104 @@
1
+ require "uri"
2
+
3
+ module Beacon
4
+ class Configuration
5
+ attr_accessor :endpoint, :environment, :deploy_sha, :auth_token,
6
+ :async, :app_root, :pillars,
7
+ :flush_interval, :flush_threshold, :queue_size,
8
+ :connect_timeout, :read_timeout,
9
+ :cache_size, :enabled, :ambient
10
+
11
+ def initialize
12
+ @endpoint = ENV["BEACON_ENDPOINT"] || "http://127.0.0.1:4680"
13
+ @environment = ENV["BEACON_ENVIRONMENT"] || ENV["RAILS_ENV"] || "development"
14
+ @deploy_sha = ENV["GIT_SHA"] || ENV["KAMAL_VERSION"]
15
+ @auth_token = ENV["BEACON_AUTH_TOKEN"]
16
+ @async = true
17
+ @app_root = Dir.pwd
18
+ @pillars = %i[outcomes perf errors]
19
+ @flush_interval = 1.0
20
+ @flush_threshold = 100
21
+ @queue_size = 10_000
22
+ @connect_timeout = 1.0
23
+ @read_timeout = 2.0
24
+ # Shared cap for the middleware's LRU caches (per-request path
25
+ # name cache and per-fingerprint stack-throttle cache). One knob
26
+ # because both caches sit on the same Middleware instance, both
27
+ # are bounded for the same reason (protect against high-cardinality
28
+ # probes), and there is no realistic scenario where one should be
29
+ # tuned independently of the other.
30
+ @cache_size = 1024
31
+
32
+ # Ambient mode: when true, middleware sends kind: 'ambient' events
33
+ # for HTTP requests in addition to perf events.
34
+ @ambient = false
35
+
36
+ # Enrichment block: called on every request to provide dimensions
37
+ # (country, plan, locale, etc.) that flow to all event kinds.
38
+ @enrich_context_block = nil
39
+
40
+ # Global kill switch. When false, Beacon::Middleware is a
41
+ # passthrough, Beacon.track returns nil, and the flusher thread
42
+ # is not started.
43
+ #
44
+ # Default resolution (in priority order):
45
+ # 1. BEACON_DISABLED explicitly set → honored in both directions.
46
+ # "1" / "true" / "yes" / "on" → disabled
47
+ # "0" / "false" / "no" / "off" → forced enabled
48
+ # 2. RAILS_ENV / RACK_ENV is "test" → disabled.
49
+ # 3. Otherwise → enabled (development, staging, production).
50
+ #
51
+ # The test-env default matches Honeybadger/Sentry/AppSignal: an
52
+ # observability gem should not chatter across a hermetic test
53
+ # suite by default. A test that WANTS to assert Beacon was called
54
+ # (via Beacon::Testing::FakeTransport) opts back in locally, or
55
+ # sets BEACON_DISABLED=0 for the whole run.
56
+ @enabled = default_enabled
57
+ end
58
+
59
+ def enabled?
60
+ @enabled && endpoint_usable?
61
+ end
62
+
63
+ def pillar?(name)
64
+ @pillars.include?(name)
65
+ end
66
+
67
+ # Register or read the enrichment block. With a block: registers it.
68
+ # Without: returns the current block (or nil). The block receives a
69
+ # Rack request and returns a Hash of dimensions (e.g. { country: "US" }).
70
+ def enrich_context(&block)
71
+ if block
72
+ @enrich_context_block = block
73
+ else
74
+ @enrich_context_block
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def default_enabled
81
+ v = ENV["BEACON_DISABLED"]
82
+ return !truthy_env?("BEACON_DISABLED") unless v.nil? || v.empty?
83
+ !test_environment?
84
+ end
85
+
86
+ def test_environment?
87
+ ENV["RAILS_ENV"] == "test" || ENV["RACK_ENV"] == "test"
88
+ end
89
+
90
+ def truthy_env?(name)
91
+ v = ENV[name]
92
+ return false if v.nil? || v.empty?
93
+ !%w[0 false no off].include?(v.downcase)
94
+ end
95
+
96
+ def endpoint_usable?
97
+ return false if endpoint.nil? || endpoint.to_s.empty?
98
+ URI.parse(endpoint.to_s)
99
+ true
100
+ rescue URI::InvalidURIError
101
+ false
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,36 @@
1
+ module Beacon
2
+ # Optional helpers for the `enrich_context` block. These are convenience
3
+ # methods — the block can return any Hash with string keys. None of these
4
+ # are required; an app that knows its users' countries from their profile
5
+ # doesn't need CDN header sniffing.
6
+ module Enrichment
7
+ # CDN geo headers checked in priority order: Cloudflare, Fastly,
8
+ # CloudFront. Returns a two-letter ISO 3166-1 country code or nil.
9
+ CDN_GEO_HEADERS = %w[
10
+ HTTP_CF_IPCOUNTRY
11
+ HTTP_FASTLY_GEO_COUNTRY
12
+ HTTP_CLOUDFRONT_VIEWER_COUNTRY
13
+ ].freeze
14
+
15
+ # Returns the two-letter ISO 3166-1 country code from CDN geo headers,
16
+ # or nil when no CDN header is present. Checks Cloudflare, Fastly, and
17
+ # CloudFront in order.
18
+ #
19
+ # Usage inside enrich_context:
20
+ # c.enrich_context do |request|
21
+ # { country: Beacon::Enrichment.country_from_cdn(request) }
22
+ # end
23
+ def self.country_from_cdn(request)
24
+ env = request.respond_to?(:env) ? request.env : request
25
+ CDN_GEO_HEADERS.each do |header|
26
+ value = env[header]
27
+ next if value.nil? || value.empty?
28
+ code = value.strip.upcase
29
+ # "XX" is Cloudflare's "unknown" sentinel; skip it.
30
+ next if code == "XX"
31
+ return code if code.match?(/\A[A-Z]{2}\z/)
32
+ end
33
+ nil
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,16 @@
1
+ require "digest/sha1"
2
+
3
+ module Beacon
4
+ # Fingerprint algorithm — normative, see .doc/definition/06-http-api.md.
5
+ # SHA1("<exception_class>|<first_app_frame_path>")
6
+ # Line numbers are intentionally excluded so cosmetic edits above the
7
+ # failing line don't shatter grouping across deploys.
8
+ module Fingerprint
9
+ LINE_SUFFIX = /:\d+\z/.freeze
10
+
11
+ def self.compute(exception_class, first_app_frame)
12
+ path = first_app_frame.to_s.sub(LINE_SUFFIX, "")
13
+ Digest::SHA1.hexdigest("#{exception_class}|#{path}")
14
+ end
15
+ end
16
+ end