Skip to content

[SDTEST-3057] Track constants usage in each Ruby file as a static dependency to enrich Test Impact Analysis data#442

Merged
anmarchenko merged 32 commits intomainfrom
anmarchenko/track_compiled_iseq_constants
Jan 9, 2026
Merged

[SDTEST-3057] Track constants usage in each Ruby file as a static dependency to enrich Test Impact Analysis data#442
anmarchenko merged 32 commits intomainfrom
anmarchenko/track_compiled_iseq_constants

Conversation

@anmarchenko
Copy link
Copy Markdown
Member

@anmarchenko anmarchenko commented Dec 15, 2025

Motivation
Bug report from a customer: tests are skipped when the code they test is using a constant defined in a separate file.

What does this PR do?
Test Impact Analysis relies on line-based code coverage. When Ruby executes the code, the coverage collector records which lines were hit. But here's the catch: constant access is not a line of code execution.

In a test like that:

it "validates the threshold" do
  expect(MyApp::Config::THRESHOLD).to eq(100)
end

When Ruby runs this test, the coverage collector sees the expect() call on that line. But MyApp::Config::THRESHOLD? That's resolved through Ruby's constant lookup mechanism - it doesn't "execute" a line in config.rb. The constant value was loaded when the file was required, and now it's retrieved from Ruby's constants table.

To solve this, we need to find constant references before the test runs. The only place that information exists? Ruby's compiled bytecode.

When Ruby parses the code, it compiles it into instructions for YARV. Among these instructions are two that handle constant access:

  • getconstant - Retrieves a constant by name
  • opt_getconstant_path - An optimized instruction for fully-qualified paths (e.g., Foo::Bar::Baz)

You can read more about these instructions in Kevin Newton's excellent Advent of YARV.

I expanded the native C extension with an ability to walk the Ruby object space to find all live Instruction Sequences (ISeqs) - this is done similar to how DI propose to do it: DataDog/dd-trace-rb#5111

Then, we process the resulting iseqs array in Ruby to:

  • Parse each ISeq's bytecode looking for getconstant and opt_getconstant_path instructions
  • Resolve constant names to source files using Object.const_source_location
  • Build a dependency map: {source_file => {dependency_file => true}}

When a test finishes, we take the line-based coverage and enrich it with static dependencies. If your test file references Constants::Config::THRESHOLD, we add config.rb to the coverage even though no lines in it were executed.

The opt_getconstant_path instruction stores the constant path as an array of symbols in its cache entry:
opt_getconstant_path <ic:0 Constants::Config::THRESHOLD>

We extract [:Constants, :Config, :THRESHOLD], build the string "Constants::Config::THRESHOLD", then call Object.const_source_location("Constants::Config::THRESHOLD") to get the file path where that constant is defined.

For getconstant, we get just the symbol name (e.g., :BaseConstant), which we try to resolve the same way—though this has limitations (see below).

This feature is experimental and enabled with environment variable DD_TEST_OPTIMIZATION_TIA_STATIC_DEPS_COVERAGE_ENABLED

Limitations
This will work only when the code is eager loaded in tests, otherwise the source location for constants will not be resolved correctly.

Class-level constant Resolution - inheritance and module inclusion happen at class definition time, not inside method bodies

class MyService < BaseService  # Not tracked
  include Loggable                        # Not tracked
  
  def process
    Config::TIMEOUT                       #  Tracked
  end
end

It doesn't work for unqualified constant names (for now):

module MyApp
  class Processor
    def call
      Config.get(:key)  # getconstant :Config - will not be resolved correctly to `MyApp::Config`
    end
  end
end

Ruby version compatibility
This feature works only for Ruby versions 3.2 and newer because it relies on opt_getconstant_path YARV instruction to be present.

In Ruby 3.1:

RubyVM::InstructionSequence.compile("A::B").to_a.last
> 
[
  ["opt_getinlinecache", "label_13", 0],
  ["putobject", true],
  ["getconstant", "A"],
  ["putobject", false],
  ["getconstant", "B"],
  ["opt_setinlinecache", 0],
  "label_13",
  ["leave"]
]

In Ruby 3.2:

RubyVM::InstructionSequence.compile("A::B").to_a.last
> 
[
  ["opt_getconstant_path", ["A", "B"]],
  ["leave"]
]

How to test the change?
There is a comprehensive testing suite provided

@datadog-official
Copy link
Copy Markdown

datadog-official Bot commented Dec 15, 2025

✅ Tests

🎉 All green!

❄️ No new flaky tests detected
🧪 All tests passed

🎯 Code Coverage
Patch Coverage: 93.43%
Overall Coverage: 97.18% (-0.17%)

View detailed report

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 4edd3b7 | Docs | Datadog PR Page | Was this helpful? Give us feedback!

@anmarchenko anmarchenko force-pushed the anmarchenko/track_compiled_iseq_constants branch 2 times, most recently from d1d20a4 to 9694345 Compare December 17, 2025 15:48
@anmarchenko anmarchenko marked this pull request as ready for review December 17, 2025 16:08
@anmarchenko anmarchenko requested review from a team as code owners December 17, 2025 16:08
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ext/datadog_ci_native/datadog_static_dependencies.c Outdated
@anmarchenko anmarchenko force-pushed the anmarchenko/track_compiled_iseq_constants branch from 2a37c96 to ee43cab Compare December 19, 2025 14:57
Copy link
Copy Markdown
Member

@ivoanjo ivoanjo left a comment

Choose a reason for hiding this comment

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

Gave it a pass, left some notes ;)

Comment thread lib/datadog/ci/source_code/static_dependencies.rb
Comment thread lib/datadog/ci/ext/settings.rb Outdated
Comment thread ext/datadog_ci_native/datadog_static_dependencies.c Outdated
Comment thread ext/datadog_ci_native/imemo_helpers.h
Comment thread ext/datadog_ci_native/datadog_static_dependencies.c Outdated
Comment thread ext/datadog_ci_native/datadog_static_dependencies.c Outdated
@anmarchenko anmarchenko force-pushed the anmarchenko/track_compiled_iseq_constants branch from 79ecd41 to 2ff1e30 Compare January 8, 2026 10:40
@anmarchenko
Copy link
Copy Markdown
Member Author

did another round of testing here: anmarchenko/forem#7

the tests that depend on constants now being correctly not skipped
image

@anmarchenko
Copy link
Copy Markdown
Member Author

we can merge it now

@anmarchenko anmarchenko merged commit 605dbb9 into main Jan 9, 2026
234 checks passed
@anmarchenko anmarchenko deleted the anmarchenko/track_compiled_iseq_constants branch January 9, 2026 11:33
@github-actions github-actions Bot added this to the 1.26.0 milestone Jan 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants