Skip to content

[OpAMP] Refactor OpAMP communication pipe - #4930

Open
RassK wants to merge 27 commits into
open-telemetry:mainfrom
RassK:opamp-com-reworks
Open

[OpAMP] Refactor OpAMP communication pipe#4930
RassK wants to merge 27 commits into
open-telemetry:mainfrom
RassK:opamp-com-reworks

Conversation

@RassK

@RassK RassK commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What

Design discussion issue open-telemetry/opamp-spec#366

This is about ensuring that communication is according to spec.

When the Agent wants to send a message to the Server and the Agent has previously sent a request to the Server that is not yet responded, the Client MUST wait until the response is received before a new request can be made.
src: https://opentelemetry.io/docs/specs/opamp/#plain-http-transport

Since spec is currently not clear that messages can be accepted in sync or async manner. This PR is a basis to support both options via configuration. If it's decided that the client must block the pipe until a full response is constructed, a follow up is needed.

Changes

  • Makes sure the client is waiting for server to respond and blocks the pipe.
  • Uses accumulator message to "queue" client's messages

Breaking changes ❗

  • No need for async send messages, since messages are always accumulated.

Notes

❗ This PR is a preview and a discussion object how to proceed to support corner cases in the spec.
Since there seems to be a consensus with this PR, we can move forward

Merge requirement checklist

  • CONTRIBUTING guidelines followed (license requirements, nullable enabled, static analysis, etc.)
  • Unit tests added/updated
  • Appropriate CHANGELOG.md files updated for non-trivial changes < TODO until the final form is decided
  • Changes in public API reviewed (if applicable)

@RassK
RassK requested a review from a team as a code owner August 4, 2026 11:12
@github-actions
github-actions Bot requested a review from stevejgordon August 4, 2026 11:12
@github-actions github-actions Bot added the comp:opamp.client Things related to OpenTelemetry.OpAmp.Client label Aug 4, 2026
@opentelemetry-pr-dashboard

opentelemetry-pr-dashboard Bot commented Aug 4, 2026

Copy link
Copy Markdown

Pull request dashboard status

Waiting on the author · refreshed 2026-08-19 12:31 UTC

Respond to 1 review item (e.g. link a commit, explain why not, ask a follow-up):

  • Inline threads: 1
Status above doesn't look right?
  • Just replied or pushed? Anything around or after the refresh time above may not be picked up yet — give it a few minutes.
  • Should this be with reviewers? Comment /dashboard route:reviewers to route it to them.
  • Anything wrong — including the routing? Report it with what you expected; it helps us improve the dashboard.

@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.69811% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.09%. Comparing base (abdb29d) to head (d288329).
⚠️ Report is 5 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...c/OpenTelemetry.OpAmp.Client/Internal/OpAmpPipe.cs 94.87% 6 Missing ⚠️
...nt/Internal/Services/Heartbeat/HeartbeatService.cs 80.00% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4930      +/-   ##
==========================================
+ Coverage   77.89%   78.09%   +0.20%     
==========================================
  Files         474      476       +2     
  Lines       20279    20306      +27     
==========================================
+ Hits        15796    15858      +62     
+ Misses       4483     4448      -35     
Flag Coverage Δ
unittests-OpAmp.Client 90.69% <96.69%> (+3.37%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...penTelemetry.OpAmp.Client/Internal/FrameBuilder.cs 82.10% <100.00%> (+0.12%) ⬆️
...nTelemetry.OpAmp.Client/Internal/FrameProcessor.cs 86.56% <100.00%> (+1.08%) ⬆️
...etry.OpAmp.Client/Internal/MessageBuilderHelper.cs 100.00% <100.00%> (ø)
...nt/Internal/Messages/AgentIdentificationMessage.cs 0.00% <ø> (ø)
...y.OpAmp.Client/Internal/Messages/CommandMessage.cs 0.00% <ø> (ø)
...ent/Internal/Messages/ConnectionSettingsMessage.cs 100.00% <ø> (ø)
...p.Client/Internal/Messages/ErrorResponseMessage.cs 0.00% <ø> (ø)
...ient/Internal/Messages/PackagesAvailableMessage.cs 0.00% <ø> (ø)
...p.Client/Internal/Messages/ServerToAgentMessage.cs 100.00% <100.00%> (ø)
...ry.OpAmp.Client/Internal/OpAmpClientEventSource.cs 80.59% <100.00%> (+31.59%) ⬆️
... and 4 more

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@stevejgordon stevejgordon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The overall direction looks reasonable and the pipe model is the right approach to satisfy the HTTP transport spec requirement.

A few initial code comments to consider. I will do another pass soon.

this.TryFlush();
}

public Task FlushAsync()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this accept a CancellationToken passed through from StopAsync?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, we discovered this as well in the internal overhaul to prevent hangs in the pipe. Using cancellation token here gives the control to user.

