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);