Skip to content
Open
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
3 changes: 3 additions & 0 deletions bun/lib/dependabot/bun/file_updater.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ class FileUpdater < Dependabot::FileUpdaters::Base
class NoChangeError < StandardError
extend T::Sig

sig { returns(T::Hash[Symbol, T.untyped]) }
attr_reader :error_context

sig { params(message: String, error_context: T::Hash[Symbol, T.untyped]).void }
def initialize(message:, error_context:)
super(message)
Expand Down
205 changes: 205 additions & 0 deletions common/lib/dependabot/shared_helpers/command_trace.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
# typed: strict
# frozen_string_literal: true

require "sorbet-runtime"

require "dependabot/shared_helpers"

module Dependabot
module SharedHelpers
# Captures diagnostic data about a single subprocess invocation so
# that ecosystem-specific errors (e.g. `NoChangeError`) can be
# debugged with full context: whether each command succeeded, its
# duration, truncated output, and an optional content-changed flag
# set by the caller after the command runs.
#
# Usage:
#
# traces = []
# Dependabot::SharedHelpers::CommandTrace.record(
# traces: traces,
# package_manager: "npm",
# command: "install --package-lock-only",
# fingerprint: "install --package-lock-only"
# ) do
# run_native_command(...)
# end
#
# The trace is appended to `traces` before the block runs so that
# callers retain visibility even when the block raises.
class CommandTrace
extend T::Sig

# Truncation limits keep individual traces small enough to ship in
# API payloads while still preserving useful debugging context.
STDOUT_LIMIT = 4_096
STDERR_LIMIT = 4_096
ERROR_MESSAGE_LIMIT = 2_048

sig { returns(String) }
attr_reader :package_manager

sig { returns(String) }
attr_reader :command

sig { returns(T.nilable(String)) }
attr_reader :fingerprint

sig { returns(Integer) }
attr_accessor :duration_ms

sig { returns(T::Boolean) }
attr_accessor :success

sig { returns(T.nilable(String)) }
attr_accessor :error_class

sig { returns(T.nilable(String)) }
attr_accessor :error_message

sig { returns(T.nilable(String)) }
attr_accessor :stdout

sig { returns(T.nilable(String)) }
attr_accessor :stderr

sig { returns(T.nilable(T::Boolean)) }
attr_accessor :content_changed_after

sig do
params(
package_manager: String,
command: String,
fingerprint: T.nilable(String)
).void
end
def initialize(package_manager:, command:, fingerprint: nil)
@package_manager = package_manager
@command = command
@fingerprint = fingerprint
@duration_ms = T.let(0, Integer)
@success = T.let(false, T::Boolean)
@error_class = T.let(nil, T.nilable(String))
@error_message = T.let(nil, T.nilable(String))
@stdout = T.let(nil, T.nilable(String))
@stderr = T.let(nil, T.nilable(String))
@content_changed_after = T.let(nil, T.nilable(T::Boolean))
end

# Hash representation suitable for inclusion in error payloads sent
# via `record_update_job_error` and for log formatting. `nil` values
# are dropped to keep payloads compact.
sig { returns(T::Hash[Symbol, T.untyped]) }
def to_h
{
package_manager: package_manager,
command: command,
fingerprint: fingerprint,
duration_ms: duration_ms,
success: success,
error_class: error_class,
error_message: error_message,
stdout: stdout,
stderr: stderr,
content_changed_after: content_changed_after
}.compact
end

# One-line, low-cardinality summary suitable for info-level logs.
sig { returns(String) }
def summary_line
status = success ? "ok" : "fail"
changed =
if content_changed_after.nil?
"content_changed=?"
else
"content_changed=#{content_changed_after}"
end
fp = fingerprint || command
"[#{package_manager}] #{fp.inspect} status=#{status} duration_ms=#{duration_ms} #{changed}"
end

# Wraps a subprocess invocation, recording timing, success/failure
# state, and (truncated) output into a new CommandTrace appended
# to `traces`. Re-raises any exception after recording so callers
# can keep their existing error-handling flow.
sig do
type_parameters(:R).params(
traces: T::Array[CommandTrace],
package_manager: String,
command: String,
fingerprint: T.nilable(String),
block: T.proc.returns(T.type_parameter(:R))
).returns(T.type_parameter(:R))
end
def self.record(traces:, package_manager:, command:, fingerprint: nil, &block)
trace = new(
package_manager: package_manager,
command: command,
fingerprint: fingerprint
)
traces << trace

start = T.let(Process.clock_gettime(Process::CLOCK_MONOTONIC), Numeric)
begin
result = block.call # rubocop:disable Performance/RedundantBlockCall
record_success(trace, start, result)
result
rescue Dependabot::SharedHelpers::HelperSubprocessFailed => e
record_subprocess_failure(trace, start, e)
raise
rescue StandardError => e
record_failure(trace, start, e)
raise
end
end

sig { params(trace: CommandTrace, start: Numeric, result: T.untyped).void }
def self.record_success(trace, start, result)
trace.duration_ms = elapsed_ms(start)
trace.success = true
trace.stdout = truncate(result.is_a?(String) ? result : nil, STDOUT_LIMIT)
end
private_class_method :record_success

