Skip to content

Commit 2f0c495

Browse files
authored
telemetry: expose agent version info via data-api and data-cli (#3530)
## Summary of Changes - Add `GET /device-link/agent-versions` endpoint to data-api and `agent-versions` subcommand to data-cli, surfacing the telemetry agent version and commit hash from `DeviceLatencySamplesHeader` written onchain since #3506 - Add `GetDeviceLatencySamplesHeader` SDK method using Solana RPC `DataSlice` to fetch only the 350-byte header instead of the full ~140KB account - Extract `ToV1Header()` on `DeviceLatencySamplesHeaderV0` for header-only V0→V1 conversion, reused by existing `ToV1()` - Bound concurrency in `GetAgentVersions` with a semaphore (cap 64) to prevent unbounded goroutine fan-out ## Diff Breakdown | Category | Files | Lines (+/-) | Net | |--------------|-------|--------------|------| | Core logic | 3 | +211 / -23 | +188 | | Scaffolding | 6 | +129 / -0 | +129 | | Tests | 5 | +368 / -1 | +367 | ~340 lines of non-test code; roughly half is core logic, half is wiring. <details> <summary>Key files (click to expand)</summary> - [`controlplane/telemetry/internal/data/device/agent_versions.go`](https://github.com/malbeclabs/doublezero/pull/3530/files#diff-0ead065494d1fcf29685878803a66c06bfaa5fa494008fd8202266177cf1ada4) — Provider method that groups circuits by origin device, fetches headers (current + previous epoch fallback), and returns version/commit per device - [`controlplane/telemetry/internal/data/cli/agent_versions.go`](https://github.com/malbeclabs/doublezero/pull/3530/files#diff-405da9067b67de87fa28b31337f60a06ab176732a464bf90a31fff58f660b899) — CLI subcommand that calls GetAgentVersions and renders a table - [`smartcontract/sdk/go/telemetry/client.go`](https://github.com/malbeclabs/doublezero/pull/3530/files#diff-80b8d7ca3aa6fd5d642584653ca346aee5be57bc77e79a89db885b782566c0ff) — New `GetDeviceLatencySamplesHeader` using DataSlice to fetch only 350 bytes, with V0/V1 deserialization - [`smartcontract/sdk/go/telemetry/state_v0.go`](https://github.com/malbeclabs/doublezero/pull/3530/files#diff-8f5df3efc61241621c8867509a324f0532369db21cf40d38fe2467362c927fac) — Extract `ToV1Header()` from `ToV1()` for header-only conversion without samples - [`controlplane/telemetry/internal/data/device/server.go`](https://github.com/malbeclabs/doublezero/pull/3530/files#diff-c60979e397bc54fb0d2a666df2410d9d93b24537a1a23bd17b43ec276104e664) — Register and handle `/device-link/agent-versions` route </details> ## Testing Verification - New provider tests for `GetAgentVersions`: valid headers, account-not-found skipping, zero-sample-index skipping, staleness filtering (>24h), epoch fallback, empty circuits - New HTTP handler tests for `/device-link/agent-versions`: success response, invalid env (400), empty results, provider error (500) - Existing SDK V0/V1 serialization round-trip tests pass after `ToV1Header` refactor
1 parent f95ae4c commit 2f0c495

16 files changed

Lines changed: 909 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ All notable changes to this project will be documented in this file.
88

99
### Changes
1010

11+
- Telemetry
12+
- Add `GET /device-link/agent-versions` endpoint to data-api and `agent-versions` subcommand to data-cli, exposing per-device telemetry agent version and commit from onchain `DeviceLatencySamplesHeader`
1113
- Smartcontract
1214
- Allow `SubscribeMulticastGroup` for users in `Pending` status so that `CreateSubscribeUser` can be followed by additional subscribe calls before the activator runs ([#3521](https://github.com/malbeclabs/doublezero/pull/3521))
1315
- Add optional `owner` field to `UpdateMulticastGroup` instruction, allowing foundation members to reassign ownership of a multicast group ([#3527](https://github.com/malbeclabs/doublezero/pull/3527))
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/signal"
8+
"syscall"
9+
10+
devicedata "github.com/malbeclabs/doublezero/controlplane/telemetry/internal/data/device"
11+
"github.com/olekukonko/tablewriter"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
type AgentVersionsCmd struct{}
16+
17+
func NewAgentVersionsCmd() *AgentVersionsCmd {
18+
return &AgentVersionsCmd{}
19+
}
20+
21+
func (c *AgentVersionsCmd) Command() *cobra.Command {
22+
cmd := &cobra.Command{
23+
Use: "agent-versions",
24+
Short: "Show telemetry agent version for each device",
25+
RunE: func(cmd *cobra.Command, args []string) error {
26+
verbose, err := cmd.Root().PersistentFlags().GetBool("verbose")
27+
if err != nil {
28+
return fmt.Errorf("failed to get verbose flag: %w", err)
29+
}
30+
env, err := cmd.Root().PersistentFlags().GetString("env")
31+
if err != nil {
32+
return fmt.Errorf("failed to get env flag: %w", err)
33+
}
34+
35+
log := newLogger(verbose)
36+
37+
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
38+
defer cancel()
39+
40+
provider, _, err := newDeviceProvider(log, env)
41+
if err != nil {
42+
log.Error("Failed to get provider", "error", err)
43+
os.Exit(1)
44+
}
45+
46+
versions, err := provider.GetAgentVersions(ctx)
47+
if err != nil {
48+
log.Error("Failed to get agent versions", "error", err)
49+
os.Exit(1)
50+
}
51+
52+
printAgentVersions(versions, env)
53+
return nil
54+
},
55+
}
56+
57+
return cmd
58+
}
59+
60+
func printAgentVersions(versions []devicedata.DeviceAgentVersion, env string) {
61+
fmt.Println("Environment:", env)
62+
fmt.Printf("Devices reporting: %d\n", len(versions))
63+
64+
table := tablewriter.NewWriter(os.Stdout)
65+
table.SetAutoWrapText(false)
66+
table.SetHeaderAlignment(tablewriter.ALIGN_CENTER)
67+
table.SetAutoFormatHeaders(false)
68+
table.SetBorder(true)
69+
table.SetRowLine(true)
70+
table.SetHeader([]string{
71+
"Device PK",
72+
"Device Code",
73+
"Version",
74+
"Commit",
75+
"Last Sample",
76+
})
77+
78+
for _, v := range versions {
79+
table.Append([]string{
80+
v.DevicePK,
81+
v.DeviceCode,
82+
v.Version,
83+
v.Commit,
84+
v.Timestamp,
85+
})
86+
}
87+
table.Render()
88+
}

controlplane/telemetry/internal/data/cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func Run() ExitCode {
4040
rootCmd.AddCommand(
4141
NewDeviceCmd().Command(),
4242
NewInternetCmd().Command(),
43+
NewAgentVersionsCmd().Command(),
4344
)
4445

4546
if err := rootCmd.Execute(); err != nil {
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package data
2+
3+
import (
4+
"context"
5+
"errors"
6+
"sort"
7+
"strings"
8+
"sync"
9+
"time"
10+
11+
"github.com/gagliardetto/solana-go"
12+
"github.com/malbeclabs/doublezero/smartcontract/sdk/go/telemetry"
13+
)
14+
15+
const maxAgentVersionStaleness = 24 * time.Hour
16+
17+
func (p *provider) GetAgentVersions(ctx context.Context) ([]DeviceAgentVersion, error) {
18+
circuits, err := p.GetCircuits(ctx)
19+
if err != nil {
20+
return nil, err
21+
}
22+
23+
currentEpoch, err := p.cfg.EpochFinder.ApproximateAtTime(ctx, time.Now().UTC())
24+
if err != nil {
25+
return nil, err
26+
}
27+
28+
// Group circuits by origin device, keeping one representative circuit per device.
29+
type deviceCircuit struct {
30+
devicePK solana.PublicKey
31+
deviceCode string
32+
circuit Circuit
33+
}
34+
seen := map[solana.PublicKey]struct{}{}
35+
var devices []deviceCircuit
36+
for _, c := range circuits {
37+
if _, ok := seen[c.OriginDevice.PK]; ok {
38+
continue
39+
}
40+
seen[c.OriginDevice.PK] = struct{}{}
41+
devices = append(devices, deviceCircuit{
42+
devicePK: c.OriginDevice.PK,
43+
deviceCode: c.OriginDevice.Code,
44+
circuit: c,
45+
})
46+
}
47+
48+
var mu sync.Mutex
49+
var wg sync.WaitGroup
50+
var results []DeviceAgentVersion
51+
now := time.Now().UTC()
52+
53+
sem := make(chan struct{}, defaultGetCircuitLatenciesPoolSize)
54+
for _, dev := range devices {
55+
wg.Add(1)
56+
sem <- struct{}{}
57+
go func(dev deviceCircuit) {
58+
defer func() { <-sem; wg.Done() }()
59+
60+
// Try current epoch first, then the previous epoch.
61+
var hdr *telemetry.DeviceLatencySamplesHeader
62+
for _, ep := range []uint64{currentEpoch, currentEpoch - 1} {
63+
h, err := p.cfg.TelemetryClient.GetDeviceLatencySamplesHeader(
64+
ctx,
65+
dev.circuit.OriginDevice.PK,
66+
dev.circuit.TargetDevice.PK,
67+
dev.circuit.Link.PK,
68+
ep,
69+
)
70+
if err != nil {
71+
if errors.Is(err, telemetry.ErrAccountNotFound) {
72+
continue
73+
}
74+
p.log.Warn("failed to get samples header", "device", dev.deviceCode, "epoch", ep, "error", err)
75+
continue
76+
}
77+
if h.NextSampleIndex == 0 {
78+
continue
79+
}
80+
hdr = h
81+
break
82+
}
83+
84+
if hdr == nil || hdr.NextSampleIndex == 0 {
85+
return
86+
}
87+
88+
ts := lastSampleTime(hdr)
89+
if now.Sub(ts) > maxAgentVersionStaleness {
90+
return
91+
}
92+
93+
version := strings.TrimRight(string(hdr.AgentVersion[:]), "\x00")
94+
commit := strings.TrimRight(string(hdr.AgentCommit[:]), "\x00")
95+
96+
mu.Lock()
97+
results = append(results, DeviceAgentVersion{
98+
DevicePK: dev.devicePK.String(),
99+
DeviceCode: dev.deviceCode,
100+
Version: version,
101+
Commit: commit,
102+
Timestamp: ts.Format(time.RFC3339),
103+
})
104+
mu.Unlock()
105+
}(dev)
106+
}
107+
wg.Wait()
108+
109+
sort.Slice(results, func(i, j int) bool {
110+
return results[i].DeviceCode < results[j].DeviceCode
111+
})
112+
113+
return results, nil
114+
}
115+
116+
func lastSampleTime(hdr *telemetry.DeviceLatencySamplesHeader) time.Time {
117+
if hdr.NextSampleIndex == 0 {
118+
return time.Time{}
119+
}
120+
tsMicros := hdr.StartTimestampMicroseconds +
121+
uint64(hdr.NextSampleIndex-1)*hdr.SamplingIntervalMicroseconds
122+
secs := int64(tsMicros / 1_000_000)
123+
nanos := int64(tsMicros%1_000_000) * 1000
124+
return time.Unix(secs, nanos)
125+
}

0 commit comments

Comments
 (0)