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,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems"
4
+
5
+ module BetterAuth
6
+ module Telemetry
7
+ module Detectors
8
+ # Database detector. Returns a small hash describing the database
9
+ # backend the host application is using (or `nil` when no signal
10
+ # is available).
11
+ #
12
+ # This is the Ruby-specific replacement for upstream's
13
+ # `detect-database.ts`, which only walked the Node `package.json`
14
+ # for known SQL/ORM packages. The Ruby port adds two earlier
15
+ # precedence rules (a caller-supplied context override and a
16
+ # `BetterAuth::Configuration` adapter check) so an application
17
+ # with a configured Better Auth adapter does not fall through to
18
+ # the generic gem fallback.
19
+ #
20
+ # ## Precedence chain (Requirement 10)
21
+ #
22
+ # 1. **Context override** — when the caller supplied a non-empty
23
+ # `context.database` string, return it verbatim with
24
+ # `version: nil`. This is the upstream `context.database`
25
+ # seam.
26
+ # 2. **Configuration adapter** — when `options` is a
27
+ # {BetterAuth::Configuration} (or a hash with a `:database`
28
+ # key) and the value is a known adapter symbol
29
+ # ({ADAPTER_SYMBOLS}) or a `BetterAuth::Adapters::*` instance
30
+ # ({ADAPTER_CLASS_MAP}), return its short identifier with
31
+ # `version: nil`.
32
+ # 3. **Gem fallback** — when neither rule above matches, walk
33
+ # `Gem.loaded_specs` in {GEM_FALLBACKS} order and return the
34
+ # first match as `{name: <gem_name>, version: <spec.version.to_s>}`.
35
+ # 4. Otherwise — `nil`.
36
+ #
37
+ # ## Failure handling
38
+ #
39
+ # The whole call is wrapped in `rescue StandardError; nil` so a
40
+ # surprise from any branch (an exotic `context` shape, a
41
+ # `Configuration` reader that raises on a partially constructed
42
+ # instance, a `Gem.loaded_specs` mutation, …) degrades to `nil`
43
+ # rather than escaping out of the init payload composition in
44
+ # {BetterAuth::Telemetry.create}.
45
+ #
46
+ # @example Context override
47
+ # ctx = BetterAuth::Telemetry::NormalizedContext.from(database: "postgresql")
48
+ # BetterAuth::Telemetry::Detectors::Database.call(nil, ctx)
49
+ # # => {name: "postgresql", version: nil}
50
+ #
51
+ # @example Configuration symbol
52
+ # config = BetterAuth::Configuration.new(secret: "...", database: :memory)
53
+ # BetterAuth::Telemetry::Detectors::Database.call(config, nil)
54
+ # # => {name: "memory", version: nil}
55
+ module Database
56
+ # Map from `BetterAuth::Adapters::*` class name to the short
57
+ # identifier reported in the init event. Class names are
58
+ # matched as strings so loading the telemetry gem does not
59
+ # autoload every adapter constant.
60
+ ADAPTER_CLASS_MAP = {
61
+ "BetterAuth::Adapters::Postgres" => "postgres",
62
+ "BetterAuth::Adapters::MySQL" => "mysql",
63
+ "BetterAuth::Adapters::SQLite" => "sqlite",
64
+ "BetterAuth::Adapters::MSSQL" => "mssql",
65
+ "BetterAuth::Adapters::Memory" => "memory"
66
+ }.freeze
67
+
68
+ # Map from a known {BetterAuth::Configuration#database} symbol
69
+ # value to the short identifier reported in the init event.
70
+ ADAPTER_SYMBOLS = {
71
+ postgres: "postgres",
72
+ mysql: "mysql",
73
+ sqlite: "sqlite",
74
+ mssql: "mssql",
75
+ memory: "memory"
76
+ }.freeze
77
+
78
+ # Gems to probe in `Gem.loaded_specs`, in upstream-spec order.
79
+ # First match wins.
80
+ GEM_FALLBACKS = %w[sequel pg mysql2 sqlite3 activerecord mongoid mongo rom-sql].freeze
81
+
82
+ module_function
83
+
84
+ # Resolve the database signal for the host application.
85
+ #
86
+ # @param options [BetterAuth::Configuration, Hash, nil] the
87
+ # options passed to {BetterAuth::Telemetry.create}. May be a
88
+ # {BetterAuth::Configuration} (production path), a raw hash
89
+ # with a `:database` key, or `nil`.
90
+ # @param context [BetterAuth::Telemetry::NormalizedContext, Hash, nil]
91
+ # the optional context. When it responds to `:database` (or
92
+ # carries a `:database` / `"database"` key) and the value is
93
+ # a non-empty string, that string short-circuits the chain.
94
+ # @return [Hash{Symbol => String, nil}, nil] either
95
+ # `{name: String, version: String|nil}` or `nil` when nothing
96
+ # matches.
97
+ def call(options, context)
98
+ override = context_override(context)
99
+ return {name: override, version: nil} if override
100
+
101
+ identifier = identify_from_options(options)
102
+ return {name: identifier, version: nil} if identifier
103
+
104
+ detect_from_gems
105
+ rescue
106
+ nil
107
+ end
108
+
109
+ # Read `database` from a {NormalizedContext}-like or hash-like
110
+ # context. Returns the raw string when present and non-empty,
111
+ # otherwise `nil`. Non-string values (e.g. a symbol set
112
+ # accidentally) are ignored to keep the wire shape stable.
113
+ #
114
+ # @param context [#database, Hash, nil]
115
+ # @return [String, nil]
116
+ def context_override(context)
117
+ return nil if context.nil?
118
+
119
+ value =
120
+ if context.respond_to?(:database)
121
+ context.database
122
+ elsif context.respond_to?(:[])
123
+ context[:database] || context["database"]
124
+ end
125
+
126
+ return nil unless value.is_a?(String)
127
+ return nil if value.empty?
128
+
129
+ value
130
+ end
131
+
132
+ # Translate the configuration's `database` value into a short
133
+ # identifier when it matches a known adapter symbol or a known
134
+ # `BetterAuth::Adapters::*` class.
135
+ #
136
+ # @param options [BetterAuth::Configuration, Hash, nil]
137
+ # @return [String, nil]
138
+ def identify_from_options(options)
139
+ database = configuration_database(options)
140
+ return nil if database.nil?
141
+
142
+ identify_adapter(database)
143
+ end
144
+
145
+ # Read `database` from a {BetterAuth::Configuration} or a raw
146
+ # hash. Returns `nil` for any other input shape.
147
+ #
148
+ # @param options [BetterAuth::Configuration, Hash, nil]
149
+ # @return [Object, nil]
150
+ def configuration_database(options)
151
+ return nil if options.nil?
152
+
153
+ if defined?(::BetterAuth::Configuration) && options.is_a?(::BetterAuth::Configuration)
154
+ return options.database
155
+ end
156
+
157
+ return options[:database] || options["database"] if options.is_a?(Hash)
158
+
159
+ nil
160
+ end
161
+
162
+ # Map a known adapter symbol or a `BetterAuth::Adapters::*`
163
+ # instance to its short identifier. Returns `nil` when the
164
+ # value is neither a known symbol nor a known adapter class.
165
+ #
166
+ # @param value [Symbol, BetterAuth::Adapters::Base, Object]
167
+ # @return [String, nil]
168
+ def identify_adapter(value)
169
+ if value.is_a?(Symbol)
170
+ return ADAPTER_SYMBOLS[value]
171
+ end
172
+
173
+ ADAPTER_CLASS_MAP[value.class.name]
174
+ end
175
+
176
+ # Walk {GEM_FALLBACKS} in order and return the first
177
+ # `Gem.loaded_specs` match as `{name:, version:}`. Returns
178
+ # `nil` when no listed gem is loaded.
179
+ #
180
+ # @return [Hash{Symbol => String}, nil]
181
+ def detect_from_gems
182
+ GEM_FALLBACKS.each do |name|
183
+ spec = ::Gem.loaded_specs[name]
184
+ next if spec.nil?
185
+
186
+ version = spec.respond_to?(:version) ? spec.version : nil
187
+ return {name: name, version: version&.to_s}
188
+ end
189
+ nil
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Telemetry
5
+ module Detectors
6
+ # Environment detector. Classifies the current process as
7
+ # `"production"`, `"ci"`, `"test"`, or `"development"`, mirroring
8
+ # the upstream `detect-runtime.ts:detectEnvironment` short-circuit
9
+ # chain.
10
+ #
11
+ # Precedence (top wins):
12
+ #
13
+ # 1. `"production"` — when any of `RACK_ENV`, `RAILS_ENV`,
14
+ # `APP_ENV` equals the literal string `"production"`.
15
+ # 2. `"ci"` — when any of the documented CI marker variables
16
+ # ({CI_VARS}) is set to a non-empty value that is not the
17
+ # case-insensitive string `"false"`.
18
+ # 3. `"test"` — when any of `RACK_ENV`, `RAILS_ENV`, `APP_ENV`
19
+ # equals the literal string `"test"`.
20
+ # 4. `"development"` — fallback when no rule above matches.
21
+ #
22
+ # The CI marker check intentionally skips the literal string
23
+ # `"false"` (case-insensitive) so a host that exports
24
+ # `CI=false` (a common pattern in non-CI shells where CI tooling
25
+ # has been opted out) is not misclassified. Empty values are also
26
+ # treated as unset.
27
+ #
28
+ # @example
29
+ # BetterAuth::Telemetry::Detectors::Environment.call
30
+ # # => "development"
31
+ module Environment
32
+ # CI marker variables, in the upstream-defined order. Any
33
+ # non-empty / non-`"false"` value flips the classifier to
34
+ # `"ci"`.
35
+ CI_VARS = %w[
36
+ CI
37
+ BUILD_ID
38
+ BUILD_NUMBER
39
+ CI_APP_ID
40
+ CI_BUILD_ID
41
+ CI_BUILD_NUMBER
42
+ CI_NAME
43
+ CONTINUOUS_INTEGRATION
44
+ RUN_ID
45
+ ].freeze
46
+
47
+ # Test/production env variable names that get inspected for the
48
+ # literal `"production"` and `"test"` strings.
49
+ TEST_VARS = %w[RACK_ENV RAILS_ENV APP_ENV].freeze
50
+
51
+ module_function
52
+
53
+ # @return [String] one of `"production"`, `"ci"`, `"test"`, or
54
+ # `"development"`.
55
+ def call
56
+ return "production" if any_env_eq?(TEST_VARS, "production")
57
+ return "ci" if ci?
58
+ return "test" if any_env_eq?(TEST_VARS, "test")
59
+
60
+ "development"
61
+ end
62
+
63
+ # @return [Boolean] true when at least one CI marker variable
64
+ # has a non-empty value that is not (case-insensitive)
65
+ # `"false"`.
66
+ def ci?
67
+ CI_VARS.any? do |key|
68
+ value = ENV[key]
69
+ next false if value.nil? || value.empty?
70
+ next false if value.casecmp("false").zero?
71
+
72
+ true
73
+ end
74
+ end
75
+
76
+ # @param keys [Array<String>] env var names to inspect.
77
+ # @param value [String] expected exact value.
78
+ # @return [Boolean] true when at least one of the named vars
79
+ # equals `value`.
80
+ def any_env_eq?(keys, value)
81
+ keys.any? { |k| ENV[k] == value }
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems"
4
+
5
+ module BetterAuth
6
+ module Telemetry
7
+ module Detectors
8
+ # Framework detector. Returns a small hash describing the Ruby
9
+ # web framework hosting the application (or `nil` when no
10
+ # supported framework gem is loaded).
11
+ #
12
+ # This is the Ruby-specific replacement for upstream's
13
+ # `detect-framework.ts`, which walked the Node `package.json`
14
+ # for known JavaScript frameworks. The Ruby port instead probes
15
+ # `Gem.loaded_specs` for the canonical Ruby web framework gems
16
+ # in declaration order; the first hit wins.
17
+ #
18
+ # ## Probe order (Requirement 11.1)
19
+ #
20
+ # 1. `rails`
21
+ # 2. `sinatra`
22
+ # 3. `hanami`
23
+ # 4. `hanami-router`
24
+ # 5. `roda`
25
+ # 6. `grape`
26
+ # 7. `rack`
27
+ #
28
+ # `rack` is intentionally last so a Rails or Sinatra app does
29
+ # not get reported as a "rack" app just because Rack is a
30
+ # transitive dependency.
31
+ #
32
+ # ## Failure handling
33
+ #
34
+ # The whole call is wrapped in `rescue StandardError; nil` so a
35
+ # surprise from `Gem.loaded_specs` (e.g. a mutated registry, a
36
+ # `respond_to?(:version)` shim that raises) degrades to `nil`
37
+ # rather than escaping out of the init payload composition in
38
+ # {BetterAuth::Telemetry.create}.
39
+ #
40
+ # Node-only frameworks (`next`, `nuxt`, `astro`, `sveltekit`,
41
+ # `solid-start`, `tanstack-start`, `hono`, `express`, `elysia`,
42
+ # `expo`) are intentionally not probed (Requirement 11.4).
43
+ #
44
+ # @example Rails app
45
+ # BetterAuth::Telemetry::Detectors::Framework.call
46
+ # # => {name: "rails", version: "7.1.3"}
47
+ #
48
+ # @example No supported framework loaded
49
+ # BetterAuth::Telemetry::Detectors::Framework.call
50
+ # # => nil
51
+ module Framework
52
+ # Gems to probe in `Gem.loaded_specs`, in upstream/spec order.
53
+ # First match wins.
54
+ GEMS = %w[rails sinatra hanami hanami-router roda grape rack].freeze
55
+
56
+ module_function
57
+
58
+ # Resolve the framework signal for the host application by
59
+ # walking {GEMS} in order against `Gem.loaded_specs`.
60
+ #
61
+ # @return [Hash{Symbol => String}, nil] either
62
+ # `{name: String, version: String}` for the first matching
63
+ # gem, or `nil` when none of the supported framework gems
64
+ # are loaded.
65
+ def call
66
+ GEMS.each do |name|
67
+ spec = ::Gem.loaded_specs[name]
68
+ next if spec.nil?
69
+
70
+ version = spec.respond_to?(:version) ? spec.version : nil
71
+ return {name: name, version: version&.to_s}
72
+ end
73
+ nil
74
+ rescue
75
+ nil
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Telemetry
5
+ module Detectors
6
+ # ProjectInfo detector. Returns a small hash describing the
7
+ # project's "package manager" — for the Ruby port, this is
8
+ # always Bundler (or `nil` when Bundler is not available).
9
+ #
10
+ # This is the Ruby-specific replacement for upstream's
11
+ # `detect-project-info.ts`, which parsed the
12
+ # `npm_config_user_agent` env var to determine the npm/yarn/pnpm
13
+ # toolchain. There is no equivalent Ruby env var; Bundler is the
14
+ # closest semantic match.
15
+ #
16
+ # ## Detection rule (Requirements 12.1 / 12.2)
17
+ #
18
+ # 1. If `Bundler` is `defined?` AND `Bundler.default_gemfile`
19
+ # succeeds (the Gemfile is locatable), return
20
+ # `{name: "bundler", version: ::Bundler::VERSION}`.
21
+ # 2. Otherwise return `nil`.
22
+ #
23
+ # ## Failure handling
24
+ #
25
+ # The whole call is wrapped in `rescue StandardError; nil` so any
26
+ # surprise from probing Bundler (e.g. a stubbed/partially-loaded
27
+ # Bundler module) degrades to `nil` rather than escaping out of
28
+ # the init payload composition in {BetterAuth::Telemetry.create}.
29
+ #
30
+ # No `npm_config_user_agent` or other Node package-manager env
31
+ # var is read (Requirement 12.3); this Ruby-specific deviation
32
+ # is intentional.
33
+ #
34
+ # @example Inside a Bundler-managed app
35
+ # BetterAuth::Telemetry::Detectors::ProjectInfo.call
36
+ # # => {name: "bundler", version: "2.5.3"}
37
+ #
38
+ # @example Bundler not loaded
39
+ # BetterAuth::Telemetry::Detectors::ProjectInfo.call
40
+ # # => nil
41
+ module ProjectInfo
42
+ module_function
43
+
44
+ # Resolve the project-info signal for the host application.
45
+ #
46
+ # @return [Hash{Symbol => String}, nil] either
47
+ # `{name: "bundler", version: <Bundler::VERSION>}` when
48
+ # Bundler is loaded and a Gemfile is locatable, otherwise
49
+ # `nil`.
50
+ def call
51
+ return nil unless bundler_loaded?
52
+ return nil unless default_gemfile_locatable?
53
+
54
+ {name: "bundler", version: ::Bundler::VERSION}
55
+ rescue
56
+ nil
57
+ end
58
+
59
+ # Whether the `Bundler` constant is defined in the current
60
+ # process. Extracted as a stub seam so tests can simulate the
61
+ # Bundler-absent case without actually unloading Bundler.
62
+ #
63
+ # @return [Boolean]
64
+ def bundler_loaded?
65
+ defined?(::Bundler) ? true : false
66
+ end
67
+
68
+ # Whether `Bundler.default_gemfile` resolves successfully.
69
+ # Bundler raises `Bundler::GemfileNotFound` (a `StandardError`
70
+ # subclass) when no Gemfile is locatable, so we treat any
71
+ # raise as "not locatable" rather than letting it escape.
72
+ #
73
+ # @return [Boolean]
74
+ def default_gemfile_locatable?
75
+ return false unless ::Bundler.respond_to?(:default_gemfile)
76
+
77
+ !::Bundler.default_gemfile.nil?
78
+ rescue
79
+ false
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Telemetry
5
+ module Detectors
6
+ # Runtime detector. Returns a small hash describing the Ruby
7
+ # interpreter currently executing the host application.
8
+ #
9
+ # This is the Ruby-specific replacement for upstream's
10
+ # `detect-runtime.ts`, which classified Node, Deno, Bun, Cloudflare
11
+ # Workers, and other JavaScript runtimes. The Ruby port is
12
+ # server-only, so all of those branches collapse into a single
13
+ # `"ruby"` case. The `:engine` field preserves enough information
14
+ # for telemetry consumers to distinguish MRI, JRuby, TruffleRuby,
15
+ # etc.
16
+ #
17
+ # The whole detector is wrapped in `rescue StandardError` so a
18
+ # surprise from `RUBY_VERSION`/`RUBY_ENGINE` (very unlikely, but
19
+ # possible under exotic patched interpreters) cannot bubble out
20
+ # of the init payload composition in
21
+ # {BetterAuth::Telemetry.create}.
22
+ #
23
+ # @example
24
+ # BetterAuth::Telemetry::Detectors::Runtime.call
25
+ # # => {name: "ruby", version: "3.3.0", engine: "ruby"}
26
+ module Runtime
27
+ module_function
28
+
29
+ # @return [Hash{Symbol => String, nil}] hash with `:name`,
30
+ # `:version`, and `:engine` keys. `:name` is always `"ruby"`.
31
+ # `:version` is `RUBY_VERSION`. `:engine` is `RUBY_ENGINE`
32
+ # when defined, otherwise the literal string `"ruby"`.
33
+ def call
34
+ {
35
+ name: "ruby",
36
+ version: RUBY_VERSION,
37
+ engine: defined?(RUBY_ENGINE) ? RUBY_ENGINE : "ruby"
38
+ }
39
+ rescue
40
+ {name: "ruby", version: nil, engine: nil}
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end