diff --git a/lib/datadog/core/configuration/components.rb b/lib/datadog/core/configuration/components.rb index 9a182213288..da956f915b0 100644 --- a/lib/datadog/core/configuration/components.rb +++ b/lib/datadog/core/configuration/components.rb @@ -177,6 +177,11 @@ def initialize(settings) # Configure non-privileged components. Datadog::Tracing::Contrib::Component.configure(settings) + + # Load the core Rails Railtie when Rails is present so all products benefit from Rails-specific setup. + if defined?(::Rails::Railtie) + require_relative '../contrib/rails/railtie' + end end # Called when a fork is detected diff --git a/lib/datadog/core/contrib/rails/railtie.rb b/lib/datadog/core/contrib/rails/railtie.rb new file mode 100644 index 00000000000..ae0f2f2a413 --- /dev/null +++ b/lib/datadog/core/contrib/rails/railtie.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require_relative 'utils' +require_relative '../../environment/process' + +module Datadog + module Core + module Contrib + module Rails + # Railtie for core Rails setup that benefits all Datadog products. + class Railtie < ::Rails::Railtie + def self.after_initialize + return unless Datadog.configuration.experimental_propagate_process_tags_enabled + + Datadog::Core::Environment::Process.rails_application_name = + Datadog::Core::Contrib::Rails::Utils.app_name + end + + # Registered after the method definition so the method exists if on_load fires immediately + # (which happens when the Railtie is loaded into an already-initialized Rails app). + ::ActiveSupport.on_load(:after_initialize) do + Datadog::Core::Contrib::Rails::Railtie.after_initialize + end + end + end + end + end +end diff --git a/lib/datadog/core/contrib/rails/utils.rb b/lib/datadog/core/contrib/rails/utils.rb index 864f8ab3c9f..b951322c4c0 100644 --- a/lib/datadog/core/contrib/rails/utils.rb +++ b/lib/datadog/core/contrib/rails/utils.rb @@ -7,11 +7,15 @@ module Rails # common utilities for Rails module Utils def self.app_name - if ::Rails::VERSION::MAJOR >= 6 - ::Rails.application.class.module_parent_name.underscore + application_name = if ::Rails::VERSION::MAJOR >= 6 + ::Rails.application.class.module_parent_name else - ::Rails.application.class.parent_name.underscore + ::Rails.application.class.parent_name end + application_name&.underscore + rescue => e + Datadog.logger.debug("Failed to extract Rails application name: #{e.class}: #{e}") + 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..61c5aa23cfc 100644 --- a/lib/datadog/core/environment/process.rb +++ b/lib/datadog/core/environment/process.rb @@ -35,6 +35,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_name.to_s) + tags << "#{Environment::Ext::TAG_RAILS_APPLICATION}:#{rails_application_name}" unless rails_application_name.empty? + @tags = tags.freeze end @@ -80,6 +83,15 @@ def self.entrypoint_basedir File.basename(File.expand_path(File.dirname($0))) end + # Sets the rails application name from other places in code + # @param name [String] the rails application name + # @return [void] + def self.rails_application_name=(name) + @rails_application_name = name + remove_instance_variable(:@tags) if instance_variable_defined?(:@tags) + remove_instance_variable(:@serialized) if instance_variable_defined?(:@serialized) + end + private_class_method :entrypoint_workdir, :entrypoint_type, :entrypoint_name, :entrypoint_basedir end end diff --git a/lib/datadog/tracing/contrib/rails/patcher.rb b/lib/datadog/tracing/contrib/rails/patcher.rb index 26a1f80e33b..d0c4f5151b7 100644 --- a/lib/datadog/tracing/contrib/rails/patcher.rb +++ b/lib/datadog/tracing/contrib/rails/patcher.rb @@ -6,7 +6,6 @@ require_relative 'log_injection' require_relative 'middlewares' require_relative 'runner' -require_relative '../../../core/contrib/rails/utils' require_relative '../semantic_logger/patcher' module Datadog diff --git a/sig/datadog/core/contrib/rails/railtie.rbs b/sig/datadog/core/contrib/rails/railtie.rbs new file mode 100644 index 00000000000..dc7d79ee7b2 --- /dev/null +++ b/sig/datadog/core/contrib/rails/railtie.rbs @@ -0,0 +1,11 @@ +module Datadog + module Core + module Contrib + module Rails + class Railtie < ::Rails::Railtie + def self.after_initialize: () -> void + end + end + end + 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..06a54577d05 100644 --- a/sig/datadog/core/environment/process.rbs +++ b/sig/datadog/core/environment/process.rbs @@ -4,11 +4,14 @@ module Datadog module Process self.@serialized: ::String self.@tags: ::Array[::String] + self.@rails_application_name: ::String? def self.serialized: () -> ::String def self.tags: () -> ::Array[::String] + def self.rails_application_name=: (::String? name) -> void + private def self.entrypoint_workdir: () -> ::String diff --git a/spec/datadog/core/contrib/rails/railtie_spec.rb b/spec/datadog/core/contrib/rails/railtie_spec.rb new file mode 100644 index 00000000000..0357f2bfed1 --- /dev/null +++ b/spec/datadog/core/contrib/rails/railtie_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'active_support/lazy_load_hooks' + +# Provide a minimal stub base class for testing without loading the full Rails framework. +# utils_spec.rb uses the same pattern (stub_const '::Rails') for the same reason. +module Rails + class Railtie; end unless defined?(Railtie) +end + +require 'lib/datadog/core/contrib/rails/railtie' + +RSpec.describe Datadog::Core::Contrib::Rails::Railtie do + describe '.after_initialize' do + subject(:after_initialize) { described_class.after_initialize } + + before do + allow(Datadog::Core::Contrib::Rails::Utils).to receive(:app_name).and_return('test_app') + end + + after do + Datadog::Core::Environment::Process.rails_application_name = nil + end + + context 'when experimental_propagate_process_tags_enabled is true' do + before do + allow(Datadog.configuration).to receive(:experimental_propagate_process_tags_enabled).and_return(true) + end + + it 'includes the rails application name in process tags' do + after_initialize + expect(Datadog::Core::Environment::Process.tags).to include('rails.application:test_app') + end + end + + context 'when experimental_propagate_process_tags_enabled is false' do + before do + allow(Datadog.configuration).to receive(:experimental_propagate_process_tags_enabled).and_return(false) + end + + it 'does not include the rails application name in process tags' do + after_initialize + expect(Datadog::Core::Environment::Process.tags).not_to include(a_string_starting_with('rails.application:')) + end + 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..0a8e64748c7 100644 --- a/spec/datadog/core/contrib/rails/utils_spec.rb +++ b/spec/datadog/core/contrib/rails/utils_spec.rb @@ -1,7 +1,58 @@ 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) } + + # TODO: This can be refactored in the future to use a real Rails application class instead of stubs. + 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 } + + it 'returns nil and logs a debug message' do + expect(Datadog.logger).to receive(:debug).with(/Failed to extract Rails application name/) + expect(app_name).to be_nil + end + 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..1c261fb775f 100644 --- a/spec/datadog/core/environment/process_spec.rb +++ b/spec/datadog/core/environment/process_spec.rb @@ -74,6 +74,23 @@ 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 + described_class.rails_application_name = 'Test::App' + end + + after do + described_class.rails_application_name = nil + 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 @@ -98,8 +115,9 @@ file.puts "gem 'datadog', path: '#{project_root_directory}', require: false" end File.write("test@_app/config/initializers/process_initializer.rb", <<-RUBY) - Rails.application.config.after_initialize do - require 'datadog/core/environment/process' + require 'datadog' + Datadog.configure { } + ActiveSupport.on_load(:after_initialize) do STDERR.puts "_dd.tags.process:\#{Datadog::Core::Environment::Process.serialized}" STDERR.flush Thread.new { Process.kill('TERM', Process.pid) } @@ -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 @@ -201,5 +220,43 @@ 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 { described_class.rails_application_name = 'test_app' } + after { described_class.rails_application_name = nil } + + 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 '::rails_application_name=' do + include_context 'with mocked process environment' + let(:program_name) { 'bin/rails' } + + after do + described_class.rails_application_name = nil + end + + it 'includes the rails app name in the tags' do + described_class.rails_application_name = "Test::App" + expect(described_class.tags).to include('rails.application:test_app') + end + + it 'invalidates the cached tags' do + described_class.tags + described_class.rails_application_name = "Test::App" + expect(described_class.tags).to include('rails.application:test_app') + end + + it 'invalidates the serialized cache' do + described_class.serialized + described_class.rails_application_name = "Test::App" + expect(described_class.serialized).to include('rails.application:test_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..a1876895b09 --- /dev/null +++ b/spec/datadog/tracing/contrib/rails/patcher_spec.rb @@ -0,0 +1,28 @@ +# 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 + + it 'sets up the tracer' do + described_class.after_initialize(app) + + expect(described_class).to have_received(:setup_tracer) + end + + it 'only sets up the tracer once per app' do + described_class.after_initialize(app) + described_class.after_initialize(app) + + expect(described_class).to have_received(:setup_tracer).once + end + end +end