Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Central Config #464

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.

This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## Unreleased

### Added

- Support for APM Agent Configuration via Kibana ([#464](https://github.com/elastic/apm-agent-ruby/pull/464))

## 2.9.1 (2019-06-28)

### Fixed
Expand Down
13 changes: 13 additions & 0 deletions docs/configuration.asciidoc
Expand Up @@ -213,6 +213,19 @@ Whether or not to attach the request headers to transactions and errors.

Whether or not to attach `ENV` from Rack to transactions and errors.

[float]
[[config-central-config]]
==== `central_config`
|============
| Environment | `Config` key | Default
| `ELASTIC_APM_CENTRAL_CONFIG` | `central_config` | `true`
|============

Enable {kibana-ref}/agent-configuration.html[APM Agent Configuration via Kibana].
If set to `true`, the client will poll the APM Server regularly for new agent configuration.

NOTE: This feature requires APM Server v7.3 or later and that the APM Server is configured with `kibana.enabled: true`.

[float]
[[config-custom-key-filters]]
==== `custom_key_filters`
Expand Down
11 changes: 9 additions & 2 deletions lib/elastic_apm/agent.rb
@@ -1,13 +1,17 @@
# frozen_string_literal: true

require 'elastic_apm/error'

require 'elastic_apm/context_builder'
require 'elastic_apm/error_builder'
require 'elastic_apm/stacktrace_builder'
require 'elastic_apm/error'

require 'elastic_apm/central_config'
require 'elastic_apm/transport/base'
require 'elastic_apm/spies'
require 'elastic_apm/metrics'

require 'elastic_apm/spies'

module ElasticAPM
# rubocop:disable Metrics/ClassLength
# @api private
Expand Down Expand Up @@ -57,20 +61,23 @@ def self.running?
!!@instance
end

# rubocop:disable Metrics/MethodLength
def initialize(config)
@config = config

@stacktrace_builder = StacktraceBuilder.new(config)
@context_builder = ContextBuilder.new(config)
@error_builder = ErrorBuilder.new(self)

@central_config = CentralConfig.new(config)
@transport = Transport::Base.new(config)
@instrumenter = Instrumenter.new(
config,
stacktrace_builder: stacktrace_builder
) { |event| enqueue event }
@metrics = Metrics.new(config) { |event| enqueue event }
end
# rubocop:enable Metrics/MethodLength

attr_reader :config, :transport, :instrumenter,
:stacktrace_builder, :context_builder, :error_builder, :metrics
Expand Down
136 changes: 136 additions & 0 deletions lib/elastic_apm/central_config.rb
@@ -0,0 +1,136 @@
# frozen_string_literal: true

require 'elastic_apm/central_config/cache_control'

module ElasticAPM
# @api private
class CentralConfig
include Logging

# @api private
class ResponseError < InternalError
def initialize(response)
@response = response
end

attr_reader :response
end
class ClientError < ResponseError; end
class ServerError < ResponseError; end

DEFAULT_MAX_AGE = 300

def initialize(config)
@config = config
@modified_options = {}
@service_info = {
'service.name': config.service_name,
'service.environment': config.environment
}.to_json
end

attr_reader :config, :task

def start
return unless config.central_config?

fetch_and_apply_config
end

def fetch_and_apply_config
Concurrent::Promise
.execute(&method(:fetch_config))
.on_success(&method(:handle_success))
.rescue(&method(:handle_error))
end

def stop
@task&.cancel
end

# rubocop:disable Metrics/MethodLength
def fetch_config
resp = perform_request

case resp.status
when 200..299
resp
when 300..399
resp
mikker marked this conversation as resolved.
Show resolved Hide resolved
when 400..499
raise ClientError, resp
when 500..599
raise ServerError, resp
mikker marked this conversation as resolved.
Show resolved Hide resolved
end
end
# rubocop:enable Metrics/MethodLength

def assign(update)
# For each updated option, store the original value,
# unless already stored
update.each_key do |key|
mikker marked this conversation as resolved.
Show resolved Hide resolved
@modified_options[key] ||= config.get(key.to_sym)&.value
end

# If the new update doesn't set a previously modified option,
# revert it to the original
@modified_options.each_key do |key|
next if update.key?(key)
update[key] = @modified_options.delete(key)
end

config.assign(update)
end

private

def handle_success(resp)
# 304 Not Modified
unless resp.status == 304
update = JSON.parse(resp.body.to_s)
assign(update)
end

info 'Updated config from APM Server'
mikker marked this conversation as resolved.
Show resolved Hide resolved

schedule_next_fetch(resp)

true
rescue Exception => e
error 'Failed to apply remote config, %s', e.inspect
debug { e.backtrace.join('\n') }
end

def handle_error(error)
error(
'Failed fetching config: %s, trying again in %d seconds',
error.response.body, DEFAULT_MAX_AGE
mikker marked this conversation as resolved.
Show resolved Hide resolved
)

assign({})

schedule_next_fetch(error.response)
end

def perform_request
Http.post(
config.server_url + '/agent/v1/config/',
body: @service_info,
headers: { etag: 1, content_type: 'application/json' }
)
end

def schedule_next_fetch(resp)
seconds =
if (cache_header = resp.headers['Cache-Control'])
CacheControl.new(cache_header).max_age
else
DEFAULT_MAX_AGE
end

@task =
Concurrent::ScheduledTask
.execute(seconds, &method(:fetch_and_apply_config))
end
end
end
34 changes: 34 additions & 0 deletions lib/elastic_apm/central_config/cache_control.rb
@@ -0,0 +1,34 @@
# frozen_string_literal: true

module ElasticAPM
class CentralConfig
# @api private
class CacheControl
def initialize(value)
@header = value
parse!(value)
end

attr_reader(
:must_revalidate,
:no_cache,
:no_store,
:no_transform,
:public,
:private,
:proxy_revalidate,
:max_age,
:s_maxage
)

private

def parse!(value)
value.split(',').each do |token|
k, v = token.split('=').map(&:strip)
instance_variable_set(:"@#{k.gsub('-', '_')}", v ? v.to_i : true)
mikker marked this conversation as resolved.
Show resolved Hide resolved
end
end
end
end
end
1 change: 1 addition & 0 deletions lib/elastic_apm/config.rb
Expand Up @@ -41,6 +41,7 @@ class Config
option :capture_body, type: :string, default: 'off'
option :capture_headers, type: :bool, default: true
option :capture_env, type: :bool, default: true
option :central_config, type: :bool, default: true
option :current_user_email_method, type: :string, default: 'email'
option :current_user_id_method, type: :string, default: 'id'
option :current_user_username_method, type: :string, default: 'username'
Expand Down
5 changes: 5 additions & 0 deletions lib/elastic_apm/config/options.rb
Expand Up @@ -112,6 +112,11 @@ def method_missing(name, *value)
end
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize

def [](key)
options[key]
end
alias :get :[]

def set(key, value)
options.fetch(key.to_sym).set(value)
rescue KeyError
Expand Down
28 changes: 28 additions & 0 deletions spec/elastic_apm/central_config/cache_control_spec.rb
@@ -0,0 +1,28 @@
# frozen_string_literal: true

module ElasticAPM
RSpec.describe CentralConfig::CacheControl do
let(:header) { nil }
subject { described_class.new(header) }

context 'with max-age' do
let(:header) { 'max-age=300' }
its(:max_age) { should be 300 }
its(:must_revalidate) { should be nil }
end

context 'with must-revalidate' do
let(:header) { 'must-revalidate' }
its(:max_age) { should be nil }
its(:must_revalidate) { should be true }
end

context 'with multiple values' do
let(:header) { 'must-revalidate, public, max-age=300' }
its(:max_age) { should be 300 }
its(:must_revalidate) { should be true }
its(:public) { should be true }
its(:private) { should be nil }
end
end
end