diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b32b53542..46341f1007 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,42 @@ jobs: needs: run_rubocop runs-on: ubuntu-22.04 services: + elasticsearch7: + image: elasticsearch:7.16.2 + env: + discovery.type: single-node + ports: + - 9200:9200 + options: >- + --health-cmd "curl http://localhost:9200/_cluster/health" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + elasticsearch8: + image: elasticsearch:8.4.2 + env: + discovery.type: single-node + xpack.security.enabled: false + ports: + - 9250:9200 + options: >- + --health-cmd "curl http://localhost:9200/_cluster/health" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + memcached: + image: memcached:latest + ports: + - 11211:11211 + options: >- + --health-cmd "timeout 5 bash -c 'cat < /dev/null > /dev/udp/127.0.0.1/11211'" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + mongodb: + image: ${{ contains(fromJson('["2.2.10", "2.3.8", "2.4.10"]'), matrix.ruby-version) && 'mongo:5.0.11' || 'mongo:latest' }} + ports: + - 27017:27017 mysql: image: mysql:5.7 env: @@ -120,19 +156,6 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 - redis: - image: redis - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - mongodb: - image: ${{ contains(fromJson('["2.2.10", "2.3.8", "2.4.10"]'), matrix.ruby-version) && 'mongo:5.0.11' || 'mongo:latest' }} - ports: - - 27017:27017 rabbitmq: image: rabbitmq:latest ports: @@ -142,12 +165,12 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 - memcached: - image: memcached:latest + redis: + image: redis ports: - - 11211:11211 + - 6379:6379 options: >- - --health-cmd "timeout 5 bash -c 'cat < /dev/null > /dev/udp/127.0.0.1/11211'" + --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 @@ -282,23 +305,35 @@ jobs: if: ${{ needs.check_jruby_multiverse.outputs.run_job == 'true' }} runs-on: ubuntu-22.04 services: - mysql: - image: mysql:5.7 + elasticsearch7: + image: elasticsearch:7.16.2 env: - MYSQL_ALLOW_EMPTY_PASSWORD: yes - options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + discovery.type: single-node ports: - - "3306:3306" - postgres: - image: postgres:latest + - 9200:9200 + options: >- + --health-cmd "curl http://localhost:9200/_cluster/health" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + elasticsearch8: + image: elasticsearch:8.4.2 + env: + discovery.type: single-node + xpack.security.enabled: false ports: - - 5432:5432 - redis: - image: redis + - 9250:9200 + options: >- + --health-cmd "curl http://localhost:9200/_cluster/health" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + memcached: + image: memcached:latest ports: - - 6379:6379 + - 11211:11211 options: >- - --health-cmd "redis-cli ping" + --health-cmd "timeout 5 bash -c 'cat < /dev/null > /dev/udp/127.0.0.1/11211'" --health-interval 10s --health-timeout 5s --health-retries 5 @@ -306,6 +341,17 @@ jobs: image: mongo:5.0.11 ports: - 27017:27017 + mysql: + image: mysql:5.7 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + ports: + - "3306:3306" + postgres: + image: postgres:latest + ports: + - 5432:5432 rabbitmq: image: rabbitmq:latest ports: @@ -315,12 +361,12 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 - memcached: - image: memcached:latest + redis: + image: redis ports: - - 11211:11211 + - 6379:6379 options: >- - --health-cmd "timeout 5 bash -c 'cat < /dev/null > /dev/udp/127.0.0.1/11211'" + --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 diff --git a/.github/workflows/ci_cron.yml b/.github/workflows/ci_cron.yml index f5b032300f..4961dce698 100644 --- a/.github/workflows/ci_cron.yml +++ b/.github/workflows/ci_cron.yml @@ -123,6 +123,42 @@ jobs: needs: run_rubocop runs-on: ubuntu-22.04 services: + elasticsearch7: + image: elasticsearch:7.16.2 + env: + discovery.type: single-node + ports: + - 9200:9200 + options: >- + --health-cmd "curl http://localhost:9200/_cluster/health" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + elasticsearch8: + image: elasticsearch:8.4.2 + env: + discovery.type: single-node + xpack.security.enabled: false + ports: + - 9250:9200 + options: >- + --health-cmd "curl http://localhost:9200/_cluster/health" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + memcached: + image: memcached:latest + ports: + - 11211:11211 + options: >- + --health-cmd "timeout 5 bash -c 'cat < /dev/null > /dev/udp/127.0.0.1/11211'" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + mongodb: + image: ${{ contains(fromJson('["2.2.10", "2.3.8", "2.4.10"]'), matrix.ruby-version) && 'mongo:5.0.11' || 'mongo:latest' }} + ports: + - 27017:27017 mysql: image: mysql:5.7 env: @@ -142,19 +178,6 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 - redis: - image: redis - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - mongodb: - image: ${{ contains(fromJson('["2.2.10", "2.3.8", "2.4.10"]'), matrix.ruby-version) && 'mongo:5.0.11' || 'mongo:latest' }} - ports: - - 27017:27017 rabbitmq: image: rabbitmq:latest ports: @@ -164,12 +187,12 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 - memcached: - image: memcached:latest + redis: + image: redis ports: - - 11211:11211 + - 6379:6379 options: >- - --health-cmd "timeout 5 bash -c 'cat < /dev/null > /dev/udp/127.0.0.1/11211'" + --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 @@ -271,23 +294,35 @@ jobs: needs: run_rubocop runs-on: ubuntu-22.04 services: - mysql: - image: mysql:5.7 + elasticsearch7: + image: elasticsearch:7.16.2 env: - MYSQL_ALLOW_EMPTY_PASSWORD: yes - options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + discovery.type: single-node ports: - - "3306:3306" - postgres: - image: postgres:latest + - 9200:9200 + options: >- + --health-cmd "curl http://localhost:9200/_cluster/health" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + elasticsearch8: + image: elasticsearch:8.4.2 + env: + discovery.type: single-node + xpack.security.enabled: false ports: - - 5432:5432 - redis: - image: redis + - 9250:9200 + options: >- + --health-cmd "curl http://localhost:9200/_cluster/health" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + memcached: + image: memcached:latest ports: - - 6379:6379 + - 11211:11211 options: >- - --health-cmd "redis-cli ping" + --health-cmd "timeout 5 bash -c 'cat < /dev/null > /dev/udp/127.0.0.1/11211'" --health-interval 10s --health-timeout 5s --health-retries 5 @@ -295,6 +330,17 @@ jobs: image: mongo:5.0.11 ports: - 27017:27017 + mysql: + image: mysql:5.7 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + ports: + - "3306:3306" + postgres: + image: postgres:latest + ports: + - 5432:5432 rabbitmq: image: rabbitmq:latest ports: @@ -304,12 +350,12 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 - memcached: - image: memcached:latest + redis: + image: redis ports: - - 11211:11211 + - 6379:6379 options: >- - --health-cmd "timeout 5 bash -c 'cat < /dev/null > /dev/udp/127.0.0.1/11211'" + --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 diff --git a/.github/workflows/scripts/rubygems-publish.rb b/.github/workflows/scripts/rubygems-publish.rb index 75272f7abe..b9c92fbe96 100644 --- a/.github/workflows/scripts/rubygems-publish.rb +++ b/.github/workflows/scripts/rubygems-publish.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true gem_name = ARGV[0] raise "gem name sans version must be supplied" if gem_name.to_s == "" @@ -21,7 +22,7 @@ if $?.to_i.zero? puts "#{gem_filename} successfully pushed to rubygems.org!" else - if result =~ /Repushing of gem versions is not allowed/ + if result.include?('Repushing of gem versions is not allowed') puts "Pushing #{gem_filename} skipped because this version is already published to rubygems.org!" exit 0 else diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f4e488972..b5bf288946 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,17 @@ ## v8.12.0 - Version 8.12.0 of the agent delivers some valuable code cleanup, increases the default number of recorded Custom Events, and announces the deprecation of Ruby 2.3. + Version 8.12.0 of the agent delivers new Elasticsearch instrumentation, some valuable code cleanup, increases the default number of recorded Custom Events, and announces the deprecation of Ruby 2.3. + + * **Support for Elasticsearch instrumentation** + + This release adds support to automatically instrument the [elasticsearch](https://rubygems.org/gems/elasticsearch) gem. Versions 7.x and 8.x are supported. [PR#1525](https://github.com/newrelic/newrelic-ruby-agent/pull/1525) + + | Configuration name | Default | Behavior | + | ----------- | ----------- |----------- | + | `instrumentation.elasticsearch` | auto | Controls auto-instrumentation of the elasticsearch library at start up. May be one of `auto`, `prepend`, `chain`, `disabled`. | + | `elasticsearch.capture_queries` | true | If `true`, the agent captures Elasticsearch queries in transaction traces. | + | `elasticsearch.obfuscate_queries` | true | If `true`, the agent obfuscates Elasticsearch queries in transaction traces. | * **Cleanup: Remove orphaned code from unit tests** @@ -18,6 +28,7 @@ Ruby 2.3 reached end of life on March 31, 2019. The Ruby agent has deprecated support for Ruby 2.3 and will make breaking changes for this version in its next major release, v9.0.0 (release date not yet planned). All 8.x.x versions of the agent will remain compatible with Ruby 2.3. + ## v8.11.0 Version 8.11.0 of the agent updates the `newrelic deployments` command to work with API keys issued to newer accounts, fixes a memory leak in the instrumentation of Curb error handling, further preps for Ruby 3.2.0 support, and includes several community member driven cleanup and improvement efforts. Thank you to everyone involved! diff --git a/docker-compose.yml b/docker-compose.yml index d550f54d13..9f05c505c4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,25 @@ version: "3.9" services: + elasticsearch7: + image: elasticsearch:7.16.2 + ports: + - "9200:9200" + environment: + - discovery.type=single-node + - bootstrap.memory_lock=true + - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + mem_limit: 1g + elasticsearch8: + image: elasticsearch:8.4.2 + ports: + - "9250:9250" + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - http.port=9250 + - bootstrap.memory_lock=true + - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + mem_limit: 1g mysql: image: mysql:5.7 restart: always @@ -68,6 +88,8 @@ services: volumes: - ".:/usr/src/app" depends_on: + - elasticsearch7 + - elasticsearch8 - mysql - memcached - mongodb diff --git a/lib/new_relic/agent/configuration/default_source.rb b/lib/new_relic/agent/configuration/default_source.rb index f0e83fb758..b44066a445 100644 --- a/lib/new_relic/agent/configuration/default_source.rb +++ b/lib/new_relic/agent/configuration/default_source.rb @@ -916,6 +916,14 @@ def self.enforce_fallback(allowed_values: nil, fallback: nil) :allowed_from_server => false, :description => 'Controls auto-instrumentation of bunny at start up. May be one of [auto|prepend|chain|disabled].' }, + :'instrumentation.elasticsearch' => { + :default => 'auto', + :public => true, + :type => String, + :dynamic_name => true, + :allowed_from_server => false, + :description => 'Controls auto-instrumentation of the elasticsearch library at start up. May be one of [auto|prepend|chain|disabled].' + }, :'instrumentation.httprb' => { :default => instrumentation_value_of(:disable_httprb), :documentation_default => 'auto', @@ -1407,6 +1415,20 @@ def self.enforce_fallback(allowed_values: nil, fallback: nil) :allowed_from_server => true, :description => 'If `true`, the agent obfuscates Mongo queries in transaction traces.' }, + :'elasticsearch.capture_queries' => { + :default => true, + :public => true, + :type => Boolean, + :allowed_from_server => true, + :description => 'If `true`, the agent captures Elasticsearch queries in transaction traces.' + }, + :'elasticsearch.obfuscate_queries' => { + :default => true, + :public => true, + :type => Boolean, + :allowed_from_server => true, + :description => 'If `true`, the agent obfuscates Elasticsearch queries in transaction traces.' + }, :'error_collector.enabled' => { :default => true, :public => true, diff --git a/lib/new_relic/agent/configuration/high_security_source.rb b/lib/new_relic/agent/configuration/high_security_source.rb index 2f6e30218f..9334a87934 100644 --- a/lib/new_relic/agent/configuration/high_security_source.rb +++ b/lib/new_relic/agent/configuration/high_security_source.rb @@ -18,6 +18,7 @@ def initialize(local_settings) :'transaction_tracer.record_sql' => record_sql_setting(local_settings, :'transaction_tracer.record_sql'), :'slow_sql.record_sql' => record_sql_setting(local_settings, :'slow_sql.record_sql'), :'mongo.obfuscate_queries' => true, + :'elasticsearch.obfuscate_queries' => true, :'transaction_tracer.record_redis_arguments' => false, :'custom_insights_events.enabled' => false, diff --git a/lib/new_relic/agent/configuration/security_policy_source.rb b/lib/new_relic/agent/configuration/security_policy_source.rb index 241be4ce2a..c462976d88 100644 --- a/lib/new_relic/agent/configuration/security_policy_source.rb +++ b/lib/new_relic/agent/configuration/security_policy_source.rb @@ -79,6 +79,15 @@ def change_setting(policies, option, new_value) change_setting(policies, :'mongo.obfuscate_queries', true) } }, + { + option: :'elasticsearch.capture_queries', + supported: true, + enabled_fn: method(:enabled?), + disabled_value: false, + permitted_fn: proc { |policies| + change_setting(policies, :'elasticsearch.obfuscate_queries', true) + } + }, { option: :'transaction_tracer.record_redis_arguments', supported: true, diff --git a/lib/new_relic/agent/database.rb b/lib/new_relic/agent/database.rb index 2589057cc1..73d8f20196 100644 --- a/lib/new_relic/agent/database.rb +++ b/lib/new_relic/agent/database.rb @@ -32,6 +32,8 @@ module Database # Take care not to the dup the query more than once as # correctly encoded may also dup the query. def capture_query(query) + return unless query + id = query.object_id query = Helper.correctly_encoded(truncate_query(query)) if query.object_id == id @@ -42,6 +44,8 @@ def capture_query(query) end def truncate_query(query) + return unless query + if query.length > (MAX_QUERY_LENGTH - 4) query[0..MAX_QUERY_LENGTH - 4] << ELLIPSIS else diff --git a/lib/new_relic/agent/datastores/mongo/event_formatter.rb b/lib/new_relic/agent/datastores/mongo/event_formatter.rb index f9c0d0a0de..1c5f01fa01 100644 --- a/lib/new_relic/agent/datastores/mongo/event_formatter.rb +++ b/lib/new_relic/agent/datastores/mongo/event_formatter.rb @@ -2,7 +2,7 @@ # See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. # frozen_string_literal: true -require 'new_relic/agent/datastores/mongo/obfuscator' +require_relative '../nosql_obfuscator' module NewRelic module Agent @@ -39,7 +39,7 @@ def self.format(command_name, database_name, command) def self.obfuscate(statement) if NewRelic::Agent.config[:'mongo.obfuscate_queries'] - statement = Obfuscator.obfuscate_statement(statement) + statement = NewRelic::Agent::Datastores::NosqlObfuscator.obfuscate_statement(statement) end statement end diff --git a/lib/new_relic/agent/datastores/mongo/metric_translator.rb b/lib/new_relic/agent/datastores/mongo/metric_translator.rb index a6c3851fcf..774b7f24d9 100644 --- a/lib/new_relic/agent/datastores/mongo/metric_translator.rb +++ b/lib/new_relic/agent/datastores/mongo/metric_translator.rb @@ -2,7 +2,7 @@ # See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. # frozen_string_literal: true -require 'new_relic/agent/datastores/mongo/obfuscator' +require 'new_relic/agent/datastores/nosql_obfuscator' require 'new_relic/agent/datastores/metric_helper' module NewRelic diff --git a/lib/new_relic/agent/datastores/mongo/obfuscator.rb b/lib/new_relic/agent/datastores/mongo/obfuscator.rb deleted file mode 100644 index 0a0fc601e1..0000000000 --- a/lib/new_relic/agent/datastores/mongo/obfuscator.rb +++ /dev/null @@ -1,43 +0,0 @@ -# This file is distributed under New Relic's license terms. -# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. -# frozen_string_literal: true - -module NewRelic - module Agent - module Datastores - module Mongo - module Obfuscator - ALLOWLIST = [:operation].freeze - - def self.obfuscate_statement(source, allowlist = ALLOWLIST) - if source.is_a?(Hash) - obfuscated = {} - source.each do |key, value| - if allowlist.include?(key) - obfuscated[key] = value - else - obfuscated[key] = obfuscate_value(value, allowlist) - end - end - obfuscated - else - obfuscate_value(source, allowlist) - end - end - - QUESTION_MARK = '?'.freeze - - def self.obfuscate_value(value, allowlist = ALLOWLIST) - if value.is_a?(Hash) - obfuscate_statement(value, allowlist) - elsif value.is_a?(Array) - value.map { |v| obfuscate_value(v, allowlist) } - else - QUESTION_MARK - end - end - end - end - end - end -end diff --git a/lib/new_relic/agent/datastores/nosql_obfuscator.rb b/lib/new_relic/agent/datastores/nosql_obfuscator.rb new file mode 100644 index 0000000000..470487a6bf --- /dev/null +++ b/lib/new_relic/agent/datastores/nosql_obfuscator.rb @@ -0,0 +1,41 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +module NewRelic + module Agent + module Datastores + module NosqlObfuscator + ALLOWLIST = [:operation].freeze + + def self.obfuscate_statement(source, allowlist = ALLOWLIST) + if source.is_a?(Hash) + obfuscated = {} + source.each do |key, value| + if allowlist.include?(key) + obfuscated[key] = value + else + obfuscated[key] = obfuscate_value(value, allowlist) + end + end + obfuscated + else + obfuscate_value(source, allowlist) + end + end + + QUESTION_MARK = '?'.freeze + + def self.obfuscate_value(value, allowlist = ALLOWLIST) + if value.is_a?(Hash) + obfuscate_statement(value, allowlist) + elsif value.is_a?(Array) + value.map { |v| obfuscate_value(v, allowlist) } + else + QUESTION_MARK + end + end + end + end + end +end diff --git a/lib/new_relic/agent/instrumentation/elasticsearch.rb b/lib/new_relic/agent/instrumentation/elasticsearch.rb new file mode 100644 index 0000000000..53cc9824df --- /dev/null +++ b/lib/new_relic/agent/instrumentation/elasticsearch.rb @@ -0,0 +1,31 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +require_relative 'elasticsearch/instrumentation' +require_relative 'elasticsearch/chain' +require_relative 'elasticsearch/prepend' + +DependencyDetection.defer do + named :elasticsearch + + depends_on do + defined?(::Elasticsearch) + end + + executes do + ::NewRelic::Agent.logger.info('Installing Elasticsearch instrumentation') + + to_instrument = if ::Gem::Version.create(::Elasticsearch::VERSION) < ::Gem::Version.create("8.0.0") + ::Elasticsearch::Transport::Client + else + ::Elastic::Transport::Client + end + + if use_prepend? + prepend_instrument to_instrument, NewRelic::Agent::Instrumentation::Elasticsearch::Prepend + else + chain_instrument NewRelic::Agent::Instrumentation::Elasticsearch + end + end +end diff --git a/lib/new_relic/agent/instrumentation/elasticsearch/chain.rb b/lib/new_relic/agent/instrumentation/elasticsearch/chain.rb new file mode 100644 index 0000000000..b88ce20e5d --- /dev/null +++ b/lib/new_relic/agent/instrumentation/elasticsearch/chain.rb @@ -0,0 +1,29 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +module NewRelic::Agent::Instrumentation + module Elasticsearch + def self.instrument! + to_instrument = if ::Gem::Version.create(::Elasticsearch::VERSION) < + ::Gem::Version.create("8.0.0") + ::Elasticsearch::Transport::Client + else + ::Elastic::Transport::Client + end + + to_instrument.class_eval do + include NewRelic::Agent::Instrumentation::Elasticsearch + + alias_method(:perform_request_without_tracing, :perform_request) + alias_method(:perform_request, :perform_request_with_tracing) + + def perform_request(*args) + perform_request_with_tracing(*args) do + perform_request_without_tracing(*args) + end + end + end + end + end +end diff --git a/lib/new_relic/agent/instrumentation/elasticsearch/instrumentation.rb b/lib/new_relic/agent/instrumentation/elasticsearch/instrumentation.rb new file mode 100644 index 0000000000..7bca0c1d45 --- /dev/null +++ b/lib/new_relic/agent/instrumentation/elasticsearch/instrumentation.rb @@ -0,0 +1,66 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true +require_relative '../../datastores/nosql_obfuscator' + +module NewRelic::Agent::Instrumentation + module Elasticsearch + PRODUCT_NAME = 'Elasticsearch' + OPERATION = 'perform_request' + + def perform_request_with_tracing(method, path, params = {}, body = nil, headers = nil) + return yield unless NewRelic::Agent::Tracer.tracing_enabled? + + segment = NewRelic::Agent::Tracer.start_datastore_segment( + product: PRODUCT_NAME, + operation: nr_operation || OPERATION, + host: nr_hosts[:host], + port_path_or_id: path, + database_name: nr_cluster_name + ) + begin + NewRelic::Agent::Tracer.capture_segment_error(segment) { yield } + ensure + if segment + segment.notice_nosql_statement(nr_reported_query(body || params)) + segment.finish + end + end + end + + private + + def nr_operation + operation_index = caller_locations.index do |line| + string = line.to_s + string.include?('lib/elasticsearch/api') && !string.include?(OPERATION) + end + return nil unless operation_index + + caller_locations[operation_index].to_s.split('`')[-1].gsub(/\W/, "") + end + + def nr_reported_query(query) + return unless NewRelic::Agent.config[:'elasticsearch.capture_queries'] + return query unless NewRelic::Agent.config[:'elasticsearch.obfuscate_queries'] + + NewRelic::Agent::Datastores::NosqlObfuscator.obfuscate_statement(query) + end + + def nr_cluster_name + return @nr_cluster_name if @nr_cluster_name + return if nr_hosts.empty? + + NewRelic::Agent.disable_all_tracing do + @nr_cluster_name ||= perform_request('GET', '_cluster/health').body["cluster_name"] + end + rescue StandardError => e + NewRelic::Agent.logger.error("Failed to get cluster name for elasticsearch", e) + nil + end + + def nr_hosts + @nr_hosts ||= (transport.hosts.first || NewRelic::EMPTY_HASH) + end + end +end diff --git a/lib/new_relic/agent/instrumentation/elasticsearch/prepend.rb b/lib/new_relic/agent/instrumentation/elasticsearch/prepend.rb new file mode 100644 index 0000000000..16d1b39a7e --- /dev/null +++ b/lib/new_relic/agent/instrumentation/elasticsearch/prepend.rb @@ -0,0 +1,13 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +module NewRelic::Agent::Instrumentation + module Elasticsearch::Prepend + include NewRelic::Agent::Instrumentation::Elasticsearch + + def perform_request(*args) + perform_request_with_tracing(*args) { super } + end + end +end diff --git a/lib/tasks/instrumentation_generator/templates/chain.tt b/lib/tasks/instrumentation_generator/templates/chain.tt index 28a39f249f..fb08db00ea 100644 --- a/lib/tasks/instrumentation_generator/templates/chain.tt +++ b/lib/tasks/instrumentation_generator/templates/chain.tt @@ -9,9 +9,10 @@ module NewRelic::Agent::Instrumentation include NewRelic::Agent::Instrumentation::<%= @class_name %> alias_method(:<%= @method.downcase %>_without_new_relic, :<%= @method.downcase %>) + alias_method(:<%= @method.downcase %>, :<%= @method.downcase %>_with_new_relic) def <%= @method.downcase %><%= "(#{@args})" unless @args.empty? %> - <%= @method.downcase %>_with_tracing<%= "(#{@args})" unless @args.empty? %> do + <%= @method.downcase %>_with_new_relic<%= "(#{@args})" unless @args.empty? %> do <%= @method.downcase %>_without_new_relic<%= "(#{@args})" unless @args.empty? %> end end diff --git a/lib/tasks/instrumentation_generator/templates/chain_method.tt b/lib/tasks/instrumentation_generator/templates/chain_method.tt index e4f4c1ad64..c318b4405f 100644 --- a/lib/tasks/instrumentation_generator/templates/chain_method.tt +++ b/lib/tasks/instrumentation_generator/templates/chain_method.tt @@ -1,7 +1,8 @@ -alias_method :<%= @method.downcase %>_without_new_relic, :<%= @method.downcase %> +alias_method(:<%= @method.downcase %>_without_new_relic, :<%= @method.downcase %>) +alias_method(:<%= @method.downcase %>, :<%= @method.downcase %>_with_new_relic) def <%= @method.downcase %><%= "(#{@args})" unless @args.empty? %> - <%= @method.downcase %>_with_tracing<%= "(#{@args})" unless @args.empty? %> do + <%= @method.downcase %>_with_new_relic<%= "(#{@args})" unless @args.empty? %> do <%= @method.downcase %>_without_new_relic<%= "(#{@args})" unless @args.empty? %> end end diff --git a/lib/tasks/instrumentation_generator/templates/instrumentation.tt b/lib/tasks/instrumentation_generator/templates/instrumentation.tt index 1b77f6ccc9..4c4054bea2 100644 --- a/lib/tasks/instrumentation_generator/templates/instrumentation.tt +++ b/lib/tasks/instrumentation_generator/templates/instrumentation.tt @@ -5,8 +5,9 @@ module NewRelic::Agent::Instrumentation module <%= @class_name %> - def <%= @method.downcase %>_with_tracing<%= "(#{@args})" unless @args.empty? %> + def <%= @method.downcase %>_with_new_relic<%= "(#{@args})" unless @args.empty? %> # add instrumentation content here + yield end end end diff --git a/lib/tasks/instrumentation_generator/templates/instrumentation_method.tt b/lib/tasks/instrumentation_generator/templates/instrumentation_method.tt index 5e4b0cf274..760d26ef50 100644 --- a/lib/tasks/instrumentation_generator/templates/instrumentation_method.tt +++ b/lib/tasks/instrumentation_generator/templates/instrumentation_method.tt @@ -1,3 +1,3 @@ -def <%= @method.downcase %>_with_tracing<%= "(#{@args})" unless @args.empty? %> +def <%= @method.downcase %>_with_new_relic<%= "(#{@args})" unless @args.empty? %> # add instrumentation content here end diff --git a/lib/tasks/instrumentation_generator/templates/prepend.tt b/lib/tasks/instrumentation_generator/templates/prepend.tt index a6c171ce0f..76e300561c 100644 --- a/lib/tasks/instrumentation_generator/templates/prepend.tt +++ b/lib/tasks/instrumentation_generator/templates/prepend.tt @@ -7,7 +7,7 @@ module NewRelic::Agent::Instrumentation include NewRelic::Agent::Instrumentation::<%= @class_name %> def <%= @method.downcase %><%= "(#{@args})" unless @args.empty? %> - <%= @method.downcase %>_with_tracing<%= "(#{@args})" unless @args.empty? %> { super } + <%= @method.downcase %>_with_new_relic<%= "(#{@args})" unless @args.empty? %> { super } end end end diff --git a/lib/tasks/instrumentation_generator/templates/prepend_method.tt b/lib/tasks/instrumentation_generator/templates/prepend_method.tt index ff1cdbf9c4..22a490efec 100644 --- a/lib/tasks/instrumentation_generator/templates/prepend_method.tt +++ b/lib/tasks/instrumentation_generator/templates/prepend_method.tt @@ -1,3 +1,3 @@ def <%= @method.downcase %><%= "(#{@args})" unless @args.empty? %> - <%= @method.downcase %>_with_tracing<%= "(#{@args})" unless @args.empty? %> { super } + <%= @method.downcase %>_with_new_relic<%= "(#{@args})" unless @args.empty? %> { super } end diff --git a/lib/tasks/instrumentation_generator/templates/test.tt b/lib/tasks/instrumentation_generator/templates/test.tt index a2e50c5fcb..5e7e64901b 100644 --- a/lib/tasks/instrumentation_generator/templates/test.tt +++ b/lib/tasks/instrumentation_generator/templates/test.tt @@ -11,5 +11,5 @@ class <%= @class_name %>InstrumentationTest < Minitest::Test NewRelic::Agent.instance.stats_engine.clear_stats end - # Add tests Here + # Add tests here end diff --git a/newrelic.yml b/newrelic.yml index db1a7e1bd0..d3e389d141 100644 --- a/newrelic.yml +++ b/newrelic.yml @@ -322,6 +322,10 @@ common: &default_settings # May be one of [auto|prepend|chain|disabled]. # instrumentation.delayed_job: auto + # Controls auto-instrumentation of the elasticsearch library at start up. + # May be one of [auto|prepend|chain|disabled]. + # instrumentation.elasticsearch: auto + # Controls auto-instrumentation of Excon at start up. # May be one of [enabled|disabled]. # instrumentation.excon: auto @@ -459,6 +463,12 @@ common: &default_settings # If true, the agent obfuscates Mongo queries in transaction traces. # mongo.obfuscate_queries: true + # If true, the agent captures Elasticsearch queries in transaction traces. + # elasticsearch.capture_queries: true + + # If true, the agent obfuscates Elasticsearch queries in transaction traces. + # elasticsearch.obfuscate_queries: true + # When true, the agent transmits data about your app to the New Relic collector. # monitor_mode: true diff --git a/test/multiverse/lib/multiverse/runner.rb b/test/multiverse/lib/multiverse/runner.rb index 77cc399ecd..097f3f96e6 100644 --- a/test/multiverse/lib/multiverse/runner.rb +++ b/test/multiverse/lib/multiverse/runner.rb @@ -101,7 +101,7 @@ def execute_suites(filter, opts) "agent" => %w[agent_only bare config_file_loading deferred_instrumentation high_security no_json json marshalling yajl], "background" => %w[delayed_job sidekiq resque], "background_2" => ["rake"], - "database" => %w[datamapper mongo redis sequel], + "database" => %w[datamapper elasticsearch mongo redis sequel], "rails" => %w[active_record active_record_pg rails rails_prepend activemerchant], "frameworks" => %w[sinatra padrino grape], "httpclients" => %w[curb excon httpclient], diff --git a/test/multiverse/suites/elasticsearch/Envfile b/test/multiverse/suites/elasticsearch/Envfile new file mode 100644 index 0000000000..6299f073f0 --- /dev/null +++ b/test/multiverse/suites/elasticsearch/Envfile @@ -0,0 +1,19 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +instrumentation_methods :chain, :prepend + +ELASTICSEARCH_VERSIONS = [ + [nil, 2.5], + ['7.17.1', 2.4] +] + +def gem_list(elasticsearch_version = nil) + <<-RB + gem 'elasticsearch'#{elasticsearch_version} + #{ruby3_gem_webrick} + RB +end + +create_gemfiles(ELASTICSEARCH_VERSIONS, gem_list) diff --git a/test/multiverse/suites/elasticsearch/config/newrelic.yml b/test/multiverse/suites/elasticsearch/config/newrelic.yml new file mode 100644 index 0000000000..5ec1d98b27 --- /dev/null +++ b/test/multiverse/suites/elasticsearch/config/newrelic.yml @@ -0,0 +1,19 @@ +--- +development: + error_collector: + enabled: true + apdex_t: 0.5 + monitor_mode: true + license_key: bootstrap_newrelic_admin_license_key_000 + instrumentation: + elasticsearch: <%= $instrumentation_method %> + app_name: test + log_level: debug + host: 127.0.0.1 + api_host: 127.0.0.1 + transaction_trace: + record_sql: obfuscated + enabled: true + stack_trace_threshold: 0.5 + transaction_threshold: 1.0 + capture_params: false diff --git a/test/multiverse/suites/elasticsearch/elasticsearch_instrumentation_test.rb b/test/multiverse/suites/elasticsearch/elasticsearch_instrumentation_test.rb new file mode 100644 index 0000000000..ae4476b30b --- /dev/null +++ b/test/multiverse/suites/elasticsearch/elasticsearch_instrumentation_test.rb @@ -0,0 +1,192 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +require 'elasticsearch' +require 'socket' + +class ElasticsearchInstrumentationTest < Minitest::Test + def setup + # Keeping the log off prevents noisy test output + @client = ::Elasticsearch::Client.new( + log: false, + hosts: "localhost:#{port}" + ) + # Ensure the client is running before the tests start + @client.cluster.health + @client.index(index: 'my-index', id: 1, body: {title: 'Test'}) + @client.indices.refresh(index: 'my-index') + end + + def teardown + @client = nil + @segment = nil + end + + def search + txn = in_transaction do + @client.search(index: 'my-index', body: {query: {match: {title: 'test'}}}) + end + @segment = txn.segments[1] + end + + def test_datastore_segment_created + search + assert_equal NewRelic::Agent::Transaction::DatastoreSegment, @segment.class + end + + def test_segment_elasticsearch_product + search + assert_equal NewRelic::Agent::Instrumentation::Elasticsearch::PRODUCT_NAME, @segment.product + end + + def test_segment_operation_is_search_when_search_method_called + search + assert_equal 'search', @segment.operation + end + + def test_segment_operation_is_index_when_index_method_called + txn = in_transaction do + @client.index(index: 'my-index', id: 1, body: {title: 'Test'}) + end + + segment = txn.segments[1] + assert_equal 'index', segment.operation + end + + def test_segment_operation_returns_OPERATION_when_api_not_called + # stubbing the constant to make sure it takes over when there's a nil value for nr_operation + NewRelic::Agent::Instrumentation::Elasticsearch.stub_const(:OPERATION, 'subdued-excitement') do + txn = in_transaction { @client.perform_request('GET', '/_search', {q: 'hi'}) } + segment = txn.segments[1] + + assert_equal NewRelic::Agent::Instrumentation::Elasticsearch::OPERATION, segment.operation + end + end + + def test_segment_host + search + assert_equal Socket.gethostname, @segment.host + end + + def test_segment_port_path_or_id_uses_path_if_present + search + assert_equal 'my-index/_search', @segment.port_path_or_id + end + + def test_segment_database_name + search + assert_equal 'docker-cluster', @segment.database_name + end + + def test_nosql_statement_recorded_params_obfuscated + with_config(:'elasticsearch.obfuscate_queries' => true) do + txn = in_transaction do + # passing q: title sets the perform_request method's params argument to + # {q: 'title'} and leaves the body argument nil + @client.search(index: 'my-index', q: '?') + end + segment = txn.segments[1] + obfuscated_query = {q: '?'} + assert_equal obfuscated_query, segment.nosql_statement + end + end + + def test_nosql_statement_recorded_params_not_obfuscated + with_config(:'elasticsearch.obfuscate_queries' => false) do + txn = in_transaction do + # passing `q: title` sets the perform_request method's params argument + # to {q: 'title'} and leaves the body argument nil + @client.search(index: 'my-index', q: 'title') + end + segment = txn.segments[1] + not_obfuscated_query = {q: 'title'} + assert_equal not_obfuscated_query, segment.nosql_statement + end + end + + def test_nosql_statement_recorded_body_obfuscated + with_config(:'elasticsearch.obfuscate_queries' => true) do + txn = in_transaction do + query = {query: {match: {title: 'test'}}} + @client.search(index: 'my-index', body: query) + end + segment = txn.segments[1] + obfuscated_query = {query: {match: {title: '?'}}} + assert_equal obfuscated_query, segment.nosql_statement + end + end + + def test_nosql_statement_recorded_body_not_obfuscated + with_config(:'elasticsearch.obfuscate_queries' => false) do + query = {query: {match: {title: 'test'}}} + txn = in_transaction do + @client.search(index: 'my-index', body: query) + end + segment = txn.segments[1] + assert_equal query, segment.nosql_statement + end + end + + def test_statement_captured + with_config(:'elasticsearch.capture_queries' => true) do + query = {query: {match: {title: 'test'}}} + ob_query = {query: {match: {title: '?'}}} + txn = in_transaction do + @client.search(index: 'my-index', body: query) + end + segment = txn.segments[1] + assert_equal ob_query, segment.nosql_statement + end + end + + def test_statement_not_captured + with_config(:'elasticsearch.capture_queries' => false) do + query = {query: {match: {title: 'test'}}} + txn = in_transaction do + @client.search(index: 'my-index', body: query) + end + segment = txn.segments[1] + assert_nil segment.nosql_statement + end + end + + def test_segment_error_captured_if_raised + txn = nil + begin + in_transaction('elastic') do |elastic_txn| + txn = elastic_txn + simulate_transport_error + end + rescue StandardError => e + # NOOP -- allowing span and transaction to notice error + end + + assert_segment_noticed_error txn, /elastic$/, transport_error_class.name, /Error/i + assert_transaction_noticed_error txn, transport_error_class.name + end + + private + + def simulate_transport_error + @client.stub(:search, raise(transport_error_class.new)) do + @client.search(index: 'my-index', q: 'title') + end + end + + def transport_error_class + if ::Gem::Version.create(Elasticsearch::VERSION) < ::Gem::Version.create("8.0.0") + ::Elasticsearch::Transport::Transport::Error + else + ::Elastic::Transport::Transport::Error + end + end + + def port + if ::Gem::Version.create(Elasticsearch::VERSION) < ::Gem::Version.create("8.0.0") + 9200 # 9200 for elasticsearch 7 + else + 9250 # 9250 for elasticsearch 8 + end + end +end diff --git a/test/new_relic/agent/configuration/security_policy_source_test.rb b/test/new_relic/agent/configuration/security_policy_source_test.rb index 0a1c7e3fcd..cd2a6ac957 100644 --- a/test/new_relic/agent/configuration/security_policy_source_test.rb +++ b/test/new_relic/agent/configuration/security_policy_source_test.rb @@ -38,6 +38,35 @@ def test_record_sql_disabled end end + def test_record_sql_enabled_elasticsearch + policies = generate_security_policies(default: false, enabled: ['record_sql']) + + with_config(:'transaction_tracer.record_sql' => 'raw', + :'slow_sql.record_sql' => 'raw', + :'elasticsearch.capture_queries' => true, + :'elasticsearch.obfuscate_queries' => false) do + source = SecurityPolicySource.new(policies) + + assert_equal 'obfuscated', source[:'transaction_tracer.record_sql'] + assert_equal 'obfuscated', source[:'slow_sql.record_sql'] + assert source[:'elasticsearch.obfuscate_queries'] + end + end + + def test_record_sql_disabled_elasticsearch + policies = generate_security_policies(default: true, disabled: ['record_sql']) + + with_config(:'transaction_tracer.record_sql' => 'raw', + :'slow_sql.record_sql' => 'raw', + :'elasticsearch.capture_queries' => true) do + source = SecurityPolicySource.new(policies) + + assert_equal 'off', source[:'transaction_tracer.record_sql'] + assert_equal 'off', source[:'slow_sql.record_sql'] + refute source[:'elasticsearch.capture_queries'] + end + end + def test_attributes_include_enabled policies = generate_security_policies(default: false, enabled: ['attributes_include']) with_config(:'attributes.include' => ['request.parameters.*'], diff --git a/test/new_relic/agent/database_test.rb b/test/new_relic/agent/database_test.rb index a0ecdbf4db..40aae7e7e0 100644 --- a/test/new_relic/agent/database_test.rb +++ b/test/new_relic/agent/database_test.rb @@ -449,6 +449,11 @@ def test_capture_query_short_query assert_equal(query, NewRelic::Agent::Database.capture_query(query)) end + def test_capture_query_nil + query = nil + assert_equal(query, NewRelic::Agent::Database.capture_query(query)) + end + def test_capture_query_long_query query = 'a' * NewRelic::Agent::Database::MAX_QUERY_LENGTH truncated_query = NewRelic::Agent::Database.capture_query(query) diff --git a/test/new_relic/agent/datastores/mongo/obfuscator_test.rb b/test/new_relic/agent/datastores/mongo/obfuscator_test.rb deleted file mode 100644 index a3ea107f9f..0000000000 --- a/test/new_relic/agent/datastores/mongo/obfuscator_test.rb +++ /dev/null @@ -1,98 +0,0 @@ -# This file is distributed under New Relic's license terms. -# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. -# frozen_string_literal: true - -require 'new_relic/agent/datastores/mongo/obfuscator' -require_relative '../../../../test_helper' - -module NewRelic - module Agent - module Datastores - module Mongo - class ObfuscatorTest < Minitest::Test - def test_obfuscator_removes_values_from_statement - selector = { - 'name' => 'soterios johnson', - :operation => :find, - :_id => "BSON::ObjectId('?')" - } - - expected = { - 'name' => '?', - :operation => :find, - :_id => '?' - } - - obfuscated = Obfuscator.obfuscate_statement(selector) - assert_equal expected, obfuscated - end - - def test_obfuscate_selector_values_skips_allowed_keys - selector = { - :benign => 'bland data', - :operation => :find, - :_id => "BSON::ObjectId('?')" - } - - expected = { - :benign => 'bland data', - :operation => :find, - :_id => '?' - } - - obfuscated = Obfuscator.obfuscate_statement(selector, [:benign, :operation]) - assert_equal expected, obfuscated - end - - def test_obfuscate_nested_hashes - selector = { - "group" => { - "ns" => "tribbles", - "$reduce" => stub("BSON::Code"), - "cond" => {}, - "initial" => {:count => 0}, - "key" => {"name" => 1} - } - } - - expected = { - "group" => { - "ns" => "?", - "$reduce" => "?", - "cond" => {}, - "initial" => {:count => "?"}, - "key" => {"name" => "?"} - } - } - - obfuscated = Obfuscator.obfuscate_statement(selector) - assert_equal expected, obfuscated - end - - def test_obfuscates_array_statement - statement = [{"$group" => {:_id => "$says", :total => {"$sum" => 1}}}] - expected = [{"$group" => {:_id => "?", :total => {"$sum" => "?"}}}] - - obfuscated = Obfuscator.obfuscate_statement(statement) - assert_equal expected, obfuscated - end - - def test_obfuscate_nested_arrays - selector = { - "aggregate" => "mongeese", - "pipeline" => [{"$group" => {:_id => "$says", :total => {"$sum" => 1}}}] - } - - expected = { - "aggregate" => "?", - "pipeline" => [{"$group" => {:_id => "?", :total => {"$sum" => "?"}}}] - } - - obfuscated = Obfuscator.obfuscate_statement(selector) - assert_equal expected, obfuscated - end - end - end - end - end -end diff --git a/test/new_relic/agent/datastores/nosql_obfuscator_test.rb b/test/new_relic/agent/datastores/nosql_obfuscator_test.rb new file mode 100644 index 0000000000..9ea05453b8 --- /dev/null +++ b/test/new_relic/agent/datastores/nosql_obfuscator_test.rb @@ -0,0 +1,96 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +require 'new_relic/agent/datastores/nosql_obfuscator' +require_relative '../../../test_helper' + +module NewRelic + module Agent + module Datastores + class NosqlObfuscatorTest < Minitest::Test + def test_obfuscator_removes_values_from_statement + selector = { + 'name' => 'soterios johnson', + :operation => :find, + :_id => "BSON::ObjectId('?')" + } + + expected = { + 'name' => '?', + :operation => :find, + :_id => '?' + } + + obfuscated = NosqlObfuscator.obfuscate_statement(selector) + assert_equal expected, obfuscated + end + + def test_obfuscate_selector_values_skips_allowed_keys + selector = { + :benign => 'bland data', + :operation => :find, + :_id => "BSON::ObjectId('?')" + } + + expected = { + :benign => 'bland data', + :operation => :find, + :_id => '?' + } + + obfuscated = NosqlObfuscator.obfuscate_statement(selector, [:benign, :operation]) + assert_equal expected, obfuscated + end + + def test_obfuscate_nested_hashes + selector = { + "group" => { + "ns" => "tribbles", + "$reduce" => stub("BSON::Code"), + "cond" => {}, + "initial" => {:count => 0}, + "key" => {"name" => 1} + } + } + + expected = { + "group" => { + "ns" => "?", + "$reduce" => "?", + "cond" => {}, + "initial" => {:count => "?"}, + "key" => {"name" => "?"} + } + } + + obfuscated = NosqlObfuscator.obfuscate_statement(selector) + assert_equal expected, obfuscated + end + + def test_obfuscates_array_statement + statement = [{"$group" => {:_id => "$says", :total => {"$sum" => 1}}}] + expected = [{"$group" => {:_id => "?", :total => {"$sum" => "?"}}}] + + obfuscated = NosqlObfuscator.obfuscate_statement(statement) + assert_equal expected, obfuscated + end + + def test_obfuscate_nested_arrays + selector = { + "aggregate" => "mongeese", + "pipeline" => [{"$group" => {:_id => "$says", :total => {"$sum" => 1}}}] + } + + expected = { + "aggregate" => "?", + "pipeline" => [{"$group" => {:_id => "?", :total => {"$sum" => "?"}}}] + } + + obfuscated = NosqlObfuscator.obfuscate_statement(selector) + assert_equal expected, obfuscated + end + end + end + end +end