diff --git a/tracer/src/Datadog.Trace/Configuration/ConfigurationKeys.Debugger.cs b/tracer/src/Datadog.Trace/Configuration/ConfigurationKeys.Debugger.cs
index e4a2481167f0..897680b9015a 100644
--- a/tracer/src/Datadog.Trace/Configuration/ConfigurationKeys.Debugger.cs
+++ b/tracer/src/Datadog.Trace/Configuration/ConfigurationKeys.Debugger.cs
@@ -179,6 +179,13 @@ internal static class Debugger
///
///
public const string CodeOriginMaxUserFrames = "DD_CODE_ORIGIN_FOR_SPANS_MAX_USER_FRAMES";
+
+ ///
+ /// Configuration key for setting probes file path to add probes via file during DynamicInstrumentation initialization.
+ /// Default value is empty.
+ ///
+ ///
+ public const string DynamicInstrumentationProbeFile = "DD_DYNAMIC_INSTRUMENTATION_PROBE_FILE";
}
}
}
diff --git a/tracer/src/Datadog.Trace/Debugger/Configurations/ConfigurationUpdater.cs b/tracer/src/Datadog.Trace/Debugger/Configurations/ConfigurationUpdater.cs
index 74e6c1fe8370..71f81f1fdabb 100644
--- a/tracer/src/Datadog.Trace/Debugger/Configurations/ConfigurationUpdater.cs
+++ b/tracer/src/Datadog.Trace/Debugger/Configurations/ConfigurationUpdater.cs
@@ -43,7 +43,13 @@ public List AcceptAdded(ProbeConfiguration configuration)
{
var result = new List();
var filteredConfiguration = ApplyConfigurationFilters(configuration);
- var comparer = new ProbeConfigurationComparer(_currentConfiguration, filteredConfiguration);
+
+ // Merge the new filtered configuration with the existing one so that
+ // _currentConfiguration always represents the union of all accepted
+ // configurations from both file and RCM
+ var mergedConfiguration = MergeConfigurations(_currentConfiguration, filteredConfiguration);
+
+ var comparer = new ProbeConfigurationComparer(_currentConfiguration, mergedConfiguration);
if (comparer.HasProbeRelatedChanges)
{
@@ -55,7 +61,7 @@ public List AcceptAdded(ProbeConfiguration configuration)
HandleRateLimitChanged(comparer);
}
- _currentConfiguration = configuration;
+ _currentConfiguration = mergedConfiguration;
return result;
}
@@ -72,6 +78,30 @@ public void AcceptRemoved(List paths)
}
}
+ private static ProbeConfiguration MergeConfigurations(ProbeConfiguration current, ProbeConfiguration incoming)
+ {
+ return new ProbeConfiguration
+ {
+ LogProbes = current.LogProbes
+ .Concat(incoming.LogProbes)
+ .Distinct()
+ .ToArray(),
+ MetricProbes = current.MetricProbes
+ .Concat(incoming.MetricProbes)
+ .Distinct()
+ .ToArray(),
+ SpanProbes = current.SpanProbes
+ .Concat(incoming.SpanProbes)
+ .Distinct()
+ .ToArray(),
+ SpanDecorationProbes = current.SpanDecorationProbes
+ .Concat(incoming.SpanDecorationProbes)
+ .Distinct()
+ .ToArray(),
+ ServiceConfiguration = incoming.ServiceConfiguration ?? current.ServiceConfiguration
+ };
+ }
+
private ProbeConfiguration ApplyConfigurationFilters(ProbeConfiguration configuration)
{
return new ProbeConfiguration()
diff --git a/tracer/src/Datadog.Trace/Debugger/DebuggerManager.cs b/tracer/src/Datadog.Trace/Debugger/DebuggerManager.cs
index ced9e8abed45..f57611eec076 100644
--- a/tracer/src/Datadog.Trace/Debugger/DebuggerManager.cs
+++ b/tracer/src/Datadog.Trace/Debugger/DebuggerManager.cs
@@ -342,7 +342,10 @@ private async Task DebouncedUpdateDynamicInstrumentationAsync(TracerSettings tra
Log.Warning("Remote configuration is not available in this environment, so Dynamic Instrumentation cannot be enabled.");
}
- return;
+ if (string.IsNullOrEmpty(debuggerSettings.ProbeFile))
+ {
+ return;
+ }
}
var requestedDiState = debuggerSettings.DynamicInstrumentationEnabled || debuggerSettings.DynamicSettings.DynamicInstrumentationEnabled == true;
diff --git a/tracer/src/Datadog.Trace/Debugger/DebuggerSettings.cs b/tracer/src/Datadog.Trace/Debugger/DebuggerSettings.cs
index 4cafde7209b8..9510ef3c1f2e 100644
--- a/tracer/src/Datadog.Trace/Debugger/DebuggerSettings.cs
+++ b/tracer/src/Datadog.Trace/Debugger/DebuggerSettings.cs
@@ -145,6 +145,8 @@ public DebuggerSettings(IConfigurationSource? source, IConfigurationTelemetry te
.Value;
SymbolDatabaseCompressionEnabled = config.WithKeys(ConfigurationKeys.Debugger.SymbolDatabaseCompressionEnabled).AsBool(true);
+
+ ProbeFile = config.WithKeys(ConfigurationKeys.Debugger.DynamicInstrumentationProbeFile).AsString() ?? string.Empty;
}
internal ImmutableDynamicDebuggerSettings DynamicSettings { get; init; } = new();
@@ -189,6 +191,8 @@ public DebuggerSettings(IConfigurationSource? source, IConfigurationTelemetry te
public int CodeOriginMaxUserFrames { get; }
+ public string ProbeFile { get; }
+
public static DebuggerSettings FromSource(IConfigurationSource source, IConfigurationTelemetry telemetry)
{
return new DebuggerSettings(source, telemetry);
diff --git a/tracer/src/Datadog.Trace/Debugger/DynamicInstrumentation.cs b/tracer/src/Datadog.Trace/Debugger/DynamicInstrumentation.cs
index ef94f2ececf4..e5a33ff2d19f 100644
--- a/tracer/src/Datadog.Trace/Debugger/DynamicInstrumentation.cs
+++ b/tracer/src/Datadog.Trace/Debugger/DynamicInstrumentation.cs
@@ -8,6 +8,7 @@
using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -24,6 +25,7 @@
using Datadog.Trace.DogStatsd;
using Datadog.Trace.Logging;
using Datadog.Trace.RemoteConfigurationManagement;
+using Datadog.Trace.Vendors.Newtonsoft.Json.Linq;
using Datadog.Trace.Vendors.Serilog.Events;
using Datadog.Trace.Vendors.StatsdClient;
using ProbeInfo = Datadog.Trace.Debugger.Expressions.ProbeInfo;
@@ -49,6 +51,7 @@ internal class DynamicInstrumentation : IDisposable
private readonly DebuggerSettings _settings;
private readonly object _instanceLock = new();
private int _disposeState;
+ private volatile ProbeConfiguration? _fileProbes;
internal DynamicInstrumentation(
DebuggerSettings settings,
@@ -103,17 +106,44 @@ private async Task InitializeAsync()
{
try
{
- var isRcmAvailable = await WaitForRcmAvailabilityAsync().ConfigureAwait(false);
- if (!isRcmAvailable)
+ // Start loading probes from file and checking RCM availability in parallel
+ var fileProbesTask = LoadProbesFromFileAsync();
+ var rcmAvailabilityTask = WaitForRcmAvailabilityAsync();
+
+ var hasFileProbes = false;
+
+ // Always attempt to load probes from file, even if RCM is unavailable
+ var probeConfiguration = await fileProbesTask.ConfigureAwait(false);
+ if (probeConfiguration != null)
{
- return;
+ _fileProbes = probeConfiguration;
+ _configurationUpdater.AcceptAdded(probeConfiguration);
+ hasFileProbes = (probeConfiguration.LogProbes.Length
+ + probeConfiguration.MetricProbes.Length
+ + probeConfiguration.SpanProbes.Length
+ + probeConfiguration.SpanDecorationProbes.Length) > 0;
}
- _subscriptionManager.SubscribeToChanges(_subscription);
- AppDomain.CurrentDomain.AssemblyLoad += CheckUnboundProbes;
- StartBackgroundProcess();
- IsInitialized = true;
- Log.Information("Dynamic Instrumentation initialization completed successfully");
+ var isRcmAvailable = await rcmAvailabilityTask.ConfigureAwait(false);
+ if (isRcmAvailable)
+ {
+ _subscriptionManager.SubscribeToChanges(_subscription);
+ }
+
+ // Start background processing and register the assembly load callback if either:
+ // - RCM is available
+ // - There are probes from file
+ if (isRcmAvailable || hasFileProbes)
+ {
+ AppDomain.CurrentDomain.AssemblyLoad += CheckUnboundProbes;
+ StartBackgroundProcess();
+ IsInitialized = true;
+ Log.Information("Dynamic Instrumentation initialization completed successfully");
+ }
+ else
+ {
+ Log.Information("Dynamic Instrumentation not initialized because RCM isn't available and no valid probes have loaded from file");
+ }
}
catch (OperationCanceledException e)
{
@@ -474,13 +504,16 @@ private ApplyDetails[] AcceptAddedConfiguration(List? confi
}
}
+ // Merge file probes with RCM probes (RCM takes precedence on ID conflicts)
+ var currentFileProbes = _fileProbes;
+
var probeConfiguration = new ProbeConfiguration()
{
ServiceConfiguration = serviceConfig,
- MetricProbes = metrics.ToArray(),
- SpanDecorationProbes = spanDecoration.ToArray(),
- LogProbes = logs.ToArray(),
- SpanProbes = spans.ToArray()
+ LogProbes = MergeProbes(currentFileProbes?.LogProbes, logs.ToArray()),
+ MetricProbes = MergeProbes(currentFileProbes?.MetricProbes, metrics.ToArray()),
+ SpanProbes = MergeProbes(currentFileProbes?.SpanProbes, spans.ToArray()),
+ SpanDecorationProbes = MergeProbes(currentFileProbes?.SpanDecorationProbes, spanDecoration.ToArray())
};
try
@@ -636,6 +669,199 @@ void DiscoveryCallback(AgentConfiguration x)
}
}
+ private async Task LoadProbesFromFileAsync()
+ {
+ if (string.IsNullOrEmpty(_settings.ProbeFile))
+ {
+ return null;
+ }
+
+ try
+ {
+ if (!File.Exists(_settings.ProbeFile))
+ {
+ Log.Warning("Probe file specified but not found: {ProbeFile}", _settings.ProbeFile);
+ return null;
+ }
+
+ Log.Information("Loading probes from file: {ProbeFile}", _settings.ProbeFile);
+
+ string fileContent;
+ using (var reader = new StreamReader(_settings.ProbeFile))
+ {
+ fileContent = await reader.ReadToEndAsync().ConfigureAwait(false);
+ }
+
+ if (string.IsNullOrWhiteSpace(fileContent))
+ {
+ Log.Debug("Probe file is empty: {ProbeFile}", _settings.ProbeFile);
+ return null;
+ }
+
+ var jArray = JArray.Parse(fileContent);
+ var logs = new List();
+ var metrics = new List();
+ var spans = new List();
+ var spanDecorations = new List();
+
+ foreach (var jToken in jArray)
+ {
+ var jObject = jToken as JObject;
+ if (jObject == null)
+ {
+ Log.Warning("Invalid probe entry in file, skipping");
+ continue;
+ }
+
+ var typeToken = jObject["type"];
+ if (typeToken == null)
+ {
+ Log.Warning("Probe entry missing 'type' field, skipping");
+ continue;
+ }
+
+ var type = typeToken.ToString();
+ try
+ {
+ switch (type)
+ {
+ case "LOG_PROBE":
+ var logProbe = jObject.ToObject();
+ if (logProbe != null)
+ {
+ logs.Add(logProbe);
+ }
+
+ break;
+ case "METRIC_PROBE":
+ var metricProbe = jObject.ToObject();
+ if (metricProbe != null)
+ {
+ metrics.Add(metricProbe);
+ }
+
+ break;
+ case "SPAN_PROBE":
+ var spanProbe = jObject.ToObject();
+ if (spanProbe != null)
+ {
+ spans.Add(spanProbe);
+ }
+
+ break;
+ case "SPAN_DECORATION_PROBE":
+ var spanDecorationProbe = jObject.ToObject();
+ if (spanDecorationProbe != null)
+ {
+ spanDecorations.Add(spanDecorationProbe);
+ }
+
+ break;
+ default:
+ Log.Warning("Unknown probe type '{Type}' in file, skipping", type);
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Failed to deserialize probe of type '{Type}', skipping", type);
+ }
+ }
+
+ var totalProbes = logs.Count + metrics.Count + spans.Count + spanDecorations.Count;
+ if (totalProbes == 0)
+ {
+ Log.Warning("No valid probes found in file: {ProbeFile}", _settings.ProbeFile);
+ return null;
+ }
+
+ // Deduplicate probes within the file by ID
+ var uniqueLogs = DeduplicateProbes(logs);
+ var uniqueMetrics = DeduplicateProbes(metrics);
+ var uniqueSpans = DeduplicateProbes(spans);
+ var uniqueSpanDecorations = DeduplicateProbes(spanDecorations);
+
+ var uniqueCount = uniqueLogs.Length + uniqueMetrics.Length + uniqueSpans.Length + uniqueSpanDecorations.Length;
+ if (uniqueCount < totalProbes)
+ {
+ Log.Debug("Removed {Count} duplicate probe(s) from file", property: totalProbes - uniqueCount);
+ }
+
+ Log.Information("Successfully loaded {Count} probes from file.", property: uniqueCount);
+
+ return new ProbeConfiguration
+ {
+ LogProbes = uniqueLogs,
+ MetricProbes = uniqueMetrics,
+ SpanProbes = uniqueSpans,
+ SpanDecorationProbes = uniqueSpanDecorations
+ };
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Failed to load probes from file: {ProbeFile}", _settings.ProbeFile);
+ return null;
+ }
+ }
+
+ private T[] DeduplicateProbes(List probes)
+ where T : ProbeDefinition
+ {
+ if (probes.Count == 0)
+ {
+ return [];
+ }
+
+ return probes
+ .GroupBy(p => p.Id)
+ .Select(g =>
+ {
+ if (g.Count() > 1)
+ {
+ Log.Warning("Duplicate probe ID '{Id}' found in file, using first occurrence", g.Key);
+ }
+
+ return g.First();
+ })
+ .ToArray();
+ }
+
+ private T[] MergeProbes(T[]? fileProbes, T[] rcmProbes)
+ where T : ProbeDefinition
+ {
+ if (fileProbes == null || fileProbes.Length == 0)
+ {
+ return rcmProbes;
+ }
+
+ if (rcmProbes.Length == 0)
+ {
+ return fileProbes;
+ }
+
+ // Combine and deduplicate by ID (RCM takes precedence over file if conflict)
+ var mergedProbes = new Dictionary(StringComparer.Ordinal);
+
+ // Add file probes first
+ foreach (var probe in fileProbes)
+ {
+ mergedProbes[probe.Id] = probe;
+ }
+
+ // Add/overwrite with RCM probes (RCM wins on conflicts)
+ foreach (var probe in rcmProbes)
+ {
+ if (mergedProbes.ContainsKey(probe.Id))
+ {
+ Log.Debug("Probe ID '{Id}' exists in both file and RCM, using RCM version", probe.Id);
+ }
+
+ mergedProbes[probe.Id] = probe;
+ }
+
+ return mergedProbes.Values.ToArray();
+ }
+
public void Dispose()
{
// Already disposed
diff --git a/tracer/test/Datadog.Trace.Tests/Debugger/DebuggerSettingsTests.cs b/tracer/test/Datadog.Trace.Tests/Debugger/DebuggerSettingsTests.cs
index 3fbe32b23cd3..6efd92e228dd 100644
--- a/tracer/test/Datadog.Trace.Tests/Debugger/DebuggerSettingsTests.cs
+++ b/tracer/test/Datadog.Trace.Tests/Debugger/DebuggerSettingsTests.cs
@@ -157,6 +157,37 @@ public void InvalidUploadFlushInterval_DefaultUsed(string value)
settings.UploadFlushIntervalMilliseconds.Should().Be(0);
}
+ [Theory]
+ [InlineData("/path/to/probes.json")]
+ [InlineData("C:\\probes\\config.json")]
+ [InlineData("probes.json")]
+ public void ProbeFile_ParsesCorrectly(string probeFilePath)
+ {
+ var settings = new DebuggerSettings(
+ new NameValueConfigurationSource(new()
+ {
+ { ConfigurationKeys.Debugger.DynamicInstrumentationProbeFile, probeFilePath }
+ }),
+ NullConfigurationTelemetry.Instance);
+
+ settings.ProbeFile.Should().Be(probeFilePath);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ public void ProbeFile_EmptyOrNull(string probeFilePath)
+ {
+ var settings = new DebuggerSettings(
+ new NameValueConfigurationSource(new()
+ {
+ { ConfigurationKeys.Debugger.DynamicInstrumentationProbeFile, probeFilePath }
+ }),
+ NullConfigurationTelemetry.Instance);
+
+ settings.ProbeFile.Should().BeEmpty();
+ }
+
public class DebuggerSettingsCodeOriginTests
{
[Theory]
diff --git a/tracer/test/Datadog.Trace.Tests/Debugger/DynamicInstrumentationTests.cs b/tracer/test/Datadog.Trace.Tests/Debugger/DynamicInstrumentationTests.cs
index 411fe407641b..ca9da6674336 100644
--- a/tracer/test/Datadog.Trace.Tests/Debugger/DynamicInstrumentationTests.cs
+++ b/tracer/test/Datadog.Trace.Tests/Debugger/DynamicInstrumentationTests.cs
@@ -5,7 +5,10 @@
using System;
using System.Collections.Generic;
+using System.IO;
+using System.Linq;
using System.Numerics;
+using System.Reflection;
using System.Threading.Tasks;
using Datadog.Trace.Agent.DiscoveryService;
using Datadog.Trace.Configuration;
@@ -43,15 +46,7 @@ public async Task DynamicInstrumentationEnabled_ServicesCalled()
var debugger = new DynamicInstrumentation(settings, discoveryService, rcmSubscriptionManagerMock, lineProbeResolver, snapshotUploader, logUploader, diagnosticsUploader, probeStatusPoller, updater, new DogStatsd.NoOpStatsd());
debugger.Initialize();
-
- // Wait for async initialization to complete
- var timeout = TimeSpan.FromSeconds(5);
- var startTime = DateTime.UtcNow;
-
- while (!debugger.IsInitialized && DateTime.UtcNow - startTime < timeout)
- {
- await Task.Delay(50);
- }
+ await WaitForInitializationAsync(debugger);
discoveryService.Called.Should().BeTrue();
debugger.IsInitialized.Should().BeTrue("Dynamic instrumentation should be initialized");
@@ -88,6 +83,397 @@ public void DynamicInstrumentationDisabled_ServicesNotCalled()
rcmSubscriptionManagerMock.ProductKeys.Contains(RcmProducts.LiveDebugging).Should().BeFalse();
}
+ private static async Task WaitForInitializationAsync(DynamicInstrumentation debugger, int timeoutSeconds = 5)
+ {
+ var timeout = TimeSpan.FromSeconds(timeoutSeconds);
+ var startTime = DateTime.UtcNow;
+
+ while (!debugger.IsInitialized && DateTime.UtcNow - startTime < timeout)
+ {
+ await Task.Delay(50);
+ }
+ }
+
+ public class ProbeFileLoadingTests : IDisposable
+ {
+ private readonly List _tempFiles = new();
+
+ public void Dispose()
+ {
+ // Clean up temp files
+ foreach (var file in _tempFiles)
+ {
+ try
+ {
+ if (File.Exists(file))
+ {
+ File.Delete(file);
+ }
+ }
+ catch
+ {
+ // Ignore cleanup errors
+ }
+ }
+ }
+
+ [Fact]
+ public async Task ProbeFile_MultipleProbeTypes_LoadsAll()
+ {
+ var probeJson = @"[
+ {
+ ""id"": ""100c9a5c-45ad-49dc-818b-c570d31e11d1"",
+ ""version"": 0,
+ ""type"": ""LOG_PROBE"",
+ ""where"": { ""sourceFile"": ""MyClass.cs"", ""lines"": [""25""] },
+ ""template"": ""Hello World"",
+ ""segments"": [{ ""str"": ""Hello World"" }],
+ ""captureSnapshot"": true,
+ ""capture"": { ""maxReferenceDepth"": 3 },
+ ""sampling"": { ""snapshotsPerSecond"": 100 }
+ },
+ {
+ ""id"": ""metric-1"",
+ ""type"": ""METRIC_PROBE"",
+ ""where"": { ""typeName"": ""MyClass"", ""methodName"": ""MyMethod"" },
+ ""kind"": ""COUNT"",
+ ""metricName"": ""my.metric""
+ },
+ {
+ ""id"": ""span-1"",
+ ""type"": ""SPAN_PROBE"",
+ ""where"": { ""typeName"": ""MyClass"", ""methodName"": ""MyMethod"" }
+ }
+ ]";
+
+ var tempFile = CreateTempProbeFile(probeJson);
+
+ var settings = DebuggerSettings.FromSource(
+ new NameValueConfigurationSource(new()
+ {
+ { ConfigurationKeys.Debugger.DynamicInstrumentationEnabled, "1" },
+ { ConfigurationKeys.Debugger.DynamicInstrumentationProbeFile, tempFile }
+ }),
+ NullConfigurationTelemetry.Instance);
+
+ var debugger = CreateDebugger(settings);
+ debugger.Initialize();
+
+ var timeout = TimeSpan.FromSeconds(5);
+ var startTime = DateTime.UtcNow;
+
+ while (GetFileProbes(debugger) is null && DateTime.UtcNow - startTime < timeout)
+ {
+ await Task.Delay(50);
+ }
+
+ var fileProbes = GetFileProbes(debugger);
+ fileProbes.Should().NotBeNull("Probe file should be loaded and applied");
+ fileProbes!.LogProbes.Should().HaveCount(1);
+ fileProbes.MetricProbes.Should().HaveCount(1);
+ fileProbes.SpanProbes.Should().HaveCount(1);
+ }
+
+ [Theory]
+ [InlineData(null, false, "non-existent file")]
+ [InlineData("{ invalid json }", true, "invalid json")]
+ [InlineData("", true, "empty file")]
+ [InlineData("[]", true, "empty array")]
+ public async Task ProbeFile_InvalidOrMissingProbeFile_InitializationContinues(string fileContent, bool createFile, string scenario)
+ {
+ string probeFilePath;
+
+ if (createFile)
+ {
+ probeFilePath = CreateTempProbeFile(fileContent ?? string.Empty);
+ }
+ else
+ {
+ probeFilePath = "/nonexistent/path/probes.json";
+ }
+
+ var settings = DebuggerSettings.FromSource(
+ new NameValueConfigurationSource(new()
+ {
+ { ConfigurationKeys.Debugger.DynamicInstrumentationEnabled, "1" },
+ { ConfigurationKeys.Debugger.DynamicInstrumentationProbeFile, probeFilePath }
+ }),
+ NullConfigurationTelemetry.Instance);
+
+ var debugger = CreateDebugger(settings);
+ debugger.Initialize();
+ await WaitForInitializationAsync(debugger);
+
+ debugger.IsInitialized.Should().BeTrue($"Initialization should complete for scenario '{scenario}'");
+ GetFileProbes(debugger).Should().BeNull($"No probes should be loaded for scenario '{scenario}'");
+ }
+
+ [Fact]
+ public async Task ProbeFile_NoProbeFileConfigured_SkipsLoading()
+ {
+ var settings = DebuggerSettings.FromSource(
+ new NameValueConfigurationSource(new()
+ {
+ { ConfigurationKeys.Debugger.DynamicInstrumentationEnabled, "1" }
+ }),
+ NullConfigurationTelemetry.Instance);
+
+ var debugger = CreateDebugger(settings);
+ debugger.Initialize();
+ await WaitForInitializationAsync(debugger);
+
+ debugger.IsInitialized.Should().BeTrue("Initialization should complete normally");
+ GetFileProbes(debugger).Should().BeNull("No probes should be loaded when no file is configured");
+ }
+
+ [Fact]
+ public async Task ProbeFile_PartiallyValidProbes_LoadsValidOnes()
+ {
+ var probeJson = @"[
+ {
+ ""id"": ""valid-1"",
+ ""type"": ""LOG_PROBE"",
+ ""where"": { ""sourceFile"": ""test.js"", ""lines"": [""10""] },
+ ""captureSnapshot"": true
+ },
+ {
+ ""id"": ""invalid-no-type"",
+ ""where"": { ""sourceFile"": ""test.js"", ""lines"": [""20""] }
+ },
+ {
+ ""id"": ""valid-2"",
+ ""type"": ""LOG_PROBE"",
+ ""where"": { ""sourceFile"": ""test.js"", ""lines"": [""30""] },
+ ""captureSnapshot"": false
+ }
+ ]";
+
+ var tempFile = CreateTempProbeFile(probeJson);
+
+ var settings = DebuggerSettings.FromSource(
+ new NameValueConfigurationSource(new()
+ {
+ { ConfigurationKeys.Debugger.DynamicInstrumentationEnabled, "1" },
+ { ConfigurationKeys.Debugger.DynamicInstrumentationProbeFile, tempFile }
+ }),
+ NullConfigurationTelemetry.Instance);
+
+ var debugger = CreateDebugger(settings);
+ debugger.Initialize();
+
+ var timeout = TimeSpan.FromSeconds(5);
+ var startTime = DateTime.UtcNow;
+
+ while (GetFileProbes(debugger) is null && DateTime.UtcNow - startTime < timeout)
+ {
+ await Task.Delay(50);
+ }
+
+ var fileProbes = GetFileProbes(debugger);
+ fileProbes.Should().NotBeNull("Valid probes should be loaded");
+ fileProbes!.LogProbes.Should().HaveCount(2, "Only valid probes should be loaded");
+ }
+
+ [Fact]
+ public async Task ProbeFile_DuplicateIdsWithinFile_KeepsFirstOccurrence()
+ {
+ var probeJson = @"[
+ {
+ ""id"": ""duplicate-id"",
+ ""type"": ""LOG_PROBE"",
+ ""where"": { ""sourceFile"": ""first.js"", ""lines"": [""10""] },
+ ""template"": ""First occurrence"",
+ ""captureSnapshot"": true
+ },
+ {
+ ""id"": ""unique-id"",
+ ""type"": ""LOG_PROBE"",
+ ""where"": { ""sourceFile"": ""unique.js"", ""lines"": [""20""] },
+ ""captureSnapshot"": true
+ },
+ {
+ ""id"": ""duplicate-id"",
+ ""type"": ""LOG_PROBE"",
+ ""where"": { ""sourceFile"": ""second.js"", ""lines"": [""30""] },
+ ""template"": ""Second occurrence"",
+ ""captureSnapshot"": false
+ }
+ ]";
+
+ var tempFile = CreateTempProbeFile(probeJson);
+
+ var settings = DebuggerSettings.FromSource(
+ new NameValueConfigurationSource(new()
+ {
+ { ConfigurationKeys.Debugger.DynamicInstrumentationEnabled, "1" },
+ { ConfigurationKeys.Debugger.DynamicInstrumentationProbeFile, tempFile }
+ }),
+ NullConfigurationTelemetry.Instance);
+
+ var debugger = CreateDebugger(settings);
+ debugger.Initialize();
+
+ var timeout = TimeSpan.FromSeconds(5);
+ var startTime = DateTime.UtcNow;
+
+ while (GetFileProbes(debugger) is null && DateTime.UtcNow - startTime < timeout)
+ {
+ await Task.Delay(50);
+ }
+
+ var fileProbes = GetFileProbes(debugger);
+ fileProbes.Should().NotBeNull("Probes should be loaded");
+ fileProbes!.LogProbes.Should().HaveCount(2, "Duplicate should be removed");
+
+ // Verify the first occurrence is kept
+ var duplicateProbe = fileProbes.LogProbes.FirstOrDefault(p => p.Id == "duplicate-id");
+ duplicateProbe.Should().NotBeNull();
+ duplicateProbe!.Where.SourceFile.Should().Be("first.js", "First occurrence should be kept");
+ duplicateProbe.Template.Should().Be("First occurrence");
+ }
+
+ private static ProbeConfiguration GetFileProbes(DynamicInstrumentation debugger)
+ {
+ var field = typeof(DynamicInstrumentation).GetField("_fileProbes", BindingFlags.Instance | BindingFlags.NonPublic);
+ field.Should().NotBeNull();
+ return (ProbeConfiguration)field.GetValue(debugger);
+ }
+
+ private string CreateTempProbeFile(string content)
+ {
+ var tempFile = Path.GetTempFileName();
+ _tempFiles.Add(tempFile);
+ File.WriteAllText(tempFile, content);
+ return tempFile;
+ }
+
+ private DynamicInstrumentation CreateDebugger(DebuggerSettings settings)
+ {
+ var discoveryService = new DiscoveryServiceMock();
+ var rcmSubscriptionManagerMock = new RcmSubscriptionManagerMock();
+ var lineProbeResolver = new LineProbeResolverMock();
+ var snapshotUploader = new SnapshotUploaderMock();
+ var logUploader = new LogUploaderMock();
+ var diagnosticsUploader = new UploaderMock();
+ var probeStatusPoller = new ProbeStatusPollerMock();
+
+ return new DynamicInstrumentation(
+ settings,
+ discoveryService,
+ rcmSubscriptionManagerMock,
+ lineProbeResolver,
+ snapshotUploader,
+ logUploader,
+ diagnosticsUploader,
+ probeStatusPoller,
+ ConfigurationUpdater.Create("env", "version"),
+ new DogStatsd.NoOpStatsd());
+ }
+ }
+
+ public class ProbeMergeUnitTests
+ {
+ [Fact]
+ public void MergeProbes_FileAndRcm_UnionOfIds()
+ {
+ var debugger = CreateDebugger();
+
+ var fileProbes = new[]
+ {
+ new LogProbe { Id = "file-probe-1" },
+ };
+
+ var rcmProbes = new[]
+ {
+ new LogProbe { Id = "rcm-probe-1" },
+ };
+
+ var merged = InvokeMergeProbes(debugger, fileProbes, rcmProbes);
+
+ merged.Select(p => p.Id).Should().BeEquivalentTo("file-probe-1", "rcm-probe-1");
+ }
+
+ [Fact]
+ public void MergeProbes_DuplicateIds_RcmWins()
+ {
+ var debugger = CreateDebugger();
+
+ var fileProbes = new[]
+ {
+ new LogProbe
+ {
+ Id = "shared-id",
+ Where = new Where { SourceFile = "file.js", Lines = new[] { "10" } },
+ Template = "From file",
+ CaptureSnapshot = true,
+ },
+ };
+
+ var rcmProbes = new[]
+ {
+ new LogProbe
+ {
+ Id = "shared-id",
+ Where = new Where { SourceFile = "rcm.js", Lines = new[] { "99" } },
+ Template = "From RCM",
+ CaptureSnapshot = false,
+ },
+ };
+
+ var merged = InvokeMergeProbes(debugger, fileProbes, rcmProbes);
+
+ merged.Should().HaveCount(1);
+
+ var probe = merged[0];
+ probe.Id.Should().Be("shared-id");
+ probe.Where.SourceFile.Should().Be("rcm.js");
+ probe.Template.Should().Be("From RCM");
+ probe.CaptureSnapshot.Should().BeFalse();
+ }
+
+ private static DynamicInstrumentation CreateDebugger()
+ {
+ var settings = DebuggerSettings.FromSource(
+ new NameValueConfigurationSource(new()
+ {
+ { ConfigurationKeys.Debugger.DynamicInstrumentationEnabled, "1" },
+ }),
+ NullConfigurationTelemetry.Instance);
+
+ var discoveryService = new DiscoveryServiceMock();
+ var rcmSubscriptionManagerMock = new RcmSubscriptionManagerMock();
+ var lineProbeResolver = new LineProbeResolverMock();
+ var snapshotUploader = new SnapshotUploaderMock();
+ var logUploader = new LogUploaderMock();
+ var diagnosticsUploader = new UploaderMock();
+ var probeStatusPoller = new ProbeStatusPollerMock();
+ var updater = ConfigurationUpdater.Create("env", "version");
+
+ return new DynamicInstrumentation(
+ settings,
+ discoveryService,
+ rcmSubscriptionManagerMock,
+ lineProbeResolver,
+ snapshotUploader,
+ logUploader,
+ diagnosticsUploader,
+ probeStatusPoller,
+ updater,
+ new DogStatsd.NoOpStatsd());
+ }
+
+ private static T[] InvokeMergeProbes(DynamicInstrumentation debugger, T[] fileProbes, T[] rcmProbes)
+ where T : ProbeDefinition
+ {
+ var method = typeof(DynamicInstrumentation).GetMethod("MergeProbes", BindingFlags.Instance | BindingFlags.NonPublic);
+ method.Should().NotBeNull();
+
+ return (T[])method!
+ .MakeGenericMethod(typeof(T))
+ .Invoke(debugger, [fileProbes, rcmProbes])!;
+ }
+ }
+
private class DiscoveryServiceMock : IDiscoveryService
{
internal bool Called { get; private set; }
@@ -126,8 +512,11 @@ private class RcmSubscriptionManagerMock : IRcmSubscriptionManager
public ICollection ProductKeys { get; } = new List();
+ public ISubscription LastSubscription { get; private set; }
+
public void SubscribeToChanges(ISubscription subscription)
{
+ LastSubscription = subscription;
foreach (var productKey in subscription.ProductKeys)
{
ProductKeys.Add(productKey);