Skip to content

Commit 7cdd395

Browse files
authored
Support client heartbeat (#296)
Implement spec change open-telemetry/opamp-spec#190
1 parent 536037b commit 7cdd395

File tree

12 files changed

+618
-388
lines changed

12 files changed

+618
-388
lines changed

client/internal/httpsender.go

+12
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,18 @@ func (h *HTTPSender) receiveResponse(ctx context.Context, resp *http.Response) {
277277
h.receiveProcessor.ProcessReceivedMessage(ctx, &response)
278278
}
279279

280+
func (h *HTTPSender) SetHeartbeatInterval(duration time.Duration) error {
281+
if duration <= 0 {
282+
return errors.New("heartbeat interval for httpclient must be greater than zero")
283+
}
284+
285+
if duration != 0 {
286+
h.SetPollingInterval(duration)
287+
}
288+
289+
return nil
290+
}
291+
280292
// SetPollingInterval sets the interval between polling. Has effect starting from the
281293
// next polling cycle.
282294
func (h *HTTPSender) SetPollingInterval(duration time.Duration) {

client/internal/httpsender_test.go

+20
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,26 @@ func TestHTTPSenderRetryForStatusTooManyRequests(t *testing.T) {
6262
srv.Close()
6363
}
6464

65+
func TestHTTPSenderSetHeartbeatInterval(t *testing.T) {
66+
sender := NewHTTPSender(&sharedinternal.NopLogger{})
67+
68+
// Default interval should be 30s as per OpAMP Specification
69+
assert.Equal(t, (30 * time.Second).Milliseconds(), sender.pollingIntervalMs)
70+
71+
// zero is invalid for http sender
72+
assert.Error(t, sender.SetHeartbeatInterval(0))
73+
assert.Equal(t, (30 * time.Second).Milliseconds(), sender.pollingIntervalMs)
74+
75+
// negative interval is invalid for http sender
76+
assert.Error(t, sender.SetHeartbeatInterval(-1))
77+
assert.Equal(t, (30 * time.Second).Milliseconds(), sender.pollingIntervalMs)
78+
79+
// zero should be valid for http sender
80+
expected := 10 * time.Second
81+
assert.NoError(t, sender.SetHeartbeatInterval(expected))
82+
assert.Equal(t, expected.Milliseconds(), sender.pollingIntervalMs)
83+
}
84+
6585
func TestAddTLSConfig(t *testing.T) {
6686
sender := NewHTTPSender(&sharedinternal.NopLogger{})
6787

client/internal/receivedprocessor.go

+8
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"sync"
7+
"time"
78

89
"github.com/open-telemetry/opamp-go/client/types"
910
"github.com/open-telemetry/opamp-go/protobufs"
@@ -216,6 +217,13 @@ func (r *receivedProcessor) rcvOpampConnectionSettings(ctx context.Context, sett
216217
return
217218
}
218219

220+
if r.hasCapability(protobufs.AgentCapabilities_AgentCapabilities_ReportsHeartbeat) {
221+
interval := time.Duration(settings.Opamp.HeartbeatIntervalSeconds) * time.Second
222+
if err := r.sender.SetHeartbeatInterval(interval); err != nil {
223+
r.logger.Errorf(ctx, "Failed to set heartbeat interval: %v", err)
224+
}
225+
}
226+
219227
if r.hasCapability(protobufs.AgentCapabilities_AgentCapabilities_AcceptsOpAMPConnectionSettings) {
220228
err := r.callbacks.OnOpampConnectionSettings(ctx, settings.Opamp)
221229
if err != nil {

client/internal/sender.go

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package internal
22

33
import (
44
"errors"
5+
"time"
56

67
"github.com/open-telemetry/opamp-go/client/types"
78
"github.com/open-telemetry/opamp-go/protobufs"
@@ -22,6 +23,9 @@ type Sender interface {
2223

2324
// SetInstanceUid sets a new instanceUid to be used for all subsequent messages to be sent.
2425
SetInstanceUid(instanceUid types.InstanceUid) error
26+
27+
// SetHeartbeatInterval sets the interval for the agent heartbeats.
28+
SetHeartbeatInterval(duration time.Duration) error
2529
}
2630

2731
// SenderCommon is partial Sender implementation that is common between WebSocket and plain

client/internal/wssender.go

+58-4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package internal
22

33
import (
44
"context"
5+
"errors"
6+
"sync/atomic"
57
"time"
68

79
"github.com/gorilla/websocket"
@@ -13,7 +15,8 @@ import (
1315
)
1416

1517
const (
16-
defaultSendCloseMessageTimeout = 5 * time.Second
18+
defaultSendCloseMessageTimeout = 5 * time.Second
19+
defaultHeartbeatIntervalSeconds = 30
1720
)
1821

1922
// WSSender implements the WebSocket client's sending portion of OpAMP protocol.
@@ -25,15 +28,24 @@ type WSSender struct {
2528
// Indicates that the sender has fully stopped.
2629
stopped chan struct{}
2730
err error
31+
32+
heartbeatIntervalUpdated chan struct{}
33+
heartbeatIntervalSeconds atomic.Int64
34+
heartbeatTimer *time.Timer
2835
}
2936

3037
// NewSender creates a new Sender that uses WebSocket to send
3138
// messages to the server.
3239
func NewSender(logger types.Logger) *WSSender {
33-
return &WSSender{
34-
logger: logger,
35-
SenderCommon: NewSenderCommon(),
40+
s := &WSSender{
41+
logger: logger,
42+
heartbeatIntervalUpdated: make(chan struct{}, 1),
43+
heartbeatTimer: time.NewTimer(0),
44+
SenderCommon: NewSenderCommon(),
3645
}
46+
s.heartbeatIntervalSeconds.Store(defaultHeartbeatIntervalSeconds)
47+
48+
return s
3749
}
3850

3951
// Start the sender and send the first message that was set via NextMessage().Update()
@@ -62,10 +74,51 @@ func (s *WSSender) StoppingErr() error {
6274
return s.err
6375
}
6476

77+
// SetHeartbeatInterval sets the heartbeat interval and triggers timer reset.
78+
func (s *WSSender) SetHeartbeatInterval(d time.Duration) error {
79+
if d < 0 {
80+
return errors.New("heartbeat interval for wsclient must be non-negative")
81+
}
82+
83+
s.heartbeatIntervalSeconds.Store(int64(d.Seconds()))
84+
select {
85+
case s.heartbeatIntervalUpdated <- struct{}{}:
86+
default:
87+
}
88+
return nil
89+
}
90+
91+
func (s *WSSender) shouldSendHeartbeat() <-chan time.Time {
92+
t := s.heartbeatTimer
93+
94+
// Before Go 1.23, the only safe way to use Reset was to [Stop] and
95+
// explicitly drain the timer first.
96+
// ref: https://pkg.go.dev/time#Timer.Reset
97+
if !t.Stop() {
98+
select {
99+
case <-t.C:
100+
default:
101+
}
102+
}
103+
104+
if d := time.Duration(s.heartbeatIntervalSeconds.Load()) * time.Second; d != 0 {
105+
t.Reset(d)
106+
return t.C
107+
}
108+
109+
// Heartbeat interval is set to Zero, disable heartbeat.
110+
return nil
111+
}
112+
65113
func (s *WSSender) run(ctx context.Context) {
66114
out:
67115
for {
68116
select {
117+
case <-s.shouldSendHeartbeat():
118+
s.NextMessage().Update(func(msg *protobufs.AgentToServer) {})
119+
s.ScheduleSend()
120+
case <-s.heartbeatIntervalUpdated:
121+
// trigger heartbeat timer reset
69122
case <-s.hasPendingMessage:
70123
s.sendNextMessage(ctx)
71124

@@ -77,6 +130,7 @@ out:
77130
}
78131
}
79132

133+
s.heartbeatTimer.Stop()
80134
close(s.stopped)
81135
}
82136

client/internal/wssender_test.go

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package internal
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
sharedinternal "github.com/open-telemetry/opamp-go/internal"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestWSSenderSetHeartbeatInterval(t *testing.T) {
12+
sender := NewSender(&sharedinternal.NopLogger{})
13+
14+
// Default interval should be 30s as per OpAMP Specification
15+
assert.Equal(t, int64((30 * time.Second).Seconds()), sender.heartbeatIntervalSeconds.Load())
16+
17+
// negative interval is invalid for http sender
18+
assert.Error(t, sender.SetHeartbeatInterval(-1))
19+
assert.Equal(t, int64((30 * time.Second).Seconds()), sender.heartbeatIntervalSeconds.Load())
20+
21+
// zero is valid for ws sender
22+
assert.NoError(t, sender.SetHeartbeatInterval(0))
23+
assert.Equal(t, int64(0), sender.heartbeatIntervalSeconds.Load())
24+
25+
var expected int64 = 10
26+
assert.NoError(t, sender.SetHeartbeatInterval(time.Duration(expected)*time.Second))
27+
assert.Equal(t, expected, sender.heartbeatIntervalSeconds.Load())
28+
}

client/wsclient_test.go

+71
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,77 @@ import (
2222
"github.com/open-telemetry/opamp-go/protobufs"
2323
)
2424

25+
func TestWSSenderReportsHeartbeat(t *testing.T) {
26+
tests := []struct {
27+
name string
28+
clientEnableHeartbeat bool
29+
serverEnableHeartbeat bool
30+
expectHeartbeats bool
31+
}{
32+
{"enable heartbeat", true, true, true},
33+
{"client disable heartbeat", false, true, false},
34+
{"server disable heartbeat", true, false, false},
35+
}
36+
37+
for _, tt := range tests {
38+
srv := internal.StartMockServer(t)
39+
40+
var firstMsg atomic.Bool
41+
var conn atomic.Value
42+
srv.OnWSConnect = func(c *websocket.Conn) {
43+
conn.Store(c)
44+
firstMsg.Store(true)
45+
}
46+
var msgCount atomic.Int64
47+
srv.OnMessage = func(msg *protobufs.AgentToServer) *protobufs.ServerToAgent {
48+
if firstMsg.Load() {
49+
firstMsg.Store(false)
50+
resp := &protobufs.ServerToAgent{
51+
InstanceUid: msg.InstanceUid,
52+
ConnectionSettings: &protobufs.ConnectionSettingsOffers{
53+
Opamp: &protobufs.OpAMPConnectionSettings{
54+
HeartbeatIntervalSeconds: 1,
55+
},
56+
},
57+
}
58+
if !tt.serverEnableHeartbeat {
59+
resp.ConnectionSettings.Opamp.HeartbeatIntervalSeconds = 0
60+
}
61+
return resp
62+
}
63+
msgCount.Add(1)
64+
return nil
65+
}
66+
67+
// Start an OpAMP/WebSocket client.
68+
settings := types.StartSettings{
69+
OpAMPServerURL: "ws://" + srv.Endpoint,
70+
}
71+
if tt.clientEnableHeartbeat {
72+
settings.Capabilities = protobufs.AgentCapabilities_AgentCapabilities_ReportsHeartbeat
73+
}
74+
client := NewWebSocket(nil)
75+
startClient(t, settings, client)
76+
77+
// Wait for connection to be established.
78+
eventually(t, func() bool { return conn.Load() != nil })
79+
80+
if tt.expectHeartbeats {
81+
assert.Eventually(t, func() bool {
82+
return msgCount.Load() >= 2
83+
}, 3*time.Second, 10*time.Millisecond)
84+
} else {
85+
assert.Never(t, func() bool {
86+
return msgCount.Load() >= 2
87+
}, 3*time.Second, 10*time.Millisecond)
88+
}
89+
90+
// Stop the client.
91+
err := client.Stop(context.Background())
92+
assert.NoError(t, err)
93+
}
94+
}
95+
2596
func TestDisconnectWSByServer(t *testing.T) {
2697
// Start a Server.
2798
srv := internal.StartMockServer(t)

internal/certs.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,9 @@ func CreateTLSCert(caCertPath, caKeyPath string) (*protobufs.TLSCertificate, err
153153

154154
// We have a client certificate with a public and private key.
155155
certificate := &protobufs.TLSCertificate{
156-
PublicKey: publicKeyPEM.Bytes(),
157-
PrivateKey: privateKeyPEM.Bytes(),
158-
CaPublicKey: caCertBytes,
156+
Cert: publicKeyPEM.Bytes(),
157+
PrivateKey: privateKeyPEM.Bytes(),
158+
CaCert: caCertBytes,
159159
}
160160

161161
return certificate, nil

internal/examples/agent/agent/agent.go

+4-4
Original file line numberDiff line numberDiff line change
@@ -536,15 +536,15 @@ func (agent *Agent) getCertFromSettings(certificate *protobufs.TLSCertificate) (
536536
// Client-initiated CSR flow. This is currently initiated when connecting
537537
// to the Server for the first time (see requestClientCertificate()).
538538
cert, err = tls.X509KeyPair(
539-
certificate.PublicKey, // We received the certificate from the Server.
539+
certificate.Cert, // We received the certificate from the Server.
540540
agent.clientPrivateKeyPEM, // Private key was earlier locally generated.
541541
)
542542
} else {
543543
// Server-initiated flow. This is currently initiated by user clicking a button in
544544
// the Server UI.
545545
// Both certificate and private key are from the Server.
546546
cert, err = tls.X509KeyPair(
547-
certificate.PublicKey,
547+
certificate.Cert,
548548
certificate.PrivateKey,
549549
)
550550
}
@@ -554,8 +554,8 @@ func (agent *Agent) getCertFromSettings(certificate *protobufs.TLSCertificate) (
554554
return nil, err
555555
}
556556

557-
if len(certificate.CaPublicKey) != 0 {
558-
caCertPB, _ := pem.Decode(certificate.CaPublicKey)
557+
if len(certificate.CaCert) != 0 {
558+
caCertPB, _ := pem.Decode(certificate.CaCert)
559559
caCert, err := x509.ParseCertificate(caCertPB.Bytes)
560560
if err != nil {
561561
agent.logger.Errorf(context.Background(), "Cannot parse CA cert: %v", err)

internal/examples/server/certman/certman.go

+5-5
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ func CreateClientTLSCertFromCSR(csr *x509.CertificateRequest) (*protobufs.TLSCer
9494

9595
// We have a client certificate with a public and private key.
9696
certificate := &protobufs.TLSCertificate{
97-
PublicKey: certPEM.Bytes(),
98-
CaPublicKey: caCertBytes,
97+
Cert: certPEM.Bytes(),
98+
CaCert: caCertBytes,
9999
}
100100

101101
return certificate, nil
@@ -144,9 +144,9 @@ func CreateClientTLSCert() (*protobufs.TLSCertificate, error) {
144144

145145
// We have a client certificate with a public and private key.
146146
certificate := &protobufs.TLSCertificate{
147-
PublicKey: certPEM.Bytes(),
148-
PrivateKey: privateKeyPEM.Bytes(),
149-
CaPublicKey: caCertBytes,
147+
Cert: certPEM.Bytes(),
148+
PrivateKey: privateKeyPEM.Bytes(),
149+
CaCert: caCertBytes,
150150
}
151151

152152
return certificate, nil

0 commit comments

Comments
 (0)