Module: BetterAuth::Telemetry

Defined in:
lib/better_auth/telemetry.rb,
lib/better_auth/telemetry/env.rb,
lib/better_auth/telemetry/create.rb,
lib/better_auth/telemetry/options.rb,
lib/better_auth/telemetry/version.rb,
lib/better_auth/telemetry/publisher.rb,
lib/better_auth/telemetry/project_id.rb,
lib/better_auth/telemetry/http_client.rb,
lib/better_auth/telemetry/logger_adapter.rb,
lib/better_auth/telemetry/noop_publisher.rb,
lib/better_auth/telemetry/detectors/runtime.rb,
lib/better_auth/telemetry/detectors/database.rb,
lib/better_auth/telemetry/detectors/framework.rb,
lib/better_auth/telemetry/detectors/auth_config.rb,
lib/better_auth/telemetry/detectors/environment.rb,
lib/better_auth/telemetry/detectors/system_info.rb,
lib/better_auth/telemetry/detectors/project_info.rb

Overview

Top-level namespace for the ‘better_auth-telemetry` gem.

See ‘BetterAuth::Telemetry.create` for the entry point used by `BetterAuth::Auth#initialize` and by tests that exercise the publisher in isolation.

Defined Under Namespace

Modules: CurrentOptions, Detectors, Env, HttpClient, Options, ProjectId Classes: LoggerAdapter, NoopPublisher, NormalizedContext, NormalizedOptions, Publisher

Constant Summary collapse

TEST_ENV_VARS =

Process-environment variables that mark the host as running inside a test suite. Mirrors Configuration#test_environment? without taking a hard dependency on a ‘Configuration` instance — the `create` entry point also accepts raw hashes and `nil`.

%w[RACK_ENV RAILS_ENV APP_ENV].freeze
VERSION =
"0.8.0"
PROJECT_ID_ALPHABET =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

(
  ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
).freeze

Class Method Summary collapse

Class Method Details

.build_track(custom_track:, debug:, endpoint:, logger:) ⇒ Proc

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Build the delivery ‘track` lambda. Three branches, in priority order (Requirements 5.2 → 5.4):

  1. ‘custom_track` present — invoke `custom_track.call(event)`. Primary testing seam and the only branch that runs without requiring `BETTER_AUTH_TELEMETRY_ENDPOINT` to be set.

  2. Debug mode active — log the JSON-pretty event via ‘logger.info(…)` and skip HTTP entirely (Requirement 5.9).

  3. Default — fire-and-forget JSON ‘POST` through a short-lived background thread calling BetterAuth::Telemetry::HttpClient.post_json, which already swallows transport errors.

Every branch wraps its dispatch in a ‘rescue StandardError` that routes the failure through `logger.error(…)`, so callable / logger-encoding / HTTP failures never propagate out of the track lambda. The lambda always returns `nil`.

Parameters:

  • custom_track (#call, nil)
  • debug (Boolean)
  • endpoint (String, nil)
  • logger (LoggerAdapter)

Returns:

  • (Proc)

    a one-arg lambda accepting a normalized event hash.



202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/better_auth/telemetry/create.rb', line 202

def self.build_track(custom_track:, debug:, endpoint:, logger:)
  if custom_track
    lambda do |event|
      custom_track.call(event)
      nil
    rescue => e
      logger.error("[better-auth.telemetry] custom_track failed: #{e.class}: #{e.message}")
      nil
    end
  elsif debug
    lambda do |event|
      logger.info(JSON.pretty_generate(event))
      nil
    rescue => e
      logger.error("[better-auth.telemetry] debug log failed: #{e.class}: #{e.message}")
      nil
    end
  else
    lambda do |event|
      Thread.new do
        HttpClient.post_json(endpoint, event, logger: logger)
      rescue => e
        logger.error("[better-auth.telemetry] http dispatch failed: #{e.class}: #{e.message}")
      end
      nil
    rescue => e
      logger.error("[better-auth.telemetry] http dispatch failed: #{e.class}: #{e.message}")
      nil
    end
  end
end

.compose_init_event(options:, norm_ctx:, anonymous_id:) ⇒ Hash{Symbol => Object}

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Compose the init event hash emitted at create time.

Each detector is invoked through safely so a single failing probe degrades that field to ‘nil` rather than aborting the whole event composition (Requirement 6.4 / 9.11). The output matches the upstream wire shape: top-level `type`, `anonymousId`, and a `payload` hash with the seven camelCase keys `config`, `runtime`, `database`, `framework`, `environment`, `systemInfo`, `packageManager` (Requirements 6.1, 6.3).

