Skip to content

Commit 40a5f6a

Browse files
committed
Move CommandTrace to common so any ecosystem can record subprocess traces
Promotes the npm/yarn/pnpm-only Dependabot::NpmAndYarn::FileUpdater::CommandTrace to Dependabot::SharedHelpers::CommandTrace. The class only depends on SharedHelpers::HelperSubprocessFailed (already in common), so any ecosystem can now record per-command diagnostics with the same wrapper. A lightweight Dependabot::NpmAndYarn::FileUpdater::CommandTrace = ... alias keeps existing references compiling without churn.
1 parent 52827c1 commit 40a5f6a

7 files changed

Lines changed: 216 additions & 214 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "sorbet-runtime"
5+
6+
require "dependabot/shared_helpers"
7+
8+
module Dependabot
9+
module SharedHelpers
10+
# Captures diagnostic data about a single subprocess invocation so
11+
# that ecosystem-specific errors (e.g. `NoChangeError`) can be
12+
# debugged with full context: whether each command succeeded, its
13+
# duration, truncated output, and an optional content-changed flag
14+
# set by the caller after the command runs.
15+
#
16+
# Usage:
17+
#
18+
# traces = []
19+
# Dependabot::SharedHelpers::CommandTrace.record(
20+
# traces: traces,
21+
# package_manager: "npm",
22+
# command: "install --package-lock-only",
23+
# fingerprint: "install --package-lock-only"
24+
# ) do
25+
# run_native_command(...)
26+
# end
27+
#
28+
# The trace is appended to `traces` before the block runs so that
29+
# callers retain visibility even when the block raises.
30+
class CommandTrace
31+
extend T::Sig
32+
33+
# Truncation limits keep individual traces small enough to ship in
34+
# API payloads while still preserving useful debugging context.
35+
STDOUT_LIMIT = 4_096
36+
STDERR_LIMIT = 4_096
37+
ERROR_MESSAGE_LIMIT = 2_048
38+
39+
sig { returns(String) }
40+
attr_reader :package_manager
41+
42+
sig { returns(String) }
43+
attr_reader :command
44+
45+
sig { returns(T.nilable(String)) }
46+
attr_reader :fingerprint
47+
48+
sig { returns(Integer) }
49+
attr_accessor :duration_ms
50+
51+
sig { returns(T::Boolean) }
52+
attr_accessor :success
53+
54+
sig { returns(T.nilable(String)) }
55+
attr_accessor :error_class
56+
57+
sig { returns(T.nilable(String)) }
58+
attr_accessor :error_message
59+
60+
sig { returns(T.nilable(String)) }
61+
attr_accessor :stdout
62+
63+
sig { returns(T.nilable(String)) }
64+
attr_accessor :stderr
65+
66+
sig { returns(T.nilable(T::Boolean)) }
67+
attr_accessor :content_changed_after
68+
69+
sig do
70+
params(
71+
package_manager: String,
72+
command: String,
73+
fingerprint: T.nilable(String)
74+
).void
75+
end
76+
def initialize(package_manager:, command:, fingerprint: nil)
77+
@package_manager = package_manager
78+
@command = command
79+
@fingerprint = fingerprint
80+
@duration_ms = T.let(0, Integer)
81+
@success = T.let(false, T::Boolean)
82+
@error_class = T.let(nil, T.nilable(String))
83+
@error_message = T.let(nil, T.nilable(String))
84+
@stdout = T.let(nil, T.nilable(String))
85+
@stderr = T.let(nil, T.nilable(String))
86+
@content_changed_after = T.let(nil, T.nilable(T::Boolean))
87+
end
88+
89+
# Hash representation suitable for inclusion in error payloads sent
90+
# via `record_update_job_error` and for log formatting. `nil` values
91+
# are dropped to keep payloads compact.
92+
sig { returns(T::Hash[Symbol, T.untyped]) }
93+
def to_h
94+
{
95+
package_manager: package_manager,
96+
command: command,
97+
fingerprint: fingerprint,
98+
duration_ms: duration_ms,
99+
success: success,
100+
error_class: error_class,
101+
error_message: error_message,
102+
stdout: stdout,
103+
stderr: stderr,
104+
content_changed_after: content_changed_after
105+
}.compact
106+
end
107+
108+
# One-line, low-cardinality summary suitable for info-level logs.
109+
sig { returns(String) }
110+
def summary_line
111+
status = success ? "ok" : "fail"
112+
changed =
113+
if content_changed_after.nil?
114+
"content_changed=?"
115+
else
116+
"content_changed=#{content_changed_after}"
117+
end
118+
fp = fingerprint || command
119+
"[#{package_manager}] #{fp.inspect} status=#{status} duration_ms=#{duration_ms} #{changed}"
120+
end
121+
122+
# Wraps a subprocess invocation, recording timing, success/failure
123+
# state, and (truncated) output into a new CommandTrace appended
124+
# to `traces`. Re-raises any exception after recording so callers
125+
# can keep their existing error-handling flow.
126+
sig do
127+
type_parameters(:R).params(
128+
traces: T::Array[CommandTrace],
129+
package_manager: String,
130+
command: String,
131+
fingerprint: T.nilable(String),
132+
block: T.proc.returns(T.type_parameter(:R))
133+
).returns(T.type_parameter(:R))
134+
end
135+
def self.record(traces:, package_manager:, command:, fingerprint: nil, &block)
136+
trace = new(
137+
package_manager: package_manager,
138+
command: command,
139+
fingerprint: fingerprint
140+
)
141+
traces << trace
142+
143+
start = T.let(Process.clock_gettime(Process::CLOCK_MONOTONIC), Numeric)
144+
begin
145+
result = block.call # rubocop:disable Performance/RedundantBlockCall
146+
record_success(trace, start, result)
147+
result
148+
rescue Dependabot::SharedHelpers::HelperSubprocessFailed => e
149+
record_subprocess_failure(trace, start, e)
150+
raise
151+
rescue StandardError => e
152+
record_failure(trace, start, e)
153+
raise
154+
end
155+
end
156+
157+
sig { params(trace: CommandTrace, start: Numeric, result: T.untyped).void }
158+
def self.record_success(trace, start, result)
159+
trace.duration_ms = elapsed_ms(start)
160+
trace.success = true
161+
trace.stdout = truncate(result.is_a?(String) ? result : nil, STDOUT_LIMIT)
162+
end
163+
private_class_method :record_success
164+
165+
sig do
166+
params(
167+
trace: CommandTrace,
168+
start: Numeric,
169+
error: Dependabot::SharedHelpers::HelperSubprocessFailed
170+
).void
171+
end
172+
def self.record_subprocess_failure(trace, start, error)
173+
record_failure(trace, start, error)
174+
stderr = error.error_context[:stderr_output]
175+
trace.stderr = truncate(stderr.is_a?(String) ? stderr : nil, STDERR_LIMIT)
176+
end
177+
private_class_method :record_subprocess_failure
178+
179+
sig { params(trace: CommandTrace, start: Numeric, error: StandardError).void }
180+
def self.record_failure(trace, start, error)
181+
trace.duration_ms = elapsed_ms(start)
182+
trace.success = false
183+
trace.error_class = error.class.name
184+
trace.error_message = truncate(error.message, ERROR_MESSAGE_LIMIT)
185+
end
186+
private_class_method :record_failure
187+
188+
sig { params(text: T.nilable(String), limit: Integer).returns(T.nilable(String)) }
189+
def self.truncate(text, limit)
190+
return nil if text.nil?
191+
return text if text.length <= limit
192+
193+
dropped = text.length - limit
194+
"#{text[0, limit]}\n... [truncated #{dropped} chars]"
195+
end
196+
private_class_method :truncate
197+
198+
sig { params(start: Numeric).returns(Integer) }
199+
def self.elapsed_ms(start)
200+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).to_i
201+
end
202+
private_class_method :elapsed_ms
203+
end
204+
end
205+
end

