Skip to content
12 changes: 7 additions & 5 deletions lib/datadog/core/contrib/rails/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +10 to +11
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would avoid using public_send, because it's slower a direct method call, and in this case, we know the method name ahead of time.

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it makes sense to show anything in logs about that or it's not that important?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't add one because I couldn't think of any information here that would be useful for debugging.

Going by the failed run before I added the rescue: https://github.com/DataDog/dd-trace-rb/actions/runs/23265763535/job/67646150038?pr=5468, if I did log the exception, it would just list out the type of error but it wasn't specific enough to track down the root cause:

     StandardError:
       StandardError
     # ./lib/datadog/core/contrib/rails/utils.rb:11:in `app_name'
     # ./lib/datadog/core/environment/process.rb:90:in `rails_application'
     # ./lib/datadog/core/environment/process.rb:39:in `tags'
     # ./lib/datadog/core/environment/process.rb:19:in `serialized'

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, if we care about app boot, and this code should be OK otherwise potential issues, we might want to show warning, that some bad things happen and something might not work as expected now. So my suggestion is more about customer resurfacing, instead of debugging purposes.

Totally up to you!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a fair point, I'll think of a useful message to add here!

Datadog.logger.debug('Failed to extract Rails application name.')
nil
end

def self.railtie_supported?
Expand Down
1 change: 1 addition & 0 deletions lib/datadog/core/environment/ext.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
21 changes: 20 additions & 1 deletion lib/datadog/core/environment/process.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require_relative 'ext'
require_relative '../tag_normalizer'
require_relative '../contrib/rails/utils'

module Datadog
module Core
Expand Down Expand Up @@ -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?
Comment on lines +39 to +40
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Core namespace suppose to be free of contribs? I have a strong feelings that it doesn't belong here

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: It feels like the Rails lookup might sit more naturally in the patcher, which already owns all the Rails-specific knowledge — leaving Process free of the Core::Contrib::Rails dependency entirely.

What if we gave Process a simple setter instead?

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

Then in tags, swap the rails_application call for a direct check on @rails_application_name. The patcher would own the lookup:

if Datadog.configuration.experimental_propagate_process_tags_enabled
  Datadog::Core::Environment::Process.rails_application_name = Core::Contrib::Rails::Utils.app_name
end

No require in process.rb, no private method — and recompute_tags! goes away too since it was only introduced for this use case. Curious what you think.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this suggestion and I'll try refactoring this way in a separate commit so it's easy to revert back if there are different suggestions to achieve this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmm, we have lib/datadog/core/contrib/rails/utils.rb sitting in core today.
Do you guys think we should move this out of core too?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just saw the Process#recompute_tags! method, and it's unfortunate that we have to have it to make it work, given the Rails of late initialization.

I agree then that instead of having the recompute tags, we should just set the application name in the sites where we call recompute tags today.

Here's my concern: we actually forgot to add Process#recompute_tags! to AppSec, since you can have AppSec on Rails without Tracing.
More importantly, if you only have Profiling enabled, the process tag rails_application_name should still be present, but they will never have an opportunity to be set. We'll have the same issue with DI only apps.

I think we probably have to subscribe to the Rails initializer just for core features, which for now would be this one (aka have an official Core::Contrib::Rails).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the point is a weak coupling, in this class nothing said that it should be aware about Rails.

Also without Rails that functionality is a dead-code. IMHO what Oleg suggests bring some loose coupling, but I would put it even further and remove knowledge of the framework at all via simple renaming

def self.application_name=(name)
  @application_name = name
  remove_instance_variable(:@tags) if instance_variable_defined?(:@tags)
  remove_instance_variable(:@serialized) if instance_variable_defined?(:@serialized)
end


@tags = tags.freeze
end

Expand Down Expand Up @@ -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<String>] 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
Expand Down
4 changes: 4 additions & 0 deletions lib/datadog/tracing/contrib/rails/patcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion sig/datadog/core/contrib/rails/utils.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions sig/datadog/core/environment/ext.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ module Datadog

TAG_ENTRYPOINT_TYPE: ::String

TAG_RAILS_APPLICATION: ::String

TAG_PROCESS_TAGS: ::String
end
end
Expand Down
4 changes: 4 additions & 0 deletions sig/datadog/core/environment/process.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ module Datadog

def self.tags: () -> ::Array[::String]

def self.recompute_tags!: () -> ::Array[::String]

private

def self.entrypoint_workdir: () -> ::String
Expand All @@ -18,6 +20,8 @@ module Datadog
def self.entrypoint_name: () -> ::String

def self.entrypoint_basedir: () -> ::String

def self.rails_application: () -> ::String?
end
end
end
Expand Down
45 changes: 45 additions & 0 deletions spec/datadog/core/contrib/rails/utils_spec.rb
Original file line number Diff line number Diff line change
@@ -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? }

Expand Down
72 changes: 72 additions & 0 deletions spec/datadog/core/environment/process_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
10 changes: 7 additions & 3 deletions spec/datadog/core/runtime/metrics_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -277,14 +277,16 @@
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

it 'sends metrics with the process tags' do
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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -373,14 +376,15 @@
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
is_expected.to include('entrypoint.workdir:test')
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

Expand Down
41 changes: 41 additions & 0 deletions spec/datadog/tracing/contrib/rails/patcher_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading