diff --git a/src/OpenTelemetry.OpAmp.Client/.publicApi/PublicAPI.Unshipped.txt b/src/OpenTelemetry.OpAmp.Client/.publicApi/PublicAPI.Unshipped.txt index 1780095e3a..c6ffd9adba 100644 --- a/src/OpenTelemetry.OpAmp.Client/.publicApi/PublicAPI.Unshipped.txt +++ b/src/OpenTelemetry.OpAmp.Client/.publicApi/PublicAPI.Unshipped.txt @@ -1,7 +1,14 @@ OpenTelemetry.OpAmp.Client.Listeners.IOpAmpListener OpenTelemetry.OpAmp.Client.Listeners.IOpAmpListener.HandleMessage(TMessage! message) -> void +OpenTelemetry.OpAmp.Client.Messages.AgentConfigFile +OpenTelemetry.OpAmp.Client.Messages.AgentConfigFile.Body.get -> System.ReadOnlySpan +OpenTelemetry.OpAmp.Client.Messages.AgentConfigFile.ContentType.get -> string? +OpenTelemetry.OpAmp.Client.Messages.AgentConfigFile.Name.get -> string! OpenTelemetry.OpAmp.Client.Messages.OpAmpMessage OpenTelemetry.OpAmp.Client.Messages.OpAmpMessage.OpAmpMessage() -> void +OpenTelemetry.OpAmp.Client.Messages.RemoteConfigMessage +OpenTelemetry.OpAmp.Client.Messages.RemoteConfigMessage.AgentConfigMap.get -> System.Collections.Generic.IReadOnlyDictionary! +OpenTelemetry.OpAmp.Client.Messages.RemoteConfigMessage.ConfigHash.get -> System.ReadOnlySpan OpenTelemetry.OpAmp.Client.OpAmpClient OpenTelemetry.OpAmp.Client.OpAmpClient.Dispose() -> void OpenTelemetry.OpAmp.Client.OpAmpClient.OpAmpClient(System.Action? configure = null) -> void diff --git a/src/OpenTelemetry.OpAmp.Client/CHANGELOG.md b/src/OpenTelemetry.OpAmp.Client/CHANGELOG.md index 63805db847..b2e9297081 100644 --- a/src/OpenTelemetry.OpAmp.Client/CHANGELOG.md +++ b/src/OpenTelemetry.OpAmp.Client/CHANGELOG.md @@ -5,11 +5,16 @@ * Add setting to configure the factory used to create `HttpClient` instances used for the OpAMP Plain HTTP transport. ([#3589](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/3589)) + * Add support for subscribing and unsubscribing to messages from the OpAMP server. ([#3593](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/3593)) + * Clean up directories and namespaces for public API. ([#3612](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/3612)) +* Expose public `RemoteConfigMessage`. + ([#3614](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/3614)) + ## 0.1.0-alpha.3 Released 2025-Nov-13 diff --git a/src/OpenTelemetry.OpAmp.Client/Internal/Messages/RemoteConfigMessage.cs b/src/OpenTelemetry.OpAmp.Client/Internal/Messages/RemoteConfigMessage.cs deleted file mode 100644 index 92d4eaa627..0000000000 --- a/src/OpenTelemetry.OpAmp.Client/Internal/Messages/RemoteConfigMessage.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -using OpAmp.Proto.V1; -using OpenTelemetry.OpAmp.Client.Messages; - -namespace OpenTelemetry.OpAmp.Client.Internal.Listeners.Messages; - -internal class RemoteConfigMessage : OpAmpMessage -{ - public RemoteConfigMessage(AgentRemoteConfig agentRemoteConfig) - { - this.RemoteConfig = agentRemoteConfig; - } - - public AgentRemoteConfig RemoteConfig { get; set; } -} diff --git a/src/OpenTelemetry.OpAmp.Client/Messages/RemoteConfiguration/AgentConfigFile.cs b/src/OpenTelemetry.OpAmp.Client/Messages/RemoteConfiguration/AgentConfigFile.cs new file mode 100644 index 0000000000..43f9f93a13 --- /dev/null +++ b/src/OpenTelemetry.OpAmp.Client/Messages/RemoteConfiguration/AgentConfigFile.cs @@ -0,0 +1,36 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Google.Protobuf; + +namespace OpenTelemetry.OpAmp.Client.Messages; + +/// +/// Represents an agent configuration file. +/// +public class AgentConfigFile +{ + private readonly ByteString body; + + internal AgentConfigFile(string name, global::OpAmp.Proto.V1.AgentConfigFile agentConfigFile) + { + this.body = agentConfigFile.Body ?? ByteString.Empty; + this.ContentType = agentConfigFile.ContentType; + this.Name = name; + } + + /// + /// Gets the byte content of the configuration file. + /// + public ReadOnlySpan Body => this.body.Span; + + /// + /// Gets the MIME Content-Type that describes the data contained in the body of the remote configuration file. + /// + public string? ContentType { get; } + + /// + /// Gets the name of this configuration file. + /// + public string Name { get; } +} diff --git a/src/OpenTelemetry.OpAmp.Client/Messages/RemoteConfiguration/RemoteConfigMessage.cs b/src/OpenTelemetry.OpAmp.Client/Messages/RemoteConfiguration/RemoteConfigMessage.cs new file mode 100644 index 0000000000..917e9f58fb --- /dev/null +++ b/src/OpenTelemetry.OpAmp.Client/Messages/RemoteConfiguration/RemoteConfigMessage.cs @@ -0,0 +1,42 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Google.Protobuf; +using OpAmp.Proto.V1; + +namespace OpenTelemetry.OpAmp.Client.Messages; + +/// +/// Represents an OpAMP server-to-agent remote configuration message. +/// +public class RemoteConfigMessage : OpAmpMessage +{ + private readonly Dictionary agentConfigMap; + private readonly ByteString configHash; + + internal RemoteConfigMessage(AgentRemoteConfig agentRemoteConfig) + { + this.agentConfigMap = new Dictionary(agentRemoteConfig.Config.ConfigMap.Count, StringComparer.Ordinal); + + foreach (var config in agentRemoteConfig.Config.ConfigMap) + { + // The value should never be null, but just in case... + if (config.Value is not null) + { + this.agentConfigMap[config.Key] = new AgentConfigFile(config.Key, config.Value); + } + } + + this.configHash = agentRemoteConfig.ConfigHash; + } + + /// + /// Gets a dictionary of agent configuration files, keyed by the name of the configuration file. + /// + public IReadOnlyDictionary AgentConfigMap => this.agentConfigMap; + + /// + /// Gets the hash value representing the current remote configuration. + /// + public ReadOnlySpan ConfigHash => this.configHash.Span; +} diff --git a/test/OpenTelemetry.OpAmp.Client.Tests/Messages/RemoteConfigMessageTests.cs b/test/OpenTelemetry.OpAmp.Client.Tests/Messages/RemoteConfigMessageTests.cs new file mode 100644 index 0000000000..45e16f19a7 --- /dev/null +++ b/test/OpenTelemetry.OpAmp.Client.Tests/Messages/RemoteConfigMessageTests.cs @@ -0,0 +1,273 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Text; +using Google.Protobuf; +using Xunit; + +namespace OpenTelemetry.OpAmp.Client.Tests.Messages; + +public class RemoteConfigMessageTests +{ + private const string JsonString = "{ \"myKey\": \"this is a value\" }"; + private const string YamlString = "enabled: true"; + private const string HashString = "dummy-hash"; + private const string Config1Name = "config1"; + private const string Config2Name = "config2"; + private const string JsonContentType = "application/json"; + private const string YamlContentType = "application/yaml"; + + [Fact] + public void Constructor_WithValidConfig_InitializesConfigMap() + { + // Arrange + var agentRemoteConfig = this.CreateAgentRemoteConfig(); + + // Act + var remoteConfigMessage = new Client.Messages.RemoteConfigMessage(agentRemoteConfig); + + // Assert + Assert.Equal(2, remoteConfigMessage.AgentConfigMap.Count); + Assert.True(remoteConfigMessage.AgentConfigMap.ContainsKey(Config1Name)); + Assert.True(remoteConfigMessage.AgentConfigMap.ContainsKey(Config2Name)); + } + + [Fact] + public void Constructor_WithEmptyConfigMap_InitializesEmptyDictionary() + { + // Arrange + var agentRemoteConfig = new global::OpAmp.Proto.V1.AgentRemoteConfig + { + Config = new global::OpAmp.Proto.V1.AgentConfigMap(), + ConfigHash = ByteString.CopyFromUtf8(HashString), + }; + + // Act + var remoteConfigMessage = new Client.Messages.RemoteConfigMessage(agentRemoteConfig); + + // Assert + Assert.Empty(remoteConfigMessage.AgentConfigMap); + } + + [Fact] + public void ConfigHash_WithValidHash_ReturnsExpectedBytes() + { + // Arrange + var agentRemoteConfig = this.CreateAgentRemoteConfig(); + var remoteConfigMessage = new Client.Messages.RemoteConfigMessage(agentRemoteConfig); + + // Act + var hash = remoteConfigMessage.ConfigHash; + + // Assert + Assert.Equal(Encoding.UTF8.GetByteCount(HashString), hash.Length); + Assert.Equal(HashString, Encoding.UTF8.GetString(hash.ToArray())); + } + + [Fact] + public void ConfigHash_WithEmptyHash_ReturnsEmptySpan() + { + // Arrange + var agentRemoteConfig = new global::OpAmp.Proto.V1.AgentRemoteConfig + { + Config = new global::OpAmp.Proto.V1.AgentConfigMap(), + ConfigHash = ByteString.Empty, + }; + var remoteConfigMessage = new Client.Messages.RemoteConfigMessage(agentRemoteConfig); + + // Act + var hashSpan = remoteConfigMessage.ConfigHash; + + // Assert + Assert.Equal(0, hashSpan.Length); + Assert.True(hashSpan.IsEmpty); + } + + [Theory] + [InlineData(Config1Name, JsonContentType, JsonString)] + [InlineData(Config2Name, YamlContentType, YamlString)] + public void AgentConfigFile_Properties_ReturnExpectedValues(string configName, string contentType, string bodyContent) + { + // Arrange + var agentRemoteConfig = this.CreateAgentRemoteConfig(); + var remoteConfigMessage = new Client.Messages.RemoteConfigMessage(agentRemoteConfig); + + // Act + var configFile = remoteConfigMessage.AgentConfigMap[configName]; + + // Assert + Assert.Equal(configName, configFile.Name); + Assert.Equal(contentType, configFile.ContentType); + Assert.Equal(bodyContent, Encoding.UTF8.GetString(configFile.Body.ToArray())); + } + + [Fact] + public void AgentConfigFile_Body_WithEmptyBody_ReturnsEmptySpan() + { + // Arrange + var configMap = new global::OpAmp.Proto.V1.AgentConfigMap(); + configMap.ConfigMap.Add("empty-config", new global::OpAmp.Proto.V1.AgentConfigFile + { + Body = ByteString.Empty, + ContentType = JsonContentType, + }); + + var agentRemoteConfig = new global::OpAmp.Proto.V1.AgentRemoteConfig + { + Config = configMap, + ConfigHash = ByteString.CopyFromUtf8(HashString), + }; + + var remoteConfigMessage = new Client.Messages.RemoteConfigMessage(agentRemoteConfig); + var configFile = remoteConfigMessage.AgentConfigMap["empty-config"]; + + // Act + var bodySpan = configFile.Body; + + // Assert + Assert.Equal(0, bodySpan.Length); + Assert.True(bodySpan.IsEmpty); + } + + [Fact] + public void AgentConfigFile_WithEmptyContentType_ReturnsEmptyString() + { + // Arrange + var configMap = new global::OpAmp.Proto.V1.AgentConfigMap(); + configMap.ConfigMap.Add("empty-content-type", new global::OpAmp.Proto.V1.AgentConfigFile + { + Body = ByteString.CopyFromUtf8(JsonString), + ContentType = string.Empty, + }); + + var agentRemoteConfig = new global::OpAmp.Proto.V1.AgentRemoteConfig + { + Config = configMap, + ConfigHash = ByteString.CopyFromUtf8(HashString), + }; + + var remoteConfigMessage = new Client.Messages.RemoteConfigMessage(agentRemoteConfig); + + // Act + var configFile = remoteConfigMessage.AgentConfigMap["empty-content-type"]; + + // Assert + Assert.Equal(string.Empty, configFile.ContentType); + } + + [Fact] + public void AgentConfigMap_IsReadOnly() + { + // Arrange + var agentRemoteConfig = this.CreateAgentRemoteConfig(); + var remoteConfigMessage = new Client.Messages.RemoteConfigMessage(agentRemoteConfig); + + // Act & Assert + Assert.IsType>( + remoteConfigMessage.AgentConfigMap, exactMatch: false); + } + + [Fact] + public void AgentConfigMap_UsesOrdinalComparison() + { + // Arrange + var configMap = new global::OpAmp.Proto.V1.AgentConfigMap(); + configMap.ConfigMap.Add("Config", new global::OpAmp.Proto.V1.AgentConfigFile + { + Body = ByteString.CopyFromUtf8(JsonString), + ContentType = JsonContentType, + }); + + var agentRemoteConfig = new global::OpAmp.Proto.V1.AgentRemoteConfig + { + Config = configMap, + ConfigHash = ByteString.CopyFromUtf8(HashString), + }; + + var remoteConfigMessage = new Client.Messages.RemoteConfigMessage(agentRemoteConfig); + + // Act & Assert - case-sensitive check + Assert.True(remoteConfigMessage.AgentConfigMap.ContainsKey("Config")); + Assert.False(remoteConfigMessage.AgentConfigMap.ContainsKey("config")); + } + + [Fact] + public void ConfigHash_MultipleAccess_ReturnsSameSpan() + { + // Arrange + var agentRemoteConfig = this.CreateAgentRemoteConfig(); + var remoteConfigMessage = new Client.Messages.RemoteConfigMessage(agentRemoteConfig); + + // Act + var hash1 = remoteConfigMessage.ConfigHash; + var hash2 = remoteConfigMessage.ConfigHash; + + // Assert + Assert.True(hash1.SequenceEqual(hash2)); + } + + [Fact] + public void AgentConfigFile_Body_MultipleAccess_ReturnsSameSpan() + { + // Arrange + var agentRemoteConfig = this.CreateAgentRemoteConfig(); + var remoteConfigMessage = new Client.Messages.RemoteConfigMessage(agentRemoteConfig); + var configFile = remoteConfigMessage.AgentConfigMap[Config1Name]; + + // Act + var body1 = configFile.Body; + var body2 = configFile.Body; + + // Assert + Assert.True(body1.SequenceEqual(body2)); + } + +#if NET + [Fact] + public void AgentConfigFile_JsonContent_CanBeDeserialized() + { + // Arrange + var agentRemoteConfig = this.CreateAgentRemoteConfig(); + var remoteConfigMessage = new Client.Messages.RemoteConfigMessage(agentRemoteConfig); + var config1 = remoteConfigMessage.AgentConfigMap[Config1Name]; + + // Act + var config = System.Text.Json.JsonSerializer.Deserialize(config1.Body); + + // Assert + Assert.NotNull(config); + Assert.Equal("this is a value", config!.MyKey); + } +#endif + + private global::OpAmp.Proto.V1.AgentRemoteConfig CreateAgentRemoteConfig() + { + var configMap = new global::OpAmp.Proto.V1.AgentConfigMap(); + + configMap.ConfigMap.Add(Config1Name, new global::OpAmp.Proto.V1.AgentConfigFile + { + Body = ByteString.CopyFromUtf8(JsonString), + ContentType = JsonContentType, + }); + + configMap.ConfigMap.Add(Config2Name, new global::OpAmp.Proto.V1.AgentConfigFile + { + Body = ByteString.CopyFromUtf8(YamlString), + ContentType = YamlContentType, + }); + + return new global::OpAmp.Proto.V1.AgentRemoteConfig + { + Config = configMap, + ConfigHash = ByteString.CopyFromUtf8(HashString), + }; + } + +#if NET + private class Config + { + [System.Text.Json.Serialization.JsonPropertyName("myKey")] + public string? MyKey { get; set; } + } +#endif +}