npm_and_yarn/spec/dependabot/npm_and_yarn/file_updater/command_trace_spec.rb renamed to common/spec/dependabot/shared_helpers/command_trace_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33

44
require "spec_helper"
55
require "dependabot/shared_helpers"
6-
require "dependabot/npm_and_yarn/file_updater/command_trace"
6+
require "dependabot/shared_helpers/command_trace"
77

8-
RSpec.describe Dependabot::NpmAndYarn::FileUpdater::CommandTrace do
8+
RSpec.describe Dependabot::SharedHelpers::CommandTrace do
99
let(:traces) { [] }
1010

1111
describe ".record" do

npm_and_yarn/lib/dependabot/npm_and_yarn/file_updater.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,19 @@
77
require "dependabot/file_updaters/artifact_updater"
88
require "dependabot/npm_and_yarn/dependency_files_filterer"
99
require "dependabot/npm_and_yarn/sub_dependency_files_filterer"
10+
require "dependabot/shared_helpers/command_trace"
1011
require "sorbet-runtime"
1112

1213
module Dependabot
1314
module NpmAndYarn
1415
class FileUpdater < Dependabot::FileUpdaters::Base # rubocop:disable Metrics/ClassLength
1516
extend T::Sig
1617

17-
require_relative "file_updater/command_trace"
18+
# Convenience alias so existing references to `CommandTrace` inside
19+
# `Dependabot::NpmAndYarn::FileUpdater` and its nested lockfile
20+
# updaters keep resolving after the class moved to `common/`.
21+
CommandTrace = Dependabot::SharedHelpers::CommandTrace
22+
1823
require_relative "file_updater/package_json_updater"
1924
require_relative "file_updater/npm_lockfile_updater"
2025
require_relative "file_updater/yarn_lockfile_updater"

0 commit comments

Comments
 (0)