diff --git a/Matrixfile b/Matrixfile index 6a4bc6a4f74..6c2652b991d 100644 --- a/Matrixfile +++ b/Matrixfile @@ -293,6 +293,11 @@ 'redis-4' => '✅ 2.5 / ✅ 2.6 / ✅ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ 3.5 / ✅ jruby', 'redis-3' => '✅ 2.5 / ✅ 2.6 / ✅ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ 3.5 / ✅ jruby', }, + 'view_component' => { + 'view_component-min' => '✅ 2.5 / ✅ 2.6 / ✅ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ 3.5 / ✅ jruby', + 'view_component-3' => '❌ 2.5 / ❌ 2.6 / ✅ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ 3.5 / ✅ jruby', + 'view_component-4' => '❌ 2.5 / ❌ 2.6 / ❌ 2.7 / ❌ 3.0 / ❌ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ 3.5 / ✅ jruby', + }, 'appsec:active_record' => { 'relational_db' => '✅ 2.5 / ✅ 2.6 / ✅ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ 3.5 / ✅ jruby', }, diff --git a/Rakefile b/Rakefile index 4bf70667735..a7c29794dab 100644 --- a/Rakefile +++ b/Rakefile @@ -286,7 +286,8 @@ namespace :spec do :stripe, :sucker_punch, :suite, - :trilogy + :trilogy, + :view_component ].each do |contrib| desc '' # "Explicitly hiding from `rake -T`" RSpec::Core::RakeTask.new(contrib) do |t, args| diff --git a/appraisal/jruby-9.2.rb b/appraisal/jruby-9.2.rb index 68ef927cfd5..b9674b217c7 100644 --- a/appraisal/jruby-9.2.rb +++ b/appraisal/jruby-9.2.rb @@ -204,6 +204,7 @@ build_coverage_matrix('rest-client') build_coverage_matrix('mongo', min: '2.1.0') build_coverage_matrix('dalli', [2]) +build_coverage_matrix('view_component', (3..4), min: '2.34.0') # NOTE: JRuby bundler failed to install some dependencies https://github.com/ruby/psych/issues/700 # and it could be re-enabled when upstream fix the issue # build_coverage_matrix('devise', min: '3.2.1') diff --git a/appraisal/jruby-9.3.rb b/appraisal/jruby-9.3.rb index e6efd68e66e..7d3cb453071 100644 --- a/appraisal/jruby-9.3.rb +++ b/appraisal/jruby-9.3.rb @@ -177,6 +177,7 @@ build_coverage_matrix('rest-client') build_coverage_matrix('mongo', min: '2.1.0') build_coverage_matrix('dalli', [2]) +build_coverage_matrix('view_component', (3..4), min: '2.34.0') # NOTE: JRuby bundler failed to install some dependencies https://github.com/ruby/psych/issues/700 # and it could be re-enabled when upstream fix the issue # build_coverage_matrix('devise', min: '3.2.1') diff --git a/appraisal/jruby-9.4.rb b/appraisal/jruby-9.4.rb index a67c34b7670..1a670790625 100644 --- a/appraisal/jruby-9.4.rb +++ b/appraisal/jruby-9.4.rb @@ -82,6 +82,7 @@ build_coverage_matrix('mongo', min: '2.1.0') build_coverage_matrix('dalli', [2]) build_coverage_matrix('karafka', min: '2.3.0') +build_coverage_matrix('view_component', (3..4), min: '2.34.0') appraise 'karafka-min' do gem 'karafka', '= 2.3.0' diff --git a/appraisal/ruby-2.5.rb b/appraisal/ruby-2.5.rb index 282bbd1066b..53786c63774 100644 --- a/appraisal/ruby-2.5.rb +++ b/appraisal/ruby-2.5.rb @@ -224,6 +224,7 @@ build_coverage_matrix('mongo', min: '2.1.0') build_coverage_matrix('dalli') build_coverage_matrix('devise', min: '3.2.1', meta: { min: { 'bigdecimal' => '1.3.4' } }) +build_coverage_matrix('view_component', min: '2.34.0') appraise 'relational_db' do gem 'activerecord', '~> 5' diff --git a/appraisal/ruby-2.6.rb b/appraisal/ruby-2.6.rb index 94a0f71d5a4..74169527890 100644 --- a/appraisal/ruby-2.6.rb +++ b/appraisal/ruby-2.6.rb @@ -177,6 +177,7 @@ build_coverage_matrix('mongo', min: '2.1.0') build_coverage_matrix('dalli', [2]) build_coverage_matrix('devise', min: '3.2.1', meta: { min: { 'bigdecimal' => '1.4.1' } }) +build_coverage_matrix('view_component', min: '2.34.0') appraise 'relational_db' do gem 'activerecord', '~> 6.0.0' diff --git a/appraisal/ruby-2.7.rb b/appraisal/ruby-2.7.rb index cfa083ac940..2ddceedd87a 100644 --- a/appraisal/ruby-2.7.rb +++ b/appraisal/ruby-2.7.rb @@ -178,6 +178,7 @@ build_coverage_matrix('mongo', min: '2.1.0') build_coverage_matrix('dalli', [2]) build_coverage_matrix('devise', min: '3.2.1') +build_coverage_matrix('view_component', [3], min: '2.34.0') appraise 'relational_db' do gem 'activerecord', '~> 6.1.0' diff --git a/appraisal/ruby-3.0.rb b/appraisal/ruby-3.0.rb index c0e3ff9a208..a24d6606fef 100644 --- a/appraisal/ruby-3.0.rb +++ b/appraisal/ruby-3.0.rb @@ -98,6 +98,7 @@ build_coverage_matrix('mongo', min: '2.1.0') build_coverage_matrix('dalli', [2]) build_coverage_matrix('devise', min: '3.2.1') +build_coverage_matrix('view_component', [3], min: '2.34.0') appraise 'karafka-min' do gem 'karafka', '= 2.3.0' diff --git a/appraisal/ruby-3.1.rb b/appraisal/ruby-3.1.rb index 6269745e08a..6fad0cd2aa2 100644 --- a/appraisal/ruby-3.1.rb +++ b/appraisal/ruby-3.1.rb @@ -99,6 +99,7 @@ build_coverage_matrix('dalli', [2]) build_coverage_matrix('karafka', min: '2.3.0') build_coverage_matrix('devise', min: '3.2.1') +build_coverage_matrix('view_component', [3], min: '2.34.0') appraise 'relational_db' do gem 'activerecord', '~> 7' diff --git a/appraisal/ruby-3.2.rb b/appraisal/ruby-3.2.rb index 0c65a83f0ec..087d2fc7f6c 100644 --- a/appraisal/ruby-3.2.rb +++ b/appraisal/ruby-3.2.rb @@ -144,6 +144,7 @@ build_coverage_matrix('dalli', [2]) build_coverage_matrix('karafka', min: '2.3.0') build_coverage_matrix('devise', min: '3.2.1') +build_coverage_matrix('view_component', (3..4), min: '2.34.0') appraise 'relational_db' do gem 'activerecord', '~> 7' diff --git a/appraisal/ruby-3.3.rb b/appraisal/ruby-3.3.rb index cdeabe2e644..9b5975380e5 100644 --- a/appraisal/ruby-3.3.rb +++ b/appraisal/ruby-3.3.rb @@ -146,6 +146,7 @@ build_coverage_matrix('dalli', [2]) build_coverage_matrix('karafka', min: '2.3.0') build_coverage_matrix('devise', min: '3.2.1') +build_coverage_matrix('view_component', (3..4), min: '2.34.0') appraise 'relational_db' do gem 'activerecord', '~> 7' diff --git a/appraisal/ruby-3.4.rb b/appraisal/ruby-3.4.rb index 92833c2f59e..cc422439f35 100644 --- a/appraisal/ruby-3.4.rb +++ b/appraisal/ruby-3.4.rb @@ -145,6 +145,7 @@ build_coverage_matrix('dalli', [2]) build_coverage_matrix('karafka', min: '2.3.0') build_coverage_matrix('devise', min: '3.2.1') +build_coverage_matrix('view_component', (3..4), min: '2.34.0') appraise 'relational_db' do # ActiveRecord locked because tests are failing with 7.1, which was attempted as a part of Ruby 3.4 testing in CI. diff --git a/appraisal/ruby-3.5.rb b/appraisal/ruby-3.5.rb index 6b0542ea9b7..26e615e4e4b 100644 --- a/appraisal/ruby-3.5.rb +++ b/appraisal/ruby-3.5.rb @@ -94,6 +94,7 @@ build_coverage_matrix('dalli', [2]) build_coverage_matrix('karafka', min: '2.3.0') build_coverage_matrix('devise', min: '3.2.1') +build_coverage_matrix('view_component', (3..4), min: '2.34.0') appraise 'relational_db' do # ActiveRecord locked because tests are failing with 7.1, which was attempted as a part of Ruby 3.4 testing in CI. diff --git a/lib/datadog/tracing/contrib/view_component/configuration/settings.rb b/lib/datadog/tracing/contrib/view_component/configuration/settings.rb new file mode 100644 index 00000000000..01b1feefff0 --- /dev/null +++ b/lib/datadog/tracing/contrib/view_component/configuration/settings.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'datadog/tracing/configuration/settings' +require_relative '../ext' + +module Datadog + module Tracing + module Contrib + module ViewComponent + module Configuration + # Custom settings for the ViewComponent integration + # @public_api + class Settings < Contrib::Configuration::Settings + option :enabled do |o| + o.type :bool + o.env Ext::ENV_ENABLED + o.default true + end + + # @!visibility private + option :analytics_enabled do |o| + o.type :bool + o.env Ext::ENV_ANALYTICS_ENABLED + o.default false + end + + option :analytics_sample_rate do |o| + o.type :float + o.env Ext::ENV_ANALYTICS_SAMPLE_RATE + o.default 1.0 + end + + option :service_name + option :component_base_path do |o| + o.type :string + o.default 'components/' + end + end + end + end + end + end +end diff --git a/lib/datadog/tracing/contrib/view_component/event.rb b/lib/datadog/tracing/contrib/view_component/event.rb new file mode 100644 index 00000000000..104df24c701 --- /dev/null +++ b/lib/datadog/tracing/contrib/view_component/event.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'datadog/tracing/contrib/active_support/notifications/event' + +module Datadog + module Tracing + module Contrib + module ViewComponent + # Defines basic behavior for an ViewComponent event. + module Event + def self.included(base) + base.include(ActiveSupport::Notifications::Event) + base.extend(ClassMethods) + end + + # Class methods for ViewComponent events. + module ClassMethods + def configuration + Datadog.configuration.tracing[:view_component] + end + + def record_exception(span, payload) + if payload[:exception_object] + span.set_error(payload[:exception_object]) + elsif payload[:exception] + # Fallback for ActiveSupport < 5.0 + span.set_error(payload[:exception]) + end + end + end + end + end + end + end +end diff --git a/lib/datadog/tracing/contrib/view_component/events.rb b/lib/datadog/tracing/contrib/view_component/events.rb new file mode 100644 index 00000000000..e8d83e64f2c --- /dev/null +++ b/lib/datadog/tracing/contrib/view_component/events.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative 'events/render' + +module Datadog + module Tracing + module Contrib + module ViewComponent + # Defines collection of instrumented ViewComponent events + module Events + ALL = [ + Events::Render + ].freeze + + module_function + + def all + self::ALL + end + + def subscriptions + all.collect(&:subscriptions).collect(&:to_a).flatten + end + + def subscribe! + all.each(&:subscribe!) + end + end + end + end + end +end diff --git a/lib/datadog/tracing/contrib/view_component/events/render.rb b/lib/datadog/tracing/contrib/view_component/events/render.rb new file mode 100644 index 00000000000..73acea32fc4 --- /dev/null +++ b/lib/datadog/tracing/contrib/view_component/events/render.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'datadog/tracing' +require 'datadog/tracing/metadata/ext' +require 'datadog/tracing/analytics' +require_relative '../ext' +require_relative '../event' + +module Datadog + module Tracing + module Contrib + module ViewComponent + module Events + # Defines instrumentation for render.view_component event + module Render + include ViewComponent::Event + + EVENT_NAME = 'render.view_component' + + module_function + + def event_name + self::EVENT_NAME + end + + def span_name + Ext::SPAN_RENDER + end + + def on_start(span, _event, _id, payload) + span.service = configuration[:service_name] if configuration[:service_name] + span.type = Tracing::Metadata::Ext::HTTP::TYPE_TEMPLATE + + span.set_tag(Tracing::Metadata::Ext::TAG_COMPONENT, Ext::TAG_COMPONENT) + span.set_tag(Tracing::Metadata::Ext::TAG_OPERATION, Ext::TAG_OPERATION_RENDER) + + span.resource = payload[:name] + span.set_tag(Ext::TAG_COMPONENT_NAME, payload[:name]) + + if (identifier = Utils.normalize_component_identifier(payload[:identifier])) + span.set_tag(Ext::TAG_COMPONENT_IDENTIFIER, identifier) + end + + # Measure service stats + Contrib::Analytics.set_measured(span) + end + end + end + end + end + end +end diff --git a/lib/datadog/tracing/contrib/view_component/ext.rb b/lib/datadog/tracing/contrib/view_component/ext.rb new file mode 100644 index 00000000000..111ea93a71b --- /dev/null +++ b/lib/datadog/tracing/contrib/view_component/ext.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Datadog + module Tracing + module Contrib + module ViewComponent + # ViewComponent integration constants + # @public_api Changing resource names, tag names, or environment variables creates breaking changes. + module Ext + ENV_ENABLED = 'DD_TRACE_VIEW_COMPONENT_ENABLED' + # @!visibility private + ENV_ANALYTICS_ENABLED = 'DD_TRACE_VIEW_COMPONENT_ANALYTICS_ENABLED' + ENV_ANALYTICS_SAMPLE_RATE = 'DD_TRACE_VIEW_COMPONENT_ANALYTICS_SAMPLE_RATE' + SPAN_RENDER = 'view_component.render' + TAG_COMPONENT = 'view_component' + TAG_OPERATION_RENDER = 'render' + TAG_COMPONENT_IDENTIFIER = 'view_component.component_identifier' + TAG_COMPONENT_NAME = 'view_component.component_name' + end + end + end + end +end diff --git a/lib/datadog/tracing/contrib/view_component/integration.rb b/lib/datadog/tracing/contrib/view_component/integration.rb new file mode 100644 index 00000000000..6a82a457cbc --- /dev/null +++ b/lib/datadog/tracing/contrib/view_component/integration.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative 'configuration/settings' +require_relative 'patcher' +require 'datadog/tracing/contrib/integration' +require 'datadog/tracing/contrib/rails/ext' +require 'datadog/core/contrib/rails/utils' + +module Datadog + module Tracing + module Contrib + module ViewComponent + # Describes the ViewComponent integration + class Integration + include Contrib::Integration + + MINIMUM_VERSION = "2.34.0" + + # @public_api Changing the integration name or integration options can cause breaking changes + register_as :view_component, auto_patch: false + def self.gem_name + 'view_component' + end + + def self.version + Gem.loaded_specs['view_component']&.version + end + + def self.loaded? + !defined?(::ViewComponent).nil? + end + + def self.compatible? + super && version >= MINIMUM_VERSION + end + + def new_configuration + Configuration::Settings.new + end + + def patcher + ViewComponent::Patcher + end + end + end + end + end +end diff --git a/lib/datadog/tracing/contrib/view_component/patcher.rb b/lib/datadog/tracing/contrib/view_component/patcher.rb new file mode 100644 index 00000000000..2026876a472 --- /dev/null +++ b/lib/datadog/tracing/contrib/view_component/patcher.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'datadog/core' +require 'datadog/tracing/contrib/patcher' +require_relative 'events' +require_relative 'ext' +require_relative 'utils' + +module Datadog + module Tracing + module Contrib + module ViewComponent + # Patcher enables patching of ViewComponent module. + module Patcher + include Contrib::Patcher + + module_function + + def target_version + Integration.version + end + + def patch + patch_renderer + end + + def patch_renderer + Events.subscribe! + end + end + end + end + end +end diff --git a/lib/datadog/tracing/contrib/view_component/utils.rb b/lib/datadog/tracing/contrib/view_component/utils.rb new file mode 100644 index 00000000000..a04b21ef4bb --- /dev/null +++ b/lib/datadog/tracing/contrib/view_component/utils.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'datadog/tracing/contrib/analytics' + +module Datadog + module Tracing + module Contrib + module ViewComponent + # common utilities for ViewComponent + module Utils + module_function + + # in ViewComponent the component identifier includes the component full path + # and it's better to avoid storing such information. This method + # returns the relative path from `components/` or the component identifier + # if a `components/` folder is not in the component full path. A wrong + # usage ensures that this method will not crash the tracing system. + def normalize_component_identifier(identifier) + return if identifier.nil? + + base_path = Datadog.configuration.tracing[:view_component][:component_base_path] + sections_view = identifier.split(base_path) + + if sections_view.length == 1 + identifier.split('/')[-1] + else + sections_view[-1] + end + rescue + identifier.to_s + end + end + end + end + end +end diff --git a/spec/datadog/tracing/contrib/view_component/integration_spec.rb b/spec/datadog/tracing/contrib/view_component/integration_spec.rb new file mode 100644 index 00000000000..06fdd2b31a6 --- /dev/null +++ b/spec/datadog/tracing/contrib/view_component/integration_spec.rb @@ -0,0 +1,66 @@ +require 'datadog/tracing/contrib/support/spec_helper' +require 'datadog/tracing/contrib/auto_instrument_examples' + +require 'datadog/tracing/contrib/view_component/integration' + +RSpec.describe Datadog::Tracing::Contrib::ViewComponent::Integration do + let(:integration) { described_class.new(:view_component) } + + describe '.version' do + subject(:version) { described_class.version } + + context 'when the "view_component" gem is loaded' do + include_context 'loaded gems', view_component: described_class::MINIMUM_VERSION + it { is_expected.to be_a_kind_of(Gem::Version) } + end + end + + describe '.loaded?' do + subject(:loaded?) { described_class.loaded? } + + context 'when ViewComponent is defined' do + before { stub_const('ViewComponent', Class.new) } + + it { is_expected.to be true } + end + + context 'when ViewComponent is not defined' do + before { hide_const('ViewComponent') } + + it { is_expected.to be false } + end + end + + describe '.compatible?' do + subject(:compatible?) { described_class.compatible? } + + context 'when "view_component" gem is loaded with a version' do + context 'that is less than the minimum' do + include_context 'loaded gems', view_component: decrement_gem_version(described_class::MINIMUM_VERSION) + it { is_expected.to be false } + end + + context 'that meets the minimum version' do + include_context 'loaded gems', view_component: described_class::MINIMUM_VERSION + it { is_expected.to be true } + end + end + + context 'when gem is not loaded' do + include_context 'loaded gems', actionpack: nil, view_component: nil + it { is_expected.to be false } + end + end + + describe '#default_configuration' do + subject(:default_configuration) { integration.default_configuration } + + it { is_expected.to be_a_kind_of(Datadog::Tracing::Contrib::ViewComponent::Configuration::Settings) } + end + + describe '#patcher' do + subject(:patcher) { integration.patcher } + + it { is_expected.to be Datadog::Tracing::Contrib::ViewComponent::Patcher } + end +end diff --git a/spec/datadog/tracing/contrib/view_component/utils_spec.rb b/spec/datadog/tracing/contrib/view_component/utils_spec.rb new file mode 100644 index 00000000000..62864712803 --- /dev/null +++ b/spec/datadog/tracing/contrib/view_component/utils_spec.rb @@ -0,0 +1,49 @@ +RSpec.describe Datadog::Tracing::Contrib::ViewComponent::Utils do + describe '#normalize_component_identifier' do + subject(:normalize_component_identifier) { described_class.normalize_component_identifier(name) } + + after { Datadog.configuration.tracing[:view_component].reset! } + + context 'with component identifer' do + let(:name) { '/rails/app/components/welcome/my_component.rb' } + + it { is_expected.to eq('welcome/my_component.rb') } + end + + context 'with nil identifer' do + let(:name) { nil } + + it { is_expected.to be(nil) } + end + + context 'with file name only' do + let(:name) { 'my_component.rb' } + + it { is_expected.to eq('my_component.rb') } + end + + context 'with identifer outside of `components/` directory' do + let(:name) { '/rails/app/other/welcome/my_component.rb' } + + it { is_expected.to eq('my_component.rb') } + end + + context 'with a custom component base path' do + before { Datadog.configuration.tracing[:view_component][:component_base_path] = 'custom/' } + + context 'with component outside of `components/` directory' do + let(:name) { '/rails/app/custom/welcome/my_component.rb' } + + it { is_expected.to eq('welcome/my_component.rb') } + end + end + + context 'with a non-string-like argument' do + let(:name) { :not_a_string } + + it 'stringifies arguments' do + is_expected.to eq('not_a_string') + end + end + end +end