Repository: open-telemetry/opamp-go
Pull request #341 (2025-01-10 22:54:06)

Client reports download status and download details
Add implementation of downloading status along with download details details as described in https://github.com/open-telemetry/opamp-spec/pull/206

When the client is downloading a package it will report the download status, and report download details (bytes per second and percent download) periodically.

tigrannajaryan: @michel-laterman are you still working on this? I left some comments above and the build is failing.
michel-laterman: @tigrannajaryan yes I'm working on it, but my time for this is very limited
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/open-telemetry/opamp-go/pull/341?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry) Report
Attention: Patch coverage is `68.42105%` with `24 lines` in your changes missing coverage. Please review.
> Project coverage is 79.71%. Comparing base [(`d6ca1cf`)](https://app.codecov.io/gh/open-telemetry/opamp-go/commit/d6ca1cfeb8ccbd25c6ffb5b98ac89bf8b48746d7?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry) to head [(`538d867`)](https://app.codecov.io/gh/open-telemetry/opamp-go/commit/538d86764763d78fc29766230dd21e5dd742ff1f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry).

| [Files with missing lines](https://app.codecov.io/gh/open-telemetry/opamp-go/pull/341?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry) | Patch % | Lines |
|---|---|---|
| [...ient/internal/package\_download\_details\_reporter.go](https://app.codecov.io/gh/open-telemetry/opamp-go/pull/341?src=pr&el=tree&filepath=client%2Finternal%2Fpackage_download_details_reporter.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry#diff-Y2xpZW50L2ludGVybmFsL3BhY2thZ2VfZG93bmxvYWRfZGV0YWlsc19yZXBvcnRlci5nbw==) | 63.15% | [14 Missing :warning: ](https://app.codecov.io/gh/open-telemetry/opamp-go/pull/341?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry) |
| [client/internal/clientcommon.go](https://app.codecov.io/gh/open-telemetry/opamp-go/pull/341?src=pr&el=tree&filepath=client%2Finternal%2Fclientcommon.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry#diff-Y2xpZW50L2ludGVybmFsL2NsaWVudGNvbW1vbi5nbw==) | 0.00% | [3 Missing and 2 partials :warning: ](https://app.codecov.io/gh/open-telemetry/opamp-go/pull/341?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry) |
| [client/internal/packagessyncer.go](https://app.codecov.io/gh/open-telemetry/opamp-go/pull/341?src=pr&el=tree&filepath=client%2Finternal%2Fpackagessyncer.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry#diff-Y2xpZW50L2ludGVybmFsL3BhY2thZ2Vzc3luY2VyLmdv) | 77.27% | [4 Missing and 1 partial :warning: ](https://app.codecov.io/gh/open-telemetry/opamp-go/pull/341?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #341      +/-   ##
==========================================
- Coverage   80.10%   79.71%   -0.39%     
==========================================
  Files          25       26       +1     
  Lines        2423     2495      +72     
==========================================
+ Hits         1941     1989      +48     
- Misses        374      395      +21     
- Partials      108      111       +3     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/open-telemetry/opamp-go/pull/341?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-telemetry).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
michel-laterman: There's currently a race condition in a test I have yet to debug

Patch:
diff --git a/.github/workflows/ossf-scorecard.yml b/.github/workflows/ossf-scorecard.yml
new file mode 100644
index 0000000..e3e196c
--- /dev/null
+++ b/.github/workflows/ossf-scorecard.yml
@@ -0,0 +1,47 @@
+name: OSSF Scorecard
+
+on:
+  push:
+    branches:
+      - main
+  schedule:
+    - cron: "45 8 * * 5" # once a week
+  workflow_dispatch:
+
+permissions: read-all
+
+jobs:
+  analysis:
+    runs-on: ubuntu-latest
+    permissions:
+      # Needed for Code scanning upload
+      security-events: write
+      # Needed for GitHub OIDC token if publish_results is true
+      id-token: write
+    steps:
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        with:
+          persist-credentials: false
+
+      - uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1
+        with:
+          results_file: results.sarif
+          results_format: sarif
+          publish_results: true
+
+      # Upload the results as artifacts (optional). Commenting out will disable
+      # uploads of run results in SARIF format to the repository Actions tab.
+      # https://docs.github.com/en/actions/advanced-guides/storing-workflow-data-as-artifacts
+      - name: "Upload artifact"
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+        with:
+          name: SARIF file
+          path: results.sarif
+          retention-days: 5
+
+      # Upload the results to GitHub's code scanning dashboard (optional).
+      # Commenting out will disable upload of results to your repo's Code Scanning dashboard
+      - name: "Upload to code-scanning"
+        uses: github/codeql-action/upload-sarif@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15
+        with:
+          sarif_file: results.sarif
diff --git a/.github/workflows/tidy.yml b/.github/workflows/tidy.yml
index 3bc448c..4644c5a 100644
--- a/.github/workflows/tidy.yml
+++ b/.github/workflows/tidy.yml
@@ -14,7 +14,7 @@ jobs:
         with:
           ref: ${{ github.head_ref }}
       - name: Setup Go
-        uses: actions/setup-go@v5.3.0
+        uses: actions/setup-go@v5.4.0
         with:
           go-version: "1.22"
       - name: Setup Go Environment
diff --git a/client/httpclient.go b/client/httpclient.go
index 8d32797..af91147 100644
--- a/client/httpclient.go
+++ b/client/httpclient.go
@@ -137,6 +137,7 @@ func (c *httpClient) runUntilStopped(ctx context.Context) {
 		c.common.PackagesStateProvider,
 		c.common.Capabilities,
 		&c.common.PackageSyncMutex,
+		c.common.DownloadReporterInterval,
 	)
 }
 
diff --git a/client/internal/clientcommon.go b/client/internal/clientcommon.go
index 8035aef..9f1bf16 100644
--- a/client/internal/clientcommon.go
+++ b/client/internal/clientcommon.go
@@ -5,6 +5,7 @@ import (
 	"errors"
 	"fmt"
 	"sync"
+	"time"
 
 	"google.golang.org/protobuf/proto"
 
@@ -60,6 +61,10 @@ type ClientCommon struct {
 
 	// Indicates that the Client is fully stopped.
 	stoppedSignal chan struct{}
+
+	// DownloadReporterInterval is the interval used to update a package's status while it is downloading.
+	// It is set to 10s by default, a min value of 1s is forced.
+	DownloadReporterInterval time.Duration
 }
 
 // NewClientCommon creates a new ClientCommon.
@@ -149,6 +154,12 @@ func (c *ClientCommon) PrepareStart(
 		return err
 	}
 
+	if settings.DownloadReporterInterval != nil && *settings.DownloadReporterInterval < time.Second {
+		c.DownloadReporterInterval = time.Second
+	} else if settings.DownloadReporterInterval != nil {
+		c.DownloadReporterInterval = *settings.DownloadReporterInterval
+	}
+
 	return nil
 }
 
diff --git a/client/internal/httpsender.go b/client/internal/httpsender.go
index 3573b1f..b5b9d93 100644
--- a/client/internal/httpsender.go
+++ b/client/internal/httpsender.go
@@ -97,10 +97,11 @@ func (h *HTTPSender) Run(
 	packagesStateProvider types.PackagesStateProvider,
 	capabilities protobufs.AgentCapabilities,
 	packageSyncMutex *sync.Mutex,
+	reporterInterval time.Duration,
 ) {
 	h.url = url
 	h.callbacks = callbacks
-	h.receiveProcessor = newReceivedProcessor(h.logger, callbacks, h, clientSyncedState, packagesStateProvider, capabilities, packageSyncMutex)
+	h.receiveProcessor = newReceivedProcessor(h.logger, callbacks, h, clientSyncedState, packagesStateProvider, capabilities, packageSyncMutex, reporterInterval)
 
 	// we need to detect if the redirect was ever set, if not, we want default behaviour
 	if callbacks.CheckRedirect != nil {
diff --git a/client/internal/inmempackagestore.go b/client/internal/inmempackagestore.go
index dc719e7..ae6a283 100644
--- a/client/internal/inmempackagestore.go
+++ b/client/internal/inmempackagestore.go
@@ -3,6 +3,7 @@ package internal
 import (
 	"context"
 	"io"
+	"sync"
 
 	"github.com/open-telemetry/opamp-go/client/types"
 	"github.com/open-telemetry/opamp-go/protobufs"
@@ -10,12 +11,14 @@ import (
 
 // InMemPackagesStore is a package store used for testing. Keeps the packages in memory.
 type InMemPackagesStore struct {
-	allPackagesHash      []byte
-	pkgState             map[string]types.PackageState
-	fileContents         map[string][]byte
-	fileSignatures       map[string][]byte
-	fileHashes           map[string][]byte
+	allPackagesHash []byte
+	pkgState        map[string]types.PackageState
+	fileContents    map[string][]byte
+	fileSignatures  map[string][]byte
+	fileHashes      map[string][]byte
+
 	lastReportedStatuses *protobufs.PackageStatuses
+	statusLock           sync.RWMutex
 
 	onAllPackagesHash func()
 }
@@ -100,10 +103,14 @@ func (l *InMemPackagesStore) GetSignature() map[string][]byte {
 }
 
 func (l *InMemPackagesStore) LastReportedStatuses() (*protobufs.PackageStatuses, error) {
+	l.statusLock.RLock()
+	defer l.statusLock.RUnlock()
 	return l.lastReportedStatuses, nil
 }
 
 func (l *InMemPackagesStore) SetLastReportedStatuses(statuses *protobufs.PackageStatuses) error {
+	l.statusLock.Lock()
+	defer l.statusLock.Unlock()
 	l.lastReportedStatuses = statuses
 	return nil
 }
diff --git a/client/internal/package_download_details_reporter.go b/client/internal/package_download_details_reporter.go
new file mode 100644
index 0000000..8053cb1
--- /dev/null
+++ b/client/internal/package_download_details_reporter.go
@@ -0,0 +1,73 @@
+package internal
+
+import (
+	"context"
+	"sync/atomic"
+	"time"
+
+	"github.com/open-telemetry/opamp-go/protobufs"
+)
+
+const downloadReporterDefaultInterval = time.Second * 10
+
+type downloadReporter struct {
+	start         time.Time
+	interval      time.Duration
+	packageLength float64
+
+	downloaded atomic.Uint64
+
+	done chan struct{}
+}
+
+func newDownloadReporter(interval time.Duration, length int) *downloadReporter {
+	if interval <= 0 {
+		interval = downloadReporterDefaultInterval
+	}
+	return &downloadReporter{
+		start:         time.Now(),
+		interval:      interval,
+		packageLength: float64(length),
+		done:          make(chan struct{}),
+	}
+}
+
+// Write tracks the number of bytes downloaded. It will never return an error.
+func (p *downloadReporter) Write(b []byte) (int, error) {
+	n := len(b)
+	p.downloaded.Add(uint64(n))
+	return n, nil
+}
+
+// report periodically calls the passed function to with the download percent and rate to update the status of a package.
+func (p *downloadReporter) report(ctx context.Context, updateFn func(context.Context, protobufs.PackageDownloadDetails) error) {
+	go func() {
+		ticker := time.NewTicker(p.interval)
+		defer ticker.Stop()
+		for {
+			select {
+			case <-ctx.Done():
+				return
+			case <-p.done:
+				return
+			case <-ticker.C:
+				downloadTime := time.Since(p.start)
+				downloaded := float64(p.downloaded.Load())
+				bps := downloaded / float64(downloadTime/time.Second)
+				var downloadPercent float64
+				if p.packageLength > 0 {
+					downloadPercent = downloaded / p.packageLength * 100
+				}
+				_ = updateFn(ctx, protobufs.PackageDownloadDetails{
+					DownloadPercent:        downloadPercent,
+					DownloadBytesPerSecond: bps,
+				})
+			}
+		}
+	}()
+}
+
+// stop the downloadReporter report goroutine
+func (p *downloadReporter) stop() {
+	close(p.done)
+}
diff --git a/client/internal/packagessyncer.go b/client/internal/packagessyncer.go
index ff6e7c0..2531961 100644
--- a/client/internal/packagessyncer.go
+++ b/client/internal/packagessyncer.go
@@ -5,8 +5,11 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"io"
 	"net/http"
+	"strconv"
 	"sync"
+	"time"
 
 	"github.com/open-telemetry/opamp-go/client/types"
 	"github.com/open-telemetry/opamp-go/protobufs"
@@ -19,6 +22,7 @@ type packagesSyncer struct {
 	clientSyncedState *ClientSyncedState
 	localState        types.PackagesStateProvider
 	sender            Sender
+	reporterInterval  time.Duration
 
 	statuses *protobufs.PackageStatuses
 	mux      *sync.Mutex
@@ -33,6 +37,7 @@ func NewPackagesSyncer(
 	clientSyncedState *ClientSyncedState,
 	packagesStateProvider types.PackagesStateProvider,
 	mux *sync.Mutex,
+	reporterInterval time.Duration,
 ) *packagesSyncer {
 	return &packagesSyncer{
 		logger:            logger,
@@ -42,6 +47,7 @@ func NewPackagesSyncer(
 		localState:        packagesStateProvider,
 		doneCh:            make(chan struct{}),
 		mux:               mux,
+		reporterInterval:  reporterInterval,
 	}
 }
 
@@ -272,6 +278,10 @@ func (s *packagesSyncer) shouldDownloadFile(ctx context.Context,
 
 // downloadFile downloads the file from the server.
 func (s *packagesSyncer) downloadFile(ctx context.Context, pkgName string, file *protobufs.DownloadableFile) error {
+	status := s.statuses.Packages[pkgName]
+	status.Status = protobufs.PackageStatusEnum_PackageStatusEnum_Downloading
+	_ = s.reportStatuses(ctx, true)
+
 	s.logger.Debugf(ctx, "Downloading package %s file from %s", pkgName, file.DownloadUrl)
 
 	req, err := http.NewRequestWithContext(ctx, "GET", file.DownloadUrl, nil)
@@ -295,13 +305,34 @@ func (s *packagesSyncer) downloadFile(ctx context.Context, pkgName string, file
 		return fmt.Errorf("cannot download file from %s, HTTP response=%v", file.DownloadUrl, resp.StatusCode)
 	}
 
-	err = s.localState.UpdateContent(ctx, pkgName, resp.Body, file.ContentHash, file.Signature)
+	// Package length is required to be able to report download percent.
+	packageLength := -1
+	if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
+		if length, err := strconv.Atoi(contentLength); err == nil {
+			packageLength = length
+		}
+	}
+	// start the download reporter
+	detailsReporter := newDownloadReporter(s.reporterInterval, packageLength)
+	detailsReporter.report(ctx, s.updateDownloadDetails(pkgName))
+	defer detailsReporter.stop()
+
+	tr := io.TeeReader(resp.Body, detailsReporter)
+	err = s.localState.UpdateContent(ctx, pkgName, tr, file.ContentHash, file.Signature)
 	if err != nil {
 		return fmt.Errorf("failed to install/update the package %s downloaded from %s: %v", pkgName, file.DownloadUrl, err)
 	}
 	return nil
 }
 
+func (s *packagesSyncer) updateDownloadDetails(pkgName string) func(context.Context, protobufs.PackageDownloadDetails) error {
+	return func(ctx context.Context, details protobufs.PackageDownloadDetails) error {
+		status := s.statuses.Packages[pkgName]
+		status.DownloadDetails = &details
+		return s.reportStatuses(ctx, true)
+	}
+}
+
 // deleteUnneededLocalPackages deletes local packages that are not
 // needed anymore. This is done by comparing the local package state
 // with the server's package state.
diff --git a/client/internal/receivedprocessor.go b/client/internal/receivedprocessor.go
index 55f2ee5..cbc23c2 100644
--- a/client/internal/receivedprocessor.go
+++ b/client/internal/receivedprocessor.go
@@ -31,6 +31,10 @@ type receivedProcessor struct {
 
 	// Agent's capabilities defined at Start() time.
 	capabilities protobufs.AgentCapabilities
+
+	// Download reporter interval value
+	// a negative number indicates that the default should be used instead.
+	downloadReporterInt time.Duration
 }
 
 func newReceivedProcessor(
@@ -41,6 +45,7 @@ func newReceivedProcessor(
 	packagesStateProvider types.PackagesStateProvider,
 	capabilities protobufs.AgentCapabilities,
 	packageSyncMutex *sync.Mutex,
+	downloadReporterInt time.Duration,
 ) receivedProcessor {
 	return receivedProcessor{
 		logger:                logger,
@@ -50,6 +55,7 @@ func newReceivedProcessor(
 		packagesStateProvider: packagesStateProvider,
 		capabilities:          capabilities,
 		packageSyncMutex:      packageSyncMutex,
+		downloadReporterInt:   downloadReporterInt,
 	}
 }
 
@@ -129,6 +135,7 @@ func (r *receivedProcessor) ProcessReceivedMessage(ctx context.Context, msg *pro
 				r.clientSyncedState,
 				r.packagesStateProvider,
 				r.packageSyncMutex,
+				r.downloadReporterInt,
 			)
 		} else {
 			r.logger.Debugf(ctx, "Ignoring PackagesAvailable, agent does not have AcceptsPackages capability")
diff --git a/client/internal/wsreceiver.go b/client/internal/wsreceiver.go
index bb9c90b..3b2d482 100644
--- a/client/internal/wsreceiver.go
+++ b/client/internal/wsreceiver.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 	"sync"
+	"time"
 
 	"github.com/gorilla/websocket"
 	"github.com/open-telemetry/opamp-go/client/types"
@@ -34,13 +35,14 @@ func NewWSReceiver(
 	packagesStateProvider types.PackagesStateProvider,
 	capabilities protobufs.AgentCapabilities,
 	packageSyncMutex *sync.Mutex,
+	reporterInterval time.Duration,
 ) *wsReceiver {
 	w := &wsReceiver{
 		conn:      conn,
 		logger:    logger,
 		sender:    sender,
 		callbacks: callbacks,
-		processor: newReceivedProcessor(logger, callbacks, sender, clientSyncedState, packagesStateProvider, capabilities, packageSyncMutex),
+		processor: newReceivedProcessor(logger, callbacks, sender, clientSyncedState, packagesStateProvider, capabilities, packageSyncMutex, reporterInterval),
 		stopped:   make(chan struct{}),
 	}
 
diff --git a/client/types/packagessyncer.go b/client/types/packagessyncer.go
index 3662e6f..b1546b4 100644
--- a/client/types/packagessyncer.go
+++ b/client/types/packagessyncer.go
@@ -87,5 +87,8 @@ type PackagesStateProvider interface {
 
 	// SetLastReportedStatuses saves the statuses in the local state. This is called
 	// periodically during syncing process to save the most recent statuses.
+	// Depending on implementation, this method may be called concurrently if a client
+	// downloads many packages at once. Implementors of this interface should take care
+	// to ensure that conflicting writes do not occur.
 	SetLastReportedStatuses(statuses *protobufs.PackageStatuses) error
 }
diff --git a/client/types/startsettings.go b/client/types/startsettings.go
index 6184d57..c8ba7c1 100644
--- a/client/types/startsettings.go
+++ b/client/types/startsettings.go
@@ -64,4 +64,9 @@ type StartSettings struct {
 	//
 	// If the ReportsHeartbeat capability is disabled, this option has no effect.
 	HeartbeatInterval *time.Duration
+
+	// Optional DownloadReporterInterval to configure how often a client reports the status of a package that is being downloaded.
+	// If nil, the default reporter interval (10s) will be used.
+	// If specified a minimum value of 1s will be enforced.
+	DownloadReporterInterval *time.Duration
 }
diff --git a/client/wsclient.go b/client/wsclient.go
index fdacdd4..99637a6 100644
--- a/client/wsclient.go
+++ b/client/wsclient.go
@@ -364,6 +364,7 @@ func (c *wsClient) runOneCycle(ctx context.Context) {
 		c.common.PackagesStateProvider,
 		c.common.Capabilities,
 		&c.common.PackageSyncMutex,
+		c.common.DownloadReporterInterval,
 	)
 
 	// When the wsclient is closed, the context passed to runOneCycle will be canceled.
diff --git a/internal/tools/go.mod b/internal/tools/go.mod
index 7655384..df5688b 100644
--- a/internal/tools/go.mod
+++ b/internal/tools/go.mod
@@ -6,7 +6,7 @@ toolchain go1.24.1
 
 require (
 	github.com/ory/go-acc v0.2.8
-	golang.org/x/tools v0.31.0
+	golang.org/x/tools v0.32.0
 	mvdan.cc/gofumpt v0.7.0
 )
 
@@ -30,8 +30,8 @@ require (
 	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/subosito/gotenv v1.2.0 // indirect
 	golang.org/x/mod v0.24.0 // indirect
-	golang.org/x/sync v0.12.0 // indirect
-	golang.org/x/sys v0.31.0 // indirect
+	golang.org/x/sync v0.13.0 // indirect
+	golang.org/x/sys v0.32.0 // indirect
 	golang.org/x/text v0.3.2 // indirect
 	gopkg.in/ini.v1 v1.57.0 // indirect
 	gopkg.in/yaml.v2 v2.3.0 // indirect
diff --git a/internal/tools/go.sum b/internal/tools/go.sum
index 3446011..4186dbc 100644
--- a/internal/tools/go.sum
+++ b/internal/tools/go.sum
@@ -169,8 +169,8 @@ golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAG
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
-golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
+golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -180,8 +180,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
-golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
+golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
@@ -191,8 +191,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
-golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
+golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
+golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=


Test patch:
diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index 80ef753..33193f3 100644
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -14,7 +14,7 @@ jobs:
       - name: Checkout Repo
         uses: actions/checkout@v4
       - name: Setup Go
-        uses: actions/setup-go@v5.3.0
+        uses: actions/setup-go@v5.4.0
         with:
           go-version: "1.22"
       - name: Setup Go Environment
@@ -47,7 +47,7 @@ jobs:
       - name: Checkout Repo
         uses: actions/checkout@v4
       - name: Setup Go
-        uses: actions/setup-go@v5.3.0
+        uses: actions/setup-go@v5.4.0
         with:
           go-version: "1.22"
       - name: Setup Go Environment
diff --git a/client/clientimpl_test.go b/client/clientimpl_test.go
index f16e278..785134d 100644
--- a/client/clientimpl_test.go
+++ b/client/clientimpl_test.go
@@ -1266,6 +1266,12 @@ type packageTestCase struct {
 	expectedFileContent map[string][]byte
 	expectedSignature   map[string][]byte
 	expectedError       string
+
+	// expectedTemporaryStatuses is a slice used by a test case to check if a non-final package status occurs.
+	// When a PackageStatuses message is added to the slice only the Packages name and Status will be checked
+	// If they are successfully observed then the corresponding entry in observedTemporaryStatuses will be marked as true
+	expectedTemporaryStatuses []*protobufs.PackageStatuses
+	observedTemporaryStatuses []bool
 }
 
 const packageUpdateErrorMsg = "cannot update packages"
@@ -1275,6 +1281,7 @@ func assertPackageStatus(t *testing.T,
 	msg *protobufs.AgentToServer,
 ) (*protobufs.ServerToAgent, bool) {
 	expectedStatusReceived := false
+	testCase.observedTemporaryStatuses = make([]bool, len(testCase.expectedTemporaryStatuses))
 
 	status := msg.PackageStatuses
 	if status == nil {
@@ -1315,6 +1322,19 @@ func assertPackageStatus(t *testing.T,
 		}
 	}
 
+	for i, tempStatus := range testCase.expectedTemporaryStatuses {
+		for name, pack := range tempStatus.Packages {
+			obsPackage, ok := status.Packages[name]
+			if !ok {
+				// name does not match
+				continue
+			}
+			if pack.Status == obsPackage.Status {
+				testCase.observedTemporaryStatuses[i] = true
+			}
+		}
+	}
+
 	return &protobufs.ServerToAgent{InstanceUid: msg.InstanceUid}, expectedStatusReceived
 }
 
@@ -1392,6 +1412,10 @@ func verifyUpdatePackages(t *testing.T, testCase packageTestCase) {
 				expectedSignature := testCase.expectedSignature[pkgName]
 				assert.EqualValues(t, expectedSignature, actualSignature)
 			}
+
+			for i, ok := range testCase.observedTemporaryStatuses {
+				assert.Truef(t, ok, "expected to observe temporary status %#v", testCase.expectedTemporaryStatuses[i])
+			}
 		}
 
 		// Client --->
@@ -1525,6 +1549,18 @@ func TestUpdatePackages(t *testing.T) {
 	errorOnCallback.errorOnCallback = true
 	tests = append(tests, errorOnCallback)
 
+	// Check that the downloading status is sent
+	downloading := createPackageTestCase("download status set", downloadSrv)
+	downloading.expectedTemporaryStatuses = append(downloading.expectedTemporaryStatuses, &protobufs.PackageStatuses{
+		Packages: map[string]*protobufs.PackageStatus{
+			"package1": {
+				Name:   "package1",
+				Status: protobufs.PackageStatusEnum_PackageStatusEnum_Downloading,
+			},
+		},
+	})
+	tests = append(tests, downloading)
+
 	// A case where we send optional headers
 	withHeaders := createPackageTestCase("with optional HTTP headers", downloadSrv)
 	withHeaders.available.Packages["package1"].File.Headers = &protobufs.Headers{Headers: []*protobufs.Header{&optionalAuthHeaders}}
diff --git a/client/internal/httpsender_test.go b/client/internal/httpsender_test.go
index 19eb1cc..a6c9869 100644
--- a/client/internal/httpsender_test.go
+++ b/client/internal/httpsender_test.go
@@ -202,7 +202,7 @@ func TestRequestInstanceUidFlagReset(t *testing.T) {
 	clientSyncedState := &ClientSyncedState{}
 	clientSyncedState.SetFlags(protobufs.AgentToServerFlags_AgentToServerFlags_RequestInstanceUid)
 	capabilities := protobufs.AgentCapabilities_AgentCapabilities_Unspecified
-	sender.receiveProcessor = newReceivedProcessor(&sharedinternal.NopLogger{}, sender.callbacks, sender, clientSyncedState, nil, capabilities, new(sync.Mutex))
+	sender.receiveProcessor = newReceivedProcessor(&sharedinternal.NopLogger{}, sender.callbacks, sender, clientSyncedState, nil, capabilities, new(sync.Mutex), time.Second)
 
 	// If we process a message with a nil AgentIdentification, or an incorrect NewInstanceUid.
 	sender.receiveProcessor.ProcessReceivedMessage(ctx,
@@ -258,7 +258,7 @@ func TestPackageUpdatesInParallel(t *testing.T) {
 
 	clientSyncedState := &ClientSyncedState{}
 	capabilities := protobufs.AgentCapabilities_AgentCapabilities_AcceptsPackages
-	sender.receiveProcessor = newReceivedProcessor(&sharedinternal.NopLogger{}, sender.callbacks, sender, clientSyncedState, localPackageState, capabilities, &mux)
+	sender.receiveProcessor = newReceivedProcessor(&sharedinternal.NopLogger{}, sender.callbacks, sender, clientSyncedState, localPackageState, capabilities, &mux, time.Second)
 
 	sender.receiveProcessor.ProcessReceivedMessage(ctx,
 		&protobufs.ServerToAgent{
@@ -331,7 +331,7 @@ func TestPackageUpdatesWithError(t *testing.T) {
 	clientSyncedState := &ClientSyncedState{}
 
 	capabilities := protobufs.AgentCapabilities_AgentCapabilities_AcceptsPackages
-	sender.receiveProcessor = newReceivedProcessor(&sharedinternal.NopLogger{}, sender.callbacks, sender, clientSyncedState, localPackageState, capabilities, &mux)
+	sender.receiveProcessor = newReceivedProcessor(&sharedinternal.NopLogger{}, sender.callbacks, sender, clientSyncedState, localPackageState, capabilities, &mux, time.Second)
 
 	// Send two messages in parallel.
 	sender.receiveProcessor.ProcessReceivedMessage(ctx,
diff --git a/client/internal/package_download_details_reporter_test.go b/client/internal/package_download_details_reporter_test.go
new file mode 100644
index 0000000..80548bc
--- /dev/null
+++ b/client/internal/package_download_details_reporter_test.go
@@ -0,0 +1,42 @@
+package internal
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/open-telemetry/opamp-go/protobufs"
+	"github.com/stretchr/testify/require"
+)
+
+func Test_DownloadReporter_Report(t *testing.T) {
+	ts := time.Now()
+	reporter := &downloadReporter{
+		start:         ts,
+		interval:      time.Millisecond,
+		packageLength: 2,
+		done:          make(chan struct{}),
+	}
+	defer reporter.stop()
+
+	// Write before report to avoid timeing issues.
+	n, err := reporter.Write([]byte{0})
+	require.NoError(t, err)
+	require.Equal(t, 1, n)
+
+	ch := make(chan protobufs.PackageDownloadDetails)
+	updateFn := func(_ context.Context, d protobufs.PackageDownloadDetails) error {
+		ch <- d
+		return nil
+	}
+	reporter.report(context.Background(), updateFn)
+
+	var details protobufs.PackageDownloadDetails
+	select {
+	case details = <-ch:
+	case <-time.After(time.Millisecond * 100):
+		t.Fatal("Did not recieve report after 100ms")
+	}
+
+	require.Equal(t, 50, int(details.DownloadPercent))
+}
diff --git a/client/internal/wsreceiver_test.go b/client/internal/wsreceiver_test.go
index 0bbbf87..17c1677 100644
--- a/client/internal/wsreceiver_test.go
+++ b/client/internal/wsreceiver_test.go
@@ -88,7 +88,7 @@ func TestServerToAgentCommand(t *testing.T) {
 			}
 			sender := WSSender{}
 			capabilities := protobufs.AgentCapabilities_AgentCapabilities_AcceptsRestartCommand
-			receiver := NewWSReceiver(TestLogger{t}, callbacks, nil, &sender, &clientSyncedState, nil, capabilities, new(sync.Mutex))
+			receiver := NewWSReceiver(TestLogger{t}, callbacks, nil, &sender, &clientSyncedState, nil, capabilities, new(sync.Mutex), time.Second)
 			receiver.processor.ProcessReceivedMessage(context.Background(), &protobufs.ServerToAgent{
 				Command: test.command,
 			})
@@ -142,7 +142,7 @@ func TestServerToAgentCommandExclusive(t *testing.T) {
 			},
 		}
 		clientSyncedState := ClientSyncedState{}
-		receiver := NewWSReceiver(TestLogger{t}, callbacks, nil, nil, &clientSyncedState, nil, test.capabilities, new(sync.Mutex))
+		receiver := NewWSReceiver(TestLogger{t}, callbacks, nil, nil, &clientSyncedState, nil, test.capabilities, new(sync.Mutex), time.Second)
 		receiver.processor.ProcessReceivedMessage(context.Background(), &protobufs.ServerToAgent{
 			Command: &protobufs.ServerToAgentCommand{
 				Type: protobufs.CommandType_CommandType_Restart,
@@ -204,7 +204,7 @@ func TestReceiverLoopStop(t *testing.T) {
 	}
 	sender := WSSender{}
 	capabilities := protobufs.AgentCapabilities_AgentCapabilities_AcceptsRestartCommand
-	receiver := NewWSReceiver(TestLogger{t}, callbacks, conn, &sender, &clientSyncedState, nil, capabilities, new(sync.Mutex))
+	receiver := NewWSReceiver(TestLogger{t}, callbacks, conn, &sender, &clientSyncedState, nil, capabilities, new(sync.Mutex), time.Second)
 	ctx, cancel := context.WithCancel(context.Background())
 
 	go func() {
@@ -246,7 +246,7 @@ func TestWSPackageUpdatesInParallel(t *testing.T) {
 	clientSyncedState := &ClientSyncedState{}
 	capabilities := protobufs.AgentCapabilities_AgentCapabilities_AcceptsPackages
 	sender := NewSender(&internal.NopLogger{})
-	receiver := NewWSReceiver(&internal.NopLogger{}, callbacks, nil, sender, clientSyncedState, localPackageState, capabilities, &mux)
+	receiver := NewWSReceiver(&internal.NopLogger{}, callbacks, nil, sender, clientSyncedState, localPackageState, capabilities, &mux, time.Second)
 
 	receiver.processor.ProcessReceivedMessage(ctx,
 		&protobufs.ServerToAgent{