better_auth-telemetry 0.8.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/CHANGELOG.md +25 -0
- data/LICENSE.md +20 -0
- data/README.md +202 -0
- data/lib/better_auth/plugins/telemetry.rb +11 -0
- data/lib/better_auth/telemetry/create.rb +293 -0
- data/lib/better_auth/telemetry/detectors/auth_config.rb +662 -0
- data/lib/better_auth/telemetry/detectors/database.rb +194 -0
- data/lib/better_auth/telemetry/detectors/environment.rb +86 -0
- data/lib/better_auth/telemetry/detectors/framework.rb +80 -0
- data/lib/better_auth/telemetry/detectors/project_info.rb +84 -0
- data/lib/better_auth/telemetry/detectors/runtime.rb +45 -0
- data/lib/better_auth/telemetry/detectors/system_info.rb +320 -0
- data/lib/better_auth/telemetry/env.rb +77 -0
- data/lib/better_auth/telemetry/http_client.rb +99 -0
- data/lib/better_auth/telemetry/logger_adapter.rb +118 -0
- data/lib/better_auth/telemetry/noop_publisher.rb +33 -0
- data/lib/better_auth/telemetry/options.rb +240 -0
- data/lib/better_auth/telemetry/project_id.rb +234 -0
- data/lib/better_auth/telemetry/publisher.rb +111 -0
- data/lib/better_auth/telemetry/version.rb +7 -0
- data/lib/better_auth/telemetry.rb +68 -0
- metadata +137 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "logger_adapter"
|
|
4
|
+
|
|
5
|
+
module BetterAuth
|
|
6
|
+
module Telemetry
|
|
7
|
+
# Value objects that normalize the heterogeneous shapes the
|
|
8
|
+
# `BetterAuth::Telemetry.create(options, context)` entry point accepts
|
|
9
|
+
# into the small, well-typed surface the rest of the pipeline depends
|
|
10
|
+
# on.
|
|
11
|
+
#
|
|
12
|
+
# Two normalizers ship together:
|
|
13
|
+
#
|
|
14
|
+
# - {NormalizedOptions} wraps the host-supplied `options`. The argument
|
|
15
|
+
# may be a {BetterAuth::Configuration}, a raw `Hash`, or `nil`.
|
|
16
|
+
# - {NormalizedContext} wraps the optional `context` hash that callers
|
|
17
|
+
# use to override telemetry-side detection (`custom_track`,
|
|
18
|
+
# `database`, `adapter`, `skip_test_check`).
|
|
19
|
+
#
|
|
20
|
+
# Both accept either snake_case (`:custom_track`, `:skip_test_check`,
|
|
21
|
+
# `:database`, `:adapter`) or camelCase (`:customTrack`,
|
|
22
|
+
# `:skipTestCheck`) keys, in either symbol or string form, so callers
|
|
23
|
+
# mirroring the upstream TypeScript API do not have to translate keys
|
|
24
|
+
# by hand.
|
|
25
|
+
#
|
|
26
|
+
# Neither value object raises on missing or `nil` input. Missing keys
|
|
27
|
+
# surface as `nil` readers (or `false` for the boolean-defaulting
|
|
28
|
+
# `skip_test_check`).
|
|
29
|
+
module Options
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Normalized view of the host `options` argument supplied to
|
|
33
|
+
# {BetterAuth::Telemetry.create}.
|
|
34
|
+
#
|
|
35
|
+
# `NormalizedOptions.from(options)` accepts:
|
|
36
|
+
#
|
|
37
|
+
# - a {BetterAuth::Configuration} instance (production path: the value
|
|
38
|
+
# `BetterAuth::Auth#initialize` passes in),
|
|
39
|
+
# - a `Hash` with snake_case or camelCase keys (mirrors the upstream
|
|
40
|
+
# `BetterAuthOptions` shape and the common test seam),
|
|
41
|
+
# - or `nil` (every reader returns `nil` / a default-fallback logger).
|
|
42
|
+
#
|
|
43
|
+
# ## Telemetry opt-in precedence
|
|
44
|
+
#
|
|
45
|
+
# `telemetry_enabled` and `telemetry_debug` use the upstream
|
|
46
|
+
# `nil`/`true`/`false` precedence semantics:
|
|
47
|
+
#
|
|
48
|
+
# - `nil` means "not configured at the option layer" (the env layer
|
|
49
|
+
# may still opt the process in via `BETTER_AUTH_TELEMETRY`).
|
|
50
|
+
# - `true` is an explicit opt-in (subject to the test-environment skip
|
|
51
|
+
# unless `skip_test_check` overrides it).
|
|
52
|
+
# - `false` is an explicit opt-out that overrides every env opt-in.
|
|
53
|
+
#
|
|
54
|
+
# The readers resolve `telemetry[:enabled]` and `telemetry[:debug]`
|
|
55
|
+
# from either a {BetterAuth::Configuration} or a raw Hash.
|
|
56
|
+
#
|
|
57
|
+
# ## Logger
|
|
58
|
+
#
|
|
59
|
+
# The {#logger} reader always returns a usable {LoggerAdapter}.
|
|
60
|
+
# When the host supplies no logger we fall back to
|
|
61
|
+
# `BetterAuth::Logger.create` via {LoggerAdapter.from} so callers
|
|
62
|
+
# never have to nil-check.
|
|
63
|
+
class NormalizedOptions
|
|
64
|
+
# @return [BetterAuth::Configuration, nil] the raw configuration
|
|
65
|
+
# instance when the host passed one, otherwise `nil`. Useful for
|
|
66
|
+
# detectors that want to read additional fields without going
|
|
67
|
+
# through this value object.
|
|
68
|
+
attr_reader :configuration
|
|
69
|
+
|
|
70
|
+
# @return [String, nil] the resolved `app_name` from the
|
|
71
|
+
# configuration or hash, or `nil` when not configured.
|
|
72
|
+
attr_reader :app_name
|
|
73
|
+
|
|
74
|
+
# @return [String, nil] the resolved `base_url` from the
|
|
75
|
+
# configuration or hash, or `nil` when not configured.
|
|
76
|
+
attr_reader :base_url
|
|
77
|
+
|
|
78
|
+
# @return [Boolean, nil] explicit option-layer opt-in / opt-out for
|
|
79
|
+
# telemetry. `nil` defers to env. See class docs for precedence.
|
|
80
|
+
attr_reader :telemetry_enabled
|
|
81
|
+
|
|
82
|
+
# @return [Boolean, nil] explicit option-layer toggle for debug
|
|
83
|
+
# mode. `nil` defers to `BETTER_AUTH_TELEMETRY_DEBUG`.
|
|
84
|
+
attr_reader :telemetry_debug
|
|
85
|
+
|
|
86
|
+
# @return [LoggerAdapter] always-usable logger adapter. Falls back
|
|
87
|
+
# to the default {BetterAuth::Logger} when no logger was supplied.
|
|
88
|
+
attr_reader :logger
|
|
89
|
+
|
|
90
|
+
# Build a {NormalizedOptions} from a {BetterAuth::Configuration},
|
|
91
|
+
# a `Hash`, or `nil`.
|
|
92
|
+
#
|
|
93
|
+
# @param options [BetterAuth::Configuration, Hash, nil]
|
|
94
|
+
# @return [NormalizedOptions]
|
|
95
|
+
def self.from(options)
|
|
96
|
+
if options.is_a?(::BetterAuth::Configuration)
|
|
97
|
+
from_configuration(options)
|
|
98
|
+
elsif options.is_a?(Hash)
|
|
99
|
+
from_hash(options)
|
|
100
|
+
else
|
|
101
|
+
new(
|
|
102
|
+
configuration: nil,
|
|
103
|
+
app_name: nil,
|
|
104
|
+
base_url: nil,
|
|
105
|
+
telemetry_enabled: nil,
|
|
106
|
+
telemetry_debug: nil,
|
|
107
|
+
raw_logger: nil
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @api private
|
|
113
|
+
def self.from_configuration(configuration)
|
|
114
|
+
telemetry =
|
|
115
|
+
if configuration.respond_to?(:telemetry) && configuration.telemetry.is_a?(Hash)
|
|
116
|
+
configuration.telemetry
|
|
117
|
+
else
|
|
118
|
+
{}
|
|
119
|
+
end
|
|
120
|
+
new(
|
|
121
|
+
configuration: configuration,
|
|
122
|
+
app_name: configuration.app_name,
|
|
123
|
+
base_url: configuration.base_url,
|
|
124
|
+
telemetry_enabled: Options.fetch_key(telemetry, :enabled, :enabled),
|
|
125
|
+
telemetry_debug: Options.fetch_key(telemetry, :debug, :debug),
|
|
126
|
+
raw_logger: configuration.logger
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @api private
|
|
131
|
+
def self.from_hash(hash)
|
|
132
|
+
telemetry = Options.fetch_key(hash, :telemetry, :telemetry)
|
|
133
|
+
telemetry = telemetry.is_a?(Hash) ? telemetry : {}
|
|
134
|
+
|
|
135
|
+
new(
|
|
136
|
+
configuration: nil,
|
|
137
|
+
app_name: Options.fetch_key(hash, :app_name, :appName),
|
|
138
|
+
base_url: Options.fetch_key(hash, :base_url, :baseURL),
|
|
139
|
+
telemetry_enabled: Options.fetch_key(telemetry, :enabled, :enabled),
|
|
140
|
+
telemetry_debug: Options.fetch_key(telemetry, :debug, :debug),
|
|
141
|
+
raw_logger: Options.fetch_key(hash, :logger, :logger)
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @api private
|
|
146
|
+
def initialize(configuration:, app_name:, base_url:, telemetry_enabled:, telemetry_debug:, raw_logger:)
|
|
147
|
+
@configuration = configuration
|
|
148
|
+
@app_name = app_name
|
|
149
|
+
@base_url = base_url
|
|
150
|
+
@telemetry_enabled = telemetry_enabled
|
|
151
|
+
@telemetry_debug = telemetry_debug
|
|
152
|
+
@logger = LoggerAdapter.from(raw_logger)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Normalized view of the optional `context` argument supplied to
|
|
157
|
+
# {BetterAuth::Telemetry.create}.
|
|
158
|
+
#
|
|
159
|
+
# `NormalizedContext.from(context)` accepts:
|
|
160
|
+
#
|
|
161
|
+
# - a `Hash` with snake_case or camelCase keys (`:custom_track` /
|
|
162
|
+
# `:customTrack`, `:skip_test_check` / `:skipTestCheck`,
|
|
163
|
+
# `:database`, `:adapter`),
|
|
164
|
+
# - or `nil` (every reader returns its default).
|
|
165
|
+
#
|
|
166
|
+
# Defaults:
|
|
167
|
+
#
|
|
168
|
+
# - {#custom_track} — `nil` when missing.
|
|
169
|
+
# - {#database} — `nil` when missing.
|
|
170
|
+
# - {#adapter} — `nil` when missing.
|
|
171
|
+
# - {#skip_test_check} — `false` when missing or `nil`. Any other
|
|
172
|
+
# value is preserved as-is so the decision layer can apply its own
|
|
173
|
+
# truthiness check.
|
|
174
|
+
class NormalizedContext
|
|
175
|
+
# @return [#call, nil] caller-supplied tracker. When present, every
|
|
176
|
+
# event is delivered to `custom_track.call(event)` instead of via
|
|
177
|
+
# HTTP. The primary testing seam.
|
|
178
|
+
attr_reader :custom_track
|
|
179
|
+
|
|
180
|
+
# @return [String, nil] override for the database name reported in
|
|
181
|
+
# the init event. Bypasses the {Detectors::Database} chain when
|
|
182
|
+
# present.
|
|
183
|
+
attr_reader :database
|
|
184
|
+
|
|
185
|
+
# @return [String, nil] adapter class name, populated by
|
|
186
|
+
# `BetterAuth::Auth#initialize`. Pass-through into the auth-config
|
|
187
|
+
# payload's `adapter` key.
|
|
188
|
+
attr_reader :adapter
|
|
189
|
+
|
|
190
|
+
# @return [Boolean] whether to bypass the
|
|
191
|
+
# `RACK_ENV/RAILS_ENV/APP_ENV == "test"` skip. Does NOT
|
|
192
|
+
# force-enable telemetry on its own; the opt-in from
|
|
193
|
+
# {NormalizedOptions} or env still has to be in place.
|
|
194
|
+
attr_reader :skip_test_check
|
|
195
|
+
|
|
196
|
+
# Build a {NormalizedContext} from a `Hash` or `nil`.
|
|
197
|
+
#
|
|
198
|
+
# @param context [Hash, nil]
|
|
199
|
+
# @return [NormalizedContext]
|
|
200
|
+
def self.from(context)
|
|
201
|
+
hash = context.is_a?(Hash) ? context : {}
|
|
202
|
+
skip = Options.fetch_key(hash, :skip_test_check, :skipTestCheck)
|
|
203
|
+
new(
|
|
204
|
+
custom_track: Options.fetch_key(hash, :custom_track, :customTrack),
|
|
205
|
+
database: Options.fetch_key(hash, :database, :database),
|
|
206
|
+
adapter: Options.fetch_key(hash, :adapter, :adapter),
|
|
207
|
+
skip_test_check: skip.nil? ? false : skip
|
|
208
|
+
)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# @api private
|
|
212
|
+
def initialize(custom_track:, database:, adapter:, skip_test_check:)
|
|
213
|
+
@custom_track = custom_track
|
|
214
|
+
@database = database
|
|
215
|
+
@adapter = adapter
|
|
216
|
+
@skip_test_check = skip_test_check
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
module Options
|
|
221
|
+
# Look up a key in a hash, accepting symbol and string forms of both
|
|
222
|
+
# the snake_case and camelCase variants. Returns `nil` when nothing
|
|
223
|
+
# matches; an explicit `nil` value also returns `nil`.
|
|
224
|
+
#
|
|
225
|
+
# @param hash [Hash]
|
|
226
|
+
# @param snake [Symbol] snake_case key (canonical Ruby form).
|
|
227
|
+
# @param camel [Symbol] camelCase key (upstream form).
|
|
228
|
+
# @return [Object, nil]
|
|
229
|
+
def self.fetch_key(hash, snake, camel)
|
|
230
|
+
return nil unless hash.is_a?(Hash)
|
|
231
|
+
|
|
232
|
+
keys = [snake, camel, snake.to_s, camel.to_s].uniq
|
|
233
|
+
keys.each do |key|
|
|
234
|
+
return hash[key] if hash.key?(key)
|
|
235
|
+
end
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
require_relative "version"
|
|
8
|
+
|
|
9
|
+
module BetterAuth
|
|
10
|
+
module Telemetry
|
|
11
|
+
# Thread-local registry that lets {ProjectId.resolve_project_name}
|
|
12
|
+
# discover the host's `app_name` without changing the public method
|
|
13
|
+
# signature `BetterAuth::Telemetry.project_id(base_url)` (Requirement
|
|
14
|
+
# 14.1).
|
|
15
|
+
#
|
|
16
|
+
# `BetterAuth::Telemetry.create` sets `app_name` for the duration of
|
|
17
|
+
# an init flow via {.with_app_name}; outside of that scope the reader
|
|
18
|
+
# returns `nil` and the project-name resolver falls through to the
|
|
19
|
+
# next rule in the chain (Bundler.locked_gems → Bundler.root).
|
|
20
|
+
#
|
|
21
|
+
# The store is per-thread so concurrent `create` calls in different
|
|
22
|
+
# threads don't clobber each other.
|
|
23
|
+
module CurrentOptions
|
|
24
|
+
KEY = :better_auth_telemetry_current_options_app_name
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# @return [String, nil] the app name set by the most recent
|
|
29
|
+
# {.with_app_name} block on the current thread, or `nil`.
|
|
30
|
+
def app_name
|
|
31
|
+
Thread.current[KEY]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param value [String, nil]
|
|
35
|
+
# @return [String, nil] the value just stored.
|
|
36
|
+
def app_name=(value)
|
|
37
|
+
Thread.current[KEY] = value
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Run `block` with `app_name` set to `value`, restoring the prior
|
|
41
|
+
# value (typically `nil`) on the way out — even when the block
|
|
42
|
+
# raises.
|
|
43
|
+
#
|
|
44
|
+
# @param value [String, nil]
|
|
45
|
+
# @yield with the thread-local app name temporarily set.
|
|
46
|
+
# @return [Object] whatever the block returns.
|
|
47
|
+
def with_app_name(value)
|
|
48
|
+
prior = Thread.current[KEY]
|
|
49
|
+
Thread.current[KEY] = value
|
|
50
|
+
yield
|
|
51
|
+
ensure
|
|
52
|
+
Thread.current[KEY] = prior
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Project-name resolver used by {BetterAuth::Telemetry.project_id}.
|
|
57
|
+
#
|
|
58
|
+
# The chain (Requirement 14.7) is:
|
|
59
|
+
#
|
|
60
|
+
# 1. {CurrentOptions.app_name} — when set and not the default
|
|
61
|
+
# `"Better Auth"`.
|
|
62
|
+
# 2. The first entry of `Bundler.locked_gems.specs` — the
|
|
63
|
+
# Gemfile.lock-pinned name of the current project.
|
|
64
|
+
# 3. `File.basename(Bundler.root)` — the directory name of the
|
|
65
|
+
# Gemfile root, used when the lockfile yields nothing useful.
|
|
66
|
+
#
|
|
67
|
+
# Every fallback is wrapped in `rescue StandardError; nil` so that a
|
|
68
|
+
# missing Bundler load, an unreadable lockfile, or any unrelated
|
|
69
|
+
# error in one rule degrades to the next rule rather than escaping
|
|
70
|
+
# to the caller (Requirement 14.8).
|
|
71
|
+
module ProjectId
|
|
72
|
+
# Upstream sentinel: the `Better Auth` literal is treated as "not
|
|
73
|
+
# configured" so the chain falls through to the Bundler signals.
|
|
74
|
+
DEFAULT_APP_NAME = "Better Auth"
|
|
75
|
+
|
|
76
|
+
module_function
|
|
77
|
+
|
|
78
|
+
# @return [String, nil] the resolved project name, or `nil` when
|
|
79
|
+
# no rule produced a non-empty string.
|
|
80
|
+
def resolve_project_name
|
|
81
|
+
from_app_name || from_locked_gems || from_bundler_root
|
|
82
|
+
rescue
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Read the host's `app_name` from {CurrentOptions}. Treats the
|
|
87
|
+
# literal `"Better Auth"` (the upstream default) as "not
|
|
88
|
+
# configured" so it never wins over the Bundler-derived rules.
|
|
89
|
+
#
|
|
90
|
+
# @return [String, nil]
|
|
91
|
+
def from_app_name
|
|
92
|
+
name = CurrentOptions.app_name
|
|
93
|
+
return nil if name.nil?
|
|
94
|
+
return nil unless name.is_a?(String)
|
|
95
|
+
return nil if name.empty?
|
|
96
|
+
return nil if name == DEFAULT_APP_NAME
|
|
97
|
+
|
|
98
|
+
name
|
|
99
|
+
rescue
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# First gemspec in `Bundler.locked_gems.specs`. Mirrors the
|
|
104
|
+
# upstream `package.json#name` lookup. Returns `nil` when Bundler
|
|
105
|
+
# is not loaded, no lockfile is locatable, or the spec list is
|
|
106
|
+
# empty.
|
|
107
|
+
#
|
|
108
|
+
# @return [String, nil]
|
|
109
|
+
def from_locked_gems
|
|
110
|
+
return nil unless defined?(::Bundler)
|
|
111
|
+
|
|
112
|
+
locked = ::Bundler.locked_gems
|
|
113
|
+
return nil if locked.nil?
|
|
114
|
+
|
|
115
|
+
spec = locked.specs&.first
|
|
116
|
+
return nil if spec.nil?
|
|
117
|
+
|
|
118
|
+
name = spec.name
|
|
119
|
+
return nil if name.nil? || name.empty?
|
|
120
|
+
|
|
121
|
+
name
|
|
122
|
+
rescue
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Directory name of `Bundler.root`. The closest Ruby analog to
|
|
127
|
+
# upstream's "directory containing package.json" fallback.
|
|
128
|
+
#
|
|
129
|
+
# @return [String, nil]
|
|
130
|
+
def from_bundler_root
|
|
131
|
+
return nil unless defined?(::Bundler)
|
|
132
|
+
|
|
133
|
+
root = ::Bundler.root
|
|
134
|
+
return nil if root.nil?
|
|
135
|
+
|
|
136
|
+
name = File.basename(root.to_s)
|
|
137
|
+
return nil if name.nil? || name.empty?
|
|
138
|
+
|
|
139
|
+
name
|
|
140
|
+
rescue
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
@project_id_cache = nil
|
|
146
|
+
@project_id_mutex = Mutex.new
|
|
147
|
+
|
|
148
|
+
# Resolve a stable, anonymous project id for telemetry.
|
|
149
|
+
#
|
|
150
|
+
# The id is derived once per process and memoized; subsequent calls
|
|
151
|
+
# — regardless of the `base_url` they pass — return the cached
|
|
152
|
+
# value (Requirement 14.6). This mirrors the upstream
|
|
153
|
+
# `projectIdCached` module-scope variable.
|
|
154
|
+
#
|
|
155
|
+
# ## Derivation chain (Requirements 14.2 – 14.5)
|
|
156
|
+
#
|
|
157
|
+
# 1. Project name resolvable AND `base_url` non-empty:
|
|
158
|
+
# `Base64(SHA-256(base_url + name))`.
|
|
159
|
+
# 2. Project name resolvable AND `base_url` nil/empty:
|
|
160
|
+
# `Base64(SHA-256(name))`.
|
|
161
|
+
# 3. No project name AND `base_url` non-empty:
|
|
162
|
+
# `Base64(SHA-256(base_url))`.
|
|
163
|
+
# 4. Otherwise: a random 32-character `[a-zA-Z0-9]` id from
|
|
164
|
+
# `SecureRandom`, matching upstream `generateId(32)`.
|
|
165
|
+
#
|
|
166
|
+
# The Bundler/lockfile probes inside {ProjectId.resolve_project_name}
|
|
167
|
+
# never raise out of this method (Requirement 14.8); a failed probe
|
|
168
|
+
# collapses to "no project name" and the chain continues at rule 3
|
|
169
|
+
# or rule 4.
|
|
170
|
+
#
|
|
171
|
+
# @param base_url [String, nil] the host's configured base URL.
|
|
172
|
+
# @return [String] the memoized anonymous project id.
|
|
173
|
+
def self.project_id(base_url)
|
|
174
|
+
cached = @project_id_cache
|
|
175
|
+
return cached if cached
|
|
176
|
+
|
|
177
|
+
@project_id_mutex.synchronize do
|
|
178
|
+
cached = @project_id_cache
|
|
179
|
+
return cached if cached
|
|
180
|
+
|
|
181
|
+
@project_id_cache = derive_project_id(base_url)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Test-only hook that clears the memoized project id cache.
|
|
186
|
+
#
|
|
187
|
+
# Wired here in task 3.6 to clear the `@project_id_cache` ivar that
|
|
188
|
+
# backs {.project_id}. Tests use this between cases that exercise
|
|
189
|
+
# different derivation rules (e.g. with vs. without a project name)
|
|
190
|
+
# so each call goes through the full chain again.
|
|
191
|
+
#
|
|
192
|
+
# @return [nil]
|
|
193
|
+
def self.reset_project_id!
|
|
194
|
+
@project_id_mutex.synchronize do
|
|
195
|
+
@project_id_cache = nil
|
|
196
|
+
end
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# @api private
|
|
201
|
+
def self.derive_project_id(base_url)
|
|
202
|
+
url = base_url.is_a?(String) ? base_url : nil
|
|
203
|
+
url = nil if url && url.empty?
|
|
204
|
+
|
|
205
|
+
name = ProjectId.resolve_project_name
|
|
206
|
+
name = nil if name.is_a?(String) && name.empty?
|
|
207
|
+
|
|
208
|
+
if name && url
|
|
209
|
+
hash_to_base64(url + name)
|
|
210
|
+
elsif name
|
|
211
|
+
hash_to_base64(name)
|
|
212
|
+
elsif url
|
|
213
|
+
hash_to_base64(url)
|
|
214
|
+
else
|
|
215
|
+
random_id_32
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# @api private
|
|
220
|
+
def self.hash_to_base64(input)
|
|
221
|
+
Base64.strict_encode64(Digest::SHA256.digest(input.to_s))
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# @api private
|
|
225
|
+
PROJECT_ID_ALPHABET = (
|
|
226
|
+
("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
|
|
227
|
+
).freeze
|
|
228
|
+
|
|
229
|
+
# @api private
|
|
230
|
+
def self.random_id_32
|
|
231
|
+
Array.new(32) { PROJECT_ID_ALPHABET[SecureRandom.random_number(PROJECT_ID_ALPHABET.length)] }.join
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "project_id"
|
|
4
|
+
|
|
5
|
+
module BetterAuth
|
|
6
|
+
module Telemetry
|
|
7
|
+
# Publisher returned from {BetterAuth::Telemetry.create} when telemetry is
|
|
8
|
+
# opted-in. The publisher is delivery-agnostic: it does not know whether
|
|
9
|
+
# the configured `track` callable forwards events over HTTP, hands them to
|
|
10
|
+
# a host-supplied `custom_track`, or routes them through the debug logger.
|
|
11
|
+
# All of that branching is built into the `track` lambda once at
|
|
12
|
+
# `create`-time, and the `Publisher` simply normalizes each event and
|
|
13
|
+
# forwards it through (Requirements 5.6, 5.7, 6.10, 15.1, 15.2).
|
|
14
|
+
#
|
|
15
|
+
# ## Responsibilities
|
|
16
|
+
#
|
|
17
|
+
# 1. Short-circuit to `nil` when `enabled` is `false`, so a disabled
|
|
18
|
+
# publisher is a noop. `#enabled?` reports the flag verbatim.
|
|
19
|
+
# 2. Lazily resolve `anonymous_id` on the first `#publish` call by
|
|
20
|
+
# delegating to {BetterAuth::Telemetry.project_id} when the publisher
|
|
21
|
+
# was constructed without one. The result is cached on the instance,
|
|
22
|
+
# so subsequent `#publish` calls reuse the same `anonymousId`.
|
|
23
|
+
# 3. Normalize each event hash to symbol keys with the upstream wire
|
|
24
|
+
# shape (`type`, `payload`, `anonymousId`). Both `:type`/`:payload`
|
|
25
|
+
# and `"type"`/`"payload"` input keys are accepted; missing
|
|
26
|
+
# `:payload` falls back to `{}`.
|
|
27
|
+
# 4. Forward the normalized event through `track.call(event)` and
|
|
28
|
+
# rescue any `StandardError` raised by the callable, routing the
|
|
29
|
+
# failure through `logger.error(...)` and returning `nil`. Errors in
|
|
30
|
+
# HTTP delivery, custom_track callbacks, or JSON encoding therefore
|
|
31
|
+
# never escape `#publish`.
|
|
32
|
+
#
|
|
33
|
+
# The publisher is intentionally stateless beyond the cached
|
|
34
|
+
# `anonymous_id`: there is no internal queue and no batching. It calls
|
|
35
|
+
# the supplied `track` lambda synchronously; the HTTP track implementation
|
|
36
|
+
# may then hand the actual POST to a short-lived background thread.
|
|
37
|
+
#
|
|
38
|
+
# @example wiring with a `RecordingTrack` (test seam)
|
|
39
|
+
# recorder = BetterAuth::Telemetry::Test::RecordingTrack.new
|
|
40
|
+
# publisher = BetterAuth::Telemetry::Publisher.new(
|
|
41
|
+
# enabled: true,
|
|
42
|
+
# anonymous_id: nil,
|
|
43
|
+
# track: recorder,
|
|
44
|
+
# base_url: "https://example.com",
|
|
45
|
+
# logger: BetterAuth::Telemetry::LoggerAdapter.from(nil)
|
|
46
|
+
# )
|
|
47
|
+
# publisher.publish(type: "ping", payload: {})
|
|
48
|
+
# recorder.last # => { type: :ping, payload: {}, anonymousId: "..." }
|
|
49
|
+
class Publisher
|
|
50
|
+
# @param enabled [Boolean] whether the publisher should forward events.
|
|
51
|
+
# When `false`, every `#publish` call is a noop returning `nil`.
|
|
52
|
+
# @param anonymous_id [String, nil] the resolved anonymous project id,
|
|
53
|
+
# or `nil` to defer resolution to the first `#publish` call.
|
|
54
|
+
# @param track [#call] callable that receives the normalized event
|
|
55
|
+
# hash. Built once by `BetterAuth::Telemetry.create` and closes over
|
|
56
|
+
# the chosen delivery mode (custom_track, debug, or http).
|
|
57
|
+
# @param base_url [String, nil] the host's base URL, forwarded to
|
|
58
|
+
# {BetterAuth::Telemetry.project_id} when lazy-resolving
|
|
59
|
+
# `anonymous_id`.
|
|
60
|
+
# @param logger [#error] log adapter used to surface delivery
|
|
61
|
+
# failures (`StandardError`) raised by `track`.
|
|
62
|
+
def initialize(enabled:, anonymous_id:, track:, base_url:, logger:)
|
|
63
|
+
@enabled = enabled
|
|
64
|
+
@anonymous_id = anonymous_id
|
|
65
|
+
@track = track
|
|
66
|
+
@base_url = base_url
|
|
67
|
+
@logger = logger
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Forward an event through the configured `track` callable.
|
|
71
|
+
#
|
|
72
|
+
# Returns `nil` when the publisher is disabled. Otherwise normalizes
|
|
73
|
+
# the input event to `{type:, payload:, anonymousId:}` (symbol keys),
|
|
74
|
+
# lazy-resolves `anonymous_id` on first use, and dispatches via
|
|
75
|
+
# `track.call(event_to_emit)`. Any `StandardError` raised by `track`
|
|
76
|
+
# is rescued and logged at error level; the call still returns `nil`.
|
|
77
|
+
#
|
|
78
|
+
# The `event` argument may carry either symbol (`:type`, `:payload`)
|
|
79
|
+
# or string (`"type"`, `"payload"`) keys; missing `:payload` defaults
|
|
80
|
+
# to `{}`. Output keys are always symbols, matching the upstream wire
|
|
81
|
+
# format.
|
|
82
|
+
#
|
|
83
|
+
# @param event [Hash] the event to publish; accepts symbol or string
|
|
84
|
+
# `:type`/`:payload` keys.
|
|
85
|
+
# @return [nil]
|
|
86
|
+
def publish(event)
|
|
87
|
+
return nil unless @enabled
|
|
88
|
+
|
|
89
|
+
@anonymous_id ||= BetterAuth::Telemetry.project_id(@base_url)
|
|
90
|
+
|
|
91
|
+
event_to_emit = {
|
|
92
|
+
type: event[:type] || event["type"],
|
|
93
|
+
payload: event[:payload] || event["payload"] || {},
|
|
94
|
+
anonymousId: @anonymous_id
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@track.call(event_to_emit)
|
|
98
|
+
nil
|
|
99
|
+
rescue => e
|
|
100
|
+
@logger.error("[better-auth.telemetry] publish failed: #{e.class}: #{e.message}")
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [Boolean] whether this publisher is opted-in. `false` means
|
|
105
|
+
# `#publish` is a noop.
|
|
106
|
+
def enabled?
|
|
107
|
+
@enabled
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Public entry point for the `better_auth-telemetry` gem.
|
|
4
|
+
#
|
|
5
|
+
# Requiring this file pulls in every internal component the telemetry
|
|
6
|
+
# pipeline depends on so callers only need a single
|
|
7
|
+
# `require "better_auth/telemetry"` to access the full public surface:
|
|
8
|
+
#
|
|
9
|
+
# - {BetterAuth::Telemetry.create} — build a publisher tailored to the
|
|
10
|
+
# host's opt-in state.
|
|
11
|
+
# - {BetterAuth::Telemetry.project_id} /
|
|
12
|
+
# {BetterAuth::Telemetry.reset_project_id!} — anonymous project id
|
|
13
|
+
# resolution and the test-only cache reset hook.
|
|
14
|
+
# - {BetterAuth::Telemetry::Publisher} /
|
|
15
|
+
# {BetterAuth::Telemetry::NoopPublisher} — the two publisher shapes
|
|
16
|
+
# `create` returns.
|
|
17
|
+
# - {BetterAuth::Telemetry::Detectors} — the seven detector modules
|
|
18
|
+
# (`Runtime`, `Environment`, `SystemInfo`, `Database`, `Framework`,
|
|
19
|
+
# `ProjectInfo`, `AuthConfig`).
|
|
20
|
+
# - Supporting value objects and helpers
|
|
21
|
+
# ({BetterAuth::Telemetry::NormalizedOptions},
|
|
22
|
+
# {BetterAuth::Telemetry::NormalizedContext},
|
|
23
|
+
# {BetterAuth::Telemetry::CurrentOptions},
|
|
24
|
+
# {BetterAuth::Telemetry::Env},
|
|
25
|
+
# {BetterAuth::Telemetry::HttpClient},
|
|
26
|
+
# {BetterAuth::Telemetry::LoggerAdapter}).
|
|
27
|
+
#
|
|
28
|
+
# The standard-library requires below are listed once at the entry
|
|
29
|
+
# point so individual internal files can rely on them being loaded.
|
|
30
|
+
# Every internal file additionally requires what it directly depends
|
|
31
|
+
# on, so any single file is independently loadable.
|
|
32
|
+
|
|
33
|
+
require "better_auth"
|
|
34
|
+
|
|
35
|
+
require "base64"
|
|
36
|
+
require "digest"
|
|
37
|
+
require "json"
|
|
38
|
+
require "net/http"
|
|
39
|
+
require "securerandom"
|
|
40
|
+
require "uri"
|
|
41
|
+
|
|
42
|
+
require_relative "telemetry/version"
|
|
43
|
+
require_relative "telemetry/noop_publisher"
|
|
44
|
+
require_relative "telemetry/logger_adapter"
|
|
45
|
+
require_relative "telemetry/options"
|
|
46
|
+
require_relative "telemetry/env"
|
|
47
|
+
require_relative "telemetry/http_client"
|
|
48
|
+
require_relative "telemetry/project_id"
|
|
49
|
+
require_relative "telemetry/publisher"
|
|
50
|
+
require_relative "telemetry/create"
|
|
51
|
+
|
|
52
|
+
require_relative "telemetry/detectors/runtime"
|
|
53
|
+
require_relative "telemetry/detectors/environment"
|
|
54
|
+
require_relative "telemetry/detectors/system_info"
|
|
55
|
+
require_relative "telemetry/detectors/database"
|
|
56
|
+
require_relative "telemetry/detectors/framework"
|
|
57
|
+
require_relative "telemetry/detectors/project_info"
|
|
58
|
+
require_relative "telemetry/detectors/auth_config"
|
|
59
|
+
|
|
60
|
+
module BetterAuth
|
|
61
|
+
# Top-level namespace for the `better_auth-telemetry` gem.
|
|
62
|
+
#
|
|
63
|
+
# See `BetterAuth::Telemetry.create` for the entry point used by
|
|
64
|
+
# `BetterAuth::Auth#initialize` and by tests that exercise the
|
|
65
|
+
# publisher in isolation.
|
|
66
|
+
module Telemetry
|
|
67
|
+
end
|
|
68
|
+
end
|