appsignal 3.0.4 → 3.0.9

Sign up to get free protection for your applications and to get access to all the features.
@@ -3,5 +3,4 @@ module Appsignal
3
3
  end
4
4
  end
5
5
 
6
- require "appsignal/probes/puma"
7
6
  require "appsignal/probes/sidekiq"
@@ -8,6 +8,7 @@ module Appsignal
8
8
  # @api private
9
9
  module System
10
10
  LINUX_TARGET = "linux".freeze
11
+ LINUX_ARM_ARCHITECTURE = "aarch64".freeze
11
12
  MUSL_TARGET = "linux-musl".freeze
12
13
  FREEBSD_TARGET = "freebsd".freeze
13
14
  GEM_EXT_PATH = File.expand_path("../../../ext", __FILE__).freeze
@@ -18,15 +19,18 @@ module Appsignal
18
19
 
19
20
  # Detect agent and extension platform build
20
21
  #
21
- # Used by `ext/extconf.rb` to select which build it should download and
22
+ # Used by `ext/*` to select which build it should download and
22
23
  # install.
23
24
  #
24
- # Use `export APPSIGNAL_BUILD_FOR_MUSL=1` if the detection doesn't work
25
- # and to force selection of the musl build.
25
+ # - Use `export APPSIGNAL_BUILD_FOR_MUSL=1` if the detection doesn't work
26
+ # and to force selection of the musl build.
27
+ # - Use `export APPSIGNAL_BUILD_FOR_LINUX_ARM=1` to enable the experimental
28
+ # Linux ARM build.
26
29
  #
27
30
  # @api private
28
31
  # @return [String]
29
32
  def self.agent_platform
33
+ return LINUX_TARGET if force_linux_arm_build?
30
34
  return MUSL_TARGET if force_musl_build?
31
35
 
32
36
  host_os = RbConfig::CONFIG["host_os"].downcase
@@ -53,6 +57,22 @@ module Appsignal
53
57
  local_os
54
58
  end
55
59
 
60
+ # Detect agent and extension architecture build
61
+ #
62
+ # Used by the `ext/*` tasks to select which architecture build it should download and install.
63
+ #
64
+ # - Use `export APPSIGNAL_BUILD_FOR_LINUX_ARM=1` to enable the experimental
65
+ # Linux ARM build.
66
+ #
67
+ # @api private
68
+ # @return [String]
69
+ def self.agent_architecture
70
+ return LINUX_ARM_ARCHITECTURE if force_linux_arm_build?
71
+
72
+ # Fallback on the Ruby
73
+ RbConfig::CONFIG["host_cpu"]
74
+ end
75
+
56
76
  # Returns whether or not the musl build was forced by the user.
57
77
  #
58
78
  # @api private
@@ -60,6 +80,13 @@ module Appsignal
60
80
  %w[true 1].include?(ENV["APPSIGNAL_BUILD_FOR_MUSL"])
61
81
  end
62
82
 
83
+ # Returns whether or not the linux ARM build was selected by the user.
84
+ #
85
+ # @api private
86
+ def self.force_linux_arm_build?
87
+ %w[true 1].include?(ENV["APPSIGNAL_BUILD_FOR_LINUX_ARM"])
88
+ end
89
+
63
90
  # @api private
64
91
  def self.versionify(version)
65
92
  Gem::Version.new(version)
@@ -526,7 +526,7 @@ module Appsignal
526
526
  end
527
527
 
528
528
  def cleaned_backtrace(backtrace)
529
- if defined?(::Rails) && backtrace
529
+ if defined?(::Rails) && Rails.respond_to?(:backtrace_cleaner) && backtrace
530
530
  ::Rails.backtrace_cleaner.clean(backtrace, nil)
531
531
  else
532
532
  backtrace
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Appsignal
4
- VERSION = "3.0.4".freeze
4
+ VERSION = "3.0.9".freeze
5
5
  end
@@ -1,27 +1,156 @@
1
- APPSIGNAL_PUMA_PLUGIN_LOADED = true
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
2
4
 