‘AuthConfig.call` and `Database.call` are passed the original `options` argument (not the NormalizedOptions wrapper) because both detectors transparently accept either a Configuration or a raw hash; the normalized view is only consumed by the decision/track-building layer.

Parameters:

  • options (BetterAuth::Configuration, Hash, nil)
  • norm_ctx (NormalizedContext)
  • anonymous_id (String)

Returns:

  • (Hash{Symbol => Object})


256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
# File 'lib/better_auth/telemetry/create.rb', line 256

def self.compose_init_event(options:, norm_ctx:, anonymous_id:)
  payload = {
    config: safely { Detectors::AuthConfig.call(options, norm_ctx) },
    runtime: safely { Detectors::Runtime.call },
    database: safely { Detectors::Database.call(options, norm_ctx) },
    framework: safely { Detectors::Framework.call },
    environment: safely { Detectors::Environment.call },
    systemInfo: safely { Detectors::SystemInfo.call },
    packageManager: safely { Detectors::ProjectInfo.call }
  }

  {
    type: "init",
    anonymousId: anonymous_id,
    payload: payload
  }
end

.compute_enabled(options_enabled:, env_truthy:, in_test_env:, skip_test_check:) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Apply the Property 3 decision table.

Parameters:

  • options_enabled (Boolean, nil)
  • env_truthy (Boolean)
  • in_test_env (Boolean)
  • skip_test_check (Boolean)

Returns:

  • (Boolean)


149
150
151
152
153
154
155
# File 'lib/better_auth/telemetry/create.rb', line 149

def self.compute_enabled(options_enabled:, env_truthy:, in_test_env:, skip_test_check:)
  opt_in = options_enabled == true || (options_enabled.nil? && env_truthy)
  overridden = options_enabled == false
  in_test_gate = in_test_env && !skip_test_check

  opt_in && !overridden && !in_test_gate
end

.create(options, context = nil) ⇒ NoopPublisher, Publisher

Public entry point used by ‘BetterAuth::Auth#initialize` (and by tests that exercise the publisher in isolation) to build a publisher tailored to the host’s opt-in state.

## Pipeline

  1. Normalize the heterogeneous ‘options` and `context` arguments into NormalizedOptions / NormalizedContext value objects so the rest of the pipeline does not have to repeatedly do snake/camelCase key lookups.

  2. Resolve ‘endpoint = Env.get(“BETTER_AUTH_TELEMETRY_ENDPOINT”)`, honoring the `OPEN_AUTH_*` alias prefix.

  3. Short-circuit: when both the endpoint and ‘custom_track` are absent there is no delivery channel and the publisher cannot do useful work, so we hand back a NoopPublisher and bypass the rest of the pipeline (Requirement 5.1).

  4. **Decision table** (Property 3 / Requirements 4.1–4.7): compute ‘enabled` from `(options_enabled, env_truthy, in_test_env, skip_test_check)` using

    opt_in       = options_enabled == true || (options_enabled.nil? && env_truthy)
    overridden   = options_enabled == false   # explicit false beats env truthy
    in_test_gate = in_test_env && !skip_test_check
    enabled      = opt_in && !overridden && !in_test_gate
    
  5. When enabled, build the delivery ‘track` lambda via build_track: `custom_track` wins, then debug-mode logging, then HTTP delivery (Requirements 5.2–5.4, 5.7, 5.9). Each branch is wrapped in a `rescue StandardError` that routes the failure through the configured logger (Requirements 21.1, 21.2) so a misbehaving sink never propagates out of the track callable.

  6. **Compose and emit the init event** (Requirement 6): resolve a stable project_id for the host (scoped to the BetterAuth::Telemetry::CurrentOptions.with_app_name block so the ‘from_app_name` rule sees the configured `app_name`), invoke each detector inside safely so a single misbehaving probe degrades to `nil` instead of aborting the init event, build the upstream-shaped `“init”, anonymousId:, payload: {…}` event with camelCase keys, and fire it through the track lambda exactly once. Errors raised by the dispatch itself surface through the rescue inside the track lambda.

  7. Return a fully-initialized Publisher that closes over the same ‘track` / `anonymous_id` / `enabled` state so subsequent `#publish` calls reuse the already-resolved id (Requirement 6.10).

