diff --git a/tracer/missing-nullability-files.csv b/tracer/missing-nullability-files.csv index d5221abe7146..133afc1defe8 100644 --- a/tracer/missing-nullability-files.csv +++ b/tracer/missing-nullability-files.csv @@ -123,7 +123,6 @@ src/Datadog.Trace/HttpOverStreams/IHttpContent.cs src/Datadog.Trace/Iast/ITaintedMap.cs src/Datadog.Trace/Iast/SourceType.cs src/Datadog.Trace/PlatformHelpers/AspNetCoreHttpRequestHandler.cs -src/Datadog.Trace/PlatformHelpers/ContainerMetadata.cs src/Datadog.Trace/PlatformHelpers/ServiceFabric.cs src/Datadog.Trace/Processors/ITagProcessor.cs src/Datadog.Trace/Processors/NormalizerTraceProcessor.cs diff --git a/tracer/src/Datadog.Trace.Tools.Runner/Utils.cs b/tracer/src/Datadog.Trace.Tools.Runner/Utils.cs index 94a778ad6642..bc0c9eb22780 100644 --- a/tracer/src/Datadog.Trace.Tools.Runner/Utils.cs +++ b/tracer/src/Datadog.Trace.Tools.Runner/Utils.cs @@ -21,6 +21,7 @@ using Datadog.Trace.Configuration.ConfigurationSources.Telemetry; using Datadog.Trace.Configuration.Telemetry; using Datadog.Trace.Logging; +using Datadog.Trace.PlatformHelpers; using Datadog.Trace.Tools.Runner.Gac; using Datadog.Trace.Util; using Spectre.Console; @@ -420,6 +421,7 @@ public static async Task CheckAgentConnectionAsync(string ag Log.Debug("Creating DiscoveryService for: {AgentUri}", settings.Manager.InitialExporterSettings.AgentUri); var discoveryService = DiscoveryService.CreateUnmanaged( settings.Manager.InitialExporterSettings, + ContainerMetadata.Instance, tcpTimeout: TimeSpan.FromSeconds(5), initialRetryDelayMs: 200, maxRetryDelayMs: 1000, diff --git a/tracer/src/Datadog.Trace/Agent/DiscoveryService/DiscoveryService.cs b/tracer/src/Datadog.Trace/Agent/DiscoveryService/DiscoveryService.cs index cc251970f675..2f3a647d9f8a 100644 --- a/tracer/src/Datadog.Trace/Agent/DiscoveryService/DiscoveryService.cs +++ b/tracer/src/Datadog.Trace/Agent/DiscoveryService/DiscoveryService.cs @@ -14,6 +14,7 @@ using Datadog.Trace.Configuration; using Datadog.Trace.HttpOverStreams; using Datadog.Trace.Logging; +using Datadog.Trace.PlatformHelpers; using Datadog.Trace.SourceGenerators; using Datadog.Trace.Util; using Datadog.Trace.Vendors.Newtonsoft.Json.Linq; @@ -46,6 +47,7 @@ internal sealed class DiscoveryService : IDiscoveryService private readonly object _lock = new(); private readonly Task _discoveryTask; private readonly IDisposable? _settingSubscription; + private readonly ContainerMetadata _containerMetadata; private IApiRequestFactory _apiRequestFactory; private AgentConfiguration? _configuration; private string? _configurationHash; @@ -54,18 +56,19 @@ internal sealed class DiscoveryService : IDiscoveryService public DiscoveryService( TracerSettings.SettingsManager settings, + ContainerMetadata containerMetadata, TimeSpan tcpTimeout, int initialRetryDelayMs, int maxRetryDelayMs, int recheckIntervalMs) - : this(CreateApiRequestFactory(settings.InitialExporterSettings, tcpTimeout), initialRetryDelayMs, maxRetryDelayMs, recheckIntervalMs) + : this(CreateApiRequestFactory(settings.InitialExporterSettings, containerMetadata.ContainerId, tcpTimeout), containerMetadata, initialRetryDelayMs, maxRetryDelayMs, recheckIntervalMs) { // Create as a "managed" service that can update the request factory _settingSubscription = settings.SubscribeToChanges(changes => { if (changes.UpdatedExporter is { } exporter) { - var newFactory = CreateApiRequestFactory(exporter, tcpTimeout); + var newFactory = CreateApiRequestFactory(exporter, containerMetadata.ContainerId, tcpTimeout); Interlocked.Exchange(ref _apiRequestFactory!, newFactory); } }); @@ -77,11 +80,13 @@ public DiscoveryService( /// public DiscoveryService( IApiRequestFactory apiRequestFactory, + ContainerMetadata containerMetadata, int initialRetryDelayMs, int maxRetryDelayMs, int recheckIntervalMs) { _apiRequestFactory = apiRequestFactory; + _containerMetadata = containerMetadata; _initialRetryDelayMs = initialRetryDelayMs; _maxRetryDelayMs = maxRetryDelayMs; _recheckIntervalMs = recheckIntervalMs; @@ -114,9 +119,10 @@ public DiscoveryService( /// /// Create a instance that responds to runtime changes in settings /// - public static DiscoveryService CreateManaged(TracerSettings settings) + public static DiscoveryService CreateManaged(TracerSettings settings, ContainerMetadata containerMetadata) => new( settings.Manager, + containerMetadata, tcpTimeout: TimeSpan.FromSeconds(15), initialRetryDelayMs: 500, maxRetryDelayMs: 5_000, @@ -125,9 +131,10 @@ public static DiscoveryService CreateManaged(TracerSettings settings) /// /// Create a instance that does _not_ respond to runtime changes in settings /// - public static DiscoveryService CreateUnmanaged(ExporterSettings exporterSettings) + public static DiscoveryService CreateUnmanaged(ExporterSettings exporterSettings, ContainerMetadata containerMetadata) => CreateUnmanaged( exporterSettings, + containerMetadata, tcpTimeout: TimeSpan.FromSeconds(15), initialRetryDelayMs: 500, maxRetryDelayMs: 5_000, @@ -138,12 +145,14 @@ public static DiscoveryService CreateUnmanaged(ExporterSettings exporterSettings /// public static DiscoveryService CreateUnmanaged( ExporterSettings exporterSettings, + ContainerMetadata containerMetadata, TimeSpan tcpTimeout, int initialRetryDelayMs, int maxRetryDelayMs, int recheckIntervalMs) => new( - CreateApiRequestFactory(exporterSettings, tcpTimeout), + CreateApiRequestFactory(exporterSettings, containerMetadata.ContainerId, tcpTimeout), + containerMetadata, initialRetryDelayMs, maxRetryDelayMs, recheckIntervalMs); @@ -297,6 +306,13 @@ internal bool RequireRefresh(string? currentHash, DateTimeOffset utcNow) private async Task ProcessDiscoveryResponse(IApiResponse response) { + // Extract and store container tags hash from response headers + var containerTagsHash = response.GetHeader(AgentHttpHeaderNames.ContainerTagsHash); + if (containerTagsHash != null) + { + _containerMetadata.ContainerTagsHash = containerTagsHash; + } + // Grab the original stream var stream = await response.GetStreamAsync().ConfigureAwait(false); @@ -440,13 +456,34 @@ public Task DisposeAsync() return _discoveryTask; } - private static IApiRequestFactory CreateApiRequestFactory(ExporterSettings exporterSettings, TimeSpan tcpTimeout) - => AgentTransportStrategy.Get( + /// + /// Builds the headers array for the discovery service, including the container ID if available. + /// Internal for testing purposes. + /// + internal static KeyValuePair[] BuildHeaders(string? containerId) + { + if (containerId != null) + { + // if container ID is available, add it to headers + return + [ + ..AgentHttpHeaderNames.MinimalHeaders, + new(AgentHttpHeaderNames.ContainerId, containerId), + ]; + } + + return AgentHttpHeaderNames.MinimalHeaders; + } + + private static IApiRequestFactory CreateApiRequestFactory(ExporterSettings exporterSettings, string? containerId, TimeSpan tcpTimeout) + { + return AgentTransportStrategy.Get( exporterSettings, productName: "discovery", tcpTimeout: tcpTimeout, - AgentHttpHeaderNames.MinimalHeaders, - () => new MinimalAgentHeaderHelper(), + BuildHeaders(containerId), + () => new MinimalAgentHeaderHelper(containerId), uri => uri); + } } } diff --git a/tracer/src/Datadog.Trace/AgentHttpHeaderNames.cs b/tracer/src/Datadog.Trace/AgentHttpHeaderNames.cs index 1c2b840aed3d..86781dfbf454 100644 --- a/tracer/src/Datadog.Trace/AgentHttpHeaderNames.cs +++ b/tracer/src/Datadog.Trace/AgentHttpHeaderNames.cs @@ -43,6 +43,11 @@ internal static class AgentHttpHeaderNames /// public const string ContainerId = "Datadog-Container-ID"; + /// + /// The hash of the container tags received from the agent. + /// + public const string ContainerTagsHash = "Datadog-Container-Tags-Hash"; + /// /// The unique identifier of the container where the traced application is running, either as the container id /// or the cgroup node controller's inode. diff --git a/tracer/src/Datadog.Trace/Ci/TestOptimization.cs b/tracer/src/Datadog.Trace/Ci/TestOptimization.cs index 60062e37cda0..c7c2682ee4ca 100644 --- a/tracer/src/Datadog.Trace/Ci/TestOptimization.cs +++ b/tracer/src/Datadog.Trace/Ci/TestOptimization.cs @@ -14,6 +14,7 @@ using Datadog.Trace.Configuration; using Datadog.Trace.Logging; using Datadog.Trace.Pdb; +using Datadog.Trace.PlatformHelpers; using Datadog.Trace.Telemetry; using Datadog.Trace.Util; using TaskExtensions = Datadog.Trace.ExtensionMethods.TaskExtensions; @@ -248,6 +249,7 @@ public void Initialize() settings: Settings, getDiscoveryServiceFunc: static s => DiscoveryService.CreateUnmanaged( s.TracerSettings.Manager.InitialExporterSettings, + ContainerMetadata.Instance, tcpTimeout: TimeSpan.FromSeconds(5), initialRetryDelayMs: 100, maxRetryDelayMs: 1000, diff --git a/tracer/src/Datadog.Trace/HttpOverStreams/MinimalAgentHeaderHelper.cs b/tracer/src/Datadog.Trace/HttpOverStreams/MinimalAgentHeaderHelper.cs index 085085ec4ca7..0cff7ade3a91 100644 --- a/tracer/src/Datadog.Trace/HttpOverStreams/MinimalAgentHeaderHelper.cs +++ b/tracer/src/Datadog.Trace/HttpOverStreams/MinimalAgentHeaderHelper.cs @@ -11,6 +11,13 @@ namespace Datadog.Trace.HttpOverStreams; internal class MinimalAgentHeaderHelper : HttpHeaderHelperBase { private static string? _metadataHeaders = null; + private static string? _metadataHeadersWithContainerId = null; + private readonly string? _containerId; + + public MinimalAgentHeaderHelper(string? containerId = null) + { + _containerId = containerId; + } protected override string MetadataHeaders { @@ -22,6 +29,18 @@ protected override string MetadataHeaders _metadataHeaders = string.Concat(headers); } + if (_containerId != null) + { + if (_metadataHeadersWithContainerId == null) + { + // Assuming we'd always get the same container ID. The first time, we use its value to build the header, + // then it's just a marker that we want the header with the containerID in it, and we don't look at its value. + _metadataHeadersWithContainerId = _metadataHeaders + $"{AgentHttpHeaderNames.ContainerId}: {_containerId}{DatadogHttpValues.CrLf}"; + } + + return _metadataHeadersWithContainerId; + } + return _metadataHeaders; } } diff --git a/tracer/src/Datadog.Trace/PlatformHelpers/ContainerMetadata.NetFramework.cs b/tracer/src/Datadog.Trace/PlatformHelpers/ContainerMetadata.NetFramework.cs index 20552d4424b6..53873ae67ae0 100644 --- a/tracer/src/Datadog.Trace/PlatformHelpers/ContainerMetadata.NetFramework.cs +++ b/tracer/src/Datadog.Trace/PlatformHelpers/ContainerMetadata.NetFramework.cs @@ -5,6 +5,8 @@ #nullable enable +using System.Threading; + #if NETFRAMEWORK namespace Datadog.Trace.PlatformHelpers; @@ -26,6 +28,16 @@ public ContainerMetadata(string containerId, string entityId) // nothing to do, just to match the other version } + /// + /// Gets or sets the container tags hash received from the agent, used by DBM/DSM + /// This is set when we receive a value for it in an http response from the agent + /// + public string? ContainerTagsHash + { + get => Volatile.Read(ref field); + set => Volatile.Write(ref field, value); + } + /// /// Gets the id of the container executing the code. /// Return null if code is not executing inside a supported container. diff --git a/tracer/src/Datadog.Trace/PlatformHelpers/ContainerMetadata.cs b/tracer/src/Datadog.Trace/PlatformHelpers/ContainerMetadata.cs index cebda5aed494..17a6cb0690fe 100644 --- a/tracer/src/Datadog.Trace/PlatformHelpers/ContainerMetadata.cs +++ b/tracer/src/Datadog.Trace/PlatformHelpers/ContainerMetadata.cs @@ -3,6 +3,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. // +#nullable enable #if !NETFRAMEWORK using System; @@ -43,20 +44,31 @@ internal sealed class ContainerMetadata public static readonly ContainerMetadata Instance = new(); - private readonly Lazy _containerId; - private readonly Lazy _entityId; + private readonly Lazy _containerId; + private readonly Lazy _entityId; private ContainerMetadata() { - _containerId = new Lazy(GetContainerIdInternal, LazyThreadSafetyMode.ExecutionAndPublication); - _entityId = new Lazy(() => GetEntityIdInternal(_containerId), LazyThreadSafetyMode.ExecutionAndPublication); + _containerId = new Lazy(GetContainerIdInternal, LazyThreadSafetyMode.ExecutionAndPublication); + _entityId = new Lazy(() => GetEntityIdInternal(_containerId), LazyThreadSafetyMode.ExecutionAndPublication); } // For use in tests only - public ContainerMetadata(string containerId, string entityId) + [TestingOnly] + public ContainerMetadata(string? containerId, string? entityId) { - _containerId = new Lazy(() => containerId); - _entityId = new Lazy(() => entityId); + _containerId = new Lazy(() => containerId); + _entityId = new Lazy(() => entityId); + } + + /// + /// Gets or sets the container tags hash received from the agent, used by DBM/DSM + /// This is set when we receive a value for it in an http response from the agent + /// + public string? ContainerTagsHash + { + get => Volatile.Read(ref field); + set => Volatile.Write(ref field, value); } /// @@ -64,7 +76,7 @@ public ContainerMetadata(string containerId, string entityId) /// Return null if code is not executing inside a supported container. /// /// The container id or null. - public string ContainerId + public string? ContainerId { get => _containerId.Value; } @@ -80,7 +92,7 @@ public string ContainerId /// /// /// The entity id or null. - public string EntityId + public string? EntityId { get => _entityId.Value; } @@ -90,7 +102,7 @@ public string EntityId /// /// Lines of text from a cgroup file. /// The container id if found; otherwise, null. - public static string ParseContainerIdFromCgroupLines(IEnumerable lines) + public static string? ParseContainerIdFromCgroupLines(IEnumerable lines) { return lines.Select(ParseContainerIdFromCgroupLine) .FirstOrDefault(id => !string.IsNullOrWhiteSpace(id)); @@ -101,7 +113,7 @@ public static string ParseContainerIdFromCgroupLines(IEnumerable lines) /// /// A single line from a cgroup file. /// The container id if found; otherwise, null. - public static string ParseContainerIdFromCgroupLine(string line) + public static string? ParseContainerIdFromCgroupLine(string line) { var lineMatch = Regex.Match(line, ContainerIdRegex); @@ -119,7 +131,7 @@ public static string ParseContainerIdFromCgroupLine(string line) /// Path to the cgroup mount point. /// Lines of text from a cgroup file. /// The cgroup node controller's inode if found; otherwise, null. - public static string ExtractInodeFromCgroupLines(string controlGroupsMountPath, IEnumerable lines) + public static string? ExtractInodeFromCgroupLines(string controlGroupsMountPath, IEnumerable lines) { foreach (var line in lines) { @@ -147,7 +159,7 @@ public static string ExtractInodeFromCgroupLines(string controlGroupsMountPath, /// /// A single line from a cgroup file. /// The controller/cgroup-node-path pair if found; otherwise, null. - public static Tuple ParseControllerAndPathFromCgroupLine(string line) + public static Tuple? ParseControllerAndPathFromCgroupLine(string line) { var lineMatch = Regex.Match(line, CgroupRegex); @@ -181,7 +193,7 @@ internal static bool TryGetInodeUsingPInvoke(string path, out long result) return false; } - static void LogError(Exception ex, string message) + static void LogError(Exception? ex, string message) { #pragma warning disable DDLOG004 // Must use constant strings - disabled as it's an integer only, and only called twice in the app lifetime if (EnvironmentHelpersNoLogging.IsClrProfilerAttachedSafe()) @@ -215,7 +227,7 @@ internal static bool TryGetInodeUsingStat(string path, out long result) } } - private static string GetContainerIdInternal() + private static string? GetContainerIdInternal() { try { @@ -236,7 +248,7 @@ private static string GetContainerIdInternal() return null; } - private static string GetEntityIdInternal(Lazy lazyContainerId) + private static string? GetEntityIdInternal(Lazy lazyContainerId) { if (lazyContainerId.Value is string containerId) { @@ -252,7 +264,7 @@ private static string GetEntityIdInternal(Lazy lazyContainerId) } } - private static string GetCgroupInode() + private static string? GetCgroupInode() { try { diff --git a/tracer/src/Datadog.Trace/TracerManagerFactory.cs b/tracer/src/Datadog.Trace/TracerManagerFactory.cs index 9f66bd798f25..ff222625c31f 100644 --- a/tracer/src/Datadog.Trace/TracerManagerFactory.cs +++ b/tracer/src/Datadog.Trace/TracerManagerFactory.cs @@ -20,6 +20,7 @@ using Datadog.Trace.Logging; using Datadog.Trace.Logging.DirectSubmission; using Datadog.Trace.Logging.TracerFlare; +using Datadog.Trace.PlatformHelpers; using Datadog.Trace.RemoteConfigurationManagement; using Datadog.Trace.RemoteConfigurationManagement.Transport; using Datadog.Trace.RuntimeMetrics; @@ -298,8 +299,8 @@ protected virtual IAgentWriter GetAgentWriter(TracerSettings settings, IStatsdMa } internal virtual IDiscoveryService GetDiscoveryService(TracerSettings settings) - => settings.AgentFeaturePollingEnabled ? - DiscoveryService.CreateManaged(settings) : + => settings.AgentFeaturePollingEnabled ? DiscoveryService.CreateManaged(settings, ContainerMetadata.Instance) + : NullDiscoveryService.Instance; } } diff --git a/tracer/test/Datadog.Trace.IntegrationTests/LibDatadog/TraceExporterTests.cs b/tracer/test/Datadog.Trace.IntegrationTests/LibDatadog/TraceExporterTests.cs index 3a5bc5a47dab..7286d658e777 100644 --- a/tracer/test/Datadog.Trace.IntegrationTests/LibDatadog/TraceExporterTests.cs +++ b/tracer/test/Datadog.Trace.IntegrationTests/LibDatadog/TraceExporterTests.cs @@ -16,6 +16,7 @@ using Datadog.Trace.DogStatsd; using Datadog.Trace.LibDatadog; using Datadog.Trace.LibDatadog.DataPipeline; +using Datadog.Trace.PlatformHelpers; using Datadog.Trace.Telemetry; using Datadog.Trace.TestHelpers; using Datadog.Trace.TestHelpers.Stats; @@ -76,7 +77,7 @@ public async Task SendsTracesUsingDataPipeline(TestTransports transport) var sampleRateResponses = new ConcurrentQueue>(); - var discovery = DiscoveryService.CreateUnmanaged(tracerSettings.Manager.InitialExporterSettings); + var discovery = DiscoveryService.CreateUnmanaged(tracerSettings.Manager.InitialExporterSettings, new ContainerMetadata(containerId: null, entityId: null)); var statsd = new NoOpStatsd(); // We have to replace the agent writer so that we can intercept the sample rate responses diff --git a/tracer/test/Datadog.Trace.IntegrationTests/StatsTests.cs b/tracer/test/Datadog.Trace.IntegrationTests/StatsTests.cs index ff2ee9b74b08..db5389f179a0 100644 --- a/tracer/test/Datadog.Trace.IntegrationTests/StatsTests.cs +++ b/tracer/test/Datadog.Trace.IntegrationTests/StatsTests.cs @@ -58,7 +58,7 @@ public async Task SendsStatsWithProcessing_Normalizer() { ConfigurationKeys.TraceDataPipelineEnabled, "false" }, }); - var discovery = DiscoveryService.CreateUnmanaged(settings.Manager.InitialExporterSettings); + var discovery = DiscoveryService.CreateUnmanaged(settings.Manager.InitialExporterSettings, new ContainerMetadata(null, null)); // Note: we are explicitly _not_ using a using here, as we dispose it ourselves manually at a specific point // and this was easiest to retrofit without changing the test structure too much. var tracer = TracerHelper.Create(settings, agentWriter: null, sampler: null, scopeManager: null, statsd: null, discoveryService: discovery); @@ -205,7 +205,7 @@ public async Task SendsStatsWithProcessing_Obfuscator() { ConfigurationKeys.TraceDataPipelineEnabled, "false" }, }); - var discovery = DiscoveryService.CreateUnmanaged(settings.Manager.InitialExporterSettings); + var discovery = DiscoveryService.CreateUnmanaged(settings.Manager.InitialExporterSettings, new ContainerMetadata(null, null)); // Note: we are explicitly _not_ using a using here, as we dispose it ourselves manually at a specific point // and this was easiest to retrofit without changing the test structure too much. var tracer = TracerHelper.Create(settings, agentWriter: null, sampler: null, scopeManager: null, statsd: null, discoveryService: discovery); @@ -366,7 +366,7 @@ private async Task SendStatsHelper(bool statsComputationEnabled, bool expectStat { ConfigurationKeys.TraceDataPipelineEnabled, "false" }, })); - var discovery = DiscoveryService.CreateUnmanaged(settings.Manager.InitialExporterSettings); + var discovery = DiscoveryService.CreateUnmanaged(settings.Manager.InitialExporterSettings, new ContainerMetadata(null, null)); // Note: we are explicitly _not_ using a using here, as we dispose it ourselves manually at a specific point // and this was easiest to retrofit without changing the test structure too much. var tracer = TracerHelper.Create(settings, agentWriter: null, sampler: null, scopeManager: null, statsd: null, discoveryService: discovery); diff --git a/tracer/test/Datadog.Trace.TestHelpers/TransportHelpers/TestApiResponse.cs b/tracer/test/Datadog.Trace.TestHelpers/TransportHelpers/TestApiResponse.cs index 0a28bc493387..cb1397f3fe7e 100644 --- a/tracer/test/Datadog.Trace.TestHelpers/TransportHelpers/TestApiResponse.cs +++ b/tracer/test/Datadog.Trace.TestHelpers/TransportHelpers/TestApiResponse.cs @@ -21,7 +21,7 @@ public TestApiResponse(int statusCode, string body, string contentType, Dictiona { StatusCode = statusCode; _body = body; - _headers = headers; + _headers = headers ?? new(); ContentTypeHeader = contentType; } diff --git a/tracer/test/Datadog.Trace.Tests/Agent/DiscoveryServiceTests.cs b/tracer/test/Datadog.Trace.Tests/Agent/DiscoveryServiceTests.cs index 215f7b229d98..aa395b44fd5a 100644 --- a/tracer/test/Datadog.Trace.Tests/Agent/DiscoveryServiceTests.cs +++ b/tracer/test/Datadog.Trace.Tests/Agent/DiscoveryServiceTests.cs @@ -4,11 +4,13 @@ // using System; +using System.Collections.Generic; using System.Net; using System.Threading; using System.Threading.Tasks; using Datadog.Trace.Agent; using Datadog.Trace.Agent.DiscoveryService; +using Datadog.Trace.PlatformHelpers; using Datadog.Trace.TestHelpers; using Datadog.Trace.TestHelpers.TransportHelpers; using Datadog.Trace.Vendors.Newtonsoft.Json; @@ -25,6 +27,8 @@ public class DiscoveryServiceTests private const int MaxRetryDelayMs = 50; private const int RecheckIntervalMs = 300_000; + private static readonly ContainerMetadata NullContainerMetadata = new(containerId: null, entityId: null); + [Fact] public async Task HandlesFlakyConfiguration() { @@ -33,7 +37,7 @@ public async Task HandlesFlakyConfiguration() x => new FaultyApiRequest(x), x => new TestApiRequest(x)); - var ds = new DiscoveryService(factory, InitialRetryDelayMs, MaxRetryDelayMs, RecheckIntervalMs); + var ds = new DiscoveryService(factory, NullContainerMetadata, InitialRetryDelayMs, MaxRetryDelayMs, RecheckIntervalMs); ds.SubscribeToChanges(x => mutex.Set()); mutex.Wait(30_000).Should().BeTrue("Should raise subscription changes"); @@ -52,7 +56,7 @@ public async Task ReturnsDeserializedConfig() var factory = new TestRequestFactory( x => new TestApiRequest(x, responseContent: GetConfig(clientDropP0s, version))); - var ds = new DiscoveryService(factory, InitialRetryDelayMs, MaxRetryDelayMs, RecheckIntervalMs); + var ds = new DiscoveryService(factory, NullContainerMetadata, InitialRetryDelayMs, MaxRetryDelayMs, RecheckIntervalMs); ds.SubscribeToChanges( x => { @@ -85,7 +89,7 @@ public async Task CalculatesConfigStateHash() var factory = new TestRequestFactory( x => new TestApiRequest(x, responseContent: serializedConfig)); - await using var ds = new DiscoveryService(factory, InitialRetryDelayMs, MaxRetryDelayMs, RecheckIntervalMs); + await using var ds = new DiscoveryService(factory, NullContainerMetadata, InitialRetryDelayMs, MaxRetryDelayMs, RecheckIntervalMs); ds.SubscribeToChanges( x => { @@ -119,7 +123,7 @@ public async Task DoesNotFireInitialCallbackIfInitialConfigNotFetched() return new TestApiRequest(x, responseContent: GetConfig()); }); - var ds = new DiscoveryService(factory, InitialRetryDelayMs, MaxRetryDelayMs, RecheckIntervalMs); + var ds = new DiscoveryService(factory, NullContainerMetadata, InitialRetryDelayMs, MaxRetryDelayMs, RecheckIntervalMs); ds.SubscribeToChanges(x => notificationFired = true); await Task.Delay(5_000); // should recheck 5 times in this duration @@ -141,7 +145,7 @@ public async Task FiresInitialCallbackIfInitialConfigAlreadyFetched() }, y => throw new Exception("Should not make a second request")); - var ds = new DiscoveryService(factory, InitialRetryDelayMs, MaxRetryDelayMs, RecheckIntervalMs); + var ds = new DiscoveryService(factory, NullContainerMetadata, InitialRetryDelayMs, MaxRetryDelayMs, RecheckIntervalMs); // make sure we have config ds.SubscribeToChanges(x => mutex.Set()); mutex.Wait(30_000).Should().BeTrue("Should make request to api"); @@ -172,7 +176,7 @@ public async Task DoesNotFireCallbackOnRecheckIfNoChangesToConfig() return new TestApiRequest(x, responseContent: GetConfig()); }); - var ds = new DiscoveryService(factory, InitialRetryDelayMs, MaxRetryDelayMs, recheckIntervalMs); + var ds = new DiscoveryService(factory, NullContainerMetadata, InitialRetryDelayMs, MaxRetryDelayMs, recheckIntervalMs); ds.SubscribeToChanges(x => Interlocked.Increment(ref notificationCount)); // fire first request mutex1.Set(); @@ -204,7 +208,7 @@ public async Task FiresCallbackOnRecheckIfHasChangesToConfig() return new TestApiRequest(x, responseContent: GetConfig(dropP0: false)); }); - var ds = new DiscoveryService(factory, InitialRetryDelayMs, MaxRetryDelayMs, recheckIntervalMs); + var ds = new DiscoveryService(factory, NullContainerMetadata, InitialRetryDelayMs, MaxRetryDelayMs, recheckIntervalMs); ds.SubscribeToChanges(x => Interlocked.Increment(ref notificationCount)); // fire first request mutex1.Set(); @@ -237,7 +241,7 @@ public async Task DoesNotFireAfterUnsubscribing() return new TestApiRequest(x, responseContent: GetConfig(dropP0: false)); }); - var ds = new DiscoveryService(factory, InitialRetryDelayMs, MaxRetryDelayMs, recheckIntervalMs); + var ds = new DiscoveryService(factory, NullContainerMetadata, InitialRetryDelayMs, MaxRetryDelayMs, recheckIntervalMs); ds.SubscribeToChanges(Callback); @@ -270,7 +274,7 @@ public async Task DisposesInATimelyManner() }, x => new TestApiRequest(x, responseContent: GetConfig(dropP0: false))); - var ds = new DiscoveryService(factory, InitialRetryDelayMs, MaxRetryDelayMs, RecheckIntervalMs); + var ds = new DiscoveryService(factory, NullContainerMetadata, InitialRetryDelayMs, MaxRetryDelayMs, RecheckIntervalMs); // should be inside recheck loop mutex.Wait(30_000).Should().BeTrue("Should make request to api"); @@ -350,7 +354,7 @@ public async Task RequireRefresh(string originalHash, string agentHash, int time { var recheckIntervalMs = 30_000; var factory = new TestRequestFactory(); - await using var ds = new DiscoveryService(factory, InitialRetryDelayMs, MaxRetryDelayMs, recheckIntervalMs); + await using var ds = new DiscoveryService(factory, NullContainerMetadata, InitialRetryDelayMs, MaxRetryDelayMs, recheckIntervalMs); var now = DateTimeOffset.UtcNow; ds.SetCurrentConfigStateHash(agentHash); @@ -375,7 +379,7 @@ public async Task HandlesFailuresInApiWithBackoff() // These are the default values in the other constructor // but setting them explicitly here as it's the behaviour we're testing // not the exact values we choose later - var ds = new DiscoveryService(factory, initialRetryDelayMs: 500, maxRetryDelayMs: 5_000, recheckIntervalMs: 30_000); + var ds = new DiscoveryService(factory, NullContainerMetadata, initialRetryDelayMs: 500, maxRetryDelayMs: 5_000, recheckIntervalMs: 30_000); ds.SubscribeToChanges(_ => mutex.Set()); // wait for 0 + 500 + 1000 + 2000 + 4000 + 5000 ms (+ 2500 buffer). @@ -387,6 +391,51 @@ public async Task HandlesFailuresInApiWithBackoff() factory.RequestsSent.Count.Should().BeInRange(3, 6, "Should make between 3 and 6 retries in 13s"); } + [Fact] + public async Task ExtractsContainerTagsHashFromResponseHeader() + { + const string expectedTagsHash = "test-container-tags-hash-123"; + using var mutex = new ManualResetEventSlim(); + + var factory = new TestRequestFactory(x => new TestApiRequest( + x, + responseContent: GetConfig(), + responseHeaders: new Dictionary { { AgentHttpHeaderNames.ContainerTagsHash, expectedTagsHash } })); + + var containerMetadata = new ContainerMetadata(containerId: null, entityId: null); + + var ds = new DiscoveryService(factory, containerMetadata, InitialRetryDelayMs, MaxRetryDelayMs, RecheckIntervalMs); + ds.SubscribeToChanges(x => mutex.Set()); + + mutex.Wait(30_000).Should().BeTrue("Should raise subscription changes"); + + // Verify the container tags hash was extracted and stored + containerMetadata.ContainerTagsHash.Should().Be(expectedTagsHash); + + await ds.DisposeAsync(); + } + + [Fact] + public void BuildHeaders_WithContainerId_IncludesContainerIdHeader() + { + const string containerId = "test-container-id-12345"; + + var headers = DiscoveryService.BuildHeaders(containerId); + + headers.Should().HaveCount(AgentHttpHeaderNames.MinimalHeaders.Length + 1); + headers.Should().Contain(AgentHttpHeaderNames.MinimalHeaders); + headers.Should().Contain(kvp => kvp.Value == containerId); + } + + [Fact] + public void BuildHeaders_WithoutContainerId_ReturnsMinimalHeaders() + { + var headers = DiscoveryService.BuildHeaders(null); + + // Should return exactly the minimal headers + headers.Should().BeEquivalentTo(AgentHttpHeaderNames.MinimalHeaders); + } + private string GetConfig(bool dropP0 = true, string version = null) => JsonConvert.SerializeObject(new MockTracerAgent.AgentConfiguration() { ClientDropP0s = dropP0, AgentVersion = version });