diff --git a/lib/datadog/core/contrib/rails/utils.rb b/lib/datadog/core/contrib/rails/utils.rb index 864f8ab3c9f..3dd140e2b8e 100644 --- a/lib/datadog/core/contrib/rails/utils.rb +++ b/lib/datadog/core/contrib/rails/utils.rb @@ -7,11 +7,13 @@ module Rails # common utilities for Rails module Utils def self.app_name - if ::Rails::VERSION::MAJOR >= 6 - ::Rails.application.class.module_parent_name.underscore - else - ::Rails.application.class.parent_name.underscore - end + namespace_method = (::Rails::VERSION::MAJOR >= 6) ? :module_parent_name : :parent_name + application_name = ::Rails.application.class.public_send(namespace_method) + application_name&.underscore + rescue + # Adds a failsafe during app boot, teardown, or test stubs where the application is not initialized and this check gets performed + Datadog.logger.debug('Failed to extract Rails application name.') + nil end def self.railtie_supported? diff --git a/lib/datadog/core/environment/ext.rb b/lib/datadog/core/environment/ext.rb index fd4579e1f97..8959e7f2559 100644 --- a/lib/datadog/core/environment/ext.rb +++ b/lib/datadog/core/environment/ext.rb @@ -41,6 +41,7 @@ module Ext TAG_ENTRYPOINT_NAME = "entrypoint.name" TAG_ENTRYPOINT_WORKDIR = "entrypoint.workdir" TAG_ENTRYPOINT_TYPE = "entrypoint.type" + TAG_RAILS_APPLICATION = "rails.application" TAG_PROCESS_TAGS = "_dd.tags.process" TAG_SERVICE = 'service' TAG_VERSION = 'version' diff --git a/lib/datadog/core/environment/process.rb b/lib/datadog/core/environment/process.rb index 7e3c6ca772b..dddcf5ee1e2 100644 --- a/lib/datadog/core/environment/process.rb +++ b/lib/datadog/core/environment/process.rb @@ -2,6 +2,7 @@ require_relative 'ext' require_relative '../tag_normalizer' +require_relative '../contrib/rails/utils' module Datadog module Core @@ -35,6 +36,9 @@ def self.tags tags << "#{Environment::Ext::TAG_ENTRYPOINT_TYPE}:#{TagNormalizer.normalize(entrypoint_type, remove_digit_start_char: false)}" + rails_application_name = TagNormalizer.normalize_process_value(rails_application.to_s) + tags << "#{Environment::Ext::TAG_RAILS_APPLICATION}:#{rails_application_name}" unless rails_application_name.empty? + @tags = tags.freeze end @@ -80,7 +84,22 @@ def self.entrypoint_basedir File.basename(File.expand_path(File.dirname($0))) end - private_class_method :entrypoint_workdir, :entrypoint_type, :entrypoint_name, :entrypoint_basedir + def self.rails_application + return unless Core::Contrib::Rails::Utils.railtie_supported? + + Core::Contrib::Rails::Utils.app_name + end + + # Sometimes we may want to force a recompute of the process tags when certain conditions have been met + # Example: Rails application names are not obtainable until after initialization + # @return [Array] the new tags array + def self.recompute_tags! + remove_instance_variable(:@tags) if instance_variable_defined?(:@tags) + remove_instance_variable(:@serialized) if instance_variable_defined?(:@serialized) + tags + end + + private_class_method :entrypoint_workdir, :entrypoint_type, :entrypoint_name, :entrypoint_basedir, :rails_application end end end diff --git a/lib/datadog/tracing/contrib/rails/patcher.rb b/lib/datadog/tracing/contrib/rails/patcher.rb index 26a1f80e33b..4853d4d1b42 100644 --- a/lib/datadog/tracing/contrib/rails/patcher.rb +++ b/lib/datadog/tracing/contrib/rails/patcher.rb @@ -8,6 +8,7 @@ require_relative 'runner' require_relative '../../../core/contrib/rails/utils' require_relative '../semantic_logger/patcher' +require_relative '../../../core/environment/process' module Datadog module Tracing @@ -76,6 +77,9 @@ def after_initialize(app) # Finish configuring the tracer after the application is initialized. # We need to wait for some things, like application name, middleware stack, etc. setup_tracer + + # Recompute process tags since the app name will be available now + Datadog::Core::Environment::Process.recompute_tags! if Datadog.configuration.experimental_propagate_process_tags_enabled end end diff --git a/sig/datadog/core/contrib/rails/utils.rbs b/sig/datadog/core/contrib/rails/utils.rbs index 49db36402bc..29d9e3e2977 100644 --- a/sig/datadog/core/contrib/rails/utils.rbs +++ b/sig/datadog/core/contrib/rails/utils.rbs @@ -3,7 +3,7 @@ module Datadog module Contrib module Rails module Utils - def self.app_name: () -> String + def self.app_name: () -> String? def self.railtie_supported?: () -> bool end diff --git a/sig/datadog/core/environment/ext.rbs b/sig/datadog/core/environment/ext.rbs index c47790adffd..56e1b7016a0 100644 --- a/sig/datadog/core/environment/ext.rbs +++ b/sig/datadog/core/environment/ext.rbs @@ -50,6 +50,8 @@ module Datadog TAG_ENTRYPOINT_TYPE: ::String + TAG_RAILS_APPLICATION: ::String + TAG_PROCESS_TAGS: ::String end end diff --git a/sig/datadog/core/environment/process.rbs b/sig/datadog/core/environment/process.rbs index 494cf77cf84..7110c6adf42 100644 --- a/sig/datadog/core/environment/process.rbs +++ b/sig/datadog/core/environment/process.rbs @@ -9,6 +9,8 @@ module Datadog def self.tags: () -> ::Array[::String] + def self.recompute_tags!: () -> ::Array[::String] + private def self.entrypoint_workdir: () -> ::String @@ -18,6 +20,8 @@ module Datadog def self.entrypoint_name: () -> ::String def self.entrypoint_basedir: () -> ::String + + def self.rails_application: () -> ::String? end end end diff --git a/spec/datadog/core/contrib/rails/utils_spec.rb b/spec/datadog/core/contrib/rails/utils_spec.rb index b3f04ed32bc..782a14e90ea 100644 --- a/spec/datadog/core/contrib/rails/utils_spec.rb +++ b/spec/datadog/core/contrib/rails/utils_spec.rb @@ -1,7 +1,52 @@ require 'lib/datadog/core/contrib/rails/utils' require 'rails/version' +require 'active_support/core_ext/string/inflections' RSpec.describe Datadog::Core::Contrib::Rails::Utils do + describe 'app_name' do + subject(:app_name) { described_class.app_name } + + let(:namespace_name) { 'custom_app' } + let(:application_class) { double('custom rails class', module_parent_name: namespace_name) } + + let(:application) { double('custom rails', class: application_class) } + + let(:rails_module) do + version_major = 7 + application_instance = application + Module.new do + version_module = Module.new do + const_set(:MAJOR, version_major) + end + + const_set(:VERSION, version_module) + define_singleton_method(:application) { application_instance } + end + end + + before do + stub_const('::Rails', rails_module) + end + + context 'when namespace is available' do + it { is_expected.to eq('custom_app') } + end + + context 'when namespace is nil' do + let(:namespace_name) { nil } + + it { is_expected.to be_nil } + end + + context 'when Rails lookup raises an error' do + before do + allow(rails_module).to receive(:application).and_raise(StandardError) + end + + it { is_expected.to be_nil } + end + end + describe 'railtie_supported?' do subject(:railtie_supported?) { described_class.railtie_supported? } diff --git a/spec/datadog/core/environment/process_spec.rb b/spec/datadog/core/environment/process_spec.rb index 7c4f681a024..ece0828dc86 100644 --- a/spec/datadog/core/environment/process_spec.rb +++ b/spec/datadog/core/environment/process_spec.rb @@ -6,6 +6,10 @@ describe '::serialized' do subject(:serialized) { described_class.serialized } + before do + allow(Datadog::Core::Contrib::Rails::Utils).to receive(:railtie_supported?).and_return(false) + end + it { is_expected.to be_a_kind_of(String) } it 'returns the same object when called multiple times' do @@ -74,6 +78,20 @@ expect(described_class.serialized).to include('entrypoint.type:script') end end + + context 'when Rails application name is available' do + include_context 'with mocked process environment' + let(:program_name) { 'bin/rails' } + + before do + allow(Datadog::Core::Contrib::Rails::Utils).to receive(:railtie_supported?).and_return(true) + allow(Datadog::Core::Contrib::Rails::Utils).to receive(:app_name).and_return('Test::App') + end + + it 'includes rails.application in serialized tags' do + expect(serialized).to include('rails.application:test_app') + end + end end describe 'Scenario: Real applications' do @@ -114,6 +132,7 @@ expect(err).to include('entrypoint.type:script') expect(err).to include('entrypoint.name:rails') expect(err).to include('entrypoint.basedir:bin') + expect(err).to include('rails.application:test_app') end end end @@ -124,6 +143,10 @@ describe '::tags' do subject(:tags) { described_class.tags } + before do + allow(Datadog::Core::Contrib::Rails::Utils).to receive(:railtie_supported?).and_return(false) + end + it { is_expected.to be_a_kind_of(Array) } it 'is an array of strings' do @@ -201,5 +224,54 @@ expect(described_class.tags).to include('entrypoint.type:script') end end + + context 'when Rails application name is available' do + include_context 'with mocked process environment' + let(:program_name) { 'bin/rails' } + + before do + allow(Datadog::Core::Contrib::Rails::Utils).to receive(:railtie_supported?).and_return(true) + allow(Datadog::Core::Contrib::Rails::Utils).to receive(:app_name).and_return('Test::App') + end + + it 'includes rails.application in tag array' do + expect(tags.length).to eq(5) + expect(tags).to include('rails.application:test_app') + end + end + end + + describe '::recompute_tags!' do + include_context 'with mocked process environment' + let(:program_name) { 'bin/rails' } + let(:default_process_tags) { ["entrypoint.workdir:app", "entrypoint.name:rails", "entrypoint.basedir:bin", "entrypoint.type:script"] } + + before do + allow(Datadog::Core::Contrib::Rails::Utils).to receive(:railtie_supported?).and_return(true) + end + + it 'recomputes the process tags when called' do + # First the Rails app doesn't have the app name yet + allow(Datadog::Core::Contrib::Rails::Utils).to receive(:app_name).and_return(nil) + expect(described_class.tags).to_not include('rails.application:test_app') + + # Now the Rails app has the app name + allow(Datadog::Core::Contrib::Rails::Utils).to receive(:app_name).and_return('MyNewApp::App') + new_process_tags = described_class.recompute_tags! + expect(new_process_tags).to include('rails.application:mynewapp_app') + expect(described_class.serialized).to include('rails.application:mynewapp_app') + end + + it 'is safe to call even if we have no tags yet' do + described_class.remove_instance_variable(:@tags) if described_class.instance_variable_defined?(:@tags) + described_class.remove_instance_variable(:@serialized) if described_class.instance_variable_defined?(:@serialized) + + # We check that the logic doesn't throw errors when we can't get the rails app name yet + allow(Datadog::Core::Contrib::Rails::Utils).to receive(:app_name).and_return(nil) + + new_process_tags = described_class.recompute_tags! + expect(new_process_tags).to include(*default_process_tags) + expect(new_process_tags).to_not include('rails.application:mynewapp_app') + end end end diff --git a/spec/datadog/core/runtime/metrics_spec.rb b/spec/datadog/core/runtime/metrics_spec.rb index 88dc5ad4130..81adeb9861f 100644 --- a/spec/datadog/core/runtime/metrics_spec.rb +++ b/spec/datadog/core/runtime/metrics_spec.rb @@ -277,7 +277,8 @@ before do allow(runtime_metrics).to receive(:statsd).and_return(statsd) allow(statsd).to receive(:gauge) - allow(Datadog::Core::Environment::Process).to receive(:tags).and_return(['entrypoint.workdir:test']) + allow(Datadog::Core::Environment::Process).to receive(:tags) + .and_return(['entrypoint.workdir:test', 'rails.application:test_app']) runtime_metrics.enabled = true end @@ -285,6 +286,7 @@ flush expect(statsd).to have_received(:gauge).with(anything, anything, hash_including(tags: array_including('entrypoint.workdir:test'))).at_least(:once) + expect(statsd).to have_received(:gauge).with(anything, anything, hash_including(tags: array_including('rails.application:test_app'))).at_least(:once) end end end @@ -328,12 +330,13 @@ context 'when :experimental_propagate_process_tags_enabled is true' do before do allow(Datadog::Core::Environment::Process).to receive(:tags) - .and_return(['entrypoint.workdir:test', 'entrypoint.name:test_script']) + .and_return(['entrypoint.workdir:test', 'entrypoint.name:test_script', 'rails.application:test_app']) end it 'includes process tags by default' do is_expected.to include('entrypoint.workdir:test') is_expected.to include('entrypoint.name:test_script') + is_expected.to include('rails.application:test_app') end end @@ -373,7 +376,7 @@ let(:options) { super().merge(experimental_propagate_process_tags_enabled: true) } before do - expect(Datadog::Core::Environment::Process).to receive(:tags).and_return(['entrypoint.workdir:test', 'entrypoint.name:test_script', 'entrypoint.basedir:test', 'entrypoint.type:script']) + expect(Datadog::Core::Environment::Process).to receive(:tags).and_return(['entrypoint.workdir:test', 'entrypoint.name:test_script', 'entrypoint.basedir:test', 'entrypoint.type:script', 'rails.application:test_app']) end it 'includes process tags when enabled' do @@ -381,6 +384,7 @@ is_expected.to include('entrypoint.name:test_script') is_expected.to include('entrypoint.basedir:test') is_expected.to include('entrypoint.type:script') + is_expected.to include('rails.application:test_app') end end diff --git a/spec/datadog/tracing/contrib/rails/patcher_spec.rb b/spec/datadog/tracing/contrib/rails/patcher_spec.rb new file mode 100644 index 00000000000..b72e11b9bfa --- /dev/null +++ b/spec/datadog/tracing/contrib/rails/patcher_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/tracing/contrib/rails/patcher' + +RSpec.describe Datadog::Tracing::Contrib::Rails::Patcher do + describe '.after_initialize' do + let(:app) { double('application') } + + before do + described_class::AFTER_INITIALIZE_ONLY_ONCE_PER_APP.delete(app) + allow(described_class).to receive(:setup_tracer) + end + + context 'when process tags are enabled' do + before do + allow(Datadog.configuration).to receive(:experimental_propagate_process_tags_enabled).and_return(true) + allow(Datadog::Core::Environment::Process).to receive(:recompute_tags!) + end + + it 'recomputes the process tags' do + described_class.after_initialize(app) + + expect(Datadog::Core::Environment::Process).to have_received(:recompute_tags!) + end + end + + context 'when process tags are not enabled' do + before do + allow(Datadog.configuration).to receive(:experimental_propagate_process_tags_enabled).and_return(false) + allow(Datadog::Core::Environment::Process).to receive(:recompute_tags!) + end + + it 'does not recompute the process tags' do + described_class.after_initialize(app) + + expect(Datadog::Core::Environment::Process).to_not have_received(:recompute_tags!) + end + end + end +end