lib/appsignal/cli/diagnose.rb
# frozen_string_literal: true
require "rbconfig"
require "etc"
require "appsignal/cli/diagnose/utils"
require "appsignal/cli/diagnose/paths"
module Appsignal
class CLI
# Command line tool to run diagnostics on your project.
#
# This command line tool is useful when testing AppSignal on a system and
# validating the local configuration. It outputs useful information to
# debug issues and it checks if AppSignal agent is able to run on the
# machine's architecture and communicate with the AppSignal servers.
#
# This diagnostic tool outputs the following:
# - if AppSignal can run on the host system.
# - if the configuration is valid and active.
# - if the Push API key is present and valid (internet connection required).
# - if the required system paths exist and are writable.
# - outputs AppSignal version information.
# - outputs information about the host system and Ruby.
# - outputs last lines from the available log files.
#
# ## Exit codes
#
# - Exits with status code `0` if the diagnose command has finished.
# - Exits with status code `1` if the diagnose command failed to finished.
#
# @example On the command line in your project
# appsignal diagnose
#
# @example With a specific environment
# appsignal diagnose --environment=production
#
# @example Automatically send the diagnose report without prompting
# appsignal diagnose --send-report
#
# @example Don't prompt about sending the report and don't sent it
# appsignal diagnose --no-send-report
#
# @see https://docs.appsignal.com/support/debugging.html Debugging AppSignal
# @see https://docs.appsignal.com/ruby/command-line/diagnose.html
# AppSignal diagnose documentation
# @since 1.1.0
class Diagnose
extend CLI::Helpers
DIAGNOSE_ENDPOINT = "https://appsignal.com/diag".freeze
module Data
def data
@data ||= Hash.new { |hash, key| hash[key] = {} }
end
def data_section(key)
@section = key
yield
@section = nil
end
def current_section
@section
end
def save(key, value)
data[current_section][key] = value
end
end
extend Data
class << self
# @param options [Hash]
# @option options :environment [String] environment to load
# configuration for.
# @return [void]
# @api private
def run(options = {})
self.coloring = options.delete(:color) { true }
$stdout.sync = true
header
print_empty_line
library_information
data[:installation] = fetch_installation_report
print_installation_report
print_empty_line
host_information
print_empty_line
configure_appsignal(options)
run_agent_diagnose_mode
print_empty_line
print_config_section
print_empty_line
check_api_key
print_empty_line
data[:process] = process_user
paths_report = Paths.new
data[:paths] = paths_report.report
print_paths_section(paths_report)
print_empty_line
transmit_report_to_appsignal if send_report_to_appsignal?(options)
end
private
def send_report_to_appsignal?(options)
puts "\nDiagnostics report"
puts " Do you want to send this diagnostics report to AppSignal?"
puts " If you share this report you will be given a link to \n" \
" AppSignal.com to validate the report.\n" \
" You can also contact us at support@appsignal.com\n" \
" with your support token.\n\n"
send_diagnostics =
if options.key?(:send_report)
if options[:send_report]
puts " Confirmed sending report using --send-report option."
true
else
puts " Not sending report. (Specified with the --no-send-report option.)"
false
end
else
yes_or_no(
" Send diagnostics report to AppSignal? (Y/n): ",
:default => "y"
)
end
unless send_diagnostics
puts " Not sending diagnostics information to AppSignal."
return false
end
true
end
def transmit_report_to_appsignal
puts " Transmitting diagnostics report"
transmitter = Transmitter.new(
DIAGNOSE_ENDPOINT,
Appsignal.config
)
response = transmitter.transmit(:diagnose => data)
unless response.code == "200"
puts " Error: Something went wrong while submitting the report "\
"to AppSignal."
puts " Response code: #{response.code}"
puts " Response body:\n#{response.body}"
return
end
begin
response_data = JSON.parse(response.body)
puts "\n Your support token: #{response_data["token"]}"
puts " View this report: https://appsignal.com/diagnose/#{response_data["token"]}"
rescue JSON::ParserError
puts " Error: Couldn't decode server response."
puts " #{response.body}"
end
end
def puts_and_save(key, label, value)
save key, value
puts_value label, value
end
def puts_value(label, value, options = {})
options[:level] ||= 1
puts "#{" " * options[:level]}#{label}: #{value}"
end
def configure_appsignal(options)
current_path = Dir.pwd
initial_config = {}
if rails_app?
data[:app][:rails] = true
current_path = Rails.root
initial_config[:name] =
Appsignal::Utils::RailsHelper.detected_rails_app_name
initial_config[:log_path] = current_path.join("log")
end
Appsignal.config = Appsignal::Config.new(
current_path,
options[:environment],
initial_config
)
Appsignal.config.write_to_environment
Appsignal.start_logger
Appsignal.logger.info("Starting AppSignal diagnose")
end
def run_agent_diagnose_mode
puts "Agent diagnostics"
unless Appsignal.extension_loaded?
puts colorize(" Extension is not loaded. No agent report created.", :red)
return
end
ENV["_APPSIGNAL_DIAGNOSE"] = "true"
diagnostics_report_string = Appsignal::Extension.diagnose
ENV.delete("_APPSIGNAL_DIAGNOSE")
begin
report = JSON.parse(diagnostics_report_string)
data[:agent] = report
print_agent_report(report)
rescue JSON::ParserError => error
puts " Error while parsing agent diagnostics report:"
puts " Error: #{error}"
puts " Output: #{diagnostics_report_string}"
data[:agent] = {
:error => error,
:output => diagnostics_report_string.split("\n")
}
end
end
def print_agent_report(report)
if report["error"]
puts " Error: #{report["error"]}"
return
end
agent_diagnostic_test_definition.each do |component, component_definition|
puts " #{component_definition[:label]}"
component_definition[:tests].each do |category, tests|
tests.each do |test_name, test_definition|
test_report = report
.fetch(component, {})
.fetch(category, {})
.fetch(test_name, {})
print_agent_test(test_definition, test_report)
end
end
end
end
def print_agent_test(definition, test)
value = test["result"]
error = test["error"]
output = test["output"]
print " #{definition[:label]}: "
display_value =
definition[:values] ? definition[:values][value] : value
print display_value.nil? ? "-" : display_value
print "\n Error: #{error}" if error
print "\n Output: #{output}" if output
print "\n"
end
def agent_diagnostic_test_definition
{
"extension" => {
:label => "Extension tests",
:tests => {
"config" => {
"valid" => {
:label => "Configuration",
:values => { true => "valid", false => "invalid" }
}
}
}
},
"agent" => {
:label => "Agent tests",
:tests => {
"boot" => {
"started" => {
:label => "Started",
:values => { true => "started", false => "not started" }
}
},
"host" => {
"uid" => { :label => "Process user id" },
"gid" => { :label => "Process user group id" }
},
"config" => {
"valid" => {
:label => "Configuration",
:values => { true => "valid", false => "invalid" }
}
},
"logger" => {
"started" => {
:label => "Logger",
:values => { true => "started", false => "not started" }
}
},
"working_directory_stat" => {
"uid" => { :label => "Working directory user id" },
"gid" => { :label => "Working directory user group id" },
"mode" => { :label => "Working directory permissions" }
},
"lock_path" => {
"created" => {
:label => "Lock path",
:values => { true => "writable", false => "not writable" }
}
}
}
}
}
end
def header
puts "AppSignal diagnose"
puts "=" * 80
puts "Use this information to debug your configuration."
puts "More information is available on the documentation site."
puts "https://docs.appsignal.com/"
puts "Send this output to support@appsignal.com if you need help."
puts "=" * 80
end
def library_information
puts "AppSignal library"
data_section :library do
save :language, "ruby"
puts_and_save :package_version, "Gem version", Appsignal::VERSION
puts_and_save :agent_version, "Agent version", Appsignal::Extension.agent_version
puts_and_save :extension_loaded, "Extension loaded", Appsignal.extension_loaded
end
end
def fetch_installation_report
path = File.expand_path("../../../../ext/install.report", __FILE__)
raw_report = File.read(path)
Utils.parse_yaml(raw_report)
rescue StandardError, Psych::SyntaxError => e # rubocop:disable Lint/ShadowedException
{
"parsing_error" => {
"error" => "#{e.class}: #{e}",
"backtrace" => e.backtrace
}.tap do |r|
r["raw"] = raw_report if raw_report
end
}
end
def print_installation_report
puts "\nExtension installation report"
install_report = data[:installation]
if install_report.key? "parsing_error"
print_installation_report_parsing_error(install_report)
return
end
print_installation_result_report(install_report)
print_installation_language_report(install_report)
print_installation_download_report(install_report)
print_installation_build_report(install_report)
print_installation_host_report(install_report)
end
def print_installation_report_parsing_error(report)
report = report["parsing_error"]
puts " Error found while parsing the report."
puts " Error: #{report["error"]}"
puts " Raw report:\n#{report["raw"]}" if report["raw"]
end
def print_installation_result_report(report)
report = report.fetch("download", {})
puts " Installation result"
puts " Status: #{report["status"]}"
puts " Message: #{report["message"]}" if report["message"]
puts " Error: #{report["error"]}" if report["error"]
end
def print_installation_language_report(report)
report = report.fetch("language", {})
puts " Language details"
puts " Implementation: #{report["implementation"]}"
puts " Ruby version: #{report["version"]}"
end
def print_installation_download_report(report)
report = report.fetch("download", {})
puts " Download details"
puts " Download URL: #{report["download_url"]}"
puts " Checksum: #{report["checksum"]}"
end
def print_installation_build_report(report)
report = report.fetch("build", {})
puts " Build details"
puts " Install time: #{report["time"]}"
puts " Architecture: #{report["architecture"]}"
puts " Target: #{report["target"]}"
puts " Musl override: #{report["musl_override"]}"
puts " Library type: #{report["library_type"]}"
puts " Source: #{report["source"]}" if report["source"] != "remote"
puts " Dependencies: #{report["dependencies"]}"
puts " Flags: #{report["flags"]}"
end
def print_installation_host_report(report)
report = report.fetch("host", {})
puts " Host details"
puts " Root user: #{report["root_user"]}"
puts " Dependencies: #{report["dependencies"]}"
end
def host_information
rbconfig = RbConfig::CONFIG
puts "Host information"
data_section :host do
puts_and_save :architecture, "Architecture", rbconfig["host_cpu"]
os_label = os = rbconfig["host_os"]
os_label = "#{os} (Microsoft Windows is not supported.)" if Gem.win_platform?
save :os, os
puts_value "Operating System", os_label
puts_and_save :language_version, "Ruby version",
"#{rbconfig["ruby_version"]}-p#{rbconfig["PATCHLEVEL"]}"
puts_value "Heroku", "true" if Appsignal::System.heroku?
save :heroku, Appsignal::System.heroku?
save :root, Process.uid.zero?
puts_value "Root user",
Process.uid.zero? ? "true (not recommended)" : "false"
puts_and_save :running_in_container, "Running in container",
Appsignal::Extension.running_in_container?
end
end
def print_config_section
puts "Configuration"
config = Appsignal.config
data[:config] = {
:options => config.config_hash.merge(:env => config.env),
:sources => {
:default => Appsignal::Config::DEFAULT_CONFIG,
:system => config.system_config,
:initial => config.initial_config,
:file => config.file_config,
:env => config.env_config
}
}
print_environment(config)
print_config_options(config)
end
def print_environment(config)
env = config.env
option = :env
option_sources = sources_for_option(option)
sources_label = config_sources_label(option, option_sources)
print " Environment: #{format_config_option(env)}"
if env == ""
message = " Warning: No environment set, no config loaded!\n" \
" Please make sure appsignal diagnose is run within your\n" \
" project directory with an environment.\n" \
" appsignal diagnose --environment=production"
puts "\n#{colorize(message, :red)}"
else
puts sources_label
end
end
def print_config_options(config)
config.config_hash.each do |key, value|
option_sources = sources_for_option(key)
sources_label = config_sources_label(key, option_sources)
puts " #{key}: #{format_config_option(value)}#{sources_label}"
end
puts "\nRead more about how the diagnose config output is rendered\n"\
"https://docs.appsignal.com/ruby/command-line/diagnose.html"
end
def sources_for_option(option)
config_sources = data[:config][:sources]
[].tap do |option_sources|
config_sources.each do |source, c|
option_sources << source if c.key?(option)
end
end
end
def config_sources_label(option, sources)
return if sources == [:default]
if sources.length == 1
" (Loaded from: #{sources.join(", ")})"
elsif sources.any?
["\n Sources:"].tap do |a|
max_source_length = sources.map(&:length).max + 1 # 1 is for ":"
sources.each do |source|
source_label = "#{source}:".ljust(max_source_length)
value = data[:config][:sources][source][option]
a << " #{source_label} #{format_config_option(value)}"
end
end.join("\n")
else
" (Not configured)"
end
end
def format_config_option(value)
case value
when NilClass
"nil"
when String
value.inspect
else
value
end
end
def process_user
return @process_user if defined?(@process_user)
process_uid = Process.uid
@process_user = {
:uid => process_uid,
:user => Utils.username_for_uid(process_uid)
}
end
def check_api_key
puts "Validation"
auth_check = ::Appsignal::AuthCheck.new(Appsignal.config)
status, error = auth_check.perform_with_result
result, color =
case status
when "200"
["valid", :green]
when "401"
["invalid", :red]
else
["Failed with status #{status}\n#{error.inspect}", :red]
end
data[:validation][:push_api_key] = result
puts_value "Validating Push API key", colorize(result, color)
end
def print_paths_section(report)
puts "Paths"
report_paths = report.paths
data[:paths].each do |name, file|
print_path_details report_paths[name][:label], file
end
end
def print_path_details(name, path)
puts " #{name}"
puts_value "Path", path[:path].to_s.inspect, :level => 2
unless path[:exists]
puts_value "Exists?", path[:exists], :level => 2
return
end
puts_value "Writable?", path[:writable], :level => 2
ownership = path[:ownership]
owned = process_user[:uid] == ownership[:uid]
owner = "#{owned} " \
"(file: #{ownership[:user]}:#{ownership[:uid]}, " \
"process: #{process_user[:user]}:#{process_user[:uid]})"
puts_value "Ownership?", owner, :level => 2
return unless path.key?(:content)
puts " Contents (last 10 lines):"
puts path[:content].last(10)
end
def print_empty_line
puts "\n"
end
def rails_app?
require "rails"
require File.expand_path(File.join(Dir.pwd, "config", "application.rb"))
true
rescue LoadError
false
end
end
end
end
end