The method itself never raises: detectors are wrapped in safely, the track lambda swallows transport failures, and the decision-layer logic is plain hash lookups and env reads.

Parameters:

  • options (BetterAuth::Configuration, Hash, nil)

    the host’s options. ‘nil` is equivalent to `{}`. When a `Hash`, both snake_case and camelCase keys are accepted.

  • context (Hash, nil) (defaults to: nil)

    optional caller-supplied context with ‘custom_track` / `database` / `adapter` / `skip_test_check` keys (snake_case or camelCase).

Returns:



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/better_auth/telemetry/create.rb', line 88

def self.create(options, context = nil)
  norm_opts = NormalizedOptions.from(options)
  norm_ctx = NormalizedContext.from(context)
  logger = norm_opts.logger

  endpoint = Env.get("BETTER_AUTH_TELEMETRY_ENDPOINT")

  # No delivery channel -> short-circuit to noop, regardless of opt-in.
  return NoopPublisher.new if endpoint_absent?(endpoint) && norm_ctx.custom_track.nil?

  enabled = compute_enabled(
    options_enabled: norm_opts.telemetry_enabled,
    env_truthy: Env.truthy?(Env.get("BETTER_AUTH_TELEMETRY")),
    in_test_env: in_test_env?,
    skip_test_check: norm_ctx.skip_test_check ? true : false
  )

  return NoopPublisher.new unless enabled

  track = build_track(
    custom_track: norm_ctx.custom_track,
    debug: debug_mode?(norm_opts),
    endpoint: endpoint,
    logger: logger
  )

  # Resolve the anonymous id under a `with_app_name` scope so the
  # `from_app_name` rule in `ProjectId.resolve_project_name` reads
  # the configured `app_name` even when the underlying
  # `BetterAuth::Telemetry.project_id` cache is cold. Once cached
  # the value is reused for the lifetime of the process; the scope
  # only matters on the very first call.
  anonymous_id = CurrentOptions.with_app_name(norm_opts.app_name) do
    BetterAuth::Telemetry.project_id(norm_opts.base_url)
  end

  init_event = compose_init_event(
    options: options,
    norm_ctx: norm_ctx,
    anonymous_id: anonymous_id
  )

  track.call(init_event)

  Publisher.new(
    enabled: true,
    anonymous_id: anonymous_id,
    track: track,
    base_url: norm_opts.base_url,
    logger: logger
  )
end

