diff --git a/src/OpenTelemetry.OpAmp.Client/.publicApi/PublicAPI.Unshipped.txt b/src/OpenTelemetry.OpAmp.Client/.publicApi/PublicAPI.Unshipped.txt index fe03eb54b3..4517bb6327 100644 --- a/src/OpenTelemetry.OpAmp.Client/.publicApi/PublicAPI.Unshipped.txt +++ b/src/OpenTelemetry.OpAmp.Client/.publicApi/PublicAPI.Unshipped.txt @@ -23,6 +23,8 @@ OpenTelemetry.OpAmp.Client.Messages.RemoteConfigMessage.ConfigHash.get -> System OpenTelemetry.OpAmp.Client.OpAmpClient OpenTelemetry.OpAmp.Client.OpAmpClient.Dispose() -> void OpenTelemetry.OpAmp.Client.OpAmpClient.OpAmpClient(System.Action? configure = null) -> void +OpenTelemetry.OpAmp.Client.OpAmpClient.SendCustomCapabilitiesAsync(System.Collections.Generic.IEnumerable! capabilities, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +OpenTelemetry.OpAmp.Client.OpAmpClient.SendCustomMessageAsync(string! capability, string! type, System.ReadOnlyMemory data, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! OpenTelemetry.OpAmp.Client.OpAmpClient.SendEffectiveConfigAsync(System.Collections.Generic.IEnumerable! files, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! OpenTelemetry.OpAmp.Client.OpAmpClient.StartAsync(System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! OpenTelemetry.OpAmp.Client.OpAmpClient.StopAsync(System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/src/OpenTelemetry.OpAmp.Client/CHANGELOG.md b/src/OpenTelemetry.OpAmp.Client/CHANGELOG.md index 1549d69648..5ef588bb35 100644 --- a/src/OpenTelemetry.OpAmp.Client/CHANGELOG.md +++ b/src/OpenTelemetry.OpAmp.Client/CHANGELOG.md @@ -5,6 +5,9 @@ * Add agent effective config reporting. ([#3716](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/3716)) +* Add ability to send custom messages. + ([#3809](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/3809)) + ## 0.1.0-alpha.4 Released 2026-Jan-14 diff --git a/src/OpenTelemetry.OpAmp.Client/Internal/FrameBuilder.cs b/src/OpenTelemetry.OpAmp.Client/Internal/FrameBuilder.cs index 5231b97c7e..002794f94e 100644 --- a/src/OpenTelemetry.OpAmp.Client/Internal/FrameBuilder.cs +++ b/src/OpenTelemetry.OpAmp.Client/Internal/FrameBuilder.cs @@ -156,6 +156,30 @@ IFrameBuilder IFrameBuilder.AddCapabilities() return this; } + IFrameBuilder IFrameBuilder.AddCustomCapabilities(IEnumerable capabilities) + { + this.EnsureInitialized(); + + this.currentMessage.CustomCapabilities = new CustomCapabilities(); + this.currentMessage.CustomCapabilities.Capabilities.Add(capabilities); + + return this; + } + + IFrameBuilder IFrameBuilder.AddCustomMessage(string capability, string type, ReadOnlyMemory data) + { + this.EnsureInitialized(); + + this.currentMessage.CustomMessage = new CustomMessage + { + Capability = capability, + Type = type, + Data = ByteString.CopyFrom(data.Span), + }; + + return this; + } + IFrameBuilder IFrameBuilder.AddEffectiveConfig(IEnumerable files) { this.EnsureInitialized(); diff --git a/src/OpenTelemetry.OpAmp.Client/Internal/FrameDispatcher.cs b/src/OpenTelemetry.OpAmp.Client/Internal/FrameDispatcher.cs index e08785bfdc..80ff4eab94 100644 --- a/src/OpenTelemetry.OpAmp.Client/Internal/FrameDispatcher.cs +++ b/src/OpenTelemetry.OpAmp.Client/Internal/FrameDispatcher.cs @@ -86,6 +86,34 @@ AgentToServer BuildEffectiveConfigMessage(FrameBuilder fb) } } + public async Task DispatchCustomCapabilitiesAsync(IEnumerable capabilities, CancellationToken token) + { + await this.DispatchFrameAsync( + BuildCustomCapabilitiesMessage, + OpAmpClientEventSource.Log.SendingCustomCapabilitiesMessage, + OpAmpClientEventSource.Log.SendCustomCapabilitiesMessageException, + token).ConfigureAwait(false); + + AgentToServer BuildCustomCapabilitiesMessage(FrameBuilder fb) + { + return fb.StartBaseMessage().AddCustomCapabilities(capabilities).Build(); + } + } + + public async Task DispatchCustomMessageAsync(string capability, string type, ReadOnlyMemory data, CancellationToken token) + { + await this.DispatchFrameAsync( + BuildCustomMessageMessage, + OpAmpClientEventSource.Log.SendingCustomMessageMessage, + OpAmpClientEventSource.Log.SendCustomMessageMessageException, + token).ConfigureAwait(false); + + AgentToServer BuildCustomMessageMessage(FrameBuilder fb) + { + return fb.StartBaseMessage().AddCustomMessage(capability, type, data).Build(); + } + } + public void Dispose() { this.syncRoot.Dispose(); diff --git a/src/OpenTelemetry.OpAmp.Client/Internal/IFrameBuilder.cs b/src/OpenTelemetry.OpAmp.Client/Internal/IFrameBuilder.cs index 57e99e62e4..2451ec022f 100644 --- a/src/OpenTelemetry.OpAmp.Client/Internal/IFrameBuilder.cs +++ b/src/OpenTelemetry.OpAmp.Client/Internal/IFrameBuilder.cs @@ -17,7 +17,11 @@ internal interface IFrameBuilder IFrameBuilder AddCapabilities(); + IFrameBuilder AddCustomCapabilities(IEnumerable capabilities); + IFrameBuilder AddEffectiveConfig(IEnumerable files); + IFrameBuilder AddCustomMessage(string capability, string type, ReadOnlyMemory data); + AgentToServer Build(); } diff --git a/src/OpenTelemetry.OpAmp.Client/Internal/OpAmpClientEventSource.cs b/src/OpenTelemetry.OpAmp.Client/Internal/OpAmpClientEventSource.cs index eaa16cdeae..c2698301f7 100644 --- a/src/OpenTelemetry.OpAmp.Client/Internal/OpAmpClientEventSource.cs +++ b/src/OpenTelemetry.OpAmp.Client/Internal/OpAmpClientEventSource.cs @@ -26,12 +26,16 @@ internal class OpAmpClientEventSource : EventSource private const int EventIdSendingHeartbeatMessage = 1_001; private const int EventIdSendingAgentDisconnectMessage = 1_002; private const int EventIdSendingEffectiveConfigMessage = 1_003; + private const int EventIdSendingCustomCapabilitiesMessage = 1_004; + private const int EventIdSendingCustomMessageMessage = 1_005; // FrameDispatcher error messages 1100-1199 private const int EventIdFailedToSendIdentificationMessage = 1_100; private const int EventIdFailedToSendHeartbeatMessage = 1_101; private const int EventIdFailedToSendAgentDisconnectMessage = 1_102; private const int EventIdFailedToSendEffectiveConfigMessage = 1_103; + private const int EventIdFailedToSendCustomCapabilitiesMessage = 1_104; + private const int EventIdFailedToSendCustomMessageMessage = 1_105; [Event(EventIdInvalidWsFrame, Message = "Received invalid WebSocket frame header: {0}. Dropping the frame.", Level = EventLevel.Warning)] public void InvalidWsFrame(string errorMessage) @@ -113,6 +117,18 @@ public void SendingEffectiveConfigMessage() this.WriteEvent(EventIdSendingEffectiveConfigMessage); } + [Event(EventIdSendingCustomCapabilitiesMessage, Message = "Sending custom capabilities message.", Level = EventLevel.Informational)] + public void SendingCustomCapabilitiesMessage() + { + this.WriteEvent(EventIdSendingCustomCapabilitiesMessage); + } + + [Event(EventIdSendingCustomMessageMessage, Message = "Sending custom message.", Level = EventLevel.Informational)] + public void SendingCustomMessageMessage() + { + this.WriteEvent(EventIdSendingCustomMessageMessage); + } + [NonEvent] public void SendIdentificationMessageException(Exception ex) { @@ -172,4 +188,34 @@ public void FailedToSendEffectiveConfigMessage(string exception) { this.WriteEvent(EventIdFailedToSendEffectiveConfigMessage, exception); } + + [NonEvent] + public void SendCustomCapabilitiesMessageException(Exception ex) + { + if (!this.IsEnabled(EventLevel.Error, EventKeywords.All)) + { + this.FailedToSendCustomCapabilitiesMessage(ex.ToInvariantString()); + } + } + + [Event(EventIdFailedToSendCustomCapabilitiesMessage, Message = "Failed to send custom capabilities message: {0}", Level = EventLevel.Error)] + public void FailedToSendCustomCapabilitiesMessage(string exception) + { + this.WriteEvent(EventIdFailedToSendCustomCapabilitiesMessage, exception); + } + + [NonEvent] + public void SendCustomMessageMessageException(Exception ex) + { + if (!this.IsEnabled(EventLevel.Error, EventKeywords.All)) + { + this.FailedToSendCustomMessageMessage(ex.ToInvariantString()); + } + } + + [Event(EventIdFailedToSendCustomMessageMessage, Message = "Failed to send a custom message: {0}", Level = EventLevel.Error)] + public void FailedToSendCustomMessageMessage(string exception) + { + this.WriteEvent(EventIdFailedToSendCustomMessageMessage, exception); + } } diff --git a/src/OpenTelemetry.OpAmp.Client/OpAmpClient.cs b/src/OpenTelemetry.OpAmp.Client/OpAmpClient.cs index 722fc58d77..0b1a3233ce 100644 --- a/src/OpenTelemetry.OpAmp.Client/OpAmpClient.cs +++ b/src/OpenTelemetry.OpAmp.Client/OpAmpClient.cs @@ -123,6 +123,30 @@ public Task SendEffectiveConfigAsync(IEnumerable files, Can return this.dispatcher.DispatchEffectiveConfigAsync(files, cancellationToken); } + /// + /// Reports custom capabilities supported by the agent. + /// + /// Capabilities list. + /// Cancellation token. + /// A task that represents the asynchronous send operation. + public Task SendCustomCapabilitiesAsync(IEnumerable capabilities, CancellationToken cancellationToken = default) + { + return this.dispatcher.DispatchCustomCapabilitiesAsync(capabilities, cancellationToken); + } + + /// + /// Sends a custom message related to a supported custom capability. + /// + /// Capability that matches a reported custom capability. + /// Type of message within the capability. + /// Contents of the message. + /// Cancellation token. + /// A task that represents the asynchronous send operation. + public Task SendCustomMessageAsync(string capability, string type, ReadOnlyMemory data, CancellationToken cancellationToken = default) + { + return this.dispatcher.DispatchCustomMessageAsync(capability, type, data, cancellationToken); + } + /// /// Disposes the OpAmpClient instance and releases all associated resources. /// diff --git a/test/OpenTelemetry.OpAmp.Client.Tests/OpAmpClientTests.cs b/test/OpenTelemetry.OpAmp.Client.Tests/OpAmpClientTests.cs index ab4fe4f15b..1f21ffd548 100644 --- a/test/OpenTelemetry.OpAmp.Client.Tests/OpAmpClientTests.cs +++ b/test/OpenTelemetry.OpAmp.Client.Tests/OpAmpClientTests.cs @@ -249,6 +249,69 @@ internal async Task SendsEffectiveConfigFile_FromFile() Assert.Equal(fileContentType, actualConfig.ContentType); } + [Fact] + internal async Task SendsCustomCapabilities() + { + // Setup OpAMP server + using var opAmpServer = new OpAmpFakeHttpServer(false); + var opAmpEndpoint = opAmpServer.Endpoint; + + using var client = new OpAmpClient(o => + { + o.ServerUrl = opAmpEndpoint; + }); + + // Setup content + string[] capabilities = ["custom-c1", "custom-c2", "custom-c3"]; + + // Act + await client.StartAsync(); + await client.SendCustomCapabilitiesAsync(capabilities); + await client.StopAsync(); + + // Assert received frames + var frames = opAmpServer.GetFrames(); + var actualCapabilities = frames[1].CustomCapabilities.Capabilities; + + Assert.Equal(3, frames.Count); // 3 frames: 1 identification, 2 custom capabilities, 3 disconnect + Assert.Equal(capabilities, actualCapabilities); + } + + [Fact] + internal async Task SendsCustomMessage() + { + // Setup OpAMP server + using var opAmpServer = new OpAmpFakeHttpServer(false); + var opAmpEndpoint = opAmpServer.Endpoint; + + using var client = new OpAmpClient(o => + { + o.ServerUrl = opAmpEndpoint; + }); + + // Setup content + const string capability = "custom-c1"; + const string type = "custom-type-1"; + const string messageContent = "a custom message contents"; + var message = Encoding.UTF8.GetBytes(messageContent); + + // Act + await client.StartAsync(); + await client.SendCustomMessageAsync(capability, type, message); + await client.StopAsync(); + + // Assert received frames + var frames = opAmpServer.GetFrames(); + var actualCapability = frames[1].CustomMessage.Capability; + var actualType = frames[1].CustomMessage.Type; + var actualMessageContent = frames[1].CustomMessage.Data.ToStringUtf8(); + + Assert.Equal(3, frames.Count); // 3 frames: 1 identification, 2 custom message, 3 disconnect + Assert.Equal(capability, actualCapability); + Assert.Equal(type, actualType); + Assert.Equal(messageContent, actualMessageContent); + } + internal class CapabilityTestData : TheoryData, IEnumerable, IEnumerable> {