diff --git a/Appraisals b/Appraisals index bbe37ab6580..f0174c5fffc 100644 --- a/Appraisals +++ b/Appraisals @@ -132,6 +132,7 @@ if RUBY_VERSION >= '2.2.2' && RUBY_PLATFORM != 'java' gem 'aws-sdk' gem 'sucker_punch' gem 'dalli' + gem 'rake' gem 'resque', '< 2.0' gem 'racecar', '>= 0.3.5' gem 'mysql2', '< 0.5', platform: :ruby @@ -154,6 +155,7 @@ else gem 'aws-sdk', '~> 2.0' gem 'sucker_punch' gem 'dalli' + gem 'rake', '< 12.3' gem 'resque', '< 2.0' gem 'mysql2', '0.3.21', platform: :ruby gem 'activerecord-mysql-adapter', platform: :ruby diff --git a/Rakefile b/Rakefile index c676a5fa430..03399e4fcb4 100644 --- a/Rakefile +++ b/Rakefile @@ -55,6 +55,7 @@ namespace :spec do :mongodb, :racecar, :rack, + :rake, :redis, :resque, :sequel, @@ -232,6 +233,7 @@ task :ci do sh 'rvm $MRI_VERSIONS --verbose do appraisal contrib rake spec:mongodb' sh 'rvm $MRI_VERSIONS --verbose do appraisal contrib rake spec:grpc' sh 'rvm $MRI_VERSIONS --verbose do appraisal contrib rake spec:racecar' + sh 'rvm $MRI_VERSIONS --verbose do appraisal contrib rake spec:rake' sh 'rvm $MRI_VERSIONS --verbose do appraisal contrib rake spec:redis' sh 'rvm $MRI_VERSIONS --verbose do appraisal contrib rake spec:resque' sh 'rvm $MRI_VERSIONS --verbose do appraisal contrib rake spec:sequel' @@ -242,6 +244,7 @@ task :ci do sh 'rvm $MRI_OLD_VERSIONS --verbose do appraisal contrib-old rake spec:excon' sh 'rvm $MRI_OLD_VERSIONS --verbose do appraisal contrib-old rake spec:faraday' sh 'rvm $MRI_OLD_VERSIONS --verbose do appraisal contrib-old rake spec:mongodb' + sh 'rvm $MRI_OLD_VERSIONS --verbose do appraisal contrib-old rake spec:rake' sh 'rvm $MRI_OLD_VERSIONS --verbose do appraisal contrib-old rake spec:redis' sh 'rvm $MRI_OLD_VERSIONS --verbose do appraisal contrib-old rake spec:resque' sh 'rvm $MRI_OLD_VERSIONS --verbose do appraisal contrib-old rake spec:sequel' diff --git a/docs/GettingStarted.md b/docs/GettingStarted.md index c081faa1e10..b6022ec2abb 100644 --- a/docs/GettingStarted.md +++ b/docs/GettingStarted.md @@ -37,6 +37,7 @@ For descriptions of terminology used in APM, take a look at the [official docume - [Racecar](#racecar) - [Rack](#rack) - [Rails](#rails) + - [Rake](#rake) - [Redis](#redis) - [Resque](#resque) - [Sequel](#sequel) @@ -257,6 +258,7 @@ For a list of available integrations, and their configuration options, please re | Racecar | `racecar` | `>= 0.3.5` | *[Link](#racecar)* | *[Link](https://github.com/zendesk/racecar)* | | Rack | `rack` | `>= 1.4.7` | *[Link](#rack)* | *[Link](https://github.com/rack/rack)* | | Rails | `rails` | `>= 3.2, < 5.2` | *[Link](#rails)* | *[Link](https://github.com/rails/rails)* | +| Rake | `rake` | `>= 12.0` | *[Link](#rake)* | *[Link](https://github.com/ruby/rake)* | | Redis | `redis` | `>= 3.2, < 4.0` | *[Link](#redis)* | *[Link](https://github.com/redis/redis-rb)* | | Resque | `resque` | `>= 1.0, < 2.0` | *[Link](#resque)* | *[Link](https://github.com/resque/resque)* | | Sequel | `sidekiq` | `>= 3.41` | *[Link](#sequel)* | *[Link](https://github.com/jeremyevans/sequel)* | @@ -754,6 +756,71 @@ Where `options` is an optional `Hash` that accepts the following parameters: | ``template_base_path`` | Used when the template name is parsed. If you don't store your templates in the ``views/`` folder, you may need to change this value | ``views/`` | | ``tracer`` | A ``Datadog::Tracer`` instance used to instrument the application. Usually you don't need to set that. | ``Datadog.tracer`` | +### Rake + +You can add instrumentation around your Rake tasks by activating the `rake` integration. Each task and its subsequent subtasks will be traced. + +To activate Rake task tracing, add the following to your `Rakefile`: + +```ruby +# At the top of your Rakefile: +require 'rake' +require 'ddtrace' + +Datadog.configure do |c| + c.use :rake, options +end + +task :my_task do + # Do something task work here... +end + +Rake::Task['my_task'].invoke +``` + +Where `options` is an optional `Hash` that accepts the following parameters: + +| Key | Description | Default | +| --- | --- | --- | +| ``enabled`` | Defines whether Rake tasks should be traced. Useful for temporarily disabling tracing. `true` or `false` | ``true`` | +| ``quantize`` | Hash containing options for quantization of task arguments. See below for more details and examples. | ``{}`` | +| ``service_name`` | Service name which the Rake task traces should be grouped under. | ``rake`` | +| ``tracer`` | A ``Datadog::Tracer`` instance used to instrument the application. Usually you don't need to set that. | ``Datadog.tracer`` | + +**Configuring task quantization behavior** + +```ruby +Datadog.configure do |c| + # Given a task that accepts :one, :two, :three... + # Invoked with 'foo', 'bar', 'baz'. + + # Default behavior: all arguments are quantized. + # `rake.invoke.args` tag --> ['?'] + # `rake.execute.args` tag --> { one: '?', two: '?', three: '?' } + c.use :rake + + # Show values for any argument matching :two exactly + # `rake.invoke.args` tag --> ['?'] + # `rake.execute.args` tag --> { one: '?', two: 'bar', three: '?' } + c.use :rake, quantize: { args: { show: [:two] } } + + # Show all values for all arguments. + # `rake.invoke.args` tag --> ['foo', 'bar', 'baz'] + # `rake.execute.args` tag --> { one: 'foo', two: 'bar', three: 'baz' } + c.use :rake, quantize: { args: { show: :all } } + + # Totally exclude any argument matching :three exactly + # `rake.invoke.args` tag --> ['?'] + # `rake.execute.args` tag --> { one: '?', two: '?' } + c.use :rake, quantize: { args: { exclude: [:three] } } + + # Remove the arguments entirely + # `rake.invoke.args` tag --> ['?'] + # `rake.execute.args` tag --> {} + c.use :rake, quantize: { args: { exclude: :all } } +end +``` + ### Redis The Redis integration will trace simple calls as well as pipelines. diff --git a/gemfiles/contrib.gemfile b/gemfiles/contrib.gemfile index b5def63fade..87a413c8e8c 100644 --- a/gemfiles/contrib.gemfile +++ b/gemfiles/contrib.gemfile @@ -21,6 +21,7 @@ gem "sidekiq" gem "aws-sdk" gem "sucker_punch" gem "dalli" +gem "rake" gem "resque", "< 2.0" gem "racecar", ">= 0.3.5" gem "mysql2", "< 0.5", platform: :ruby diff --git a/gemfiles/contrib_old.gemfile b/gemfiles/contrib_old.gemfile index fb0cbb2d77e..fe8f47346d2 100644 --- a/gemfiles/contrib_old.gemfile +++ b/gemfiles/contrib_old.gemfile @@ -19,6 +19,7 @@ gem "sidekiq", "4.0.0" gem "aws-sdk", "~> 2.0" gem "sucker_punch" gem "dalli" +gem "rake", "< 12.3" gem "resque", "< 2.0" gem "mysql2", "0.3.21", platform: :ruby gem "activerecord-mysql-adapter", platform: :ruby diff --git a/lib/ddtrace.rb b/lib/ddtrace.rb index 6283aac02d6..d8623bc3e4d 100644 --- a/lib/ddtrace.rb +++ b/lib/ddtrace.rb @@ -71,6 +71,7 @@ def configure(target = configuration, opts = {}) require 'ddtrace/contrib/sucker_punch/patcher' require 'ddtrace/contrib/mongodb/patcher' require 'ddtrace/contrib/dalli/patcher' +require 'ddtrace/contrib/rake/patcher' require 'ddtrace/contrib/resque/patcher' require 'ddtrace/contrib/racecar/patcher' require 'ddtrace/contrib/sidekiq/patcher' diff --git a/lib/ddtrace/contrib/rake/instrumentation.rb b/lib/ddtrace/contrib/rake/instrumentation.rb new file mode 100644 index 00000000000..fe6be8ec140 --- /dev/null +++ b/lib/ddtrace/contrib/rake/instrumentation.rb @@ -0,0 +1,70 @@ +module Datadog + module Contrib + module Rake + # Instrumentation for Rake tasks + module Instrumentation + SPAN_NAME_INVOKE = 'rake.invoke'.freeze + SPAN_NAME_EXECUTE = 'rake.execute'.freeze + + def self.included(base) + base.send(:prepend, InstanceMethods) + end + + # Instance methods for Rake instrumentation + module InstanceMethods + def invoke(*args) + return super unless enabled? + + tracer.trace(SPAN_NAME_INVOKE) do |span| + super + annotate_invoke!(span, args) + end + end + + def execute(args = nil) + return super unless enabled? + + tracer.trace(SPAN_NAME_EXECUTE) do |span| + super + annotate_execute!(span, args) + end + end + + private + + def annotate_invoke!(span, args) + span.resource = name + span.set_tag('rake.task.arg_names', arg_names) + span.set_tag('rake.invoke.args', quantize_args(args)) unless args.nil? + rescue StandardError => e + Datadog::Tracer.log.debug("Error while tracing Rake invoke: #{e.message}") + end + + def annotate_execute!(span, args) + span.resource = name + span.set_tag('rake.execute.args', quantize_args(args.to_hash)) unless args.nil? + rescue StandardError => e + Datadog::Tracer.log.debug("Error while tracing Rake execute: #{e.message}") + end + + def quantize_args(args) + quantize_options = Datadog.configuration[:rake][:quantize][:args] + Datadog::Quantization::Hash.format(args, quantize_options) + end + + def enabled? + configuration[:enabled] == true + end + + def tracer + configuration[:tracer] + end + + def configuration + Datadog.configuration[:rake] + end + end + end + end + end +end diff --git a/lib/ddtrace/contrib/rake/patcher.rb b/lib/ddtrace/contrib/rake/patcher.rb new file mode 100644 index 00000000000..aac12728975 --- /dev/null +++ b/lib/ddtrace/contrib/rake/patcher.rb @@ -0,0 +1,53 @@ +require 'ddtrace/ext/app_types' +require 'ddtrace/contrib/rake/instrumentation' + +module Datadog + module Contrib + module Rake + # Patcher for Rake instrumentation + module Patcher + include Base + + register_as :rake + option :service_name, default: 'rake' + option :tracer, default: Datadog.tracer + option :enabled, default: true + option :quantize, default: {} + + module_function + + def patch + return patched? if patched? || !compatible? + + patch_rake + + # Set service info + configuration[:tracer].set_service_info( + configuration[:service_name], + 'rake', + Ext::AppTypes::WORKER + ) + + @patched = true + end + + def patched? + return @patched if defined?(@patched) + @patched = false + end + + def patch_rake + ::Rake::Task.send(:include, Instrumentation) + end + + def compatible? + RUBY_VERSION >= '2.0.0' && defined?(::Rake) + end + + def configuration + Datadog.configuration[:rake] + end + end + end + end +end diff --git a/lib/ddtrace/quantization/hash.rb b/lib/ddtrace/quantization/hash.rb index 95fdd12c178..3680984d004 100644 --- a/lib/ddtrace/quantization/hash.rb +++ b/lib/ddtrace/quantization/hash.rb @@ -14,21 +14,24 @@ module Hash module_function def format(hash_obj, options = {}) + options ||= {} format!(hash_obj, options) rescue StandardError options[:placeholder] || PLACEHOLDER end def format!(hash_obj, options = {}) + options ||= {} options = merge_options(DEFAULT_OPTIONS, options) format_hash(hash_obj, options) end def format_hash(hash_obj, options = {}) - return hash_obj if options[:show] == :all - case hash_obj when ::Hash + return {} if options[:exclude] == :all + return hash_obj if options[:show] == :all + hash_obj.each_with_object({}) do |(key, value), quantized| if options[:show].include?(key.to_sym) quantized[key] = value @@ -71,7 +74,13 @@ def merge_options(original, additional) end # Exclude - options[:exclude] = (original[:exclude] || []).dup.concat(additional[:exclude] || []).uniq + # If either is :all, value becomes :all + options[:exclude] = if original[:exclude] == :all || additional[:exclude] == :all + :all + else + (original[:exclude] || []).dup.concat(additional[:exclude] || []).uniq + end + options[:placeholder] = additional[:placeholder] || original[:placeholder] end end diff --git a/spec/ddtrace/contrib/rake/instrumentation_spec.rb b/spec/ddtrace/contrib/rake/instrumentation_spec.rb new file mode 100644 index 00000000000..27a1ec12265 --- /dev/null +++ b/spec/ddtrace/contrib/rake/instrumentation_spec.rb @@ -0,0 +1,224 @@ +require 'spec_helper' + +require 'securerandom' +require 'rake' +require 'rake/tasklib' +require 'ddtrace' +require 'ddtrace/contrib/rake/patcher' + +RSpec.describe Datadog::Contrib::Rake::Instrumentation do + let(:tracer) { Datadog::Tracer.new(writer: FauxWriter.new) } + let(:configuration_options) { { tracer: tracer, enabled: true } } + let(:spans) { tracer.writer.spans } + let(:span) { spans.first } + + before(:each) do + skip('Rake integration incompatible.') unless Datadog::Contrib::Rake::Patcher.compatible? + + # Reset options (that might linger from other tests) + Datadog.configuration[:rake].reset_options! + + # Patch Rake + Datadog.configure do |c| + c.use :rake, configuration_options + end + end + + after(:each) do + # We don't want instrumentation enabled during the rest of the test suite... + Datadog.configure do |c| + c.use :rake, enabled: false + end + end + + def reset_task!(task_name) + if Rake::Task.task_defined?(task_name) + Rake::Task[task_name].reenable + Rake::Task[task_name].clear + end + end + + let(:task_name) { :test_rake_instrumentation } + let(:task_body) { Proc.new { |task, args| spy.call(task, args) } } + let(:task_arg_names) { [] } + let(:task_class) do + stub_const('RakeInstrumentationTestTask', Class.new(Rake::TaskLib)).tap do |task_class| + tb = task_body + task_class.send(:define_method, :initialize) do |name = task_name, *args| + task(name, *args, &tb) + end + end + end + let(:task) { Rake::Task[task_name] } + let(:spy) { double('spy') } + + describe '#invoke' do + shared_examples_for 'a single task execution' do + before(:each) do + expect(spy).to receive(:call) do |invocation_task, invocation_args| + expect(invocation_task).to eq(task) + expect(invocation_args.to_hash).to eq(args_hash) + end + task.invoke(*args) + end + + let(:invoke_span) { spans.find { |s| s.name == described_class::SPAN_NAME_INVOKE } } + let(:execute_span) { spans.find { |s| s.name == described_class::SPAN_NAME_EXECUTE } } + + it do + expect(spans).to have(2).items + end + + describe '\'rake.invoke\' span' do + it do + expect(invoke_span.name).to eq(described_class::SPAN_NAME_INVOKE) + expect(invoke_span.resource).to eq(task_name.to_s) + expect(invoke_span.parent_id).to eq(0) + end + end + + describe '\'rake.execute\' span' do + it do + expect(execute_span.name).to eq(described_class::SPAN_NAME_EXECUTE) + expect(execute_span.resource).to eq(task_name.to_s) + expect(execute_span.parent_id).to eq(invoke_span.span_id) + end + end + end + + context 'for a task' do + let(:args_hash) { {} } + let(:task_arg_names) { args_hash.keys } + let(:args) { args_hash.values } + + let(:define_task!) do + reset_task!(task_name) + Rake::Task.define_task(task_name, *task_arg_names, &task_body) + end + + before(:each) { define_task! } + + context 'without args' do + it_behaves_like 'a single task execution' do + describe '\'rake.invoke\' span tags' do + it do + expect(invoke_span.get_tag('rake.task.arg_names')).to eq([].to_s) + expect(invoke_span.get_tag('rake.invoke.args')).to eq(['?'].to_s) + end + end + + describe '\'rake.execute\' span tags' do + it do + expect(execute_span.get_tag('rake.task.arg_names')).to be nil + expect(execute_span.get_tag('rake.execute.args')).to eq({}.to_s) + end + end + end + end + + context 'with args' do + let(:args_hash) { { one: 1, two: 2, three: 3 } } + it_behaves_like 'a single task execution' do + describe '\'rake.invoke\' span tags' do + it do + expect(invoke_span.get_tag('rake.task.arg_names')).to eq([:one, :two, :three].to_s) + expect(invoke_span.get_tag('rake.invoke.args')).to eq(['?'].to_s) + end + end + + describe '\'rake.execute\' span tags' do + it do + expect(execute_span.get_tag('rake.arg_names')).to be nil + expect(execute_span.get_tag('rake.execute.args')).to eq({ one: '?', two: '?', three: '?' }.to_s) + end + end + end + end + + context 'with a prerequisite task' do + let(:prerequisite_task_name) { :test_rake_instrumentation_prerequisite } + let(:prerequisite_task_body) { Proc.new { |task, args| prerequisite_spy.call(task, args) } } + let(:prerequisite_spy) { double('prerequisite spy') } + let(:prerequisite_task) { Rake::Task[prerequisite_task_name] } + + let(:define_task!) do + reset_task!(task_name) + reset_task!(prerequisite_task_name) + Rake::Task.define_task(prerequisite_task_name, &prerequisite_task_body) + Rake::Task.define_task(task_name => prerequisite_task_name, &task_body) + end + + before(:each) do + expect(prerequisite_spy).to receive(:call) do |invocation_task, invocation_args| + expect(invocation_task).to eq(prerequisite_task) + expect(invocation_args.to_hash).to eq({}) + end.ordered + + expect(spy).to receive(:call) do |invocation_task, invocation_args| + expect(invocation_task).to eq(task) + expect(invocation_args.to_hash).to eq(args_hash) + end.ordered + + task.invoke(*args) + end + + let(:invoke_span) { spans.find { |s| s.name == described_class::SPAN_NAME_INVOKE } } + let(:prerequisite_task_execute_span) do + spans.find do |s| + s.name == described_class::SPAN_NAME_EXECUTE \ + && s.resource == prerequisite_task_name.to_s + end + end + let(:task_execute_span) do + spans.find do |s| + s.name == described_class::SPAN_NAME_EXECUTE \ + && s.resource == task_name.to_s + end + end + + it do + expect(spans).to have(3).items + end + + describe '\'rake.invoke\' span' do + it do + expect(invoke_span.name).to eq(described_class::SPAN_NAME_INVOKE) + expect(invoke_span.resource).to eq(task_name.to_s) + expect(invoke_span.parent_id).to eq(0) + expect(invoke_span.get_tag('rake.task.arg_names')).to eq([].to_s) + expect(invoke_span.get_tag('rake.invoke.args')).to eq(['?'].to_s) + end + end + + describe 'prerequisite \'rake.execute\' span' do + it do + expect(prerequisite_task_execute_span.name).to eq(described_class::SPAN_NAME_EXECUTE) + expect(prerequisite_task_execute_span.resource).to eq(prerequisite_task_name.to_s) + expect(prerequisite_task_execute_span.parent_id).to eq(invoke_span.span_id) + expect(prerequisite_task_execute_span.get_tag('rake.task.arg_names')).to be nil + expect(prerequisite_task_execute_span.get_tag('rake.execute.args')).to eq({}.to_s) + end + end + + describe 'task \'rake.execute\' span' do + it do + expect(task_execute_span.name).to eq(described_class::SPAN_NAME_EXECUTE) + expect(task_execute_span.resource).to eq(task_name.to_s) + expect(task_execute_span.parent_id).to eq(invoke_span.span_id) + expect(task_execute_span.get_tag('rake.task.arg_names')).to be nil + expect(task_execute_span.get_tag('rake.execute.args')).to eq({}.to_s) + end + end + end + + context 'defined by a class' do + let(:define_task!) do + reset_task!(task_name) + task_class.new(task_name, *task_arg_names) + end + + it_behaves_like 'a single task execution' + end + end + end +end diff --git a/spec/ddtrace/quantization/hash_spec.rb b/spec/ddtrace/quantization/hash_spec.rb new file mode 100644 index 00000000000..19d830d84dd --- /dev/null +++ b/spec/ddtrace/quantization/hash_spec.rb @@ -0,0 +1,67 @@ +# encoding: utf-8 +require 'spec_helper' + +require 'ddtrace/quantization/hash' + +RSpec.describe Datadog::Quantization::Hash do + describe '#format' do + subject(:result) { described_class.format(hash, options) } + let(:options) { {} } + + context 'given a Hash' do + let(:hash) { { one: 'foo', two: 'bar', three: 'baz' } } + + context 'default behavior' do + it { is_expected.to eq({ one: '?', two: '?', three: '?' }) } + end + + context 'with show: value' do + let(:options) { { show: [:two] } } + it { is_expected.to eq({ one: '?', two: 'bar', three: '?' }) } + end + + context 'with show: :all' do + let(:options) { { show: :all } } + it { is_expected.to eq(hash) } + end + + context 'with exclude: value' do + let(:options) { { exclude: [:three] } } + it { is_expected.to eq({ one: '?', two: '?' }) } + end + + context 'with exclude: :all' do + let(:options) { { exclude: :all } } + it { is_expected.to eq({}) } + end + end + + context 'given an Array' do + let(:hash) { [ 'foo', 'bar', 'baz' ] } + + context 'default behavior' do + it { is_expected.to eq(['?']) } + end + + context 'with show: value' do + let(:options) { { show: [:two] } } + it { is_expected.to eq(['?']) } + end + + context 'with show: :all' do + let(:options) { { show: :all } } + it { is_expected.to eq(hash) } + end + + context 'with exclude: value' do + let(:options) { { exclude: [:three] } } + it { is_expected.to eq(['?']) } + end + + context 'with exclude: :all' do + let(:options) { { exclude: :all } } + it { is_expected.to eq(['?']) } + end + end + end +end