Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SDTEST-228] Auto instrumentation #259

Merged
merged 15 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .standard_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ ignore:
- Style/Alias
- spec/datadog/ci/contrib/minitest/instrumentation_spec.rb:
- Lint/ConstantDefinitionInBlock
- spec/datadog/ci/contrib/minitest_auto_instrument/instrumentation_spec.rb:
- Lint/ConstantDefinitionInBlock
- spec/datadog/ci/contrib/timecop/instrumentation_spec.rb:
- Lint/ConstantDefinitionInBlock
14 changes: 6 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
# Datadog Test Visibility for Ruby
# Datadog Test Optimization for Ruby

[![Gem Version](https://badge.fury.io/rb/datadog-ci.svg)](https://badge.fury.io/rb/datadog-ci)
[![YARD documentation](https://img.shields.io/badge/YARD-documentation-blue)](https://datadoghq.dev/datadog-ci-rb/)
[![codecov](https://codecov.io/gh/DataDog/datadog-ci-rb/branch/main/graph/badge.svg)](https://app.codecov.io/gh/DataDog/datadog-ci-rb/branch/main)
[![CircleCI](https://dl.circleci.com/status-badge/img/gh/DataDog/datadog-ci-rb/tree/main.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/DataDog/datadog-ci-rb/tree/main)

Datadog's Ruby Library for instrumenting your tests.
Learn more on our [official website](https://docs.datadoghq.com/tests/) and check out our [documentation for this library](https://docs.datadoghq.com/tests/setup/ruby/?tab=cloudciprovideragentless).

## Features

- [Test Visibility](https://docs.datadoghq.com/tests/) - collect metrics and results for your tests
- [Intelligent test runner](https://docs.datadoghq.com/intelligent_test_runner/) - save time by selectively running only tests affected by code changes
- [Auto test retries](https://docs.datadoghq.com/tests/auto_test_retries/?tab=ruby) - retrying failing tests up to N times to avoid failing your build due to flaky tests
- [Early flake detection](https://docs.datadoghq.com/tests/early_flake_detection?tab=ruby) - Datadog’s test flakiness solution that identifies flakes early by running newly added tests multiple times
- [Test impact analysis](https://docs.datadoghq.com/tests/test_impact_analysis/) - save time by selectively running only tests affected by code changes
- [Flaky test management](https://docs.datadoghq.com/tests/flaky_test_management/) - track, alert, search your flaky tests in Datadog UI
- [Auto test retries](https://docs.datadoghq.com/tests/flaky_test_management/auto_test_retries/?tab=ruby) - retrying failing tests up to N times to avoid failing your build due to flaky tests
- [Early flake detection](https://docs.datadoghq.com/tests/flaky_test_management/early_flake_detection/?tab=ruby) - Datadog’s test flakiness solution that identifies flakes early by running newly added tests multiple times
- [Search and manage CI tests](https://docs.datadoghq.com/tests/search/)
- [Enhance developer workflows](https://docs.datadoghq.com/tests/developer_workflows)
- [Flaky test management](https://docs.datadoghq.com/tests/guides/flaky_test_management/)
- [Add custom measures to your tests](https://docs.datadoghq.com/tests/guides/add_custom_measures/?tab=ruby)
- [Browser tests integration with Datadog RUM](https://docs.datadoghq.com/tests/browser_tests)

Expand All @@ -37,7 +35,7 @@ If you used [test visibility for Ruby](https://docs.datadoghq.com/tests/setup/ru
## Setup

- [Test visibility setup](https://docs.datadoghq.com/tests/setup/ruby/?tab=cloudciprovideragentless)
- [Intelligent test runner setup](https://docs.datadoghq.com/intelligent_test_runner/setup/ruby) (test visibility setup is required before setting up intelligent test runner)
- [Test impact analysis setup](https://docs.datadoghq.com/tests/test_impact_analysis/setup/ruby/?tab=cloudciprovideragentless) (test visibility setup is required before setting up test impact analysis)

## Contributing

Expand Down
8 changes: 8 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ TEST_METADATA = {
"minitest" => {
"minitest-5" => "✅ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ jruby"
},
"minitest_auto_instrument" => {
"minitest-5" => "✅ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ❌ jruby"
},
"activesupport" => {
"activesupport-4" => "✅ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ jruby",
"activesupport-5" => "✅ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ jruby",
Expand All @@ -76,6 +79,9 @@ TEST_METADATA = {
"knapsack_rspec" => {
"knapsack_pro-7-rspec-3" => "✅ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ❌ jruby"
},
"knapsack_auto_instrument" => {
"knapsack_pro-7-rspec-3" => "✅ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ❌ jruby"
},
"selenium" => {
"selenium-4-capybara-3" => "❌ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ❌ 3.4 / ✅ jruby"
},
Expand Down Expand Up @@ -149,10 +155,12 @@ namespace :spec do
rspec
minitest
minitest_shoulda_context
minitest_auto_instrument
activesupport
ci_queue_minitest
ci_queue_rspec
knapsack_rspec
knapsack_auto_instrument
selenium timecop
].each do |contrib|
desc "" # "Explicitly hiding from `rake -T`"
Expand Down
4 changes: 3 additions & 1 deletion exe/ddcirb
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@

require "datadog/ci/cli/cli"

Datadog::CI::CLI.exec(ARGV.first)
command = ARGV.shift

Datadog::CI::CLI.exec(command, ARGV)
3 changes: 3 additions & 0 deletions lib/datadog/ci/auto_instrument.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
require "datadog/ci"

Datadog::CI::Contrib::Instrumentation.auto_instrument
6 changes: 5 additions & 1 deletion lib/datadog/ci/cli/cli.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
require "datadog"
require "datadog/ci"

require_relative "command/exec"
require_relative "command/skippable_tests_percentage"
require_relative "command/skippable_tests_percentage_estimate"

module Datadog
module CI
module CLI
def self.exec(action)
def self.exec(action, args = [])
case action
when "exec"
Command::Exec.new(args).exec
when "skipped-tests", "skippable-tests"
Command::SkippableTestsPercentage.new.exec
when "skipped-tests-estimate", "skippable-tests-estimate"
Expand All @@ -17,6 +20,7 @@ def self.exec(action)
puts("Usage: bundle exec ddcirb [command] [options]. Available commands:")
puts(" skippable-tests - calculates the exact percentage of skipped tests and prints it to stdout or file")
puts(" skippable-tests-estimate - estimates the percentage of skipped tests and prints it to stdout or file")
puts(" exec YOUR_TEST_COMMAND - automatically instruments your test command with Datadog and executes it")
end
end
end
Expand Down
29 changes: 29 additions & 0 deletions lib/datadog/ci/cli/command/exec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
require_relative "base"
require_relative "../../test_optimisation/skippable_percentage/estimator"

module Datadog
module CI
module CLI
module Command
class Exec < Base
def initialize(args)
super()

@args = args
end

def exec
rubyopts = [
"-rdatadog/ci/auto_instrument"
]

existing_rubyopt = ENV["RUBYOPT"]
ENV["RUBYOPT"] = existing_rubyopt ? "#{existing_rubyopt} #{rubyopts.join(" ")}" : rubyopts.join(" ")

Kernel.exec(*@args)
end
end
end
end
end
end
2 changes: 1 addition & 1 deletion lib/datadog/ci/contrib/cucumber/integration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def version
end

def loaded?
!defined?(::Cucumber).nil? && !defined?(::Cucumber::Runtime).nil?
!defined?(::Cucumber).nil? && !defined?(::Cucumber::Runtime).nil? && !defined?(::Cucumber::Configuration).nil?
end

def compatible?
Expand Down
129 changes: 104 additions & 25 deletions lib/datadog/ci/contrib/instrumentation.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require "datadog/core/utils/only_once"

module Datadog
module CI
module Contrib
Expand All @@ -16,34 +18,63 @@ def self.register_integration(integration_class)
@registry[integration_name(integration_class)] = integration_class.new
end

# Auto instrumentation of all integrations.
#
# Registers a :script_compiled tracepoint to watch for new Ruby files being loaded.
# On every file load it checks if any of the integrations are patchable now.
# Only the integrations that are available in the environment are checked.
def self.auto_instrument
Datadog.logger.debug("Auto instrumenting all integrations...")

auto_instrumented_integrations = fetch_auto_instrumented_integrations
if auto_instrumented_integrations.empty?
Datadog.logger.warn(
"Auto instrumentation was requested, but no available integrations were found. " \
"Tests will be run without Datadog instrumentation."
)
return
end

# note that `Kernel.require` might be called from a different thread, so
# there is a possibility of concurrent execution of this tracepoint
mutex = Mutex.new
script_compiled_tracepoint = TracePoint.new(:script_compiled) do |tp|
all_patched = true

mutex.synchronize do
auto_instrumented_integrations.each do |integration|
next if integration.patched?

all_patched = false
next unless integration.loaded?

auto_configure_datadog

Datadog.logger.debug("#{integration.class} is loaded")
patch_integration(integration)
end

if all_patched
Datadog.logger.debug("All expected integrations are patched, disabling the script_compiled tracepoint")

tp.disable
end
end
end
script_compiled_tracepoint.enable
end

# Manual instrumentation of a specific integration.
#
# This method is called when user has `c.ci.instrument :integration_name` in their code.
def self.instrument(integration_name, options = {}, &block)
integration = fetch_integration(integration_name)
# when manually instrumented, it might be configured via code
integration.configure(options, &block)

return unless integration.enabled

patch_results = integration.patch
if patch_results[:ok]
# try to patch dependant integrations (for example knapsack that depends on rspec)
dependants = integration.dependants
.map { |name| fetch_integration(name) }
.filter { |integration| integration.patchable? }

Datadog.logger.debug("Found dependent integrations for #{integration_name}: #{dependants}")

dependants.each do |dependent_integration|
dependent_integration.patch
end
else
error_message = <<-ERROR
Available?: #{patch_results[:available]}, Loaded?: #{patch_results[:loaded]},
Compatible?: #{patch_results[:compatible]}, Patchable?: #{patch_results[:patchable]}"
ERROR
Datadog.logger.warn("Unable to patch #{integration_name} (#{error_message})")
end
patch_integration(integration, with_dependencies: true)
end

# This method instruments all additional test libraries (ex: selenium-webdriver) that need to be instrumented
Expand All @@ -58,15 +89,11 @@ def self.instrument_on_session_start

@registry.each do |name, integration|
next unless integration.late_instrument?
next unless integration.enabled

Datadog.logger.debug "#{name} is allowed to be late instrumented"

patch_results = integration.patch
if patch_results[:ok]
Datadog.logger.debug("#{name} is patched")
else
Datadog.logger.debug("#{name} is not patched (#{patch_results})")
end
patch_integration(integration)
end
end

Expand All @@ -82,6 +109,58 @@ def self.integration_name(subclass)
raise "Integration name could not be derived for #{subclass}" if result.nil?
result
end

def self.patch_integration(integration, with_dependencies: false)
patch_results = integration.patch

if patch_results[:ok]
Datadog.logger.debug("#{integration.class} is patched")

return unless with_dependencies

# try to patch dependant integrations (for example knapsack that depends on rspec)
dependants = integration.dependants
.map { |name| fetch_integration(name) }
.filter { |integration| integration.patchable? }

Datadog.logger.debug("Found dependent integrations for #{integration.class}: #{dependants}")

dependants.each do |dependent_integration|
patch_integration(dependent_integration, with_dependencies: true)
end

else
Datadog.logger.debug("Attention: #{integration.class} is not patched (#{patch_results})")
end
end

def self.fetch_auto_instrumented_integrations
@registry.filter_map do |name, integration|
# ignore integrations that are not in the Gemfile or have incompatible versions
next unless integration.compatible?

# late instrumented integrations will be patched when the test session starts
next if integration.late_instrument?

Datadog.logger.debug("#{name} should be auto instrumented")
integration
end
end

def self.auto_configure_datadog
configure_once.run do
Datadog.logger.debug("Applying Datadog configuration in CI mode...")
Datadog.configure do |c|
c.ci.enabled = true
c.tracing.enabled = true
end
end
end

# This is not thread safe, it is synchronized by the caller in the tracepoint
def self.configure_once
@configure_once ||= Datadog::Core::Utils::OnlyOnce.new
end
end
end
end
Expand Down
10 changes: 6 additions & 4 deletions lib/datadog/ci/contrib/integration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,7 @@ def patcher

# @!visibility private
def patch
# @type var patcher_klass: untyped
patcher_klass = patcher
if !patchable? || patcher_klass.nil?
if !patchable? || patcher.nil?
return {
ok: false,
available: available?,
Expand All @@ -105,10 +103,14 @@ def patch
}
end

patcher_klass.patch
patcher.patch
{ok: true}
end

def patched?
patcher&.patched?
end

# Can the patch for this integration be applied automatically?
# @return [Boolean] can the tracer activate this instrumentation without explicit user input?
def late_instrument?
Expand Down
3 changes: 2 additions & 1 deletion lib/datadog/ci/contrib/minitest/integration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ def version
end

def loaded?
!defined?(::Minitest).nil?
!defined?(::Minitest).nil? && !defined?(::Minitest::Runnable).nil? && !defined?(::Minitest::Test).nil? &&
!defined?(::Minitest::CompositeReporter).nil?
end

def compatible?
Expand Down
2 changes: 0 additions & 2 deletions lib/datadog/ci/contrib/patcher.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# frozen_string_literal: true

require "datadog/core/utils/only_once"
require "datadog/core/telemetry/logger"

module Datadog
module CI
Expand Down Expand Up @@ -42,7 +41,6 @@ def patch
# @param e [Exception]
def on_patch_error(e)
Datadog.logger.error("Failed to apply #{patch_name} patch. Cause: #{e} Location: #{Array(e.backtrace).first}")
Datadog::Core::Telemetry::Logger.report(e, description: "Failed to apply #{patch_name} patch")

@patch_error_result = {
type: e.class.name,
Expand Down
4 changes: 3 additions & 1 deletion lib/datadog/ci/contrib/rspec/integration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ def version

def loaded?
!defined?(::RSpec).nil? && !defined?(::RSpec::Core).nil? &&
!defined?(::RSpec::Core::Example).nil?
!defined?(::RSpec::Core::Example).nil? &&
!defined?(::RSpec::Core::Runner).nil? &&
!defined?(::RSpec::Core::ExampleGroup).nil?
end

def compatible?
Expand Down
2 changes: 1 addition & 1 deletion lib/datadog/ci/test_retries/strategy/retry_new.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def calculate_total_retries_limit(library_settings, test_session)
end
@total_limit = (tests_count * percentage_limit / 100.0).ceil
Datadog.logger.debug do
"Retry new tests total limit is [#{@total_limit}] (#{percentage_limit}%) of #{tests_count}"
"Retry new tests total limit is [#{@total_limit}] (#{percentage_limit}% of #{tests_count})"
end
end

Expand Down
Empty file.
Loading
Loading