Skip to content

Commit a30dccf

Browse files
authored
DI: Fix line instrumentation when target line raises an exception (#4900)
1 parent 187d8ea commit a30dccf

File tree

5 files changed

+184
-29
lines changed

5 files changed

+184
-29
lines changed

lib/datadog/di/instrumenter.rb

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -307,33 +307,52 @@ def hook_line(probe, &block)
307307
# are invoked for *each* line of Ruby executed.
308308
# TODO find out exactly when the path in trace point is relative.
309309
# Looks like this is the case when line trace point is not targeted?
310-
if iseq || tp.lineno == probe.line_no && (
310+
continue = iseq || tp.lineno == probe.line_no && (
311311
probe.file == tp.path || probe.file_matches?(tp.path)
312312
)
313-
continue = if condition = probe.condition
314-
locals = Instrumenter.get_local_variables(tp)
315-
context = EL::Context.new(locals: locals, target: tp.self)
316-
condition.satisfied?(context)
317-
else
318-
true
313+
314+
# We set the trace point on :return to be able to instrument
315+
# 'end' lines. This also causes the trace point to be invoked on
316+
# non-'end' lines when a line raises an exception, since the
317+
# exception causes the method to stop executing and stack unwends.
318+
# We do not want two invocations of the trace point.
319+
# Therefore, if a trace point is invoked with a :line event,
320+
# mark it as such and ignore subsequent :return events.
321+
continue &&= if probe.executed_on_line?
322+
tp.event == :line
323+
else
324+
if tp.event == :line
325+
probe.executed_on_line!
319326
end
320-
if continue and rate_limiter.nil? || rate_limiter.allow? # standard:disable Style/AndOr
321-
serialized_locals = if probe.capture_snapshot?
322-
serializer.serialize_vars(locals || Instrumenter.get_local_variables(tp),
323-
depth: probe.max_capture_depth || settings.dynamic_instrumentation.max_capture_depth,
324-
attribute_count: probe.max_capture_attribute_count || settings.dynamic_instrumentation.max_capture_attribute_count,)
325-
end
326-
if probe.capture_snapshot?
327-
serializer.serialize_value(tp.self,
328-
depth: probe.max_capture_depth || settings.dynamic_instrumentation.max_capture_depth,
329-
attribute_count: probe.max_capture_attribute_count || settings.dynamic_instrumentation.max_capture_attribute_count,)
330-
end
331-
# & is to stop steep complaints, block is always present here.
332-
block&.call(probe: probe,
333-
serialized_locals: serialized_locals,
334-
target_self: tp.self,
335-
path: tp.path, caller_locations: caller_locations)
327+
true
328+
end
329+
330+
continue &&= if condition = probe.condition
331+
locals = Instrumenter.get_local_variables(tp)
332+
context = EL::Context.new(locals: locals, target: tp.self)
333+
condition.satisfied?(context)
334+
else
335+
true
336+
end
337+
338+
continue &&= rate_limiter.nil? || rate_limiter.allow? # standard:disable Style/AndOr
339+
340+
if continue
341+
serialized_locals = if probe.capture_snapshot?
342+
serializer.serialize_vars(locals || Instrumenter.get_local_variables(tp),
343+
depth: probe.max_capture_depth || settings.dynamic_instrumentation.max_capture_depth,
344+
attribute_count: probe.max_capture_attribute_count || settings.dynamic_instrumentation.max_capture_attribute_count,)
336345
end
346+
if probe.capture_snapshot?
347+
serializer.serialize_value(tp.self,
348+
depth: probe.max_capture_depth || settings.dynamic_instrumentation.max_capture_depth,
349+
attribute_count: probe.max_capture_attribute_count || settings.dynamic_instrumentation.max_capture_attribute_count,)
350+
end
351+
# & is to stop steep complaints, block is always present here.
352+
block&.call(probe: probe,
353+
serialized_locals: serialized_locals,
354+
target_self: tp.self,
355+
path: tp.path, caller_locations: caller_locations)
337356
end
338357
rescue => exc
339358
raise if settings.dynamic_instrumentation.internal.propagate_all_exceptions

lib/datadog/di/probe.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,15 @@ def file_matches?(path)
190190
def emitting_notified?
191191
!!@emitting_notified
192192
end
193+
194+
def executed_on_line?
195+
!!@executed_on_line
196+
end
197+
198+
def executed_on_line!
199+
# TODO lock?
200+
@executed_on_line = true
201+
end
193202
end
194203
end
195204
end

sig/datadog/di/probe.rbs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ module Datadog
2020
@capture_snapshot: bool
2121

2222
@rate_limiter: Datadog::Core::RateLimiter
23+
24+
@executed_on_line: bool?
2325

2426
def initialize: (id: String, type: Symbol, ?file: String?, ?line_no: Integer?, ?type_name: String?, ?method_name: String?, ?template: String?, ?capture_snapshot: bool,
2527
?max_capture_depth: Integer, ?max_capture_attribute_count: Integer?, ?rate_limit: Integer) -> void
@@ -52,6 +54,9 @@ module Datadog
5254
def location: () -> ::String
5355
def file_matches?: (String path) -> bool
5456

57+
def executed_on_line!: -> void
58+
def executed_on_line?: -> bool
59+
5560
attr_accessor instrumentation_module: Module?
5661
attr_accessor instrumentation_trace_point: TracePoint?
5762
attr_accessor instrumented_path: String?

spec/datadog/di/hook_line_load.rb

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
# Comment line - not executable
22

33
class HookLineLoadTestClass
4-
def test_method # Line 2
5-
42 # Line 3
6-
end
4+
def test_method # Line 4
5+
42 # Line 5
6+
end # Line 6
77

88
def test_method_with_local
99
local = 42 # standard:disable Style/RedundantAssignment
@@ -16,15 +16,24 @@ def test_method_with_arg(arg)
1616
end
1717

1818
class HookLineIvarLoadTestClass
19+
class TestException < StandardError
20+
end
21+
1922
def initialize
2023
@ivar = 42
2124
end
2225

2326
def test_method
2427
1337 # Line 24
2528
end
29+
30+
def test_exception
31+
local = 42
32+
raise TestException, 'Intentional exception' # Line 32
33+
local # Line 33 # standard:disable Lint/UnreachableCode
34+
end
2635
end
2736

28-
unless (actual = File.read(__FILE__).count("\n")) == 30
29-
raise "Wrong number of lines in hook_line_load.rb: actual #{actual}, expected 30"
37+
unless (actual = File.read(__FILE__).count("\n")) == 39
38+
raise "Wrong number of lines in hook_line_load.rb: actual #{actual}, expected 39"
3039
end

spec/datadog/di/instrumenter_spec.rb

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,37 @@
886886
%i[caller_locations path probe serialized_locals target_self]
887887
end
888888

889+
shared_examples 'multiple invocations' do
890+
# Since the instrumentation mutates the state of the probe,
891+
# verify that the state mutation is not breaking the instrumentation.
892+
context 'when the code is executed multiple times' do
893+
before do
894+
load File.join(File.dirname(__FILE__), 'hook_line_load.rb')
895+
end
896+
897+
let(:probe) do
898+
Datadog::DI::Probe.new(file: 'hook_line_load.rb', line_no: 5,
899+
id: 1, type: :log, rate_limit: rate_limit)
900+
end
901+
902+
it 'invokes the instrumentation every time' do
903+
expect_any_instance_of(TracePoint).to receive(:enable).and_call_original
904+
905+
instrumenter.hook_line(probe) do |payload|
906+
observed_calls << payload
907+
end
908+
909+
HookLineLoadTestClass.new.test_method
910+
HookLineLoadTestClass.new.test_method
911+
912+
expect(observed_calls.length).to eq 2
913+
914+
expect(observed_calls[0].keys.sort).to eq(call_keys)
915+
expect(observed_calls[1].keys.sort).to eq(call_keys)
916+
end
917+
end
918+
end
919+
889920
context 'when called without a block' do
890921
let(:probe) do
891922
instance_double(Datadog::DI::Probe)
@@ -999,7 +1030,7 @@
9991030
end
10001031
end
10011032

1002-
context 'line inside of method' do
1033+
context 'line inside of method without code tracking' do
10031034
before do
10041035
# We need untargeted trace points for this test since the line
10051036
# being instrumented has already been loaded.
@@ -1035,6 +1066,8 @@
10351066
expect(File.basename(frame.path)).to eq 'hook_line.rb'
10361067
end
10371068
end
1069+
1070+
include_examples 'multiple invocations'
10381071
end
10391072

10401073
context 'when hooking same line twice with identical but different probes' do
@@ -1116,6 +1149,51 @@
11161149
expect(observed_calls.first).to be_a(Hash)
11171150
end
11181151

1152+
context 'end line of a method' do
1153+
before do
1154+
load File.join(File.dirname(__FILE__), 'hook_line_load.rb')
1155+
end
1156+
1157+
let(:probe) do
1158+
Datadog::DI::Probe.new(file: 'hook_line_load.rb', line_no: 6,
1159+
id: 1, type: :log, rate_limit: rate_limit)
1160+
end
1161+
1162+
it 'invokes the instrumentation' do
1163+
expect_any_instance_of(TracePoint).to receive(:enable).and_call_original
1164+
1165+
instrumenter.hook_line(probe) do |payload|
1166+
observed_calls << payload
1167+
end
1168+
1169+
HookLineLoadTestClass.new.test_method
1170+
1171+
expect(observed_calls.length).to eq 1
1172+
1173+
expect(observed_calls[0].keys.sort).to eq(call_keys)
1174+
end
1175+
1176+
# Since the instrumentation mutates the state of the probe,
1177+
# verify that the state mutation is not breaking the instrumentation.
1178+
context 'when the code is executed multiple times' do
1179+
it 'invokes the instrumentation every time' do
1180+
expect_any_instance_of(TracePoint).to receive(:enable).and_call_original
1181+
1182+
instrumenter.hook_line(probe) do |payload|
1183+
observed_calls << payload
1184+
end
1185+
1186+
HookLineLoadTestClass.new.test_method
1187+
HookLineLoadTestClass.new.test_method
1188+
1189+
expect(observed_calls.length).to eq 2
1190+
1191+
expect(observed_calls[0].keys.sort).to eq(call_keys)
1192+
expect(observed_calls[1].keys.sort).to eq(call_keys)
1193+
end
1194+
end
1195+
end
1196+
11191197
context 'when instrumenting a line in loaded but not tracked file' do
11201198
let(:probe) do
11211199
Datadog::DI::Probe.new(file: 'hook_line.rb', line_no: 3,
@@ -1129,6 +1207,8 @@
11291207
end.to raise_error(Datadog::DI::Error::DITargetNotInRegistry, /File matching probe path.*was loaded and is not in code tracker registry/)
11301208
end
11311209
end
1210+
1211+
include_examples 'multiple invocations'
11321212
end
11331213

11341214
context 'when method is recursive' do
@@ -1208,6 +1288,39 @@
12081288
end
12091289
end
12101290

1291+
context 'when the instrumented line raises an exception' do
1292+
include_context 'with code tracking'
1293+
1294+
before do
1295+
load File.join(File.dirname(__FILE__), 'hook_line_load.rb')
1296+
end
1297+
1298+
let(:probe) do
1299+
Datadog::DI::Probe.new(file: 'hook_line_load.rb', line_no: 32,
1300+
id: 1, type: :log, rate_limit: rate_limit)
1301+
end
1302+
1303+
let(:payload) do
1304+
expect_any_instance_of(TracePoint).to receive(:enable).and_call_original
1305+
1306+
instrumenter.hook_line(probe) do |payload|
1307+
observed_calls << payload
1308+
end
1309+
1310+
expect do
1311+
HookLineIvarLoadTestClass.new.test_exception
1312+
end.to raise_error(HookLineIvarLoadTestClass::TestException)
1313+
1314+
expect(observed_calls.length).to eq 1
1315+
observed_calls.first
1316+
end
1317+
1318+
it 'invokes callback with expected keys' do
1319+
expect(payload).to be_a(Hash)
1320+
expect(payload.keys.sort).to eq(call_keys)
1321+
end
1322+
end
1323+
12111324
context 'when there is a condition' do
12121325
include_context 'with code tracking'
12131326

0 commit comments

Comments
 (0)