summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>2015-12-28 13:42:15 +0000
committerDmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>2015-12-28 13:42:15 +0000
commit540eb0a9affef14329418b32be0dcd60f2b66e29 (patch)
treef2562ea6c206c46aedc534497a55fe93f55e97cd
parentc08cb923ec4af9cfb70031259051d5c0c70cad21 (diff)
parent1be5668ae0e663015d384ea7d8b404f9eeb5b478 (diff)
downloadgitlab-ce-540eb0a9affef14329418b32be0dcd60f2b66e29.tar.gz
Merge branch 'influxdb' into 'master'
Storing of application metrics in InfluxDB This adds support for tracking metrics in InfluxDB, which in turn can be visualized using Grafana. For more information see #2936. See merge request !2042
-rw-r--r--Gemfile8
-rw-r--r--Gemfile.lock31
-rw-r--r--Procfile2
-rw-r--r--app/workers/metrics_worker.rb33
-rw-r--r--config/gitlab.yml.example21
-rw-r--r--config/initializers/metrics.rb57
-rw-r--r--lib/gitlab/metrics.rb64
-rw-r--r--lib/gitlab/metrics/delta.rb32
-rw-r--r--lib/gitlab/metrics/instrumentation.rb146
-rw-r--r--lib/gitlab/metrics/metric.rb34
-rw-r--r--lib/gitlab/metrics/obfuscated_sql.rb47
-rw-r--r--lib/gitlab/metrics/rack_middleware.rb49
-rw-r--r--lib/gitlab/metrics/sampler.rb98
-rw-r--r--lib/gitlab/metrics/sidekiq_middleware.rb30
-rw-r--r--lib/gitlab/metrics/subscribers/action_view.rb53
-rw-r--r--lib/gitlab/metrics/subscribers/active_record.rb48
-rw-r--r--lib/gitlab/metrics/system.rb35
-rw-r--r--lib/gitlab/metrics/transaction.rb66
-rw-r--r--spec/lib/gitlab/metrics/delta_spec.rb16
-rw-r--r--spec/lib/gitlab/metrics/instrumentation_spec.rb234
-rw-r--r--spec/lib/gitlab/metrics/metric_spec.rb57
-rw-r--r--spec/lib/gitlab/metrics/obfuscated_sql_spec.rb87
-rw-r--r--spec/lib/gitlab/metrics/rack_middleware_spec.rb63
-rw-r--r--spec/lib/gitlab/metrics/sampler_spec.rb97
-rw-r--r--spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb34
-rw-r--r--spec/lib/gitlab/metrics/subscribers/action_view_spec.rb37
-rw-r--r--spec/lib/gitlab/metrics/subscribers/active_record_spec.rb32
-rw-r--r--spec/lib/gitlab/metrics/system_spec.rb29
-rw-r--r--spec/lib/gitlab/metrics/transaction_spec.rb77
-rw-r--r--spec/lib/gitlab/metrics_spec.rb36
-rw-r--r--spec/workers/metrics_worker_spec.rb52
31 files changed, 1693 insertions, 12 deletions
diff --git a/Gemfile b/Gemfile
index 7c3a0ca016f..2a1c4f7d73a 100644
--- a/Gemfile
+++ b/Gemfile
@@ -215,6 +215,14 @@ gem 'select2-rails', '~> 3.5.9'
gem 'virtus', '~> 1.0.1'
gem 'net-ssh', '~> 3.0.1'
+# Metrics
+group :metrics do
+ gem 'allocations', '~> 1.0', require: false, platform: :mri
+ gem 'method_source', '~> 0.8', require: false
+ gem 'influxdb', '~> 0.2', require: false
+ gem 'connection_pool', '~> 2.0', require: false
+end
+
group :development do
gem "foreman"
gem 'brakeman', '~> 3.1.0', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 5e1529642e7..c4cadbafa26 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -49,6 +49,7 @@ GEM
addressable (2.3.8)
after_commit_queue (1.3.0)
activerecord (>= 3.0)
+ allocations (1.0.1)
annotate (2.6.10)
activerecord (>= 3.2, <= 4.3)
rake (~> 10.4)
@@ -65,7 +66,7 @@ GEM
attr_encrypted (1.3.4)
encryptor (>= 1.3.0)
attr_required (1.0.0)
- autoprefixer-rails (6.1.1)
+ autoprefixer-rails (6.1.2)
execjs
json
awesome_print (1.2.0)
@@ -104,7 +105,7 @@ GEM
bundler-audit (0.4.0)
bundler (~> 1.2)
thor (~> 0.18)
- byebug (8.2.0)
+ byebug (8.2.1)
cal-heatmap-rails (0.0.1)
capybara (2.4.4)
mime-types (>= 1.16)
@@ -119,6 +120,7 @@ GEM
activemodel (>= 3.2.0)
activesupport (>= 3.2.0)
json (>= 1.7)
+ cause (0.1)
charlock_holmes (0.7.3)
chunky_png (1.3.5)
cliver (0.3.2)
@@ -142,10 +144,10 @@ GEM
term-ansicolor (~> 1.3)
thor (~> 0.19.1)
tins (~> 1.6.0)
- crack (0.4.2)
+ crack (0.4.3)
safe_yaml (~> 1.0.0)
creole (0.5.0)
- d3_rails (3.5.6)
+ d3_rails (3.5.11)
railties (>= 3.1.0)
daemons (1.2.3)
database_cleaner (1.4.1)
@@ -232,7 +234,7 @@ GEM
ipaddress (~> 0.5)
nokogiri (~> 1.5, >= 1.5.11)
opennebula
- fog-brightbox (0.9.0)
+ fog-brightbox (0.10.1)
fog-core (~> 1.22)
fog-json
inflecto (~> 0.0.2)
@@ -251,7 +253,7 @@ GEM
fog-core (>= 1.21.0)
fog-json
fog-xml (>= 0.0.1)
- fog-sakuracloud (1.4.0)
+ fog-sakuracloud (1.5.0)
fog-core
fog-json
fog-softlayer (1.0.2)
@@ -279,11 +281,11 @@ GEM
ruby-progressbar (~> 1.4)
gemnasium-gitlab-service (0.2.6)
rugged (~> 0.21)
- gemojione (2.1.0)
+ gemojione (2.1.1)
json
get_process_mem (0.2.0)
gherkin-ruby (0.3.2)
- github-linguist (4.7.2)
+ github-linguist (4.7.3)
charlock_holmes (~> 0.7.3)
escape_utils (~> 1.1.0)
mime-types (>= 1.19)
@@ -300,7 +302,7 @@ GEM
posix-spawn (~> 0.3)
gitlab_emoji (0.2.0)
gemojione (~> 2.1)
- gitlab_git (7.2.21)
+ gitlab_git (7.2.22)
activesupport (~> 4.0)
charlock_holmes (~> 0.7.3)
github-linguist (~> 4.7.0)
@@ -372,6 +374,9 @@ GEM
i18n (0.7.0)
ice_nine (0.11.1)
inflecto (0.0.2)
+ influxdb (0.2.3)
+ cause
+ json
ipaddress (0.8.0)
jquery-atwho-rails (1.3.2)
jquery-rails (4.0.5)
@@ -419,7 +424,7 @@ GEM
net-ldap (0.12.1)
net-ssh (3.0.1)
netrc (0.11.0)
- newrelic-grape (2.0.0)
+ newrelic-grape (2.1.0)
grape
newrelic_rpm
newrelic_rpm (3.9.4.245)
@@ -797,7 +802,7 @@ GEM
coercible (~> 1.0)
descendants_tracker (~> 0.0, >= 0.0.3)
equalizer (~> 0.0, >= 0.0.9)
- warden (1.2.3)
+ warden (1.2.4)
rack (>= 1.0)
web-console (2.2.1)
activemodel (>= 4.0)
@@ -828,6 +833,7 @@ DEPENDENCIES
acts-as-taggable-on (~> 3.4)
addressable (~> 2.3.8)
after_commit_queue
+ allocations (~> 1.0)
annotate (~> 2.6.0)
asana (~> 0.4.0)
asciidoctor (~> 1.5.2)
@@ -850,6 +856,7 @@ DEPENDENCIES
charlock_holmes (~> 0.7.3)
coffee-rails (~> 4.1.0)
colorize (~> 0.7.0)
+ connection_pool (~> 2.0)
coveralls (~> 0.8.2)
creole (~> 0.5.0)
d3_rails (~> 3.5.5)
@@ -887,6 +894,7 @@ DEPENDENCIES
hipchat (~> 1.5.0)
html-pipeline (~> 1.11.0)
httparty (~> 0.13.3)
+ influxdb (~> 0.2)
jquery-atwho-rails (~> 1.3.2)
jquery-rails (~> 4.0.0)
jquery-scrollto-rails (~> 1.4.3)
@@ -895,6 +903,7 @@ DEPENDENCIES
kaminari (~> 0.16.3)
letter_opener (~> 1.1.2)
mail_room (~> 0.6.1)
+ method_source (~> 0.8)
minitest (~> 5.7.0)
mousetrap-rails (~> 1.4.6)
mysql2 (~> 0.3.16)
diff --git a/Procfile b/Procfile
index 9cfdee7040f..bbafdd33a2d 100644
--- a/Procfile
+++ b/Procfile
@@ -3,5 +3,5 @@
# lib/support/init.d, which call scripts in bin/ .
#
web: bundle exec unicorn_rails -p ${PORT:="3000"} -E ${RAILS_ENV:="development"} -c ${UNICORN_CONFIG:="config/unicorn.rb"}
-worker: bundle exec sidekiq -q post_receive -q mailers -q archive_repo -q system_hook -q project_web_hook -q gitlab_shell -q incoming_email -q runner -q common -q default
+worker: bundle exec sidekiq -q post_receive -q mailers -q archive_repo -q system_hook -q project_web_hook -q gitlab_shell -q incoming_email -q runner -q common -q default -q metrics
# mail_room: bundle exec mail_room -q -c config/mail_room.yml
diff --git a/app/workers/metrics_worker.rb b/app/workers/metrics_worker.rb
new file mode 100644
index 00000000000..b15dc819c5c
--- /dev/null
+++ b/app/workers/metrics_worker.rb
@@ -0,0 +1,33 @@
+class MetricsWorker
+ include Sidekiq::Worker
+
+ sidekiq_options queue: :metrics
+
+ def perform(metrics)
+ prepared = prepare_metrics(metrics)
+
+ Gitlab::Metrics.pool.with do |connection|
+ connection.write_points(prepared)
+ end
+ end
+
+ def prepare_metrics(metrics)
+ metrics.map do |hash|
+ new_hash = hash.symbolize_keys
+
+ new_hash[:tags].each do |key, value|
+ if value.blank?
+ new_hash[:tags].delete(key)
+ else
+ new_hash[:tags][key] = escape_value(value)
+ end
+ end
+
+ new_hash
+ end
+ end
+
+ def escape_value(value)
+ value.to_s.gsub('=', '\\=')
+ end
+end
diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example
index 79fc7423b31..da6d4005da6 100644
--- a/config/gitlab.yml.example
+++ b/config/gitlab.yml.example
@@ -449,9 +449,26 @@ production: &base
#
# Ban an IP for one hour (3600s) after too many auth attempts
# bantime: 3600
+ metrics:
+ host: localhost
+ enabled: false
+ # The name of the InfluxDB database to store metrics in.
+ database: gitlab
+ # Credentials to use for logging in to InfluxDB.
+ # username:
+ # password:
+ # The amount of InfluxDB connections to open.
+ # pool_size: 16
+ # The timeout of a connection in seconds.
+ # timeout: 10
+ # The minimum amount of milliseconds a method call has to take before it's
+ # tracked. Defaults to 10.
+ # method_call_threshold: 10
development:
<<: *base
+ metrics:
+ enabled: false
test:
<<: *base
@@ -494,6 +511,10 @@ test:
user_filter: ''
group_base: 'ou=groups,dc=example,dc=com'
admin_group: ''
+ metrics:
+ enabled: false
staging:
<<: *base
+ metrics:
+ enabled: false
diff --git a/config/initializers/metrics.rb b/config/initializers/metrics.rb
new file mode 100644
index 00000000000..a47d2bf59a6
--- /dev/null
+++ b/config/initializers/metrics.rb
@@ -0,0 +1,57 @@
+if Gitlab::Metrics.enabled?
+ require 'influxdb'
+ require 'socket'
+ require 'connection_pool'
+ require 'method_source'
+
+ # These are manually require'd so the classes are registered properly with
+ # ActiveSupport.
+ require 'gitlab/metrics/subscribers/action_view'
+ require 'gitlab/metrics/subscribers/active_record'
+
+ Gitlab::Application.configure do |config|
+ config.middleware.use(Gitlab::Metrics::RackMiddleware)
+ end
+
+ Sidekiq.configure_server do |config|
+ config.server_middleware do |chain|
+ chain.add Gitlab::Metrics::SidekiqMiddleware
+ end
+ end
+
+ # This instruments all methods residing in app/models that (appear to) use any
+ # of the ActiveRecord methods. This has to take place _after_ initializing as
+ # for some unknown reason calling eager_load! earlier breaks Devise.
+ Gitlab::Application.config.after_initialize do
+ Rails.application.eager_load!
+
+ models = Rails.root.join('app', 'models').to_s
+
+ regex = Regexp.union(
+ ActiveRecord::Querying.public_instance_methods(false).map(&:to_s)
+ )
+
+ Gitlab::Metrics::Instrumentation.
+ instrument_class_hierarchy(ActiveRecord::Base) do |_, method|
+ loc = method.source_location
+
+ loc && loc[0].start_with?(models) && method.source =~ regex
+ end
+ end
+
+ Gitlab::Metrics::Instrumentation.configure do |config|
+ config.instrument_instance_methods(Gitlab::Shell)
+
+ config.instrument_methods(Gitlab::Git)
+
+ Gitlab::Git.constants.each do |name|
+ const = Gitlab::Git.const_get(name)
+
+ config.instrument_methods(const) if const.is_a?(Module)
+ end
+ end
+
+ GC::Profiler.enable
+
+ Gitlab::Metrics::Sampler.new.start
+end
diff --git a/lib/gitlab/metrics.rb b/lib/gitlab/metrics.rb
new file mode 100644
index 00000000000..d6f60732455
--- /dev/null
+++ b/lib/gitlab/metrics.rb
@@ -0,0 +1,64 @@
+module Gitlab
+ module Metrics
+ RAILS_ROOT = Rails.root.to_s
+ METRICS_ROOT = Rails.root.join('lib', 'gitlab', 'metrics').to_s
+ PATH_REGEX = /^#{RAILS_ROOT}\/?/
+
+ def self.pool_size
+ Settings.metrics['pool_size'] || 16
+ end
+
+ def self.timeout
+ Settings.metrics['timeout'] || 10
+ end
+
+ def self.enabled?
+ !!Settings.metrics['enabled']
+ end
+
+ def self.mri?
+ RUBY_ENGINE == 'ruby'
+ end
+
+ def self.method_call_threshold
+ Settings.metrics['method_call_threshold'] || 10
+ end
+
+ def self.pool
+ @pool
+ end
+
+ def self.hostname
+ @hostname
+ end
+
+ # Returns a relative path and line number based on the last application call
+ # frame.
+ def self.last_relative_application_frame
+ frame = caller_locations.find do |l|
+ l.path.start_with?(RAILS_ROOT) && !l.path.start_with?(METRICS_ROOT)
+ end
+
+ if frame
+ return frame.path.sub(PATH_REGEX, ''), frame.lineno
+ else
+ return nil, nil
+ end
+ end
+
+ @hostname = Socket.gethostname
+
+ # When enabled this should be set before being used as the usual pattern
+ # "@foo ||= bar" is _not_ thread-safe.
+ if enabled?
+ @pool = ConnectionPool.new(size: pool_size, timeout: timeout) do
+ host = Settings.metrics['host']
+ db = Settings.metrics['database']
+ user = Settings.metrics['username']
+ pw = Settings.metrics['password']
+
+ InfluxDB::Client.new(db, host: host, username: user, password: pw)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/delta.rb b/lib/gitlab/metrics/delta.rb
new file mode 100644
index 00000000000..bcf28eed84d
--- /dev/null
+++ b/lib/gitlab/metrics/delta.rb
@@ -0,0 +1,32 @@
+module Gitlab
+ module Metrics
+ # Class for calculating the difference between two numeric values.
+ #
+ # Every call to `compared_with` updates the internal value. This makes it
+ # possible to use a single Delta instance to calculate the delta over time
+ # of an ever increasing number.
+ #
+ # Example usage:
+ #
+ # delta = Delta.new(0)
+ #
+ # delta.compared_with(10) # => 10
+ # delta.compared_with(15) # => 5
+ # delta.compared_with(20) # => 5
+ class Delta
+ def initialize(value = 0)
+ @value = value
+ end
+
+ # new_value - The value to compare with as a Numeric.
+ #
+ # Returns a new Numeric (depending on the type of `new_value`).
+ def compared_with(new_value)
+ delta = new_value - @value
+ @value = new_value
+
+ delta
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/instrumentation.rb b/lib/gitlab/metrics/instrumentation.rb
new file mode 100644
index 00000000000..06fc2f25948
--- /dev/null
+++ b/lib/gitlab/metrics/instrumentation.rb
@@ -0,0 +1,146 @@
+module Gitlab
+ module Metrics
+ # Module for instrumenting methods.
+ #
+ # This module allows instrumenting of methods without having to actually
+ # alter the target code (e.g. by including modules).
+ #
+ # Example usage:
+ #
+ # Gitlab::Metrics::Instrumentation.instrument_method(User, :by_login)
+ module Instrumentation
+ SERIES = 'method_calls'
+
+ def self.configure
+ yield self
+ end
+
+ # Instruments a class method.
+ #
+ # mod - The module to instrument as a Module/Class.
+ # name - The name of the method to instrument.
+ def self.instrument_method(mod, name)
+ instrument(:class, mod, name)
+ end
+
+ # Instruments an instance method.
+ #
+ # mod - The module to instrument as a Module/Class.
+ # name - The name of the method to instrument.
+ def self.instrument_instance_method(mod, name)
+ instrument(:instance, mod, name)
+ end
+
+ # Recursively instruments all subclasses of the given root module.
+ #
+ # This can be used to for example instrument all ActiveRecord models (as
+ # these all inherit from ActiveRecord::Base).
+ #
+ # This method can optionally take a block to pass to `instrument_methods`
+ # and `instrument_instance_methods`.
+ #
+ # root - The root module for which to instrument subclasses. The root
+ # module itself is not instrumented.
+ def self.instrument_class_hierarchy(root, &block)
+ visit = root.subclasses
+
+ until visit.empty?
+ klass = visit.pop
+
+ instrument_methods(klass, &block)
+ instrument_instance_methods(klass, &block)
+
+ klass.subclasses.each { |c| visit << c }
+ end
+ end
+
+ # Instruments all public methods of a module.
+ #
+ # This method optionally takes a block that can be used to determine if a
+ # method should be instrumented or not. The block is passed the receiving
+ # module and an UnboundMethod. If the block returns a non truthy value the
+ # method is not instrumented.
+ #
+ # mod - The module to instrument.
+ def self.instrument_methods(mod)
+ mod.public_methods(false).each do |name|
+ method = mod.method(name)
+
+ if method.owner == mod.singleton_class
+ if !block_given? || block_given? && yield(mod, method)
+ instrument_method(mod, name)
+ end
+ end
+ end
+ end
+
+ # Instruments all public instance methods of a module.
+ #
+ # See `instrument_methods` for more information.
+ #
+ # mod - The module to instrument.
+ def self.instrument_instance_methods(mod)
+ mod.public_instance_methods(false).each do |name|
+ method = mod.instance_method(name)
+
+ if method.owner == mod
+ if !block_given? || block_given? && yield(mod, method)
+ instrument_instance_method(mod, name)
+ end
+ end
+ end
+ end
+
+ # Instruments a method.
+ #
+ # type - The type (:class or :instance) of method to instrument.
+ # mod - The module containing the method.
+ # name - The name of the method to instrument.
+ def self.instrument(type, mod, name)
+ return unless Metrics.enabled?
+
+ name = name.to_sym
+ alias_name = :"_original_#{name}"
+ target = type == :instance ? mod : mod.singleton_class
+
+ if type == :instance
+ target = mod
+ label = "#{mod.name}##{name}"
+ else
+ target = mod.singleton_class
+ label = "#{mod.name}.#{name}"
+ end
+
+ target.class_eval <<-EOF, __FILE__, __LINE__ + 1
+ alias_method #{alias_name.inspect}, #{name.inspect}
+
+ def #{name}(*args, &block)
+ trans = Gitlab::Metrics::Instrumentation.transaction
+
+ if trans
+ start = Time.now
+ retval = __send__(#{alias_name.inspect}, *args, &block)
+ duration = (Time.now - start) * 1000.0
+
+ if duration >= Gitlab::Metrics.method_call_threshold
+ trans.add_metric(Gitlab::Metrics::Instrumentation::SERIES,
+ { duration: duration },
+ method: #{label.inspect})
+ end
+
+ retval
+ else
+ __send__(#{alias_name.inspect}, *args, &block)
+ end
+ end
+ EOF
+ end
+
+ # Small layer of indirection to make it easier to stub out the current
+ # transaction.
+ def self.transaction
+ Transaction.current
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/metric.rb b/lib/gitlab/metrics/metric.rb
new file mode 100644
index 00000000000..f592f4e571f
--- /dev/null
+++ b/lib/gitlab/metrics/metric.rb
@@ -0,0 +1,34 @@
+module Gitlab
+ module Metrics
+ # Class for storing details of a single metric (label, value, etc).
+ class Metric
+ attr_reader :series, :values, :tags, :created_at
+
+ # series - The name of the series (as a String) to store the metric in.
+ # values - A Hash containing the values to store.
+ # tags - A Hash containing extra tags to add to the metrics.
+ def initialize(series, values, tags = {})
+ @values = values
+ @series = series
+ @tags = tags
+ @created_at = Time.now.utc
+ end
+
+ # Returns a Hash in a format that can be directly written to InfluxDB.
+ def to_hash
+ {
+ series: @series,
+ tags: @tags.merge(
+ hostname: Metrics.hostname,
+ ruby_engine: RUBY_ENGINE,
+ ruby_version: RUBY_VERSION,
+ gitlab_version: Gitlab::VERSION,
+ process_type: Sidekiq.server? ? 'sidekiq' : 'rails'
+ ),
+ values: @values,
+ timestamp: @created_at.to_i
+ }
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/obfuscated_sql.rb b/lib/gitlab/metrics/obfuscated_sql.rb
new file mode 100644
index 00000000000..481aca56efb
--- /dev/null
+++ b/lib/gitlab/metrics/obfuscated_sql.rb
@@ -0,0 +1,47 @@
+module Gitlab
+ module Metrics
+ # Class for producing SQL queries with sensitive data stripped out.
+ class ObfuscatedSQL
+ REPLACEMENT = /
+ \d+(\.\d+)? # integers, floats
+ | '.+?' # single quoted strings
+ | \/.+?(?<!\\)\/ # regexps (including escaped slashes)
+ /x
+
+ MYSQL_REPLACEMENTS = /
+ ".+?" # double quoted strings
+ /x
+
+ # Regex to replace consecutive placeholders with a single one indicating
+ # the length. This can be useful when a "IN" statement uses thousands of
+ # IDs (storing this would just be a waste of space).
+ CONSECUTIVE = /(\?(\s*,\s*)?){2,}/
+
+ # sql - The raw SQL query as a String.
+ def initialize(sql)
+ @sql = sql
+ end
+
+ # Returns a new, obfuscated SQL query.
+ def to_s
+ regex = REPLACEMENT
+
+ if Gitlab::Database.mysql?
+ regex = Regexp.union(regex, MYSQL_REPLACEMENTS)
+ end
+
+ sql = @sql.gsub(regex, '?').gsub(CONSECUTIVE) do |match|
+ "#{match.count(',') + 1} values"
+ end
+
+ # InfluxDB escapes double quotes upon output, so lets get rid of them
+ # whenever we can.
+ if Gitlab::Database.postgresql?
+ sql = sql.delete('"')
+ end
+
+ sql
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/rack_middleware.rb b/lib/gitlab/metrics/rack_middleware.rb
new file mode 100644
index 00000000000..5c0587c4c51
--- /dev/null
+++ b/lib/gitlab/metrics/rack_middleware.rb
@@ -0,0 +1,49 @@
+module Gitlab
+ module Metrics
+ # Rack middleware for tracking Rails requests.
+ class RackMiddleware
+ CONTROLLER_KEY = 'action_controller.instance'
+
+ def initialize(app)
+ @app = app
+ end
+
+ # env - A Hash containing Rack environment details.
+ def call(env)
+ trans = transaction_from_env(env)
+ retval = nil
+
+ begin
+ retval = trans.run { @app.call(env) }
+
+ # Even in the event of an error we want to submit any metrics we
+ # might've gathered up to this point.
+ ensure
+ if env[CONTROLLER_KEY]
+ tag_controller(trans, env)
+ end
+
+ trans.finish
+ end
+
+ retval
+ end
+
+ def transaction_from_env(env)
+ trans = Transaction.new
+
+ trans.add_tag(:request_method, env['REQUEST_METHOD'])
+ trans.add_tag(:request_uri, env['REQUEST_URI'])
+
+ trans
+ end
+
+ def tag_controller(trans, env)
+ controller = env[CONTROLLER_KEY]
+ label = "#{controller.class.name}##{controller.action_name}"
+
+ trans.add_tag(:action, label)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/sampler.rb b/lib/gitlab/metrics/sampler.rb
new file mode 100644
index 00000000000..828ee1f8c62
--- /dev/null
+++ b/lib/gitlab/metrics/sampler.rb
@@ -0,0 +1,98 @@
+module Gitlab
+ module Metrics
+ # Class that sends certain metrics to InfluxDB at a specific interval.
+ #
+ # This class is used to gather statistics that can't be directly associated
+ # with a transaction such as system memory usage, garbage collection
+ # statistics, etc.
+ class Sampler
+ # interval - The sampling interval in seconds.
+ def initialize(interval = 15)
+ @interval = interval
+ @metrics = []
+
+ @last_minor_gc = Delta.new(GC.stat[:minor_gc_count])
+ @last_major_gc = Delta.new(GC.stat[:major_gc_count])
+
+ if Gitlab::Metrics.mri?
+ require 'allocations'
+
+ Allocations.start
+ end
+ end
+
+ def start
+ Thread.new do
+ Thread.current.abort_on_exception = true
+
+ loop do
+ sleep(@interval)
+
+ sample
+ end
+ end
+ end
+
+ def sample
+ sample_memory_usage
+ sample_file_descriptors
+ sample_objects
+ sample_gc
+
+ flush
+ ensure
+ GC::Profiler.clear
+ @metrics.clear
+ end
+
+ def flush
+ MetricsWorker.perform_async(@metrics.map(&:to_hash))
+ end
+
+ def sample_memory_usage
+ @metrics << Metric.new('memory_usage', value: System.memory_usage)
+ end
+
+ def sample_file_descriptors
+ @metrics << Metric.
+ new('file_descriptors', value: System.file_descriptor_count)
+ end
+
+ if Metrics.mri?
+ def sample_objects
+ sample = Allocations.to_hash
+ counts = sample.each_with_object({}) do |(klass, count), hash|
+ hash[klass.name] = count
+ end
+
+ # Symbols aren't allocated so we'll need to add those manually.
+ counts['Symbol'] = Symbol.all_symbols.length
+
+ counts.each do |name, count|
+ @metrics << Metric.new('object_counts', { count: count }, type: name)
+ end
+ end
+ else
+ def sample_objects
+ end
+ end
+
+ def sample_gc
+ time = GC::Profiler.total_time * 1000.0
+ stats = GC.stat.merge(total_time: time)
+
+ # We want the difference of GC runs compared to the last sample, not the
+ # total amount since the process started.
+ stats[:minor_gc_count] =
+ @last_minor_gc.compared_with(stats[:minor_gc_count])
+
+ stats[:major_gc_count] =
+ @last_major_gc.compared_with(stats[:major_gc_count])
+
+ stats[:count] = stats[:minor_gc_count] + stats[:major_gc_count]
+
+ @metrics << Metric.new('gc_statistics', stats)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/sidekiq_middleware.rb b/lib/gitlab/metrics/sidekiq_middleware.rb
new file mode 100644
index 00000000000..ec10707d1fb
--- /dev/null
+++ b/lib/gitlab/metrics/sidekiq_middleware.rb
@@ -0,0 +1,30 @@
+module Gitlab
+ module Metrics
+ # Sidekiq middleware for tracking jobs.
+ #
+ # This middleware is intended to be used as a server-side middleware.
+ class SidekiqMiddleware
+ def call(worker, message, queue)
+ # We don't want to track the MetricsWorker itself as otherwise we'll end
+ # up in an infinite loop.
+ if worker.class == MetricsWorker
+ yield
+ return
+ end
+
+ trans = Transaction.new
+
+ begin
+ trans.run { yield }
+ ensure
+ tag_worker(trans, worker)
+ trans.finish
+ end
+ end
+
+ def tag_worker(trans, worker)
+ trans.add_tag(:action, "#{worker.class.name}#perform")
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/subscribers/action_view.rb b/lib/gitlab/metrics/subscribers/action_view.rb
new file mode 100644
index 00000000000..7e0dcf99d92
--- /dev/null
+++ b/lib/gitlab/metrics/subscribers/action_view.rb
@@ -0,0 +1,53 @@
+module Gitlab
+ module Metrics
+ module Subscribers
+ # Class for tracking the rendering timings of views.
+ class ActionView < ActiveSupport::Subscriber
+ attach_to :action_view
+
+ SERIES = 'views'
+
+ def render_template(event)
+ track(event) if current_transaction
+ end
+
+ alias_method :render_view, :render_template
+
+ private
+
+ def track(event)
+ values = values_for(event)
+ tags = tags_for(event)
+
+ current_transaction.add_metric(SERIES, values, tags)
+ end
+
+ def relative_path(path)
+ path.gsub(/^#{Rails.root.to_s}\/?/, '')
+ end
+
+ def values_for(event)
+ { duration: event.duration }
+ end
+
+ def tags_for(event)
+ path = relative_path(event.payload[:identifier])
+ tags = { view: path }
+
+ file, line = Metrics.last_relative_application_frame
+
+ if file and line
+ tags[:file] = file
+ tags[:line] = line
+ end
+
+ tags
+ end
+
+ def current_transaction
+ Transaction.current
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/subscribers/active_record.rb b/lib/gitlab/metrics/subscribers/active_record.rb
new file mode 100644
index 00000000000..d947c128ce2
--- /dev/null
+++ b/lib/gitlab/metrics/subscribers/active_record.rb
@@ -0,0 +1,48 @@
+module Gitlab
+ module Metrics
+ module Subscribers
+ # Class for tracking raw SQL queries.
+ #
+ # Queries are obfuscated before being logged to ensure no private data is
+ # exposed via InfluxDB/Grafana.
+ class ActiveRecord < ActiveSupport::Subscriber
+ attach_to :active_record
+
+ SERIES = 'sql_queries'
+
+ def sql(event)
+ return unless current_transaction
+
+ values = values_for(event)
+ tags = tags_for(event)
+
+ current_transaction.add_metric(SERIES, values, tags)
+ end
+
+ private
+
+ def values_for(event)
+ { duration: event.duration }
+ end
+
+ def tags_for(event)
+ sql = ObfuscatedSQL.new(event.payload[:sql]).to_s
+ tags = { sql: sql }
+
+ file, line = Metrics.last_relative_application_frame
+
+ if file and line
+ tags[:file] = file
+ tags[:line] = line
+ end
+
+ tags
+ end
+
+ def current_transaction
+ Transaction.current
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/system.rb b/lib/gitlab/metrics/system.rb
new file mode 100644
index 00000000000..83371265278
--- /dev/null
+++ b/lib/gitlab/metrics/system.rb
@@ -0,0 +1,35 @@
+module Gitlab
+ module Metrics
+ # Module for gathering system/process statistics such as the memory usage.
+ #
+ # This module relies on the /proc filesystem being available. If /proc is
+ # not available the methods of this module will be stubbed.
+ module System
+ if File.exist?('/proc')
+ # Returns the current process' memory usage in bytes.
+ def self.memory_usage
+ mem = 0
+ match = File.read('/proc/self/status').match(/VmRSS:\s+(\d+)/)
+
+ if match and match[1]
+ mem = match[1].to_f * 1024
+ end
+
+ mem
+ end
+
+ def self.file_descriptor_count
+ Dir.glob('/proc/self/fd/*').length
+ end
+ else
+ def self.memory_usage
+ 0.0
+ end
+
+ def self.file_descriptor_count
+ 0
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/metrics/transaction.rb b/lib/gitlab/metrics/transaction.rb
new file mode 100644
index 00000000000..568f9d6ae0c
--- /dev/null
+++ b/lib/gitlab/metrics/transaction.rb
@@ -0,0 +1,66 @@
+module Gitlab
+ module Metrics
+ # Class for storing metrics information of a single transaction.
+ class Transaction
+ THREAD_KEY = :_gitlab_metrics_transaction
+
+ SERIES = 'transactions'
+
+ attr_reader :uuid, :tags
+
+ def self.current
+ Thread.current[THREAD_KEY]
+ end
+
+ # name - The name of this transaction as a String.
+ def initialize
+ @metrics = []
+ @uuid = SecureRandom.uuid
+
+ @started_at = nil
+ @finished_at = nil
+
+ @tags = {}
+ end
+
+ def duration
+ @finished_at ? (@finished_at - @started_at) * 1000.0 : 0.0
+ end
+
+ def run
+ Thread.current[THREAD_KEY] = self
+
+ @started_at = Time.now
+
+ yield
+ ensure
+ @finished_at = Time.now
+
+ Thread.current[THREAD_KEY] = nil
+ end
+
+ def add_metric(series, values, tags = {})
+ tags = tags.merge(transaction_id: @uuid)
+
+ @metrics << Metric.new(series, values, tags)
+ end
+
+ def add_tag(key, value)
+ @tags[key] = value
+ end
+
+ def finish
+ track_self
+ submit
+ end
+
+ def track_self
+ add_metric(SERIES, { duration: duration }, @tags)
+ end
+
+ def submit
+ MetricsWorker.perform_async(@metrics.map(&:to_hash))
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/delta_spec.rb b/spec/lib/gitlab/metrics/delta_spec.rb
new file mode 100644
index 00000000000..718387cdee1
--- /dev/null
+++ b/spec/lib/gitlab/metrics/delta_spec.rb
@@ -0,0 +1,16 @@
+require 'spec_helper'
+
+describe Gitlab::Metrics::Delta do
+ let(:delta) { described_class.new }
+
+ describe '#compared_with' do
+ it 'returns the delta as a Numeric' do
+ expect(delta.compared_with(5)).to eq(5)
+ end
+
+ it 'bases the delta on a previously used value' do
+ expect(delta.compared_with(5)).to eq(5)
+ expect(delta.compared_with(15)).to eq(10)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/instrumentation_spec.rb b/spec/lib/gitlab/metrics/instrumentation_spec.rb
new file mode 100644
index 00000000000..a7eab9d11cc
--- /dev/null
+++ b/spec/lib/gitlab/metrics/instrumentation_spec.rb
@@ -0,0 +1,234 @@
+require 'spec_helper'
+
+describe Gitlab::Metrics::Instrumentation do
+ let(:transaction) { Gitlab::Metrics::Transaction.new }
+
+ before do
+ @dummy = Class.new do
+ def self.foo(text = 'foo')
+ text
+ end
+
+ def bar(text = 'bar')
+ text
+ end
+ end
+
+ allow(@dummy).to receive(:name).and_return('Dummy')
+ end
+
+ describe '.configure' do
+ it 'yields self' do
+ described_class.configure do |c|
+ expect(c).to eq(described_class)
+ end
+ end
+ end
+
+ describe '.instrument_method' do
+ describe 'with metrics enabled' do
+ before do
+ allow(Gitlab::Metrics).to receive(:enabled?).and_return(true)
+
+ described_class.instrument_method(@dummy, :foo)
+ end
+
+ it 'renames the original method' do
+ expect(@dummy).to respond_to(:_original_foo)
+ end
+
+ it 'calls the instrumented method with the correct arguments' do
+ expect(@dummy.foo).to eq('foo')
+ end
+
+ it 'tracks the call duration upon calling the method' do
+ allow(Gitlab::Metrics).to receive(:method_call_threshold).
+ and_return(0)
+
+ allow(described_class).to receive(:transaction).
+ and_return(transaction)
+
+ expect(transaction).to receive(:add_metric).
+ with(described_class::SERIES, an_instance_of(Hash),
+ method: 'Dummy.foo')
+
+ @dummy.foo
+ end
+
+ it 'does not track method calls below a given duration threshold' do
+ allow(Gitlab::Metrics).to receive(:method_call_threshold).
+ and_return(100)
+
+ expect(transaction).to_not receive(:add_metric)
+
+ @dummy.foo
+ end
+ end
+
+ describe 'with metrics disabled' do
+ before do
+ allow(Gitlab::Metrics).to receive(:enabled?).and_return(false)
+ end
+
+ it 'does not instrument the method' do
+ described_class.instrument_method(@dummy, :foo)
+
+ expect(@dummy).to_not respond_to(:_original_foo)
+ end
+ end
+ end
+
+ describe '.instrument_instance_method' do
+ describe 'with metrics enabled' do
+ before do
+ allow(Gitlab::Metrics).to receive(:enabled?).and_return(true)
+
+ described_class.
+ instrument_instance_method(@dummy, :bar)
+ end
+
+ it 'renames the original method' do
+ expect(@dummy.method_defined?(:_original_bar)).to eq(true)
+ end
+
+ it 'calls the instrumented method with the correct arguments' do
+ expect(@dummy.new.bar).to eq('bar')
+ end
+
+ it 'tracks the call duration upon calling the method' do
+ allow(Gitlab::Metrics).to receive(:method_call_threshold).
+ and_return(0)
+
+ allow(described_class).to receive(:transaction).
+ and_return(transaction)
+
+ expect(transaction).to receive(:add_metric).
+ with(described_class::SERIES, an_instance_of(Hash),
+ method: 'Dummy#bar')
+
+ @dummy.new.bar
+ end
+
+ it 'does not track method calls below a given duration threshold' do
+ allow(Gitlab::Metrics).to receive(:method_call_threshold).
+ and_return(100)
+
+ expect(transaction).to_not receive(:add_metric)
+
+ @dummy.new.bar
+ end
+ end
+
+ describe 'with metrics disabled' do
+ before do
+ allow(Gitlab::Metrics).to receive(:enabled?).and_return(false)
+ end
+
+ it 'does not instrument the method' do
+ described_class.
+ instrument_instance_method(@dummy, :bar)
+
+ expect(@dummy.method_defined?(:_original_bar)).to eq(false)
+ end
+ end
+ end
+
+ describe '.instrument_class_hierarchy' do
+ before do
+ allow(Gitlab::Metrics).to receive(:enabled?).and_return(true)
+
+ @child1 = Class.new(@dummy) do
+ def self.child1_foo; end
+ def child1_bar; end
+ end
+
+ @child2 = Class.new(@child1) do
+ def self.child2_foo; end
+ def child2_bar; end
+ end
+ end
+
+ it 'recursively instruments a class hierarchy' do
+ described_class.instrument_class_hierarchy(@dummy)
+
+ expect(@child1).to respond_to(:_original_child1_foo)
+ expect(@child2).to respond_to(:_original_child2_foo)
+
+ expect(@child1.method_defined?(:_original_child1_bar)).to eq(true)
+ expect(@child2.method_defined?(:_original_child2_bar)).to eq(true)
+ end
+
+ it 'does not instrument the root module' do
+ described_class.instrument_class_hierarchy(@dummy)
+
+ expect(@dummy).to_not respond_to(:_original_foo)
+ expect(@dummy.method_defined?(:_original_bar)).to eq(false)
+ end
+ end
+
+ describe '.instrument_methods' do
+ before do
+ allow(Gitlab::Metrics).to receive(:enabled?).and_return(true)
+ end
+
+ it 'instruments all public class methods' do
+ described_class.instrument_methods(@dummy)
+
+ expect(@dummy).to respond_to(:_original_foo)
+ end
+
+ it 'only instruments methods directly defined in the module' do
+ mod = Module.new do
+ def kittens
+ end
+ end
+
+ @dummy.extend(mod)
+
+ described_class.instrument_methods(@dummy)
+
+ expect(@dummy).to_not respond_to(:_original_kittens)
+ end
+
+ it 'can take a block to determine if a method should be instrumented' do
+ described_class.instrument_methods(@dummy) do
+ false
+ end
+
+ expect(@dummy).to_not respond_to(:_original_foo)
+ end
+ end
+
+ describe '.instrument_instance_methods' do
+ before do
+ allow(Gitlab::Metrics).to receive(:enabled?).and_return(true)
+ end
+
+ it 'instruments all public instance methods' do
+ described_class.instrument_instance_methods(@dummy)
+
+ expect(@dummy.method_defined?(:_original_bar)).to eq(true)
+ end
+
+ it 'only instruments methods directly defined in the module' do
+ mod = Module.new do
+ def kittens
+ end
+ end
+
+ @dummy.include(mod)
+
+ described_class.instrument_instance_methods(@dummy)
+
+ expect(@dummy.method_defined?(:_original_kittens)).to eq(false)
+ end
+
+ it 'can take a block to determine if a method should be instrumented' do
+ described_class.instrument_instance_methods(@dummy) do
+ false
+ end
+
+ expect(@dummy.method_defined?(:_original_bar)).to eq(false)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/metric_spec.rb b/spec/lib/gitlab/metrics/metric_spec.rb
new file mode 100644
index 00000000000..ec39bc9cce8
--- /dev/null
+++ b/spec/lib/gitlab/metrics/metric_spec.rb
@@ -0,0 +1,57 @@
+require 'spec_helper'
+
+describe Gitlab::Metrics::Metric do
+ let(:metric) do
+ described_class.new('foo', { number: 10 }, { host: 'localtoast' })
+ end
+
+ describe '#series' do
+ subject { metric.series }
+
+ it { is_expected.to eq('foo') }
+ end
+
+ describe '#values' do
+ subject { metric.values }
+
+ it { is_expected.to eq({ number: 10 }) }
+ end
+
+ describe '#tags' do
+ subject { metric.tags }
+
+ it { is_expected.to eq({ host: 'localtoast' }) }
+ end
+
+ describe '#to_hash' do
+ it 'returns a Hash' do
+ expect(metric.to_hash).to be_an_instance_of(Hash)
+ end
+
+ describe 'the returned Hash' do
+ let(:hash) { metric.to_hash }
+
+ it 'includes the series' do
+ expect(hash[:series]).to eq('foo')
+ end
+
+ it 'includes the tags' do
+ expect(hash[:tags]).to be_an_instance_of(Hash)
+
+ expect(hash[:tags][:hostname]).to be_an_instance_of(String)
+ expect(hash[:tags][:ruby_engine]).to be_an_instance_of(String)
+ expect(hash[:tags][:ruby_version]).to be_an_instance_of(String)
+ expect(hash[:tags][:gitlab_version]).to be_an_instance_of(String)
+ expect(hash[:tags][:process_type]).to be_an_instance_of(String)
+ end
+
+ it 'includes the values' do
+ expect(hash[:values]).to eq({ number: 10 })
+ end
+
+ it 'includes the timestamp' do
+ expect(hash[:timestamp]).to be_an_instance_of(Fixnum)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/obfuscated_sql_spec.rb b/spec/lib/gitlab/metrics/obfuscated_sql_spec.rb
new file mode 100644
index 00000000000..0f01ee588c9
--- /dev/null
+++ b/spec/lib/gitlab/metrics/obfuscated_sql_spec.rb
@@ -0,0 +1,87 @@
+require 'spec_helper'
+
+describe Gitlab::Metrics::ObfuscatedSQL do
+ describe '#to_s' do
+ describe 'using single values' do
+ it 'replaces a single integer' do
+ sql = described_class.new('SELECT x FROM y WHERE a = 10')
+
+ expect(sql.to_s).to eq('SELECT x FROM y WHERE a = ?')
+ end
+
+ it 'replaces a single float' do
+ sql = described_class.new('SELECT x FROM y WHERE a = 10.5')
+
+ expect(sql.to_s).to eq('SELECT x FROM y WHERE a = ?')
+ end
+
+ it 'replaces a single quoted string' do
+ sql = described_class.new("SELECT x FROM y WHERE a = 'foo'")
+
+ expect(sql.to_s).to eq('SELECT x FROM y WHERE a = ?')
+ end
+
+ if Gitlab::Database.mysql?
+ it 'replaces a double quoted string' do
+ sql = described_class.new('SELECT x FROM y WHERE a = "foo"')
+
+ expect(sql.to_s).to eq('SELECT x FROM y WHERE a = ?')
+ end
+ end
+
+ it 'replaces a single regular expression' do
+ sql = described_class.new('SELECT x FROM y WHERE a = /foo/')
+
+ expect(sql.to_s).to eq('SELECT x FROM y WHERE a = ?')
+ end
+
+ it 'replaces regular expressions using escaped slashes' do
+ sql = described_class.new('SELECT x FROM y WHERE a = /foo\/bar/')
+
+ expect(sql.to_s).to eq('SELECT x FROM y WHERE a = ?')
+ end
+ end
+
+ describe 'using consecutive values' do
+ it 'replaces multiple integers' do
+ sql = described_class.new('SELECT x FROM y WHERE z IN (10, 20, 30)')
+
+ expect(sql.to_s).to eq('SELECT x FROM y WHERE z IN (3 values)')
+ end
+
+ it 'replaces multiple floats' do
+ sql = described_class.new('SELECT x FROM y WHERE z IN (1.5, 2.5, 3.5)')
+
+ expect(sql.to_s).to eq('SELECT x FROM y WHERE z IN (3 values)')
+ end
+
+ it 'replaces multiple single quoted strings' do
+ sql = described_class.new("SELECT x FROM y WHERE z IN ('foo', 'bar')")
+
+ expect(sql.to_s).to eq('SELECT x FROM y WHERE z IN (2 values)')
+ end
+
+ if Gitlab::Database.mysql?
+ it 'replaces multiple double quoted strings' do
+ sql = described_class.new('SELECT x FROM y WHERE z IN ("foo", "bar")')
+
+ expect(sql.to_s).to eq('SELECT x FROM y WHERE z IN (2 values)')
+ end
+ end
+
+ it 'replaces multiple regular expressions' do
+ sql = described_class.new('SELECT x FROM y WHERE z IN (/foo/, /bar/)')
+
+ expect(sql.to_s).to eq('SELECT x FROM y WHERE z IN (2 values)')
+ end
+ end
+
+ if Gitlab::Database.postgresql?
+ it 'replaces double quotes' do
+ sql = described_class.new('SELECT "x" FROM "y"')
+
+ expect(sql.to_s).to eq('SELECT x FROM y')
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/rack_middleware_spec.rb b/spec/lib/gitlab/metrics/rack_middleware_spec.rb
new file mode 100644
index 00000000000..a143fe4cfcd
--- /dev/null
+++ b/spec/lib/gitlab/metrics/rack_middleware_spec.rb
@@ -0,0 +1,63 @@
+require 'spec_helper'
+
+describe Gitlab::Metrics::RackMiddleware do
+ let(:app) { double(:app) }
+
+ let(:middleware) { described_class.new(app) }
+
+ let(:env) { { 'REQUEST_METHOD' => 'GET', 'REQUEST_URI' => '/foo' } }
+
+ describe '#call' do
+ before do
+ expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish)
+ end
+
+ it 'tracks a transaction' do
+ expect(app).to receive(:call).with(env).and_return('yay')
+
+ expect(middleware.call(env)).to eq('yay')
+ end
+
+ it 'tags a transaction with the name and action of a controller' do
+ klass = double(:klass, name: 'TestController')
+ controller = double(:controller, class: klass, action_name: 'show')
+
+ env['action_controller.instance'] = controller
+
+ allow(app).to receive(:call).with(env)
+
+ expect(middleware).to receive(:tag_controller).
+ with(an_instance_of(Gitlab::Metrics::Transaction), env)
+
+ middleware.call(env)
+ end
+ end
+
+ describe '#transaction_from_env' do
+ let(:transaction) { middleware.transaction_from_env(env) }
+
+ it 'returns a Transaction' do
+ expect(transaction).to be_an_instance_of(Gitlab::Metrics::Transaction)
+ end
+
+ it 'tags the transaction with the request method and URI' do
+ expect(transaction.tags[:request_method]).to eq('GET')
+ expect(transaction.tags[:request_uri]).to eq('/foo')
+ end
+ end
+
+ describe '#tag_controller' do
+ let(:transaction) { middleware.transaction_from_env(env) }
+
+ it 'tags a transaction with the name and action of a controller' do
+ klass = double(:klass, name: 'TestController')
+ controller = double(:controller, class: klass, action_name: 'show')
+
+ env['action_controller.instance'] = controller
+
+ middleware.tag_controller(transaction, env)
+
+ expect(transaction.tags[:action]).to eq('TestController#show')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/sampler_spec.rb b/spec/lib/gitlab/metrics/sampler_spec.rb
new file mode 100644
index 00000000000..69376c0b79b
--- /dev/null
+++ b/spec/lib/gitlab/metrics/sampler_spec.rb
@@ -0,0 +1,97 @@
+require 'spec_helper'
+
+describe Gitlab::Metrics::Sampler do
+ let(:sampler) { described_class.new(5) }
+
+ after do
+ Allocations.stop if Gitlab::Metrics.mri?
+ end
+
+ describe '#start' do
+ it 'gathers a sample at a given interval' do
+ expect(sampler).to receive(:sleep).with(5)
+ expect(sampler).to receive(:sample)
+ expect(sampler).to receive(:loop).and_yield
+
+ sampler.start.join
+ end
+ end
+
+ describe '#sample' do
+ it 'samples various statistics' do
+ expect(sampler).to receive(:sample_memory_usage)
+ expect(sampler).to receive(:sample_file_descriptors)
+ expect(sampler).to receive(:sample_objects)
+ expect(sampler).to receive(:sample_gc)
+ expect(sampler).to receive(:flush)
+
+ sampler.sample
+ end
+
+ it 'clears any GC profiles' do
+ expect(sampler).to receive(:flush)
+ expect(GC::Profiler).to receive(:clear)
+
+ sampler.sample
+ end
+ end
+
+ describe '#flush' do
+ it 'schedules the metrics using Sidekiq' do
+ expect(MetricsWorker).to receive(:perform_async).
+ with([an_instance_of(Hash)])
+
+ sampler.sample_memory_usage
+ sampler.flush
+ end
+ end
+
+ describe '#sample_memory_usage' do
+ it 'adds a metric containing the memory usage' do
+ expect(Gitlab::Metrics::System).to receive(:memory_usage).
+ and_return(9000)
+
+ expect(Gitlab::Metrics::Metric).to receive(:new).
+ with('memory_usage', value: 9000).
+ and_call_original
+
+ sampler.sample_memory_usage
+ end
+ end
+
+ describe '#sample_file_descriptors' do
+ it 'adds a metric containing the amount of open file descriptors' do
+ expect(Gitlab::Metrics::System).to receive(:file_descriptor_count).
+ and_return(4)
+
+ expect(Gitlab::Metrics::Metric).to receive(:new).
+ with('file_descriptors', value: 4).
+ and_call_original
+
+ sampler.sample_file_descriptors
+ end
+ end
+
+ describe '#sample_objects' do
+ it 'adds a metric containing the amount of allocated objects' do
+ expect(Gitlab::Metrics::Metric).to receive(:new).
+ with('object_counts', an_instance_of(Hash), an_instance_of(Hash)).
+ at_least(:once).
+ and_call_original
+
+ sampler.sample_objects
+ end
+ end
+
+ describe '#sample_gc' do
+ it 'adds a metric containing garbage collection statistics' do
+ expect(GC::Profiler).to receive(:total_time).and_return(0.24)
+
+ expect(Gitlab::Metrics::Metric).to receive(:new).
+ with('gc_statistics', an_instance_of(Hash)).
+ and_call_original
+
+ sampler.sample_gc
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb b/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb
new file mode 100644
index 00000000000..05214efc565
--- /dev/null
+++ b/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb
@@ -0,0 +1,34 @@
+require 'spec_helper'
+
+describe Gitlab::Metrics::SidekiqMiddleware do
+ let(:middleware) { described_class.new }
+
+ describe '#call' do
+ it 'tracks the transaction' do
+ worker = Class.new.new
+
+ expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish)
+
+ middleware.call(worker, 'test', :test) { nil }
+ end
+
+ it 'does not track jobs of the MetricsWorker' do
+ worker = MetricsWorker.new
+
+ expect(Gitlab::Metrics::Transaction).to_not receive(:new)
+
+ middleware.call(worker, 'test', :test) { nil }
+ end
+ end
+
+ describe '#tag_worker' do
+ it 'adds the worker class and action to the transaction' do
+ trans = Gitlab::Metrics::Transaction.new
+ worker = double(:worker, class: double(:class, name: 'TestWorker'))
+
+ expect(trans).to receive(:add_tag).with(:action, 'TestWorker#perform')
+
+ middleware.tag_worker(trans, worker)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/subscribers/action_view_spec.rb b/spec/lib/gitlab/metrics/subscribers/action_view_spec.rb
new file mode 100644
index 00000000000..c6cd584663f
--- /dev/null
+++ b/spec/lib/gitlab/metrics/subscribers/action_view_spec.rb
@@ -0,0 +1,37 @@
+require 'spec_helper'
+
+describe Gitlab::Metrics::Subscribers::ActionView do
+ let(:transaction) { Gitlab::Metrics::Transaction.new }
+
+ let(:subscriber) { described_class.new }
+
+ let(:event) do
+ root = Rails.root.to_s
+
+ double(:event, duration: 2.1,
+ payload: { identifier: "#{root}/app/views/x.html.haml" })
+ end
+
+ before do
+ allow(subscriber).to receive(:current_transaction).and_return(transaction)
+
+ allow(Gitlab::Metrics).to receive(:last_relative_application_frame).
+ and_return(['app/views/x.html.haml', 4])
+ end
+
+ describe '#render_template' do
+ it 'tracks rendering of a template' do
+ values = { duration: 2.1 }
+ tags = {
+ view: 'app/views/x.html.haml',
+ file: 'app/views/x.html.haml',
+ line: 4
+ }
+
+ expect(transaction).to receive(:add_metric).
+ with(described_class::SERIES, values, tags)
+
+ subscriber.render_template(event)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb b/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb
new file mode 100644
index 00000000000..05b6cc14716
--- /dev/null
+++ b/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb
@@ -0,0 +1,32 @@
+require 'spec_helper'
+
+describe Gitlab::Metrics::Subscribers::ActiveRecord do
+ let(:transaction) { Gitlab::Metrics::Transaction.new }
+
+ let(:subscriber) { described_class.new }
+
+ let(:event) do
+ double(:event, duration: 0.2,
+ payload: { sql: 'SELECT * FROM users WHERE id = 10' })
+ end
+
+ before do
+ allow(subscriber).to receive(:current_transaction).and_return(transaction)
+
+ allow(Gitlab::Metrics).to receive(:last_relative_application_frame).
+ and_return(['app/models/foo.rb', 4])
+ end
+
+ describe '#sql' do
+ it 'tracks the execution of a SQL query' do
+ sql = 'SELECT * FROM users WHERE id = ?'
+ values = { duration: 0.2 }
+ tags = { sql: sql, file: 'app/models/foo.rb', line: 4 }
+
+ expect(transaction).to receive(:add_metric).
+ with(described_class::SERIES, values, tags)
+
+ subscriber.sql(event)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/system_spec.rb b/spec/lib/gitlab/metrics/system_spec.rb
new file mode 100644
index 00000000000..f8c1d956ca1
--- /dev/null
+++ b/spec/lib/gitlab/metrics/system_spec.rb
@@ -0,0 +1,29 @@
+require 'spec_helper'
+
+describe Gitlab::Metrics::System do
+ if File.exist?('/proc')
+ describe '.memory_usage' do
+ it "returns the process' memory usage in bytes" do
+ expect(described_class.memory_usage).to be > 0
+ end
+ end
+
+ describe '.file_descriptor_count' do
+ it 'returns the amount of open file descriptors' do
+ expect(described_class.file_descriptor_count).to be > 0
+ end
+ end
+ else
+ describe '.memory_usage' do
+ it 'returns 0.0' do
+ expect(described_class.memory_usage).to eq(0.0)
+ end
+ end
+
+ describe '.file_descriptor_count' do
+ it 'returns 0' do
+ expect(described_class.file_descriptor_count).to eq(0)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics/transaction_spec.rb b/spec/lib/gitlab/metrics/transaction_spec.rb
new file mode 100644
index 00000000000..5f17ff8ee75
--- /dev/null
+++ b/spec/lib/gitlab/metrics/transaction_spec.rb
@@ -0,0 +1,77 @@
+require 'spec_helper'
+
+describe Gitlab::Metrics::Transaction do
+ let(:transaction) { described_class.new }
+
+ describe '#duration' do
+ it 'returns the duration of a transaction in seconds' do
+ transaction.run { sleep(0.5) }
+
+ expect(transaction.duration).to be >= 0.5
+ end
+ end
+
+ describe '#run' do
+ it 'yields the supplied block' do
+ expect { |b| transaction.run(&b) }.to yield_control
+ end
+
+ it 'stores the transaction in the current thread' do
+ transaction.run do
+ expect(Thread.current[described_class::THREAD_KEY]).to eq(transaction)
+ end
+ end
+
+ it 'removes the transaction from the current thread upon completion' do
+ transaction.run { }
+
+ expect(Thread.current[described_class::THREAD_KEY]).to be_nil
+ end
+ end
+
+ describe '#add_metric' do
+ it 'adds a metric tagged with the transaction UUID' do
+ expect(Gitlab::Metrics::Metric).to receive(:new).
+ with('foo', { number: 10 }, { transaction_id: transaction.uuid })
+
+ transaction.add_metric('foo', number: 10)
+ end
+ end
+
+ describe '#add_tag' do
+ it 'adds a tag' do
+ transaction.add_tag(:foo, 'bar')
+
+ expect(transaction.tags).to eq({ foo: 'bar' })
+ end
+ end
+
+ describe '#finish' do
+ it 'tracks the transaction details and submits them to Sidekiq' do
+ expect(transaction).to receive(:track_self)
+ expect(transaction).to receive(:submit)
+
+ transaction.finish
+ end
+ end
+
+ describe '#track_self' do
+ it 'adds a metric for the transaction itself' do
+ expect(transaction).to receive(:add_metric).
+ with(described_class::SERIES, { duration: transaction.duration }, {})
+
+ transaction.track_self
+ end
+ end
+
+ describe '#submit' do
+ it 'submits the metrics to Sidekiq' do
+ transaction.track_self
+
+ expect(MetricsWorker).to receive(:perform_async).
+ with([an_instance_of(Hash)])
+
+ transaction.submit
+ end
+ end
+end
diff --git a/spec/lib/gitlab/metrics_spec.rb b/spec/lib/gitlab/metrics_spec.rb
new file mode 100644
index 00000000000..ebc69f8a75f
--- /dev/null
+++ b/spec/lib/gitlab/metrics_spec.rb
@@ -0,0 +1,36 @@
+require 'spec_helper'
+
+describe Gitlab::Metrics do
+ describe '.pool_size' do
+ it 'returns a Fixnum' do
+ expect(described_class.pool_size).to be_an_instance_of(Fixnum)
+ end
+ end
+
+ describe '.timeout' do
+ it 'returns a Fixnum' do
+ expect(described_class.timeout).to be_an_instance_of(Fixnum)
+ end
+ end
+
+ describe '.enabled?' do
+ it 'returns a boolean' do
+ expect([true, false].include?(described_class.enabled?)).to eq(true)
+ end
+ end
+
+ describe '.hostname' do
+ it 'returns a String containing the hostname' do
+ expect(described_class.hostname).to eq(Socket.gethostname)
+ end
+ end
+
+ describe '.last_relative_application_frame' do
+ it 'returns an Array containing a file path and line number' do
+ file, line = described_class.last_relative_application_frame
+
+ expect(line).to eq(30)
+ expect(file).to eq('spec/lib/gitlab/metrics_spec.rb')
+ end
+ end
+end
diff --git a/spec/workers/metrics_worker_spec.rb b/spec/workers/metrics_worker_spec.rb
new file mode 100644
index 00000000000..18260ea0c24
--- /dev/null
+++ b/spec/workers/metrics_worker_spec.rb
@@ -0,0 +1,52 @@
+require 'spec_helper'
+
+describe MetricsWorker do
+ let(:worker) { described_class.new }
+
+ describe '#perform' do
+ it 'prepares and writes the metrics to InfluxDB' do
+ connection = double(:connection)
+ pool = double(:pool)
+
+ expect(pool).to receive(:with).and_yield(connection)
+ expect(connection).to receive(:write_points).with(an_instance_of(Array))
+ expect(Gitlab::Metrics).to receive(:pool).and_return(pool)
+
+ worker.perform([{ 'series' => 'kittens', 'tags' => {} }])
+ end
+ end
+
+ describe '#prepare_metrics' do
+ it 'returns a Hash with the keys as Symbols' do
+ metrics = worker.prepare_metrics([{ 'values' => {}, 'tags' => {} }])
+
+ expect(metrics).to eq([{ values: {}, tags: {} }])
+ end
+
+ it 'escapes tag values' do
+ metrics = worker.prepare_metrics([
+ { 'values' => {}, 'tags' => { 'foo' => 'bar=' } }
+ ])
+
+ expect(metrics).to eq([{ values: {}, tags: { 'foo' => 'bar\\=' } }])
+ end
+
+ it 'drops empty tags' do
+ metrics = worker.prepare_metrics([
+ { 'values' => {}, 'tags' => { 'cats' => '', 'dogs' => nil } }
+ ])
+
+ expect(metrics).to eq([{ values: {}, tags: {} }])
+ end
+ end
+
+ describe '#escape_value' do
+ it 'escapes an equals sign' do
+ expect(worker.escape_value('foo=')).to eq('foo\\=')
+ end
+
+ it 'casts values to Strings' do
+ expect(worker.escape_value(10)).to eq('10')
+ end
+ end
+end