3
5
  # AppSignal Puma plugin
4
6
  #
5
- # This plugin ensures the minutely probe thread is started with the Puma
6
- # minutely probe in the Puma master process.
7
- #
8
- # The constant {APPSIGNAL_PUMA_PLUGIN_LOADED} is here to mark the Plugin as
9
- # loaded by the rest of the AppSignal gem. This ensures that the Puma minutely
10
- # probe is not also started in every Puma workers, which was the old behavior.
11
- # See {Appsignal::Hooks::PumaHook#install} for more information.
7
+ # This plugin ensures Puma metrics are sent to the AppSignal agent using StatsD.
12
8
  #
13
9
  # For even more information:
14
10
  # https://docs.appsignal.com/ruby/integrations/puma.html
15
- Puma::Plugin.create do
16
- def start(launcher = nil)
17
- launcher.events.on_booted do
18
- require "appsignal"
19
- if ::Puma.respond_to?(:stats)
20
- require "appsignal/probes/puma"
21
- Appsignal::Minutely.probes.register :puma, Appsignal::Probes::PumaProbe
11
+ Puma::Plugin.create do # rubocop:disable Metrics/BlockLength
12
+ def start(launcher)
13
+ @launcher = launcher
14
+ @launcher.events.debug "AppSignal: Puma plugin start."
15
+ in_background do
16
+ @launcher.events.debug "AppSignal: Start Puma stats collection loop."
17
+ plugin = AppsignalPumaPlugin.new
18
+
19
+ loop do
20
+ begin
21
+ # Implement similar behavior to minutely probes.
22
+ # Initial sleep to wait until the app is fully initalized.
23
+ # Then loop every 60 seconds and collect the Puma stats as AppSignal
24
+ # metrics.
25
+ sleep sleep_time
26
+
27
+ @launcher.events.debug "AppSignal: Collecting Puma stats."
28
+ stats = fetch_puma_stats
29
+ if stats
30
+ plugin.call(stats)
31
+ else
32
+ @launcher.events.log "AppSignal: No Puma stats to report."
33
+ end
34
+ rescue StandardError => error
35
+ log_error "Error while processing metrics.", error
36
+ end
22
37
  end