sig do
params(
trace: CommandTrace,
start: Numeric,
error: Dependabot::SharedHelpers::HelperSubprocessFailed
).void
end
def self.record_subprocess_failure(trace, start, error)
record_failure(trace, start, error)
stderr = error.error_context[:stderr_output]
trace.stderr = truncate(stderr.is_a?(String) ? stderr : nil, STDERR_LIMIT)
end
private_class_method :record_subprocess_failure

sig { params(trace: CommandTrace, start: Numeric, error: StandardError).void }
def self.record_failure(trace, start, error)
trace.duration_ms = elapsed_ms(start)
trace.success = false
trace.error_class = error.class.name
trace.error_message = truncate(error.message, ERROR_MESSAGE_LIMIT)
end
private_class_method :record_failure

sig { params(text: T.nilable(String), limit: Integer).returns(T.nilable(String)) }
def self.truncate(text, limit)
return nil if text.nil?
return text if text.length <= limit

dropped = text.length - limit
"#{text[0, limit]}\n... [truncated #{dropped} chars]"
end
private_class_method :truncate

sig { params(start: Numeric).returns(Integer) }
def self.elapsed_ms(start)
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).to_i
end
private_class_method :elapsed_ms
end
end
end
152 changes: 152 additions & 0 deletions common/spec/dependabot/shared_helpers/command_trace_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# typed: false
# frozen_string_literal: true

require "spec_helper"
require "dependabot/shared_helpers"
require "dependabot/shared_helpers/command_trace"

RSpec.describe Dependabot::SharedHelpers::CommandTrace do
let(:traces) { [] }

describe ".record" do
it "records a successful command and returns the block result" do
result = described_class.record(
traces: traces,
package_manager: "npm",
command: "install --package-lock-only",
fingerprint: "install --package-lock-only"
) do
"ok-stdout"
end

expect(result).to eq("ok-stdout")
expect(traces.length).to eq(1)

trace = traces.first
expect(trace.package_manager).to eq("npm")
expect(trace.command).to eq("install --package-lock-only")
expect(trace.fingerprint).to eq("install --package-lock-only")
expect(trace.success).to be(true)
expect(trace.stdout).to eq("ok-stdout")
expect(trace.stderr).to be_nil
expect(trace.error_class).to be_nil
expect(trace.duration_ms).to be >= 0
end

it "appends the trace before the block runs (visible on raise)" do
expect do
described_class.record(
traces: traces,
package_manager: "npm",
command: "explode"
) do
raise StandardError, "boom"
end
end.to raise_error(StandardError, "boom")

expect(traces.length).to eq(1)
trace = traces.first
expect(trace.success).to be(false)
expect(trace.error_class).to eq("StandardError")
expect(trace.error_message).to eq("boom")
end

it "captures stderr from a HelperSubprocessFailed failure" do
err = Dependabot::SharedHelpers::HelperSubprocessFailed.new(
message: "subprocess error",
error_context: { stderr_output: "the stderr details" }
)

expect do
described_class.record(
traces: traces,
package_manager: "pnpm",
command: "install --lockfile-only"
) do
raise err
end
end.to raise_error(Dependabot::SharedHelpers::HelperSubprocessFailed)

trace = traces.first
expect(trace.success).to be(false)
expect(trace.error_class).to eq("Dependabot::SharedHelpers::HelperSubprocessFailed")
expect(trace.stderr).to eq("the stderr details")
end

it "truncates oversized stdout, stderr, and error messages" do
big_stdout = "x" * (described_class::STDOUT_LIMIT + 500)
result = described_class.record(
traces: traces,
package_manager: "yarn",
command: "install"
) { big_stdout }

expect(result).to eq(big_stdout)
trace = traces.first
expect(trace.stdout.length).to be < big_stdout.length
expect(trace.stdout).to include("truncated 500 chars")
end

it "leaves stdout nil when the block returns a non-string value" do
described_class.record(
traces: traces,
package_manager: "yarn",
command: "noop"
) { 42 }

expect(traces.first.stdout).to be_nil
expect(traces.first.success).to be(true)
end
end

describe "#to_h" do
it "drops nil-valued fields" do
described_class.record(
traces: traces,
package_manager: "npm",
command: "install"
) { "out" }

hash = traces.first.to_h
expect(hash.keys).to include(:package_manager, :command, :duration_ms, :success, :stdout)
expect(hash.keys).not_to include(:error_class, :error_message, :stderr, :content_changed_after)
end

it "includes content_changed_after when set" do
described_class.record(
traces: traces,
package_manager: "npm",
command: "install"
) { "out" }
traces.first.content_changed_after = false

expect(traces.first.to_h[:content_changed_after]).to be(false)
end
end

describe "#summary_line" do
it "produces a one-line summary that includes status and content_changed marker" do
described_class.record(
traces: traces,
package_manager: "npm",
command: "install --package-lock-only"
) { "out" }
traces.first.content_changed_after = false

line = traces.first.summary_line
expect(line).to include("[npm]")
expect(line).to include("status=ok")
expect(line).to include("content_changed=false")
end

it "uses '?' when content_changed_after has not been set" do
described_class.record(
traces: traces,
package_manager: "yarn",
command: "install"
) { "out" }

expect(traces.first.summary_line).to include("content_changed=?")
end
end
end
Loading
Loading