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.
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "rbconfig"
5
+ require "rubygems"
6
+ require "timeout"
7
+
8
+ module BetterAuth
9
+ module Telemetry
10
+ module Detectors
11
+ # System info detector. Returns a hash describing the host
12
+ # platform, container/WSL state, deployment vendor, and a few
13
+ # cheap host-level signals (cpu count, memory, isTTY).
14
+ #
15
+ # This is the Ruby-specific replacement for upstream's
16
+ # `detect-system-info.ts` (and the Node-specific block inside
17
+ # `node.ts`). The Ruby port collapses both upstream variants into
18
+ # a single server-side detector that uses `RbConfig`,
19
+ # `Gem::Platform.local`, `Etc`, `IO.popen`, and a few `File.exist?`
20
+ # / `File.read` probes against well-known paths.
21
+ #
22
+ # ## Ruby-specific deviations from upstream
23
+ #
24
+ # - `cpuModel` is always `nil`. There is no portable Ruby stdlib
25
+ # API for the model string, and exposing a partial detection
26
+ # (e.g. parsing `/proc/cpuinfo`) would only work on Linux.
27
+ # - `cpuSpeed` (an upstream key) is **omitted entirely** from the
28
+ # returned hash, rather than emitted as `nil`. Including it
29
+ # would invite consumers to assume it can ever be populated by
30
+ # the Ruby implementation. The README documents this.
31
+ # - `memory` is read from `/proc/meminfo` on Linux and from
32
+ # `sysctl -n hw.memsize` on macOS via {.read_sysctl_memsize}
33
+ # under a 1s {Timeout.timeout}. On other platforms (and on
34
+ # read failures) it is `nil`.
35
+ #
36
+ # ## Failure handling (Requirement 9.11)
37
+ #
38
+ # Every probe is invoked through {.safely}, which is just
39
+ # `yield rescue StandardError; nil`. A surprise from any single
40
+ # probe degrades that field to `nil` rather than escaping out of
41
+ # the init payload composition in
42
+ # {BetterAuth::Telemetry.create}.
43
+ #
44
+ # Each helper probe ({.detect_vendor}, {.platform}, {.release},
45
+ # {.architecture}, {.cpu_count}, {.total_memory_bytes}, {.wsl?},
46
+ # {.docker?}, {.tty?}) is exposed as a `module_function` so it
47
+ # can be stubbed with `Minitest::Mock#stub` in the
48
+ # corresponding test, exercising the per-field rescue path.
49
+ module SystemInfo
50
+ # Vendor table. The list and order mirror upstream's
51
+ # `getVendor` short-circuit chain in
52
+ # `upstream/better-auth/1.6.9/packages/telemetry/src/detectors/detect-system-info.ts`.
53
+ # First match wins; a missing match yields `nil`.
54
+ #
55
+ # Each entry is `[vendor_name, [marker_env_var, ...]]`. A
56
+ # vendor matches when any of its marker variables is set to a
57
+ # non-empty value.
58
+ VENDORS = [
59
+ ["cloudflare", %w[CF_PAGES CF_PAGES_URL CF_ACCOUNT_ID]],
60
+ ["vercel", %w[VERCEL VERCEL_URL VERCEL_ENV]],
61
+ ["netlify", %w[NETLIFY NETLIFY_URL]],
62
+ ["render", %w[RENDER RENDER_URL RENDER_INTERNAL_HOSTNAME RENDER_SERVICE_ID]],
63
+ ["aws", %w[AWS_LAMBDA_FUNCTION_NAME AWS_EXECUTION_ENV LAMBDA_TASK_ROOT]],
64
+ ["gcp", %w[GOOGLE_CLOUD_FUNCTION_NAME GOOGLE_CLOUD_PROJECT GCP_PROJECT K_SERVICE]],
65
+ ["azure", %w[AZURE_FUNCTION_NAME FUNCTIONS_WORKER_RUNTIME WEBSITE_INSTANCE_ID WEBSITE_SITE_NAME]],
66
+ ["deno-deploy", %w[DENO_DEPLOYMENT_ID DENO_REGION]],
67
+ ["fly-io", %w[FLY_APP_NAME FLY_REGION FLY_ALLOC_ID]],
68
+ ["railway", %w[RAILWAY_STATIC_URL RAILWAY_ENVIRONMENT_NAME]],
69
+ ["heroku", %w[DYNO HEROKU_APP_NAME]],
70
+ ["digitalocean", %w[DO_DEPLOYMENT_ID DO_APP_NAME DIGITALOCEAN]],
71
+ ["koyeb", %w[KOYEB KOYEB_DEPLOYMENT_ID KOYEB_APP_NAME]]
72
+ ].freeze
73
+
74
+ # Cap on the `sysctl` subprocess reading macOS `hw.memsize`. A
75
+ # well-behaved `sysctl` returns essentially instantly; the cap
76
+ # only exists so a hung subprocess cannot block init.
77
+ SYSCTL_TIMEOUT_SECONDS = 1
78
+
79
+ module_function
80
+
81
+ # Compose the system-info hash emitted as
82
+ # `payload[:systemInfo]` in the init event.
83
+ #
84
+ # @return [Hash{Symbol => Object, nil}] hash with keys
85
+ # `:deploymentVendor`, `:systemPlatform`, `:systemRelease`,
86
+ # `:systemArchitecture`, `:cpuCount`, `:cpuModel`, `:memory`,
87
+ # `:isWSL`, `:isDocker`, `:isTTY`. Any individual field may
88
+ # be `nil` when the underlying probe is unsupported on the
89
+ # host or raises. The key `:cpuSpeed` is intentionally
90
+ # absent.
91
+ def call
92
+ {
93
+ deploymentVendor: safely { detect_vendor },
94
+ systemPlatform: safely { platform },
95
+ systemRelease: safely { release },
96
+ systemArchitecture: safely { architecture },
97
+ cpuCount: safely { cpu_count },
98
+ cpuModel: nil,
99
+ memory: safely { total_memory_bytes },
100
+ isWSL: safely { wsl? },
101
+ isDocker: safely { docker? },
102
+ isTTY: safely { tty? }
103
+ }
104
+ end
105
+
106
+ # Run `block` and rescue any `StandardError` to `nil`. The
107
+ # whole detector composes its return hash by calling each
108
+ # probe through this helper, so a raising probe degrades only
109
+ # that field rather than aborting the entire detector.
110
+ #
111
+ # @yield the probe to run.
112
+ # @return [Object, nil] whatever the block returns, or `nil`
113
+ # if the block raised a `StandardError`.
114
+ def safely
115
+ yield
116
+ rescue
117
+ nil
118
+ end
119
+
120
+ # Match the first vendor whose marker variables are present in
121
+ # `ENV`. Mirrors upstream's `getVendor` short-circuit chain.
122
+ #
123
+ # @return [String, nil] the vendor name (`"vercel"`,
124
+ # `"cloudflare"`, …) or `nil` when no vendor matches.
125
+ def detect_vendor
126
+ VENDORS.each do |(name, keys)|
127
+ return name if keys.any? { |k| has_env_marker?(k) }
128
+ end
129
+ nil
130
+ end
131
+
132
+ # @return [Boolean] whether `ENV[key]` is set to a non-empty
133
+ # string. Mirrors upstream's `Boolean(env[k])`.
134
+ def has_env_marker?(key)
135
+ value = ENV[key]
136
+ !value.nil? && !value.empty?
137
+ end
138
+
139
+ # Short platform identifier matching upstream `os.platform()`
140
+ # style.
141
+ #
142
+ # @return [String, nil] one of `"linux"`, `"darwin"`,
143
+ # `"windows"`, `"freebsd"`, `"openbsd"`, `"netbsd"`,
144
+ # `"sunos"`, `"aix"`. Falls back to
145
+ # `Gem::Platform.local.os` (or the raw `host_os`) when the
146
+ # `host_os` token does not match a known prefix.
147
+ def platform
148
+ host_os = RbConfig::CONFIG["host_os"].to_s.downcase
149
+ case host_os
150
+ when /linux/ then "linux"
151
+ when /darwin/ then "darwin"
152
+ when /mswin|mingw|cygwin/ then "windows"
153
+ when /freebsd/ then "freebsd"
154
+ when /openbsd/ then "openbsd"
155
+ when /netbsd/ then "netbsd"
156
+ when /sunos|solaris/ then "sunos"
157
+ when /aix/ then "aix"
158
+ else
159
+ (Gem::Platform.local.os if defined?(::Gem::Platform)) || host_os
160
+ end
161
+ end
162
+
163
+ # Operating-system release string. Prefers `Etc.uname[:release]`
164
+ # (e.g. `"5.15.0-92-generic"` on Linux, `"24.6.0"` on macOS).
165
+ # Falls back to the trailing version digits of
166
+ # `RbConfig::CONFIG["host_os"]` (e.g. `"darwin25"` → `"25"`)
167
+ # when `Etc.uname` is unavailable.
168
+ #
169
+ # @return [String, nil]
170
+ def release
171
+ if defined?(::Etc) && ::Etc.respond_to?(:uname)
172
+ value = ::Etc.uname[:release]
173
+ return value if value.is_a?(String) && !value.empty?
174
+ end
175
+ host_os = RbConfig::CONFIG["host_os"].to_s
176
+ tail = host_os[/\d.*\z/]
177
+ (tail.nil? || tail.empty?) ? nil : tail
178
+ end
179
+
180
+ # Short architecture identifier matching upstream `os.arch()`
181
+ # style.
182
+ #
183
+ # @return [String, nil] e.g. `"x64"`, `"arm64"`, `"ia32"`.
184
+ # Falls back to `Gem::Platform.local.cpu` (or the raw
185
+ # `host_cpu`) when the value does not match a known token.
186
+ def architecture
187
+ host_cpu = RbConfig::CONFIG["host_cpu"].to_s.downcase
188
+ case host_cpu
189
+ when "x86_64", "amd64", "x64" then "x64"
190
+ when "aarch64", "arm64" then "arm64"
191
+ when "i386", "i686", "x86" then "ia32"
192
+ when /ppc64/ then "ppc64"
193
+ when /ppc/ then "ppc"
194
+ when /arm/ then "arm"
195
+ else
196
+ (Gem::Platform.local.cpu if defined?(::Gem::Platform)) || host_cpu
197
+ end
198
+ end
199
+
200
+ # @return [Integer, nil] the value returned by
201
+ # `Etc.nprocessors`, reported verbatim including `0`. The
202
+ # outer `safely` wrapper in {.call} maps an `Etc.nprocessors`
203
+ # raise to `nil`.
204
+ def cpu_count
205
+ ::Etc.nprocessors
206
+ end
207
+
208
+ # Total system memory in bytes when reachable on the host
209
+ # platform, otherwise `nil`.
210
+ #
211
+ # @return [Integer, nil]
212
+ def total_memory_bytes
213
+ case platform
214
+ when "linux"
215
+ read_meminfo_bytes
216
+ when "darwin"
217
+ read_sysctl_memsize
218
+ end
219
+ end
220
+
221
+ # Read `MemTotal` from `/proc/meminfo`. The field reports
222
+ # kilobytes; we multiply to bytes to match upstream's
223
+ # `os.totalmem()` units.
224
+ #
225
+ # @return [Integer, nil]
226
+ def read_meminfo_bytes
227
+ File.foreach("/proc/meminfo") do |line|
228
+ if (m = line.match(/\AMemTotal:\s+(\d+)\s+kB/i))
229
+ return m[1].to_i * 1024
230
+ end
231
+ end
232
+ nil
233
+ rescue
234
+ nil
235
+ end
236
+
237
+ # Run `sysctl -n hw.memsize` under a 1s timeout. The
238
+ # subprocess writes a single integer (bytes) to stdout.
239
+ #
240
+ # @return [Integer, nil]
241
+ def read_sysctl_memsize
242
+ output = Timeout.timeout(SYSCTL_TIMEOUT_SECONDS) do
243
+ IO.popen(["sysctl", "-n", "hw.memsize"], err: File::NULL, &:read)
244
+ end
245
+ return nil if output.nil? || output.strip.empty?
246
+ value = output.strip.to_i
247
+ (value > 0) ? value : nil
248
+ rescue
249
+ nil
250
+ end
251
+
252
+ # Detect Docker via well-known sentinels.
253
+ #
254
+ # @return [Boolean] `true` when `/.dockerenv` exists OR
255
+ # `/proc/self/cgroup` exists and contains the literal
256
+ # substring `"docker"`; `false` otherwise.
257
+ def docker?
258
+ return true if File.exist?("/.dockerenv")
259
+ if File.exist?("/proc/self/cgroup")
260
+ return true if File.read("/proc/self/cgroup").include?("docker")
261
+ end
262
+ false
263
+ rescue
264
+ false
265
+ end
266
+
267
+ # Detect WSL.
268
+ #
269
+ # `true` iff `RUBY_PLATFORM` indicates Linux AND either
270
+ # `Etc.uname[:release]` or `/proc/version` contains the
271
+ # case-insensitive substring `"microsoft"`, AND the host is
272
+ # not detected as inside a non-Docker container (the
273
+ # `/run/.containerenv` sentinel).
274
+ #
275
+ # @return [Boolean]
276
+ def wsl?
277
+ return false unless RUBY_PLATFORM.to_s.include?("linux")
278
+
279
+ return false unless microsoft_marker?
280
+ return false if non_docker_container?
281
+
282
+ true
283
+ rescue
284
+ false
285
+ end
286
+
287
+ # @return [Boolean] whether either `Etc.uname[:release]` or
288
+ # `/proc/version` contains `"microsoft"` (case-insensitive).
289
+ def microsoft_marker?
290
+ if defined?(::Etc) && ::Etc.respond_to?(:uname)
291
+ release_str = ::Etc.uname[:release].to_s
292
+ return true if release_str.downcase.include?("microsoft")
293
+ end
294
+
295
+ if File.exist?("/proc/version")
296
+ return true if File.read("/proc/version").downcase.include?("microsoft")
297
+ end
298
+
299
+ false
300
+ rescue
301
+ false
302
+ end
303
+
304
+ # @return [Boolean] whether the host is detected as a non-Docker
305
+ # container — `/run/.containerenv` is present AND
306
+ # {.docker?} is `false`.
307
+ def non_docker_container?
308
+ File.exist?("/run/.containerenv") && !docker?
309
+ rescue
310
+ false
311
+ end
312
+
313
+ # @return [Boolean] `$stdout.tty?`.
314
+ def tty?
315
+ $stdout.tty?
316
+ end
317
+ end
318
+ end
319
+ end
320
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "better_auth/env"
4
+
5
+ module BetterAuth
6
+ module Telemetry
7
+ # Telemetry-side wrapper around {BetterAuth::Env} that exposes the
8
+ # two helpers the rest of the telemetry pipeline depends on:
9
+ #
10
+ # - {.get} — read a `BETTER_AUTH_*` environment variable while
11
+ # transparently honoring the `OPEN_AUTH_*` alias prefix.
12
+ # - {.truthy?} — classify a resolved env string as truthy using the
13
+ # same rules upstream applies in
14
+ # `packages/core/src/env/env-impl.ts:getBooleanEnvVar`.
15
+ #
16
+ # The wrapper is intentionally thin: {BetterAuth::Env.get} already
17
+ # implements the dual-prefix resolution, so {.get} just delegates.
18
+ # Wrapping it here gives the telemetry package a single, named seam
19
+ # the orchestrator code can reach for and the tests can drive against,
20
+ # without leaking the core env module into every detector.
21
+ #
22
+ # ## Truthy semantics
23
+ #
24
+ # An environment value is considered a `Truthy_Env_Value`
25
+ # (Requirement 3.6) when **all three** of these conditions hold for
26
+ # the resolved string:
27
+ #
28
+ # 1. it is not empty,
29
+ # 2. it is not the literal `"0"`, and
30
+ # 3. `value.casecmp("false") != 0` (i.e. not `"false"` / `"FALSE"`
31
+ # / `"False"` / etc).
32
+ #
33
+ # Anything else — including `nil`, `""`, `"0"`, and any casing of
34
+ # `"false"` — is falsy. This mirrors the upstream behavior so the
35
+ # Ruby port classifies opt-in toggles identically to the Node port.
36
+ #
37
+ # The classifier accepts any input type: non-string values are
38
+ # coerced via `#to_s` before classification. That makes it safe to
39
+ # forward boolean defaults straight from option hashes
40
+ # (`Env.truthy?(options[:telemetry][:debug])`) without callers
41
+ # having to type-check first.
42
+ module Env
43
+ module_function
44
+
45
+ # Resolve the value of a telemetry environment variable.
46
+ #
47
+ # Accepts the canonical `BETTER_AUTH_*` name and delegates to
48
+ # {BetterAuth::Env.get}, which checks the `OPEN_AUTH_*` alias
49
+ # first and falls back to the canonical name. Returns `nil` when
50
+ # neither variant is set (or both are empty).
51
+ #
52
+ # @param name [String, Symbol] canonical `BETTER_AUTH_*`
53
+ # environment variable name (e.g. `"BETTER_AUTH_TELEMETRY"`).
54
+ # @return [String, nil] the resolved value, or `nil` when absent.
55
+ def get(name)
56
+ ::BetterAuth::Env.get(name)
57
+ end
58
+
59
+ # Classify an environment value as truthy.
60
+ #
61
+ # @param value [Object, nil] typically a `String` returned from
62
+ # {.get}, but any value is accepted; non-strings are coerced via
63
+ # `#to_s`. `nil` coerces to `""` and is falsy.
64
+ # @return [Boolean] `true` when the resolved string is non-empty,
65
+ # not `"0"`, and not (case-insensitively) `"false"`. `false`
66
+ # otherwise.
67
+ def truthy?(value)
68
+ string = value.to_s
69
+ return false if string.empty?
70
+ return false if string == "0"
71
+ return false if string.casecmp("false") == 0
72
+
73
+ true
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ require_relative "version"
8
+
9
+ module BetterAuth
10
+ module Telemetry
11
+ # Synchronous JSON-over-HTTP delivery used by the telemetry publisher
12
+ # when an endpoint is configured and debug mode is off. Implemented on
13
+ # top of `Net::HTTP` so the gem ships with zero external HTTP runtime
14
+ # dependencies (Requirement 1.8).
15
+ #
16
+ # Every transport-level failure (DNS errors, refused connections, TLS
17
+ # errors, JSON encoding errors, malformed URLs, timeouts, non-2xx
18
+ # responses surfaced as exceptions) is rescued at the `StandardError`
19
+ # boundary and routed through the supplied logger at error level.
20
+ # Non-`StandardError` exceptions (`Interrupt`, `SystemExit`,
21
+ # `SignalException`, `NoMemoryError`) are intentionally allowed to
22
+ # propagate, matching the "fail closed on signals" convention used by
23
+ # the rest of the telemetry pipeline.
24
+ #
25
+ # The method always returns `nil`, regardless of success, failure, or
26
+ # response status. Callers MUST treat it strictly as fire-and-forget;
27
+ # the response body and status are intentionally not exposed because
28
+ # consumers should never make publish decisions based on transport
29
+ # outcomes (Requirements 5.3, 5.6, 5.8).
30
+ #
31
+ # ## Timeouts
32
+ #
33
+ # `open_timeout` and `read_timeout` are both bounded at 5 seconds so
34
+ # telemetry delivery can never block application initialization for
35
+ # an unbounded period (Requirement 5.8).
36
+ #
37
+ # ## Headers
38
+ #
39
+ # - `Content-Type: application/json`
40
+ # - `User-Agent: better_auth-telemetry/<VERSION>` where `<VERSION>` is
41
+ # {BetterAuth::Telemetry::VERSION}.
42
+ #
43
+ # @example successful delivery
44
+ # BetterAuth::Telemetry::HttpClient.post_json(
45
+ # "https://telemetry.example.com/ingest",
46
+ # { type: "init", payload: {} },
47
+ # logger: logger_adapter
48
+ # ) # => nil
49
+ #
50
+ # @example unreachable host
51
+ # BetterAuth::Telemetry::HttpClient.post_json(
52
+ # "http://127.0.0.1:1",
53
+ # { type: "init", payload: {} },
54
+ # logger: logger_adapter
55
+ # ) # => nil; logger.error called once
56
+ module HttpClient
57
+ # Bounded `open_timeout` for `Net::HTTP.start`. See Requirement 5.8.
58
+ OPEN_TIMEOUT_SECONDS = 5
59
+
60
+ # Bounded `read_timeout` for `Net::HTTP.start`. See Requirement 5.8.
61
+ READ_TIMEOUT_SECONDS = 5
62
+
63
+ # Issue a synchronous JSON `POST` to `url`. Always returns `nil` and
64
+ # never raises a `StandardError`.
65
+ #
66
+ # @param url [String] the absolute endpoint URL. `https` is treated
67
+ # as TLS-enabled (`use_ssl: true`).
68
+ # @param body [Hash, Array, Object] the payload, encoded via
69
+ # `JSON.generate`.
70
+ # @param logger [#error] a logger-shaped object (typically
71
+ # {BetterAuth::Telemetry::LoggerAdapter}) used to record
72
+ # transport failures at error level.
73
+ # @return [nil]
74
+ def self.post_json(url, body, logger:)
75
+ uri = URI.parse(url)
76
+
77
+ request = Net::HTTP::Post.new(uri)
78
+ request["Content-Type"] = "application/json"
79
+ request["User-Agent"] = "better_auth-telemetry/#{BetterAuth::Telemetry::VERSION}"
80
+ request.body = JSON.generate(body)
81
+
82
+ Net::HTTP.start(
83
+ uri.host,
84
+ uri.port,
85
+ use_ssl: uri.scheme == "https",
86
+ open_timeout: OPEN_TIMEOUT_SECONDS,
87
+ read_timeout: READ_TIMEOUT_SECONDS
88
+ ) do |http|
89
+ http.request(request)
90
+ end
91
+
92
+ nil
93
+ rescue => e
94
+ logger.error("[better-auth.telemetry] http delivery failed: #{e.class}: #{e.message}")
95
+ nil
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "better_auth/logger"
4
+
5
+ module BetterAuth
6
+ module Telemetry
7
+ # Thin wrapper that normalizes any logger-shaped object into the two-method
8
+ # surface the telemetry pipeline depends on: `#info(message)` and
9
+ # `#error(message)`. Every dispatch is wrapped in a `rescue StandardError`
10
+ # so a misbehaving logger can never propagate out of telemetry code paths
11
+ # (Requirements 5.5, 21.1, 21.2, 21.3).
12
+ #
13
+ # ## Per-dispatch selection rule
14
+ #
15
+ # On every `#info` / `#error` call, in order:
16
+ #
17
+ # 1. If the wrapped logger responds to the requested level
18
+ # (`:info` or `:error`), call it.
19
+ # 2. Otherwise, if the wrapped logger responds to `:call`, invoke
20
+ # `logger.call(level, message)`.
21
+ # 3. Otherwise, fall back to `Kernel.warn(message)`.
22
+ #
23
+ # Any `StandardError` raised by the chosen step is swallowed and the call
24
+ # returns `nil`. Non-`StandardError` exceptions (`Interrupt`,
25
+ # `SystemExit`, `SignalException`, `NoMemoryError`) are intentionally
26
+ # allowed to propagate.
27
+ #
28
+ # ## Construction
29
+ #
30
+ # Use {LoggerAdapter.from} to build an adapter from a host-supplied
31
+ # `options.logger`. When no logger is configured, the factory falls back
32
+ # to `BetterAuth::Logger.create` so callers always get a usable adapter
33
+ # that responds to `info` and `error`.
34
+ #
35
+ # @example wrap a Ruby stdlib `Logger`
36
+ # adapter = BetterAuth::Telemetry::LoggerAdapter.from(Logger.new($stderr))
37
+ # adapter.info("opted-in")
38
+ #
39
+ # @example wrap a callable logger
40
+ # adapter = BetterAuth::Telemetry::LoggerAdapter.from(->(level, msg) { puts "[#{level}] #{msg}" })
41
+ #
42
+ # @example default fallback
43
+ # adapter = BetterAuth::Telemetry::LoggerAdapter.from(nil)
44
+ # adapter.error("boom") # routed through BetterAuth::Logger.create
45
+ class LoggerAdapter
46
+ # Build a {LoggerAdapter} from the host-supplied logger, falling back to
47
+ # the default {BetterAuth::Logger} when none is configured.
48
+ #
49
+ # Selection rules:
50
+ #
51
+ # - If `options_logger` is non-`nil` and responds to both `:info` and
52
+ # `:error`, wrap it as-is.
53
+ # - Else if `options_logger` is non-`nil` and responds to `:call`, wrap
54
+ # the callable.
55
+ # - Else fall back to `BetterAuth::Logger.create`.
56
+ #
57
+ # @param options_logger [Object, nil] the logger to wrap; may be a
58
+ # `Logger`-shaped object, a callable (`#call(level, message)`), or
59
+ # `nil`.
60
+ # @return [LoggerAdapter] a fresh adapter with `#info` and `#error`.
61
+ def self.from(options_logger)
62
+ return new(options_logger) if logger_shape?(options_logger)
63
+ return new(options_logger) if callable_shape?(options_logger)
64
+
65
+ new(::BetterAuth::Logger.create)
66
+ end
67
+
68
+ # @api private
69
+ def self.logger_shape?(logger)
70
+ !logger.nil? && logger.respond_to?(:info) && logger.respond_to?(:error)
71
+ end
72
+
73
+ # @api private
74
+ def self.callable_shape?(logger)
75
+ !logger.nil? && logger.respond_to?(:call)
76
+ end
77
+
78
+ # @param logger [Object] any object that responds to `:info`/`:error`,
79
+ # or that responds to `:call`, or that responds to neither (in which
80
+ # case dispatch falls back to `Kernel.warn`).
81
+ def initialize(logger)
82
+ @logger = logger
83
+ end
84
+
85
+ # Dispatch an info-level log entry through the wrapped logger.
86
+ #
87
+ # @param message [String] the message to log.
88
+ # @return [nil]
89
+ def info(message)
90
+ log(:info, message)
91
+ end
92
+
93
+ # Dispatch an error-level log entry through the wrapped logger.
94
+ #
95
+ # @param message [String] the message to log.
96
+ # @return [nil]
97
+ def error(message)
98
+ log(:error, message)
99
+ end
100
+
101
+ private
102
+
103
+ def log(level, message)
104
+ if @logger.respond_to?(level)
105
+ @logger.public_send(level, message)
106
+ elsif @logger.respond_to?(:call)
107
+ @logger.call(level, message)
108
+ else
109
+ Kernel.warn(message)
110
+ end
111
+ nil
112
+ rescue
113
+ # Requirement 21.3: logger errors must not propagate.
114
+ nil
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Telemetry
5
+ # Publisher returned from {BetterAuth::Telemetry.create} when telemetry is
6
+ # disabled, when no `BETTER_AUTH_TELEMETRY_ENDPOINT` is configured and no
7
+ # `custom_track` is supplied, or when the soft-load fallback inside
8
+ # {BetterAuth::Auth#initialize} cannot load the telemetry gem.
9
+ #
10
+ # Calling `#publish` on a `NoopPublisher` is always safe: the method
11
+ # accepts any event-shaped argument, performs no work, raises no error,
12
+ # and returns `nil`. `#enabled?` always reports `false`. This lets
13
+ # callers treat `auth.telemetry` as a non-nullable, always-callable
14
+ # collaborator without having to nil-check before each `publish` call.
15
+ #
16
+ # @example
17
+ # publisher = BetterAuth::Telemetry::NoopPublisher.new
18
+ # publisher.publish(type: "ping", payload: {}) # => nil
19
+ # publisher.enabled? # => false
20
+ class NoopPublisher
21
+ # @param _event [Object] any event payload; ignored.
22
+ # @return [nil]
23
+ def publish(_event)
24
+ nil
25
+ end
26
+
27
+ # @return [Boolean] always `false`.
28
+ def enabled?
29
+ false
30
+ end
31
+ end
32
+ end
33
+ end