23
- Appsignal.start
24
- Appsignal.start_logger
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def sleep_time
44
+ 60 # seconds
45
+ end
46
+
47
+ def log_error(message, error)
48
+ @launcher.events.log "AppSignal: #{message}\n" \
49
+ "#{error.class}: #{error.message}\n#{error.backtrace.join("\n")}"
50
+ end
51
+
52
+ def fetch_puma_stats
53
+ if Puma.respond_to? :stats_hash # Puma >= 5.0.0
54
+ Puma.stats_hash
55
+ elsif Puma.respond_to? :stats # Puma < 5.0.0
56
+ # Puma.stats_hash returns symbolized keys as well
57
+ JSON.parse Puma.stats, :symbolize_names => true
58
+ end
59
+ rescue StandardError => error
60
+ log_error "Error while parsing Puma stats.", error
61
+ nil
62
+ end
63
+ end
64
+
65
+ # AppsignalPumaPlugin
66
+ #
67
+ # Class to handle the logic of translating the Puma stats to AppSignal metrics.
68
+ #
69
+ # @api private
70
+ class AppsignalPumaPlugin
71
+ def initialize
72
+ @hostname = fetch_hostname
73
+ @statsd = Statsd.new
74
+ end
75
+
76
+ def call(stats)
77
+ counts = {}
78
+ count_keys = [:backlog, :running, :pool_capacity, :max_threads]
79
+
80
+ if stats[:worker_status] # Clustered mode - Multiple workers
81
+ stats[:worker_status].each do |worker|
82
+ stat = worker[:last_status]
83
+ count_keys.each do |key|
84
+ count_if_present counts, key, stat
85
+ end
86
+ end
87
+
88
+ gauge(:workers, stats[:workers], :type => :count)
89
+ gauge(:workers, stats[:booted_workers], :type => :booted)
90
+ gauge(:workers, stats[:old_workers], :type => :old)
91
+ else # Single mode - Single worker
92
+ count_keys.each do |key|
93
+ count_if_present counts, key, stats
94
+ end
95
+ end
96
+
97
+ gauge(:connection_backlog, counts[:backlog]) if counts[:backlog]
98
+ gauge(:pool_capacity, counts[:pool_capacity]) if counts[:pool_capacity]
99
+ gauge(:threads, counts[:running], :type => :running) if counts[:running]
100
+ gauge(:threads, counts[:max_threads], :type => :max) if counts[:max_threads]
101
+ end
102
+
103
+ private
104
+
105
+ attr_reader :hostname
106
+
107
+ def fetch_hostname
108
+ # Configure hostname as reported for the Puma metrics with the
109
+ # APPSIGNAL_HOSTNAME environment variable.
110
+ env_hostname = ENV["APPSIGNAL_HOSTNAME"]
111
+ return env_hostname if env_hostname
112
+
113
+ # Auto detect hostname as fallback. May be inaccurate.
114
+ Socket.gethostname
115
+ end
116
+
117
+ def gauge(field, count, tags = {})
118
+ @statsd.gauge("puma_#{field}", count, tags.merge(:hostname => hostname))
119
+ end
120
+
121
+ def count_if_present(counts, key, stats)
122
+ stat_value = stats[key]
123
+ return unless stat_value
124
+
125
+ counts[key] ||= 0
126
+ counts[key] += stat_value
127
+ end
128
+
129
+ class Statsd
130
+ def initialize
131
+ # StatsD server location as configured in AppSignal agent StatsD server.
132
+ @host = "127.0.0.1"
133
+ @port = 8125
134
+ end
135
+
136
+ def gauge(metric_name, value, tags)
137
+ send_metric "g", metric_name, value, tags
138
+ end
139
+
140
+ private
141
+
142
+ attr_reader :host, :port
143
+
144
+ def send_metric(type, metric_name, metric_value, tags_hash)
145
+ tags = tags_hash.map { |key, value| "#{key}:#{value}" }.join(",")
146
+ data = "#{metric_name}:#{metric_value}|#{type}|##{tags}"
147
+
148
+ # Open (and close) a new socket every time because we don't know when the
149
+ # plugin will exit and when to cleanly close the socket connection.
150
+ socket = UDPSocket.new
151
+ socket.send(data, 0, host, port)
152
+ ensure
153
+ socket && socket.close
25
154
  end
26
155
  end
27
156
  end
