Skip to content

Commit 8c1c03a

Browse files
authored
:skip injection if .NET OTel packages installed (#324)
Makes .NET injection more conservative by skipping auto-instrumentation when the target app already references OpenTelemetry packages. The injector inspects the *.deps.json file of the target application when it is available. If the dependency graph already includes OpenTelemetry*, the injector skips adding .NET auto-instrumentation environment.
1 parent d8251a6 commit 8c1c03a

4 files changed

Lines changed: 290 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
change_type: bug_fix
2+
component: injection
3+
note: Skip .NET auto-instrumentation for applications that already reference OpenTelemetry packages.
4+
issues: [267]
5+
change_logs: [user]

DESIGN.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ The approach taken by the OpenTelemetry injector is as follows:
8383
* Use `setenv` to set or modify the required environment variables (`NODE_OPTIONS`, `JAVA_TOOL_OPTIONS`,
8484
`OTEL_RESOURCE_ATTRIBUTES` etc.)
8585

86+
For .NET, the injector applies an additional check before setting the profiler
87+
and startup-hook related environment variables. It resolves the target application and inspects the adjacent
88+
`*.deps.json` file when available, standing down only when the dependency graph already references
89+
`OpenTelemetry*` packages. This keeps the injector fail-open while still reducing the risk of double-instrumentation.
90+
8691
If this sounds convoluted, and more complex than it should be, read on!
8792
The next section outlines which alternative approaches have been considered, and the shortcomings of each of them.
8893

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ Here is an overview of the modifications that the injector will apply:
136136

137137
* It sets (or appends to) `NODE_OPTIONS` to activate the Node.js instrumentation agent.
138138
* It adds a `-javaagent` flag to `JAVA_TOOL_OPTIONS` to activate the Java OTel SDK.
139-
* It sets the required environment variables for activating the OpenTelemetry SDK for .NET:
139+
* It conditionally sets the required environment variables for activating the OpenTelemetry SDK for .NET:
140140
* `CORECLR_ENABLE_PROFILING`
141141
* `CORECLR_PROFILER`
142142
* `CORECLR_PROFILER_PATH`
@@ -147,6 +147,10 @@ Here is an overview of the modifications that the injector will apply:
147147
* Note that the injector will not append to existing environment variables but overwrite them unconditionally if
148148
they are already set.
149149
In contrast to other runtimes, .NET does not support adding multiple agents.
150+
* To reduce the risk of double-instrumentation, the injector inspects the adjacent `*.deps.json` file of the
151+
target application when it is available.
152+
* The injector stands down if that `.deps.json` file already references `OpenTelemetry*` packages.
153+
* If the `.deps.json` file is missing, unreadable, or malformed, the injector proceeds with .NET injection.
150154
* It inspects specific existing environment variables and populates `OTEL_RESOURCE_ATTRIBUTES` with additional resource
151155
attributes. These environment variables need to be set externally (for example by a Kubernetes operator with a mutating
152156
webhook on the pod spec template of the workload). If `OTEL_RESOURCE_ATTRIBUTES` is already set, the additional

src/dotnet.zig

Lines changed: 275 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
const builtin = @import("builtin");
55
const std = @import("std");
66

7+
const args_parser = @import("args_parser.zig");
78
const config = @import("config.zig");
89
const libc = @import("libc.zig");
910
const print = @import("print.zig");
10-
const types = @import("types.zig");
1111
const test_util = @import("test_util.zig");
12+
const types = @import("types.zig");
1213

1314
const testing = std.testing;
1415

@@ -33,6 +34,9 @@ pub const DotnetValues = struct {
3334
const coreclr_enable_profiling_value = "1";
3435
// See https://opentelemetry.io/docs/zero-code/dotnet/configuration/#net-clr-profiler.
3536
const coreclr_profiler_value = "{918728DD-259F-4A6A-AC2B-B85E1B658318}";
37+
const dotnet_host_name = "dotnet";
38+
const max_dotnet_metadata_file_size = 1024 * 1024;
39+
const opentelemetry_dependency_prefix = "OpenTelemetry";
3640

3741
pub const CachedDotnetValues = struct {
3842
values: ?DotnetValues,
@@ -45,6 +49,14 @@ const DotnetError = error{
4549
OutOfMemory,
4650
};
4751

52+
const DotnetMetadataPaths = struct {
53+
deps_path: []u8,
54+
55+
fn freeAll(self: DotnetMetadataPaths, allocator: std.mem.Allocator) void {
56+
allocator.free(self.deps_path);
57+
}
58+
};
59+
4860
pub const coreclr_enable_profiling_env_var_name = "CORECLR_ENABLE_PROFILING";
4961
pub const coreclr_profiler_env_var_name = "CORECLR_PROFILER";
5062
pub const coreclr_profiler_path_env_var_name = "CORECLR_PROFILER_PATH";
@@ -99,6 +111,14 @@ fn doGetDotnetValues(gpa: std.mem.Allocator, dotnet_path_prefix: []u8, dotnet_in
99111
return cached_dotnet_values.values;
100112
}
101113

114+
if (!shouldInjectDotnet(gpa)) {
115+
cached_dotnet_values = .{
116+
.values = null,
117+
.done = true,
118+
};
119+
return null;
120+
}
121+
102122
if (libc_flavor) |libc_f| {
103123
const dotnet_values = determineDotnetValues(
104124
gpa,
@@ -146,6 +166,154 @@ fn doGetDotnetValues(gpa: std.mem.Allocator, dotnet_path_prefix: []u8, dotnet_in
146166
unreachable;
147167
}
148168

169+
fn shouldInjectDotnet(allocator: std.mem.Allocator) bool {
170+
const cmdline_args = args_parser.cmdLineForPID(allocator) catch |err| {
171+
print.printDebug("Proceeding with the injection of the .NET OpenTelemetry instrumentation. Could not read the process command line: {}", .{err});
172+
return true;
173+
};
174+
defer {
175+
for (cmdline_args) |arg| allocator.free(arg);
176+
allocator.free(cmdline_args);
177+
}
178+
179+
const self_exe_path = std.fs.selfExePathAlloc(allocator) catch |err| {
180+
print.printDebug("Proceeding with the injection of the .NET OpenTelemetry instrumentation. Could not resolve the executable path: {}", .{err});
181+
return true;
182+
};
183+
defer allocator.free(self_exe_path);
184+
185+
const maybe_app_path = resolveManagedApplicationPath(allocator, cmdline_args, self_exe_path) catch |err| {
186+
print.printDebug("Proceeding with the injection of the .NET OpenTelemetry instrumentation. Could not determine the managed application path: {}", .{err});
187+
return true;
188+
};
189+
const app_path = maybe_app_path orelse {
190+
print.printDebug("Proceeding with the injection of the .NET OpenTelemetry instrumentation. The process does not look like a recognized .NET application startup.", .{});
191+
return true;
192+
};
193+
defer allocator.free(app_path);
194+
195+
const metadata_paths = createDotnetMetadataPaths(allocator, app_path) catch |err| {
196+
print.printDebug("Proceeding with the injection of the .NET OpenTelemetry instrumentation. Could not determine the application metadata paths: {}", .{err});
197+
return true;
198+
};
199+
defer metadata_paths.freeAll(allocator);
200+
201+
const deps_content = readSmallTextFileAlloc(allocator, metadata_paths.deps_path) catch |err| {
202+
print.printDebug("Proceeding with the injection of the .NET OpenTelemetry instrumentation. Could not read {s}: {}", .{ metadata_paths.deps_path, err });
203+
return true;
204+
};
205+
defer allocator.free(deps_content);
206+
207+
if (depsJsonContainsOpenTelemetryDependency(allocator, deps_content)) |contains_opentelemetry| {
208+
if (contains_opentelemetry) {
209+
print.printInfo("Skipping the injection of the .NET OpenTelemetry instrumentation because {s} already references OpenTelemetry packages.", .{metadata_paths.deps_path});
210+
return false;
211+
}
212+
} else |err| {
213+
print.printDebug("Proceeding with the injection of the .NET OpenTelemetry instrumentation. Could not parse {s} safely: {}", .{ metadata_paths.deps_path, err });
214+
return true;
215+
}
216+
217+
return true;
218+
}
219+
220+
fn resolveManagedApplicationPath(
221+
allocator: std.mem.Allocator,
222+
cmdline_args: []const []const u8,
223+
self_exe_path: []const u8,
224+
) !?[]u8 {
225+
if (cmdline_args.len == 0) {
226+
return null;
227+
}
228+
229+
if (std.mem.eql(u8, std.fs.path.basename(cmdline_args[0]), dotnet_host_name)) {
230+
for (cmdline_args[1..]) |arg| {
231+
if (arg.len == 0 or arg[0] == '-') {
232+
continue;
233+
}
234+
if (std.mem.endsWith(u8, arg, ".dll") or std.mem.endsWith(u8, arg, ".exe")) {
235+
return try allocator.dupe(u8, arg);
236+
}
237+
}
238+
return null;
239+
}
240+
241+
return try allocator.dupe(u8, self_exe_path);
242+
}
243+
244+
fn createDotnetMetadataPaths(allocator: std.mem.Allocator, app_path: []const u8) !DotnetMetadataPaths {
245+
const app_base_path =
246+
if (std.mem.endsWith(u8, app_path, ".dll") or std.mem.endsWith(u8, app_path, ".exe"))
247+
app_path[0 .. app_path.len - 4]
248+
else
249+
app_path;
250+
251+
return .{
252+
.deps_path = try std.fmt.allocPrint(allocator, "{s}.deps.json", .{app_base_path}),
253+
};
254+
}
255+
256+
fn readSmallTextFileAlloc(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
257+
const file =
258+
if (std.fs.path.isAbsolute(path))
259+
try std.fs.openFileAbsolute(path, .{})
260+
else
261+
try std.fs.cwd().openFile(path, .{});
262+
defer file.close();
263+
264+
return file.readToEndAlloc(allocator, max_dotnet_metadata_file_size);
265+
}
266+
267+
fn depsJsonContainsOpenTelemetryDependency(allocator: std.mem.Allocator, content: []const u8) !bool {
268+
var parsed = try std.json.parseFromSlice(std.json.Value, allocator, content, .{});
269+
defer parsed.deinit();
270+
271+
return jsonContainsOpenTelemetryDependency(parsed.value);
272+
}
273+
274+
fn jsonContainsOpenTelemetryDependency(value: std.json.Value) bool {
275+
switch (value) {
276+
.object => |object| {
277+
var iterator = object.iterator();
278+
while (iterator.next()) |entry| {
279+
if (jsonObjectKeyLooksLikeOpenTelemetryDependency(entry.key_ptr.*)) {
280+
return true;
281+
}
282+
if (jsonContainsOpenTelemetryDependency(entry.value_ptr.*)) {
283+
return true;
284+
}
285+
}
286+
return false;
287+
},
288+
.array => |array| {
289+
for (array.items) |item| {
290+
if (jsonContainsOpenTelemetryDependency(item)) {
291+
return true;
292+
}
293+
}
294+
return false;
295+
},
296+
else => return false,
297+
}
298+
}
299+
300+
fn jsonObjectKeyLooksLikeOpenTelemetryDependency(key: []const u8) bool {
301+
const dependency_name =
302+
if (std.mem.indexOfScalar(u8, key, '/')) |slash_index|
303+
key[0..slash_index]
304+
else
305+
key;
306+
307+
return std.mem.startsWith(u8, dependency_name, opentelemetry_dependency_prefix);
308+
}
309+
310+
fn getJsonObject(value: std.json.Value) ?std.json.ObjectMap {
311+
return switch (value) {
312+
.object => |object| object,
313+
else => null,
314+
};
315+
}
316+
149317
test "doGetDotnetValues: should return null value if the libc flavor has not been set" {
150318
const allocator = testing.allocator;
151319
_resetState();
@@ -198,6 +366,112 @@ test "doGetDotnetValues: should return null value if the profiler path cannot be
198366
try test_util.expectWithMessage(dotnet_values == null, "dotnet_values == null");
199367
}
200368

369+
test "resolveManagedApplicationPath: dotnet host uses managed assembly argument" {
370+
const allocator = testing.allocator;
371+
372+
const cmdline_args = [_][]const u8{
373+
"/usr/bin/dotnet",
374+
"/app/MyApp.dll",
375+
"--urls",
376+
"http://localhost:8080",
377+
};
378+
379+
const app_path = (try resolveManagedApplicationPath(allocator, &cmdline_args, "/usr/bin/dotnet")) orelse return error.Unexpected;
380+
defer allocator.free(app_path);
381+
382+
try testing.expectEqualStrings("/app/MyApp.dll", app_path);
383+
}
384+
385+
test "resolveManagedApplicationPath: direct apphost launch uses executable path" {
386+
const allocator = testing.allocator;
387+
388+
const cmdline_args = [_][]const u8{
389+
"/app/MyApp",
390+
"--urls",
391+
"http://localhost:8080",
392+
};
393+
394+
const app_path = (try resolveManagedApplicationPath(allocator, &cmdline_args, "/app/MyApp")) orelse return error.Unexpected;
395+
defer allocator.free(app_path);
396+
397+
try testing.expectEqualStrings("/app/MyApp", app_path);
398+
}
399+
400+
test "resolveManagedApplicationPath: dotnet host without managed assembly returns null" {
401+
const cmdline_args = [_][]const u8{
402+
"/usr/bin/dotnet",
403+
"--info",
404+
};
405+
406+
try test_util.expectWithMessage((try resolveManagedApplicationPath(testing.allocator, &cmdline_args, "/usr/bin/dotnet")) == null, "app path should be null");
407+
}
408+
409+
test "createDotnetMetadataPaths: managed dll path produces deps path" {
410+
const allocator = testing.allocator;
411+
412+
const metadata_paths = try createDotnetMetadataPaths(allocator, "/app/MyApp.dll");
413+
defer metadata_paths.freeAll(allocator);
414+
415+
try testing.expectEqualStrings("/app/MyApp.deps.json", metadata_paths.deps_path);
416+
}
417+
418+
test "createDotnetMetadataPaths: apphost path produces deps path" {
419+
const allocator = testing.allocator;
420+
421+
const metadata_paths = try createDotnetMetadataPaths(allocator, "/app/MyApp");
422+
defer metadata_paths.freeAll(allocator);
423+
424+
try testing.expectEqualStrings("/app/MyApp.deps.json", metadata_paths.deps_path);
425+
}
426+
427+
test "depsJsonContainsOpenTelemetryDependency: false when no OpenTelemetry packages are present" {
428+
const content =
429+
\\{
430+
\\ "libraries": {
431+
\\ "Newtonsoft.Json/13.0.3": {
432+
\\ "type": "package"
433+
\\ }
434+
\\ }
435+
\\}
436+
;
437+
438+
try test_util.expectWithMessage(!(try depsJsonContainsOpenTelemetryDependency(testing.allocator, content)), "deps should not contain OpenTelemetry");
439+
}
440+
441+
test "depsJsonContainsOpenTelemetryDependency: true when OpenTelemetry package is present" {
442+
const content =
443+
\\{
444+
\\ "libraries": {
445+
\\ "OpenTelemetry/1.11.0": {
446+
\\ "type": "package"
447+
\\ }
448+
\\ }
449+
\\}
450+
;
451+
452+
try test_util.expectWithMessage(try depsJsonContainsOpenTelemetryDependency(testing.allocator, content), "deps should contain OpenTelemetry");
453+
}
454+
455+
test "depsJsonContainsOpenTelemetryDependency: true when OpenTelemetry target entry is present" {
456+
const content =
457+
\\{
458+
\\ "targets": {
459+
\\ ".NETCoreApp,Version=v9.0": {
460+
\\ "OpenTelemetry.Extensions.Hosting/1.11.0": {
461+
\\ "runtime": {}
462+
\\ }
463+
\\ }
464+
\\ }
465+
\\}
466+
;
467+
468+
try test_util.expectWithMessage(try depsJsonContainsOpenTelemetryDependency(testing.allocator, content), "deps should contain OpenTelemetry");
469+
}
470+
471+
test "depsJsonContainsOpenTelemetryDependency: rejects malformed json" {
472+
try testing.expectError(error.UnexpectedEndOfInput, depsJsonContainsOpenTelemetryDependency(testing.allocator, "{"));
473+
}
474+
201475
fn determineDotnetValues(
202476
gpa: std.mem.Allocator,
203477
dotnet_path_prefix: []u8,

0 commit comments

Comments
 (0)