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