@@ -270,9 +270,10 @@ describe Appsignal::CLI::Diagnose, :api_stub => true, :send_report => :yes_cli_i
270
270
  "build" => {
271
271
  "time" => kind_of(String),
272
272
  "package_path" => File.expand_path("../../../../../", __FILE__),
273
- "architecture" => rbconfig["host_cpu"],
273
+ "architecture" => Appsignal::System.agent_architecture,
274
274
  "target" => Appsignal::System.agent_platform,
275
275
  "musl_override" => false,
276
+ "linux_arm_override" => false,
276
277
  "library_type" => jruby ? "dynamic" : "static",
277
278
  "source" => "remote",
278
279
  "dependencies" => kind_of(Hash),
@@ -301,9 +302,10 @@ describe Appsignal::CLI::Diagnose, :api_stub => true, :send_report => :yes_cli_i
301
302
  " Checksum: verified",
302
303
  "Build details",
303
304
  " Install time: 20",
304
- " Architecture: #{rbconfig["host_cpu"]}",
305
+ " Architecture: #{Appsignal::System.agent_architecture}",
305
306
  " Target: #{Appsignal::System.agent_platform}",
306
307
  " Musl override: false",
308
+ " Linux ARM override: false",
307
309
  " Library type: #{jruby ? "dynamic" : "static"}",
308
310
  " Dependencies: {",
309
311
  " Flags: {",
@@ -0,0 +1,74 @@
1
+ describe Appsignal::Hooks::ExconHook do
2
+ before :context do
3
+ start_agent
4
+ end
5
+
6
+ context "with Excon" do
7
+ before(:context) do
8
+ class Excon
9
+ def self.defaults
10
+ @defaults ||= {}
11
+ end
12
+ end
13
+ Appsignal::Hooks::ExconHook.new.install
14
+ end
15
+ after(:context) { Object.send(:remove_const, :Excon) }
16
+
17
+ describe "#dependencies_present?" do
18
+ subject { described_class.new.dependencies_present? }
19
+
20
+ it { is_expected.to be_truthy }
21
+ end
22
+
23
+ describe "#install" do
24
+ it "adds the AppSignal instrumentor to Excon" do
25
+ expect(Excon.defaults[:instrumentor]).to eql(Appsignal::Integrations::ExconIntegration)
26
+ end
27
+ end
28
+
29
+ describe "instrumentation" do
30
+ let!(:transaction) do
31
+ Appsignal::Transaction.create("uuid", Appsignal::Transaction::HTTP_REQUEST, "test")
32
+ end
33
+ around { |example| keep_transactions { example.run } }
34
+
35
+ it "instruments a http request" do
36
+ data = {
37
+ :host => "www.google.com",
38
+ :method => :get,
39
+ :scheme => "http"
40
+ }
41
+ Excon.defaults[:instrumentor].instrument("excon.request", data) {}
42
+
43
+ expect(transaction.to_h["events"]).to include(
44
+ hash_including(
45
+ "name" => "request.excon",
46
+ "title" => "GET http://www.google.com",
47
+ "body" => ""
48
+ )
49
+ )
50
+ end
51
+
52
+ it "instruments a http response" do
53
+ data = { :host => "www.google.com" }
54
+ Excon.defaults[:instrumentor].instrument("excon.response", data) {}
55
+
56
+ expect(transaction.to_h["events"]).to include(
57
+ hash_including(
58
+ "name" => "response.excon",
59
+ "title" => "www.google.com",
60
+ "body" => ""
61
+ )
62
+ )
63
+ end
64
+ end
65
+ end
66
+
67
+ context "without Excon" do
68
+ describe "#dependencies_present?" do
69
+ subject { described_class.new.dependencies_present? }
70
+
71
+ it { is_expected.to be_falsy }
72
+ end
73
+ end
74
+ end
@@ -35,29 +35,6 @@ describe Appsignal::Hooks::PumaHook do
35
35
  # Does not error on call
36
36
  Appsignal::Hooks::PumaHook.new.install
37
37
  end
38
-
39
- context "with APPSIGNAL_PUMA_PLUGIN_LOADED defined" do
40
- before do
41
- # Set in lib/puma/appsignal.rb
42
- APPSIGNAL_PUMA_PLUGIN_LOADED = true
43
- end
44
- after { Object.send :remove_const, :APPSIGNAL_PUMA_PLUGIN_LOADED }
45
-
46
- it "does not add the Puma minutely probe" do
47
- Appsignal::Hooks::PumaHook.new.install
48
- expect(Appsignal::Minutely.probes[:puma]).to be_nil
49
- end
50
- end
51
-
52
- context "without APPSIGNAL_PUMA_PLUGIN_LOADED defined" do
53
- it "adds the Puma minutely probe" do
54
- expect(defined?(APPSIGNAL_PUMA_PLUGIN_LOADED)).to be_nil
55
-
56
- Appsignal::Hooks::PumaHook.new.install
57
- probe = Appsignal::Minutely.probes[:puma]
58
- expect(probe).to eql(Appsignal::Probes::PumaProbe)
59
- end
60
- end
61
38
  end
62
39
 
63
40
  context "when in clustered mode" do
@@ -81,29 +58,6 @@ describe Appsignal::Hooks::PumaHook do
81
58
  cluster.stop_workers
82
59
  expect(cluster.instance_variable_get(:@called)).to be(true)
83
60
  end
84
-
85
- context "with APPSIGNAL_PUMA_PLUGIN_LOADED defined" do
86
- before do
87
- # Set in lib/puma/appsignal.rb
88
- APPSIGNAL_PUMA_PLUGIN_LOADED = true
89
- end
90
- after { Object.send :remove_const, :APPSIGNAL_PUMA_PLUGIN_LOADED }
91
-
92
- it "does not add the Puma minutely probe" do
93
- Appsignal::Hooks::PumaHook.new.install
94
- expect(Appsignal::Minutely.probes[:puma]).to be_nil
95
- end
96
- end
97
-
98
- context "without APPSIGNAL_PUMA_PLUGIN_LOADED defined" do
99
- it "adds the Puma minutely probe" do
100
- expect(defined?(APPSIGNAL_PUMA_PLUGIN_LOADED)).to be_nil
101
-
102
- Appsignal::Hooks::PumaHook.new.install
103
- probe = Appsignal::Minutely.probes[:puma]
104
- expect(probe).to eql(Appsignal::Probes::PumaProbe)
105
- end
106
- end
107
61
  end
108
62
  end
109
63
  end
@@ -33,16 +33,17 @@ describe Appsignal::Hooks::RedisHook do
33
33
 
34
34
  context "instrumentation" do
35
35
  before do
36
+ start_agent
36
37
  # Stub Redis::Client class so that it doesn't perform an actual
37
38
  # Redis query. This class will be included (prepended) with the
38
39
  # AppSignal Redis integration.
39
40
  stub_const("Redis::Client", Class.new do
40
41
  def id
41
- :stub_id
42
+ "stub_id"
42
43
  end
43
44
 
44
- def process(_commands)
45
- :stub_process
45
+ def write(_commands)
46
+ "stub_write"
46
47
  end
47
48
  end)
48
49
  # Load the integration again for the stubbed Redis::Client class.
@@ -50,17 +51,40 @@ describe Appsignal::Hooks::RedisHook do
50
51
  # track if it was installed already or not.
51
52
  Appsignal::Hooks::RedisHook.new.install
52
53
  end
54
+ let!(:transaction) do
55
+ Appsignal::Transaction.create("uuid", Appsignal::Transaction::HTTP_REQUEST, "test")
56
+ end
57
+ around { |example| keep_transactions { example.run } }
53
58
 
54
59
  it "instrument a redis call" do
55
- Appsignal::Transaction.create("uuid", Appsignal::Transaction::HTTP_REQUEST, "test")
56
- expect(Appsignal::Transaction.current).to receive(:start_event)
57
- .at_least(:once)
58
- expect(Appsignal::Transaction.current).to receive(:finish_event)
59
- .at_least(:once)
60
- .with("query.redis", :stub_id, "get ?", 0)
60
+ client = Redis::Client.new
61
+ expect(client.write([:get, "key"])).to eql("stub_write")
61
62
 
63
+ transaction_hash = transaction.to_h
64
+ expect(transaction_hash["events"]).to include(
65
+ hash_including(
66
+ "name" => "query.redis",
67
+ "body" => "get ?",
68
+ "title" => "stub_id"
69
+ )
70
+ )
71
+ end
72
+
73
+ it "instrument a redis script call" do
62
74
  client = Redis::Client.new
63
- expect(client.process([[:get, "key"]])).to eql(:stub_process)
75
+ script = "return redis.call('set',KEYS[1],ARGV[1])"
76
+ keys = ["foo"]
77
+ argv = ["bar"]
78
+ expect(client.write([:eval, script, keys.size, keys, argv])).to eql("stub_write")
79
+
80
+ transaction_hash = transaction.to_h
81
+ expect(transaction_hash["events"]).to include(
82
+ hash_including(
83
+ "name" => "query.redis",
84
+ "body" => "#{script} ? ?",
85
+ "title" => "stub_id"
86
+ )
87
+ )
64
88
  end
65
89
  end
66
90
  end