.debug_mode?(norm_opts) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Decide whether debug mode is active. The option-layer flag wins when explicitly ‘true`; otherwise we defer to the env classifier via BetterAuth::Telemetry::Env.truthy? on `BETTER_AUTH_TELEMETRY_DEBUG` (which honors the `OPEN_AUTH_*` alias prefix as well). Mirrors Requirement 5.4.

Parameters:

Returns:

  • (Boolean)


175
176
177
# File 'lib/better_auth/telemetry/create.rb', line 175

def self.debug_mode?(norm_opts)
  norm_opts.telemetry_debug == true || Env.truthy?(Env.get("BETTER_AUTH_TELEMETRY_DEBUG"))
end

.derive_project_id(base_url) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/better_auth/telemetry/project_id.rb', line 201

def self.derive_project_id(base_url)
  url = base_url.is_a?(String) ? base_url : nil
  url = nil if url && url.empty?

  name = ProjectId.resolve_project_name
  name = nil if name.is_a?(String) && name.empty?

  if name && url
    hash_to_base64(url + name)
  elsif name
    hash_to_base64(name)
  elsif url
    hash_to_base64(url)
  else
    random_id_32
  end
end

.endpoint_absent?(endpoint) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns:

  • (Boolean)


158
159
160
# File 'lib/better_auth/telemetry/create.rb', line 158

def self.endpoint_absent?(endpoint)
  endpoint.nil? || (endpoint.respond_to?(:empty?) && endpoint.empty?)
end

.hash_to_base64(input) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



220
221
222
# File 'lib/better_auth/telemetry/project_id.rb', line 220

def self.hash_to_base64(input)
  Base64.strict_encode64(Digest::SHA256.digest(input.to_s))
end

.in_test_env?Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns:

  • (Boolean)


163
164
165
# File 'lib/better_auth/telemetry/create.rb', line 163

def self.in_test_env?
  TEST_ENV_VARS.any? { |k| ENV[k] == "test" }
end

.project_id(base_url) ⇒ String

Resolve a stable, anonymous project id for telemetry.

The id is derived once per process and memoized; subsequent calls — regardless of the ‘base_url` they pass — return the cached value (Requirement 14.6). This mirrors the upstream `projectIdCached` module-scope variable.

## Derivation chain (Requirements 14.2 – 14.5)

  1. Project name resolvable AND ‘base_url` non-empty: `Base64(SHA-256(base_url + name))`.

  2. Project name resolvable AND ‘base_url` nil/empty: `Base64(SHA-256(name))`.

  3. No project name AND ‘base_url` non-empty: `Base64(SHA-256(base_url))`.

  4. Otherwise: a random 32-character ‘[a-zA-Z0-9]` id from `SecureRandom`, matching upstream `generateId(32)`.

The Bundler/lockfile probes inside BetterAuth::Telemetry::ProjectId.resolve_project_name never raise out of this method (Requirement 14.8); a failed probe collapses to “no project name” and the chain continues at rule 3 or rule 4.

Parameters:

  • base_url (String, nil)

    the host’s configured base URL.

Returns:

  • (String)

    the memoized anonymous project id.



173
174
175
176
177
178
179
180
181
182
183
# File 'lib/better_auth/telemetry/project_id.rb', line 173

def self.project_id(base_url)
  cached = @project_id_cache
  return cached if cached

  @project_id_mutex.synchronize do
    cached = @project_id_cache
    return cached if cached

    @project_id_cache = derive_project_id(base_url)
  end
end

.random_id_32Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



230
231
232
# File 'lib/better_auth/telemetry/project_id.rb', line 230

def self.random_id_32
  Array.new(32) { PROJECT_ID_ALPHABET[SecureRandom.random_number(PROJECT_ID_ALPHABET.length)] }.join
end

.reset_project_id!nil

Test-only hook that clears the memoized project id cache.

Wired here in task 3.6 to clear the ‘@project_id_cache` ivar that backs project_id. Tests use this between cases that exercise different derivation rules (e.g. with vs. without a project name) so each call goes through the full chain again.

Returns:

  • (nil)


193
194
195
196
197
198
# File 'lib/better_auth/telemetry/project_id.rb', line 193

def self.reset_project_id!
  @project_id_mutex.synchronize do
    @project_id_cache = nil
  end
  nil
end

.safely { ... } ⇒ Object?

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Run ‘block` and rescue any `StandardError` to `nil`. Used to bound each detector invocation in compose_init_event so a raising probe degrades only that field rather than aborting the whole init event.

Non-‘StandardError` exceptions (`Interrupt`, `SystemExit`, `SignalException`, `NoMemoryError`) are intentionally allowed to propagate.

Yields:

  • the probe to run.

Returns:

  • (Object, nil)

    whatever the block returns, or ‘nil` if the block raised a `StandardError`.



287
288
289
290
291
# File 'lib/better_auth/telemetry/create.rb', line 287

def self.safely
  yield
rescue
  nil
end