0b456cc

{
lock (this.frameLock)
{
this.isBusy = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is correct for the "happy path", but what if the the web socket connection is closed or dropped? I think we'd need a mechanism for WsReciever to notify through when that happens so isBusy can be reset?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, retry / reconnect paths are missing currently intentionally. Seems a larger scope to focus on separately.

public async Task StopAsync(CancellationToken token = default)
{
// Drain queued data.
await this.FlushAsync()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pass the CancellationToken here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this.AppendMessage(MessageBuilderHelper.AppendAgentDisconnect);

// Send disconnect.
await this.FlushAsync()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IFrameBuilder AddCustomMessage(string capability, string type, ReadOnlyMemory<byte> data);

AgentToServer Build();
IFrameBuilder Clear();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this belong on this interface? It's beyond the concern of this abstraction. It seems to only be used from a test and could still existing on the FrameBuilder directly for that call site.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems it became a leftover, removed.

.ConfigureAwait(false);
}

this.AppendMessage(MessageBuilderHelper.AppendIdentification);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we flush here to ensure identification is sent before heartbeats (and other services) are started? Otherwise, a lost identification message will cause heartbeats to be sent to a server it had never seen.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TryFlush should send it instantly since the pipe is initially free. I added Flush just in case, so nothing weird should not happen.

internal sealed class FrameProcessor
{
private readonly ConcurrentDictionary<Type, IReadOnlyList<object>> listeners = [];
private readonly ConcurrentBag<Action<ServerToAgent>> internalListeners = [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be ConcurrentBag? One one internal listener is accepted in the ctor, can we just store that?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only issue today is that the pipe does not have a control over the processor (it does not construct it). Pipe itself is a user, like any internal services could be.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That doesn't explain the need for ConcurrentBag. Looking at the code, OpAmpPipe is the only caller of SubscribeToServerMessages, and it calls it exactly once from its constructor. There's also no Unsubscribe method, so the concurrent-collection semantics are never exercised.

A simple Action<ServerToAgent>? field would be clearer and more appropriate here (as it stands) and doesn't carry the misleading implication that subscribers can be dynamically added/removed concurrently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simple Action<ServerToAgent>? would meant that the owner should have set it. But because OpAmpPipe doesn't own it, then the control who sets it becomes vague. I do understand that there is a single use currently but such pattern leaves too many doors open.

I refactored this and removed that new functionality and reused the sub/unsub pattern already existing. This also helps in the future if you need to check multiple fields (sub-messages) without having any sync logic of waiting for multiple callbacks.

40877b3

Comment thread src/OpenTelemetry.OpAmp.Client/Internal/OpAmpPipe.cs Outdated
@Kielek
Kielek requested a review from stevejgordon August 13, 2026 10:45

foreach (var listener in this.internalListeners)
{
listener.Invoke(message);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we wrap this in try/catch to avoid exceptions bubbling out? We already do that for public listeners.


namespace OpenTelemetry.OpAmp.Client.Internal;

internal sealed class OpAmpPipe : IDisposable

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Genral comment for the OpAmpPipe implementation - For HTTP, gating on server-frame receipt looks spec-aligned: every POST gets a ServerToAgent response and PlainHttpTransport processes it before SendAsync returns. For WebSocket, the spec (in my re-reading) is full-duplex with no response requirement, so only clearing isBusy in OnServerFrameReceived can stall the pipe if the server doesn’t reply to every agent send (or unblock on an unrelated server message). I think we need to consider transport-specific pipe behavior: response-gated for HTTP, send-completion-gated for WebSocket.

{
OpAmpClientEventSource.Log.SendingMessage();

await this.transport.SendAsync(message, this.tokenSource.Token)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This token is only cancelled on dispose. Should we accept the tokens through from FlushAsync/StopAsync or link the the caller cancellation tokens with tokenSource.Token to ensure correct in-flight cancellation?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems a good catch, 3ae9596


await this.dispatcher.DispatchHeartbeatAsync(report, this.cts.Token)
.ConfigureAwait(false);
this.pipe.AppendMessage(MessageBuilderHelper.AppendHeartbeat(report));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This no longer passes the cancellation token. Can this race with Stop? Would checking this.cts.IsCancellationRequested first before appending be reasonable here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, seems IsCancellationRequested check is appropriate here. d288329

@stevejgordon

stevejgordon commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

@RassK Sorry, my feedback and your new commit overlapped, so some of my comments may no longer apply. I like the direction of the refactor.

{
var message = ServerToAgent.Parser.ParseFrom(sequence);

this.Dispatch(new ServerToAgentMessage(message));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth checking listeners.ContainsKey(typeof(ServerToAgentMessage)) before allocating and dispatching a message no one is subscriber to?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that might be useful for other types as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp:opamp.client Things related to OpenTelemetry.OpAmp.Client

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants