lib/gdk/telemetry.rb (169 lines of code) (raw):
# frozen_string_literal: true
require 'etc'
require 'openssl'
autoload :GitlabSDK, 'gitlab-sdk'
autoload :Sentry, 'sentry-ruby'
autoload :SnowplowTracker, 'snowplow-tracker'
module GDK
module Telemetry
ANALYTICS_APP_ID = 'e2e967c0-785f-40ae-9b45-5a05f729a27f'
ANALYTICS_BASE_URL = 'https://collector.prod-1.gl-product-analytics.com'
# Track events emitted on CI in https://gitlab.com/gitlab-org/quality/tooling/gdk-playground/
CI_ANALYTICS_APP_ID = '6a31192c-6567-40a3-9413-923abc790f05'
SENTRY_DSN = 'https://4e771163209528e15a6a66a6e674ddc3@new-sentry.gitlab.net/38'
PROMPT_TEXT = <<~TEXT.chomp
To improve GDK, GitLab would like to collect basic error and usage information, including your platform and architecture.
Would you like to send telemetry anonymously to GitLab? [y/n]
TEXT
FLUSH_TIMEOUT_SECONDS = 3
def self.with_telemetry(command)
return yield unless telemetry_enabled?
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
err = nil
begin
result = yield
rescue StandardError => e
err = e
result = false
ensure
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
end
send_telemetry(result, command, duration: duration)
result
ensure
raise err if err
end
def self.send_telemetry(success, command, duration:)
# This is tightly coupled to GDK commands and returns false when the system call exits with a non-zero status.
status = success ? 'Finish' : 'Failed'
client.identify(GDK.config.telemetry.username)
client.track("#{status} #{command} #{ARGV}", payload.merge(duration: duration))
end
def self.flush_events(async: false)
Timeout.timeout(FLUSH_TIMEOUT_SECONDS) do
client.flush_events(async: async)
end
rescue Timeout::Error
GDK::Output.warn(
"Could not flush telemetry events within #{FLUSH_TIMEOUT_SECONDS} seconds. Is #{telemetry_host} blocked or unreachable?"
)
end
def self.environment
GDK.config.telemetry.environment
end
def self.version_manager
return 'asdf' unless GDK.config.asdf.opt_out?
return 'mise' if GDK.config.mise.enabled?
'none'
end
def self.session_id
@session_id ||= SecureRandom.uuid
end
def self.client
return @client if @client
default_app_id = ENV['CI'] ? CI_ANALYTICS_APP_ID : ANALYTICS_APP_ID
app_id = ENV.fetch('GITLAB_SDK_APP_ID', default_app_id)
host = telemetry_host
SnowplowTracker::LOGGER.level = Logger::WARN
SnowplowTracker::LOGGER.extend LoggerWithoutBacktrace
at_exit do
# Flush all pending events synchronously before exit.
GDK::Telemetry.flush_events
end
@client = GitlabSDK::Client.new(app_id: app_id, host: host, buffer_size: 10)
end
def self.telemetry_host
ENV.fetch('GITLAB_SDK_HOST', ANALYTICS_BASE_URL)
end
def self.init_sentry
return if Sentry.configuration
Sentry.init do |config|
config.dsn = SENTRY_DSN
config.breadcrumbs_logger = [:sentry_logger]
config.traces_sample_rate = 1.0
config.logger.level = Logger::WARN
# Pseudonymize server name using checksum for username and hostname
config.server_name = OpenSSL::Digest::SHA256.hexdigest([GDK.config.telemetry.username, config.server_name].join(':'))[0, 16]
config.before_send = lambda do |event, hint|
exception = hint[:exception]
# Workaround for using fingerprint to make certain errors distinct.
# See https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2842#note_1927103517
event.transaction = exception.message if exception.is_a?(Shellout::ShelloutBaseError)
event
end
end
Sentry.set_user(username: GDK.config.telemetry.username)
end
def self.capture_exception(message, attachment: nil)
return unless telemetry_enabled?
if message.is_a?(Exception)
exception = message.dup
else
exception = StandardError.new(message)
exception.set_backtrace(caller)
end
# Drop the caller GDK::Telemetry.capture_exception to make errors distinct.
exception.set_backtrace(exception.backtrace.drop(1)) if exception.backtrace
init_sentry
Sentry.configure_scope do |scope|
scope.set_context('gdk', payload)
end
Sentry.add_attachment(**attachment) if attachment
Sentry.capture_exception(exception)
end
def self.telemetry_enabled?
return false if ENV['GDK_TELEMETRY'] == '0'
GDK.config.telemetry.enabled
end
def self.payload
{
session_id: session_id,
environment: environment,
platform: GDK::Machine.platform,
architecture: GDK::Machine.architecture,
version_manager: version_manager,
team_member: team_member?,
enabled_services: enabled_services,
cpu_count: Etc.nprocessors
}
end
# Returns true if the user has configured a @gitlab.com email for git.
#
# This should only be used for telemetry and NEVER for authentication.
def self.team_member?
return @team_member if defined?(@team_member)
@team_member = Shellout.new(%w[git config --get user.email])
.run.include?('@gitlab.com')
return @team_member if @team_member
@team_member =
if GDK::Machine.macos?
# See https://handbook.gitlab.com/handbook/security/corporate/systems/jamf/setup/
Shellout
.new(%w[profiles status -type enrollment])
.execute(display_output: false, display_error: false)
.read_stdout.include?('gitlab.jamfcloud.com')
elsif GDK::Machine.linux?
# GitLab hostname standard
hostname_match = -> { /\A\S+--\d+-\w+\z$/.match?(Etc.uname[:nodename]) }
file_contains = ->(file, regexp) { File.exist?(file) && File.foreach(file).any?(regexp) }
hostname_match.call ||
!!file_contains.call(Pathname(Dir.home).join('.config/zoomus.conf'), /gitlab\.zoom\.us/)
else
false
end
end
def self.enabled_services
GDK::ToolVersionsUpdater.enabled_services
end
def self.update_settings(answer)
enabled = answer == 'y'
if enabled != GDK.config.telemetry.enabled
GDK.config.bury!('telemetry.enabled', enabled)
changes_made = true
end
if enabled
username = GDK.config.telemetry.username
anonymized = /\A\h{32}\z/.match?(username)
unless anonymized
GDK.config.bury!('telemetry.username', SecureRandom.hex)
GDK::Output.info('Telemetry username has been anonymized.')
changes_made = true
end
end
GDK.config.save_yaml! if changes_made
end
# To reduce noise skip long backtraces for all system call errors like "connection refused".
module LoggerWithoutBacktrace
def warn(message)
message.set_backtrace([]) if message.is_a?(SystemCallError)
super
end
end
end
end