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 +7 -0
- data/README.md +165 -0
- data/lib/beacon/client.rb +132 -0
- data/lib/beacon/configuration.rb +104 -0
- data/lib/beacon/enrichment.rb +36 -0
- data/lib/beacon/fingerprint.rb +16 -0
- data/lib/beacon/flusher.rb +245 -0
- data/lib/beacon/integrations/action_mailer.rb +63 -0
- data/lib/beacon/integrations/active_job.rb +58 -0
- data/lib/beacon/log_throttle.rb +63 -0
- data/lib/beacon/lru.rb +67 -0
- data/lib/beacon/middleware.rb +316 -0
- data/lib/beacon/path_normalizer.rb +39 -0
- data/lib/beacon/queue.rb +71 -0
- data/lib/beacon/rails.rb +178 -0
- data/lib/beacon/testing.rb +97 -0
- data/lib/beacon/transport.rb +136 -0
- data/lib/beacon/version.rb +3 -0
- data/lib/beacon-client.rb +12 -0
- data/lib/beacon.rb +123 -0
- data/lib/tasks/beacon.rake +47 -0
- metadata +87 -0
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
|