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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7f696d840e8f9fb96751e211c5ad327bf255c3460b99c7adfccab3cbb9c10a1d
4
+ data.tar.gz: 3b513054876f4c07d8ecfd2bca1705a94b3406226c3270815b084fa5450429fe
5
+ SHA512:
6
+ metadata.gz: b17c3cebcaae59c8a9cc0d4b936ec2f793fa4004cadf6599aacdaa806b407e6fcd1631036d0039c1746f4dcb43641731a8221deb226eaf6e31fc6dfecb6b52c7
7
+ data.tar.gz: 437bd20b0f121eb0fa8d9f884545e2f0b1978a4c3f73c4bf6d1de9261d1f8d2cf3d410c188a03f1967f17d3c22c687315bcb97fd65300258a80b846ca42faaff
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## 0.8.0
4
+
5
+ - Initial release. Ports the upstream `@better-auth/telemetry` package
6
+ (vendored at `upstream/better-auth/1.6.9/packages/telemetry/`) into the
7
+ Ruby monorepo as the canonical `better_auth-telemetry` gem with a paired
8
+ `openauth-telemetry` alias.
9
+ - Opt-in only. Telemetry is disabled by default and skipped under
10
+ `RACK_ENV=test` / `RAILS_ENV=test` / `APP_ENV=test` unless
11
+ `context[:skip_test_check]` bypasses the gate.
12
+ - Supports both the `BETTER_AUTH_*` and `OPEN_AUTH_*` environment-variable
13
+ prefixes for `BETTER_AUTH_TELEMETRY`, `BETTER_AUTH_TELEMETRY_DEBUG`, and
14
+ `BETTER_AUTH_TELEMETRY_ENDPOINT` via `BetterAuth::Env.get`.
15
+ - HTTP delivery uses Ruby's standard library (`Net::HTTP`) with a 5-second
16
+ open + read timeout. No external HTTP-client gem is required at runtime.
17
+ - Soft-loaded by `BetterAuth::Auth#initialize`: when bundled, `auth.telemetry`
18
+ returns a publisher; when not bundled, it returns a noop publisher whose
19
+ `#publish` is a safe no-op.
20
+ - Mirrors upstream redaction rules and camelCase wire-format keys for
21
+ `payload.config`. Ruby-specific deviations (single Ruby implementation,
22
+ `runtime.engine` extra key, `cpuSpeed` omitted, `cpuModel` always `nil`,
23
+ `packageManager` reflects Bundler, framework/database probe lists,
24
+ `appName` not emitted) are documented in the README.
25
+ - No file under `upstream/better-auth/1.6.9/` is modified.
data/LICENSE.md ADDED
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+ Copyright (c) 2024 - present
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
5
+ this software and associated documentation files (the "Software"), to deal in
6
+ the Software without restriction, including without limitation the rights to
7
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
8
+ the Software, and to permit persons to whom the Software is furnished to do so,
9
+ subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
16
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
18
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
19
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20
+ DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,202 @@
1
+ # better_auth-telemetry
2
+
3
+ Opt-in telemetry package for Better Auth Ruby. Ports the upstream
4
+ `@better-auth/telemetry` package (vendored at
5
+ `upstream/better-auth/1.6.9/packages/telemetry/`) using only Ruby's standard
6
+ library.
7
+
8
+ Telemetry is **disabled by default**. The package never sends data unless an
9
+ operator explicitly opts in, and it is automatically skipped when the host
10
+ process is running under `RACK_ENV=test`, `RAILS_ENV=test`, or `APP_ENV=test`.
11
+ It is not configured through `plugins: [...]`; it is an optional gem that core
12
+ soft-loads when available.
13
+
14
+ ## Installation
15
+
16
+ Add the gem:
17
+
18
+ ```ruby
19
+ gem "better_auth-telemetry"
20
+ ```
21
+
22
+ When the gem is bundled, `BetterAuth::Auth#initialize` automatically wires
23
+ `auth.telemetry` to a publisher. When the gem is **not** bundled, `auth.telemetry`
24
+ is still safe to call: it returns a noop publisher whose `#publish` is a no-op.
25
+ Core's behavior is unchanged either way.
26
+
27
+ Require `better_auth/telemetry` only when using the telemetry API directly:
28
+
29
+ ```ruby
30
+ require "better_auth/telemetry"
31
+ ```
32
+
33
+ ## Opting in
34
+
35
+ Two equivalent ways to opt in. Either is sufficient on its own.
36
+
37
+ ### Via options
38
+
39
+ ```ruby
40
+ auth = BetterAuth.auth(
41
+ secret: ENV.fetch("BETTER_AUTH_SECRET"),
42
+ database: :postgres,
43
+ telemetry: { enabled: true }
44
+ )
45
+ ```
46
+
47
+ An explicit `telemetry: { enabled: false }` always wins over the env var:
48
+ setting `options[:telemetry][:enabled] = false` disables telemetry even when
49
+ `BETTER_AUTH_TELEMETRY=1` is set.
50
+
51
+ ### Via environment variables
52
+
53
+ The package reads every variable through `BetterAuth::Env.get`, which honors
54
+ both the `BETTER_AUTH_*` and `OPEN_AUTH_*` prefixes. The `OPEN_AUTH_*` form
55
+ takes precedence over the `BETTER_AUTH_*` form when both are set.
56
+
57
+ | Purpose | `BETTER_AUTH_*` form | `OPEN_AUTH_*` form |
58
+ |--------------|--------------------------------|------------------------------|
59
+ | Opt in | `BETTER_AUTH_TELEMETRY` | `OPEN_AUTH_TELEMETRY` |
60
+ | Debug mode | `BETTER_AUTH_TELEMETRY_DEBUG` | `OPEN_AUTH_TELEMETRY_DEBUG` |
61
+ | Endpoint URL | `BETTER_AUTH_TELEMETRY_ENDPOINT` | `OPEN_AUTH_TELEMETRY_ENDPOINT` |
62
+
63
+ A value is treated as truthy when it is non-empty, not equal to `"0"`, and not
64
+ equal to (case-insensitive) `"false"`. Unset and empty are both treated as
65
+ absent. No other telemetry environment variables are recognized.
66
+
67
+ ```bash
68
+ export BETTER_AUTH_TELEMETRY=1
69
+ export BETTER_AUTH_TELEMETRY_ENDPOINT=https://telemetry.example.com/ingest
70
+ ```
71
+
72
+ ## Test environment skip
73
+
74
+ When `RACK_ENV`, `RAILS_ENV`, or `APP_ENV` equals `"test"`, telemetry is skipped
75
+ even if it is otherwise opted in. Bypass this skip by setting
76
+ `context[:skip_test_check] = true`. `skip_test_check` only bypasses the test
77
+ gate; it does not opt telemetry in on its own.
78
+
79
+ ```ruby
80
+ BetterAuth::Telemetry.create(
81
+ options,
82
+ { skip_test_check: true } # opt-in still required via options or env
83
+ )
84
+ ```
85
+
86
+ ## Debug mode
87
+
88
+ When debug mode is on (`options[:telemetry][:debug] = true` or
89
+ `BETTER_AUTH_TELEMETRY_DEBUG` set to a truthy value), every event is logged via
90
+ the configured logger using `logger.info(JSON.pretty_generate(event))` and
91
+ **no HTTP request is made**. This is the recommended mode for inspecting what
92
+ the package would send before pointing it at a real endpoint.
93
+
94
+ ```ruby
95
+ auth = BetterAuth.auth(
96
+ secret: ENV.fetch("BETTER_AUTH_SECRET"),
97
+ database: :postgres,
98
+ telemetry: { enabled: true, debug: true }
99
+ )
100
+ ```
101
+
102
+ When neither debug mode nor `custom_track` is configured and an endpoint is
103
+ set, the publisher starts a short-lived background thread that POSTs JSON
104
+ events to the endpoint via `Net::HTTP` with a 5-second open + read timeout.
105
+ HTTP telemetry is fire-and-forget, so constructing `BetterAuth.auth` is not
106
+ blocked by a slow or unavailable endpoint. Any `StandardError` raised during
107
+ HTTP delivery is rescued and logged at error level rather than propagated.
108
+
109
+ ## The `custom_track` injection seam
110
+
111
+ `context[:custom_track]` is a callable (typically a `Proc` or lambda) that
112
+ receives every event in lieu of HTTP delivery. It is the testing seam used by
113
+ the gem's own test suite, and it is also useful in production to forward
114
+ events to an in-process queue, an audit log, or a custom collector.
115
+
116
+ ```ruby
117
+ recorder = []
118
+ custom_track = ->(event) { recorder << event }
119
+
120
+ publisher = BetterAuth::Telemetry.create(
121
+ { secret: "x", database: :memory, telemetry: { enabled: true } },
122
+ { custom_track: custom_track, skip_test_check: true }
123
+ )
124
+
125
+ publisher.publish(type: "ping", payload: { hello: "world" })
126
+
127
+ # recorder now contains the init event plus { type: "ping", payload: { hello: "world" }, anonymousId: "..." }
128
+ ```
129
+
130
+ If `custom_track` raises, the exception is rescued, logged at error level, and
131
+ swallowed; `#publish` always returns `nil`. The `anonymousId` on every event
132
+ emitted by a single publisher is the same string, derived from
133
+ `BetterAuth::Telemetry.project_id(base_url)`.
134
+
135
+ The package accepts both snake_case and camelCase keys on the context for
136
+ parity with callers mirroring upstream type definitions: `:custom_track` and
137
+ `:customTrack` are equivalent, as are `:skip_test_check` and `:skipTestCheck`.
138
+
139
+ ## Differences from upstream
140
+
141
+ The upstream `@better-auth/telemetry` package targets multiple JavaScript
142
+ runtimes (Node, Bun, Deno, edge) and ships two build entrypoints. This Ruby
143
+ port collapses both upstream variants into a single server-side Ruby
144
+ implementation and adapts every detector to idiomatic Ruby. The wire format
145
+ preserves upstream camelCase keys and redaction rules so existing telemetry
146
+ consumers can ingest events from Ruby projects without schema branching.
147
+
148
+ The intentional Ruby-specific deviations are:
149
+
150
+ - **Single Ruby implementation.** No Node, Bun, Deno, or edge runtime
151
+ branches. Detectors do not probe for `npm_config_user_agent`, do not walk
152
+ `node_modules`, and do not classify against JavaScript-only runtimes.
153
+ - **`runtime.engine` extra key.** The runtime payload includes an `:engine`
154
+ key (`"ruby"`, `"jruby"`, `"truffleruby"`) sourced from `RUBY_ENGINE` so
155
+ consumers can distinguish Ruby implementations. Upstream emits only `name`
156
+ and `version`.
157
+ - **`cpuSpeed` omitted.** Upstream's `systemInfo.cpuSpeed` field is not
158
+ emitted at all on the Ruby side. There is no portable Ruby standard-library
159
+ API for CPU speed, and emitting `nil` would invite consumers to assume the
160
+ field can ever be populated.
161
+ - **`cpuModel` always `nil`.** The `systemInfo.cpuModel` key is present in the
162
+ payload (so the schema matches upstream) but is always `nil`. Ruby has no
163
+ portable standard-library API for the CPU model string.
164
+ - **`packageManager` reflects Bundler, not npm.** When Bundler is loadable
165
+ and a Gemfile is locatable, `payload.packageManager` is
166
+ `{ name: "bundler", version: Bundler::VERSION }`. Otherwise the field is
167
+ `nil`. Upstream's `npm_config_user_agent` parsing has no Ruby analogue.
168
+ - **Framework probe list is Ruby-specific.** The framework detector inspects
169
+ `Gem.loaded_specs` for `rails`, `sinatra`, `hanami`, `hanami-router`,
170
+ `roda`, `grape`, `rack` (in that order). Node-only frameworks (`next`,
171
+ `nuxt`, `astro`, `sveltekit`, `solid-start`, `tanstack-start`, `hono`,
172
+ `express`, `elysia`, `expo`) are intentionally not probed.
173
+ - **Database probe list is Ruby-specific.** The database detector falls back
174
+ to `Gem.loaded_specs` for `sequel`, `pg`, `mysql2`, `sqlite3`,
175
+ `activerecord`, `mongoid`, `mongo`, `rom-sql` (in that order) when no
176
+ context override or `BetterAuth::Adapters::*` adapter class match is found.
177
+ - **Standard library only HTTP.** HTTP delivery uses `Net::HTTP` with a
178
+ 5-second open + read timeout inside a short-lived background thread. No
179
+ external HTTP-client gem is required at runtime, and HTTP delivery does not
180
+ block `BetterAuth.auth` construction.
181
+ - **Explicit false is a strong opt-out.** `telemetry: { enabled: false }`
182
+ disables telemetry even when `BETTER_AUTH_TELEMETRY` or `OPEN_AUTH_TELEMETRY`
183
+ is truthy. This is intentionally stricter than upstream so application
184
+ configuration can override process-wide env vars.
185
+ - **snake_case canonical context keys, with camelCase synonyms accepted.**
186
+ The Ruby-canonical context keys are `:custom_track`, `:database`,
187
+ `:adapter`, `:skip_test_check`. The package also accepts the camelCase
188
+ variants (`:customTrack`, `:skipTestCheck`) for parity with callers
189
+ mirroring upstream type definitions.
190
+ - **`appName` is not emitted.** The `app_name` value is used internally by
191
+ `BetterAuth::Telemetry.project_id` to derive the `anonymousId` but is
192
+ intentionally not emitted as a payload field, since it can be
193
+ user-identifying.
194
+ - **Public `BetterAuth::Telemetry.reset_project_id!` testing helper.** A
195
+ module-level helper is exposed for resetting the memoized
196
+ `anonymous_id` between tests. It has no effect on production behavior and
197
+ exists solely so test suites can assert deterministic project_id derivation
198
+ across opt-in / opt-out cycles.
199
+
200
+ ## License
201
+
202
+ MIT
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Soft-load probe shim for `better_auth-telemetry`.
4
+ #
5
+ # The core package soft-loads `require "better_auth/telemetry"` when building
6
+ # `auth.telemetry`. This shim keeps the plugin-style path loadable for callers
7
+ # that still require it directly, then delegates to the canonical public entry
8
+ # point.
9
+ #
10
+ # Implements Requirements 16.1 and 16.2.
11
+ require "better_auth/telemetry"
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "env"
6
+ require_relative "http_client"
7
+ require_relative "noop_publisher"
8
+ require_relative "options"
9
+ require_relative "project_id"
10
+ require_relative "publisher"
11
+
12
+ require_relative "detectors/auth_config"
13
+ require_relative "detectors/database"
14
+ require_relative "detectors/environment"
15
+ require_relative "detectors/framework"
16
+ require_relative "detectors/project_info"
17
+ require_relative "detectors/runtime"
18
+ require_relative "detectors/system_info"
19
+
20
+ module BetterAuth
21
+ module Telemetry
22
+ # Process-environment variables that mark the host as running inside a
23
+ # test suite. Mirrors {BetterAuth::Configuration#test_environment?}
24
+ # without taking a hard dependency on a `Configuration` instance — the
25
+ # `create` entry point also accepts raw hashes and `nil`.
26
+ TEST_ENV_VARS = %w[RACK_ENV RAILS_ENV APP_ENV].freeze
27
+
28
+ # Public entry point used by `BetterAuth::Auth#initialize` (and by
29
+ # tests that exercise the publisher in isolation) to build a
30
+ # publisher tailored to the host's opt-in state.
31
+ #
32
+ # ## Pipeline
33
+ #
34
+ # 1. Normalize the heterogeneous `options` and `context` arguments
35
+ # into {NormalizedOptions} / {NormalizedContext} value objects so
36
+ # the rest of the pipeline does not have to repeatedly do
37
+ # snake/camelCase key lookups.
38
+ # 2. Resolve `endpoint = Env.get("BETTER_AUTH_TELEMETRY_ENDPOINT")`,
39
+ # honoring the `OPEN_AUTH_*` alias prefix.
40
+ # 3. **Short-circuit**: when both the endpoint and `custom_track`
41
+ # are absent there is no delivery channel and the publisher
42
+ # cannot do useful work, so we hand back a {NoopPublisher} and
43
+ # bypass the rest of the pipeline (Requirement 5.1).
44
+ # 4. **Decision table** (Property 3 / Requirements 4.1–4.7):
45
+ # compute `enabled` from `(options_enabled, env_truthy,
46
+ # in_test_env, skip_test_check)` using
47
+ #
48
+ # opt_in = options_enabled == true || (options_enabled.nil? && env_truthy)
49
+ # overridden = options_enabled == false # explicit false beats env truthy
50
+ # in_test_gate = in_test_env && !skip_test_check
51
+ # enabled = opt_in && !overridden && !in_test_gate
52
+ #
53
+ # 5. When enabled, build the delivery `track` lambda via
54
+ # {.build_track}: `custom_track` wins, then debug-mode logging,
55
+ # then HTTP delivery (Requirements 5.2–5.4, 5.7, 5.9). Each
56
+ # branch is wrapped in a `rescue StandardError` that routes the
57
+ # failure through the configured logger (Requirements 21.1,
58
+ # 21.2) so a misbehaving sink never propagates out of the track
59
+ # callable.
60
+ # 6. **Compose and emit the init event** (Requirement 6): resolve a
61
+ # stable {.project_id} for the host (scoped to the
62
+ # {CurrentOptions.with_app_name} block so the `from_app_name`
63
+ # rule sees the configured `app_name`), invoke each detector
64
+ # inside {.safely} so a single misbehaving probe degrades to
65
+ # `nil` instead of aborting the init event, build the
66
+ # upstream-shaped `{type: "init", anonymousId:, payload: {...}}`
67
+ # event with camelCase keys, and fire it through the track
68
+ # lambda exactly once. Errors raised by the dispatch itself
69
+ # surface through the rescue inside the track lambda.
70
+ # 7. Return a fully-initialized {Publisher} that closes over the
71
+ # same `track` / `anonymous_id` / `enabled` state so subsequent
72
+ # `#publish` calls reuse the already-resolved id (Requirement
73
+ # 6.10).
74
+ #
75
+ # The method itself never raises: detectors are wrapped in
76
+ # {.safely}, the track lambda swallows transport failures, and the
77
+ # decision-layer logic is plain hash lookups and env reads.
78
+ #
79
+ # @param options [BetterAuth::Configuration, Hash, nil] the host's
80
+ # options. `nil` is equivalent to `{}`. When a `Hash`, both
81
+ # snake_case and camelCase keys are accepted.
82
+ # @param context [Hash, nil] optional caller-supplied context with
83
+ # `custom_track` / `database` / `adapter` / `skip_test_check`
84
+ # keys (snake_case or camelCase).
85
+ # @return [NoopPublisher, Publisher] a noop publisher when telemetry
86
+ # has no delivery channel or is disabled, otherwise a fully-formed
87
+ # {Publisher}.
88
+ def self.create(options, context = nil)
89
+ norm_opts = NormalizedOptions.from(options)
90
+ norm_ctx = NormalizedContext.from(context)
91
+ logger = norm_opts.logger
92
+
93
+ endpoint = Env.get("BETTER_AUTH_TELEMETRY_ENDPOINT")
94
+
95
+ # No delivery channel -> short-circuit to noop, regardless of opt-in.
96
+ return NoopPublisher.new if endpoint_absent?(endpoint) && norm_ctx.custom_track.nil?
97
+
98
+ enabled = compute_enabled(
99
+ options_enabled: norm_opts.telemetry_enabled,
100
+ env_truthy: Env.truthy?(Env.get("BETTER_AUTH_TELEMETRY")),
101
+ in_test_env: in_test_env?,
102
+ skip_test_check: norm_ctx.skip_test_check ? true : false
103
+ )
104
+
105
+ return NoopPublisher.new unless enabled
106
+
107
+ track = build_track(
108
+ custom_track: norm_ctx.custom_track,
109
+ debug: debug_mode?(norm_opts),
110
+ endpoint: endpoint,
111
+ logger: logger
112
+ )
113
+
114
+ # Resolve the anonymous id under a `with_app_name` scope so the
115
+ # `from_app_name` rule in `ProjectId.resolve_project_name` reads
116
+ # the configured `app_name` even when the underlying
117
+ # `BetterAuth::Telemetry.project_id` cache is cold. Once cached
118
+ # the value is reused for the lifetime of the process; the scope
119
+ # only matters on the very first call.
120
+ anonymous_id = CurrentOptions.with_app_name(norm_opts.app_name) do
121
+ BetterAuth::Telemetry.project_id(norm_opts.base_url)
122
+ end
123
+
124
+ init_event = compose_init_event(
125
+ options: options,
126
+ norm_ctx: norm_ctx,
127
+ anonymous_id: anonymous_id
128
+ )
129
+
130
+ track.call(init_event)
131
+
132
+ Publisher.new(
133
+ enabled: true,
134
+ anonymous_id: anonymous_id,
135
+ track: track,
136
+ base_url: norm_opts.base_url,
137
+ logger: logger
138
+ )
139
+ end
140
+
141
+ # Apply the Property 3 decision table.
142
+ #
143
+ # @api private
144
+ # @param options_enabled [Boolean, nil]
145
+ # @param env_truthy [Boolean]
146
+ # @param in_test_env [Boolean]
147
+ # @param skip_test_check [Boolean]
148
+ # @return [Boolean]
149
+ def self.compute_enabled(options_enabled:, env_truthy:, in_test_env:, skip_test_check:)
150
+ opt_in = options_enabled == true || (options_enabled.nil? && env_truthy)
151
+ overridden = options_enabled == false
152
+ in_test_gate = in_test_env && !skip_test_check
153
+
154
+ opt_in && !overridden && !in_test_gate
155
+ end
156
+
157
+ # @api private
158
+ def self.endpoint_absent?(endpoint)
159
+ endpoint.nil? || (endpoint.respond_to?(:empty?) && endpoint.empty?)
160
+ end
161
+
162
+ # @api private
163
+ def self.in_test_env?
164
+ TEST_ENV_VARS.any? { |k| ENV[k] == "test" }
165
+ end
166
+
167
+ # Decide whether debug mode is active. The option-layer flag wins
168
+ # when explicitly `true`; otherwise we defer to the env classifier
169
+ # via {Env.truthy?} on `BETTER_AUTH_TELEMETRY_DEBUG` (which honors
170
+ # the `OPEN_AUTH_*` alias prefix as well). Mirrors Requirement 5.4.
171
+ #
172
+ # @api private
173
+ # @param norm_opts [NormalizedOptions]
174
+ # @return [Boolean]
175
+ def self.debug_mode?(norm_opts)
176
+ norm_opts.telemetry_debug == true || Env.truthy?(Env.get("BETTER_AUTH_TELEMETRY_DEBUG"))
177
+ end
178
+
179
+ # Build the delivery `track` lambda. Three branches, in priority
180
+ # order (Requirements 5.2 → 5.4):
181
+ #
182
+ # 1. `custom_track` present — invoke `custom_track.call(event)`.
183
+ # Primary testing seam and the only branch that runs without
184
+ # requiring `BETTER_AUTH_TELEMETRY_ENDPOINT` to be set.
185
+ # 2. Debug mode active — log the JSON-pretty event via
186
+ # `logger.info(...)` and skip HTTP entirely (Requirement 5.9).
187
+ # 3. Default — fire-and-forget JSON `POST` through a short-lived
188
+ # background thread calling {HttpClient.post_json}, which
189
+ # already swallows transport errors.
190
+ #
191
+ # Every branch wraps its dispatch in a `rescue StandardError` that
192
+ # routes the failure through `logger.error(...)`, so callable /
193
+ # logger-encoding / HTTP failures never propagate out of the track
194
+ # lambda. The lambda always returns `nil`.
195
+ #
196
+ # @api private
197
+ # @param custom_track [#call, nil]
198
+ # @param debug [Boolean]
199
+ # @param endpoint [String, nil]
200
+ # @param logger [LoggerAdapter]
201
+ # @return [Proc] a one-arg lambda accepting a normalized event hash.
202
+ def self.build_track(custom_track:, debug:, endpoint:, logger:)
203
+ if custom_track
204
+ lambda do |event|
205
+ custom_track.call(event)
206
+ nil
207
+ rescue => e
208
+ logger.error("[better-auth.telemetry] custom_track failed: #{e.class}: #{e.message}")
209
+ nil
210
+ end
211
+ elsif debug
212
+ lambda do |event|
213
+ logger.info(JSON.pretty_generate(event))
214
+ nil
215
+ rescue => e
216
+ logger.error("[better-auth.telemetry] debug log failed: #{e.class}: #{e.message}")
217
+ nil
218
+ end
219
+ else
220
+ lambda do |event|
221
+ Thread.new do
222
+ HttpClient.post_json(endpoint, event, logger: logger)
223
+ rescue => e
224
+ logger.error("[better-auth.telemetry] http dispatch failed: #{e.class}: #{e.message}")
225
+ end
226
+ nil
227
+ rescue => e
228
+ logger.error("[better-auth.telemetry] http dispatch failed: #{e.class}: #{e.message}")
229
+ nil
230
+ end
231
+ end
232
+ end
233
+
234
+ # Compose the init event hash emitted at create time.
235
+ #
236
+ # Each detector is invoked through {.safely} so a single failing
237
+ # probe degrades that field to `nil` rather than aborting the
238
+ # whole event composition (Requirement 6.4 / 9.11). The output
239
+ # matches the upstream wire shape: top-level `type`,
240
+ # `anonymousId`, and a `payload` hash with the seven camelCase
241
+ # keys `config`, `runtime`, `database`, `framework`,
242
+ # `environment`, `systemInfo`, `packageManager`
243
+ # (Requirements 6.1, 6.3).
244
+ #
245
+ # `AuthConfig.call` and `Database.call` are passed the original
246
+ # `options` argument (not the {NormalizedOptions} wrapper) because
247
+ # both detectors transparently accept either a
248
+ # {BetterAuth::Configuration} or a raw hash; the normalized view
249
+ # is only consumed by the decision/track-building layer.
250
+ #
251
+ # @api private
252
+ # @param options [BetterAuth::Configuration, Hash, nil]
253
+ # @param norm_ctx [NormalizedContext]
254
+ # @param anonymous_id [String]
255
+ # @return [Hash{Symbol => Object}]
256
+ def self.compose_init_event(options:, norm_ctx:, anonymous_id:)
257
+ payload = {
258
+ config: safely { Detectors::AuthConfig.call(options, norm_ctx) },
259
+ runtime: safely { Detectors::Runtime.call },
260
+ database: safely { Detectors::Database.call(options, norm_ctx) },
261
+ framework: safely { Detectors::Framework.call },
262
+ environment: safely { Detectors::Environment.call },
263
+ systemInfo: safely { Detectors::SystemInfo.call },
264
+ packageManager: safely { Detectors::ProjectInfo.call }
265
+ }
266
+
267
+ {
268
+ type: "init",
269
+ anonymousId: anonymous_id,
270
+ payload: payload
271
+ }
272
+ end
273
+
274
+ # Run `block` and rescue any `StandardError` to `nil`. Used to
275
+ # bound each detector invocation in {.compose_init_event} so a
276
+ # raising probe degrades only that field rather than aborting the
277
+ # whole init event.
278
+ #
279
+ # Non-`StandardError` exceptions (`Interrupt`, `SystemExit`,
280
+ # `SignalException`, `NoMemoryError`) are intentionally allowed to
281
+ # propagate.
282
+ #
283
+ # @api private
284
+ # @yield the probe to run.
285
+ # @return [Object, nil] whatever the block returns, or `nil` if
286
+ # the block raised a `StandardError`.
287
+ def self.safely
288
+ yield
289
+ rescue
290
+ nil
291
+ end
292
+ end
293
+ end