Skip to content

Commit 4a89220

Browse files
committed
feat: implement GPU and system metrics collection and reporting in InfluxDB line protocol format
1 parent 9b1e66e commit 4a89220

8 files changed

Lines changed: 480 additions & 3 deletions

File tree

docs/api.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,9 @@ components:
386386
- shutdown
387387
license_expiration:
388388
type: integer
389+
metrics:
390+
type: string
391+
description: InfluxDB v2 line protocol string with GPU/system/worker metrics
389392
required:
390393
- timestamp
391394
- gpus
@@ -946,6 +949,9 @@ paths:
946949
- shutdown
947950
license_expiration:
948951
type: integer
952+
metrics:
953+
type: string
954+
description: InfluxDB v2 line protocol string with GPU/system/worker metrics
949955
required:
950956
- timestamp
951957
- gpus

internal/agent/agent.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -965,20 +965,25 @@ func (a *Agent) reportStatus() error {
965965
return err
966966
}
967967

968-
// 6. Send request
968+
// 6. Collect metrics (best-effort, never blocks status report)
969+
now := time.Now()
970+
metricsStr := a.collectMetricsLineProtocol(gpuStatuses, workerStatuses, now)
971+
972+
// 7. Send request
969973
req := &api.AgentStatusRequest{
970-
Timestamp: time.Now(),
974+
Timestamp: now,
971975
GPUs: gpuStatuses,
972976
Workers: workerStatuses,
973977
LicenseExpiration: licenseExpiration,
978+
Metrics: metricsStr,
974979
}
975980

976981
resp, err := a.client.ReportAgentStatus(a.ctx, a.agentID, req)
977982
if err != nil {
978983
return err
979984
}
980985

981-
// 7. Handle response
986+
// 8. Handle response
982987
a.handleReportResponse(resp)
983988

984989
return nil
@@ -1266,6 +1271,10 @@ func parseLicenseExpiration(licensePlain string) int64 {
12661271
// The file is located at ~/.gpugo/config/{workerID}_share_codes
12671272
// with one share code per line. Workers read this file for URL-based auth.
12681273
func (a *Agent) writeShareCodes(workerID string, codes []string) error {
1274+
if len(codes) == 0 {
1275+
return nil
1276+
}
1277+
12691278
path := filepath.Join(a.paths.ConfigDir(), workerID+"_share_codes")
12701279

12711280
// Ensure config directory exists

internal/agent/device.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ func ConvertMetricsToGPUMetrics(metrics map[string]*hvapi.GPUUsageMetrics) map[s
5454
VRAMUsedMb: int64(m.MemoryBytes / (1024 * 1024)),
5555
Temperature: m.Temperature,
5656
PowerUsageW: float64(m.PowerUsage),
57+
PCIeRxKB: m.Rx,
58+
PCIeTxKB: m.Tx,
5759
}
5860
}
5961
return result

internal/agent/metrics.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package agent
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"time"
7+
8+
"github.com/NexusGPU/gpu-go/internal/api"
9+
"k8s.io/klog/v2"
10+
)
11+
12+
// workerStatusToInt maps worker status strings to integers for metrics storage.
13+
func workerStatusToInt(status string) int {
14+
switch status {
15+
case workerStatusRunning:
16+
return 1
17+
case workerStatusPending:
18+
return 2
19+
case workerStatusStopping:
20+
return 3
21+
case workerStatusStopped:
22+
return 0
23+
default:
24+
return -1
25+
}
26+
}
27+
28+
// buildMetricsLineProtocol builds InfluxDB v2 line protocol for GPU, system,
29+
// and worker metrics. Each line is newline-separated. Timestamp is in milliseconds.
30+
func buildMetricsLineProtocol(
31+
agentID string,
32+
hostname string,
33+
gpuMetrics map[string]*api.GPUMetrics,
34+
gpuConfigs []api.GPUStatus,
35+
workerStatuses []api.WorkerStatus,
36+
systemMetrics *api.SystemMetrics,
37+
timestampMs int64,
38+
) string {
39+
var b strings.Builder
40+
41+
// Build a lookup from GPU ID -> config for vendor/model/vram_total
42+
gpuConfigMap := make(map[string]*api.GPUStatus, len(gpuConfigs))
43+
for i := range gpuConfigs {
44+
gpuConfigMap[gpuConfigs[i].GPUID] = &gpuConfigs[i]
45+
}
46+
47+
// gpu_metrics lines
48+
for gpuID, m := range gpuMetrics {
49+
model := ""
50+
vendor := ""
51+
vramTotalMb := int64(0)
52+
if cfg, ok := gpuConfigMap[gpuID]; ok {
53+
model = cfg.Model
54+
vendor = cfg.Vendor
55+
vramTotalMb = cfg.VRAMMb
56+
}
57+
58+
b.WriteString("gpu_metrics,agent_id=")
59+
b.WriteString(escapeTagValue(agentID))
60+
b.WriteString(",gpu_id=")
61+
b.WriteString(escapeTagValue(m.GPUID))
62+
b.WriteString(",model=")
63+
b.WriteString(escapeTagValue(model))
64+
b.WriteString(",vendor=")
65+
b.WriteString(escapeTagValue(vendor))
66+
fmt.Fprintf(&b, " utilization=%.2f,vram_used_mb=%di,vram_total_mb=%di,temperature=%.1f,power_usage_w=%.1f,pcie_rx_kb=%.1f,pcie_tx_kb=%.1f %d",
67+
m.Utilization, m.VRAMUsedMb, vramTotalMb, m.Temperature, m.PowerUsageW, m.PCIeRxKB, m.PCIeTxKB, timestampMs)
68+
b.WriteByte('\n')
69+
}
70+
71+
// system_metrics line
72+
if systemMetrics != nil {
73+
b.WriteString("system_metrics,agent_id=")
74+
b.WriteString(escapeTagValue(agentID))
75+
b.WriteString(",hostname=")
76+
b.WriteString(escapeTagValue(hostname))
77+
fmt.Fprintf(&b, " cpu_usage=%.2f,memory_used_mb=%di,memory_total_mb=%di %d",
78+
systemMetrics.CPUUsage, systemMetrics.MemoryUsedMb, systemMetrics.MemoryTotalMb, timestampMs)
79+
b.WriteByte('\n')
80+
}
81+
82+
// worker_metrics lines
83+
for _, w := range workerStatuses {
84+
b.WriteString("worker_metrics,agent_id=")
85+
b.WriteString(escapeTagValue(agentID))
86+
b.WriteString(",worker_id=")
87+
b.WriteString(escapeTagValue(w.WorkerID))
88+
fmt.Fprintf(&b, " status=%di,connections=%di,restarts=%di %d",
89+
workerStatusToInt(w.Status), len(w.Connections), w.Restarts, timestampMs)
90+
b.WriteByte('\n')
91+
}
92+
93+
return strings.TrimRight(b.String(), "\n")
94+
}
95+
96+
// escapeTagValue escapes special characters in InfluxDB line protocol tag values.
97+
// Spaces, commas, and equals signs must be backslash-escaped.
98+
func escapeTagValue(s string) string {
99+
s = strings.ReplaceAll(s, " ", "\\ ")
100+
s = strings.ReplaceAll(s, ",", "\\,")
101+
s = strings.ReplaceAll(s, "=", "\\=")
102+
return s
103+
}
104+
105+
// collectMetricsLineProtocol gathers GPU and system metrics, then builds
106+
// the InfluxDB line protocol string. Returns empty string on failure.
107+
func (a *Agent) collectMetricsLineProtocol(
108+
gpuStatuses []api.GPUStatus,
109+
workerStatuses []api.WorkerStatus,
110+
now time.Time,
111+
) string {
112+
// Collect GPU metrics from hypervisor (best-effort)
113+
var gpuMetrics map[string]*api.GPUMetrics
114+
if a.hypervisorMgr != nil && a.hypervisorMgr.IsStarted() {
115+
hvMetrics, err := a.hypervisorMgr.GetDeviceMetrics()
116+
if err != nil {
117+
klog.V(4).Infof("Failed to collect GPU metrics: %v", err)
118+
} else {
119+
gpuMetrics = ConvertMetricsToGPUMetrics(hvMetrics)
120+
}
121+
}
122+
123+
// Collect system metrics (best-effort, nil on non-Linux)
124+
sysMetrics := collectSystemMetrics()
125+
126+
// Nothing to report
127+
if len(gpuMetrics) == 0 && sysMetrics == nil && len(workerStatuses) == 0 {
128+
return ""
129+
}
130+
131+
return buildMetricsLineProtocol(
132+
a.agentID,
133+
a.hostname,
134+
gpuMetrics,
135+
gpuStatuses,
136+
workerStatuses,
137+
sysMetrics,
138+
now.UnixMilli(),
139+
)
140+
}

internal/agent/metrics_test.go

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package agent
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/NexusGPU/gpu-go/internal/api"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestBuildMetricsLineProtocol_SingleGPU(t *testing.T) {
12+
gpuMetrics := map[string]*api.GPUMetrics{
13+
"gpu-001": {
14+
GPUID: "gpu-001",
15+
Utilization: 75.5,
16+
VRAMUsedMb: 8192,
17+
Temperature: 72.0,
18+
PowerUsageW: 250.0,
19+
PCIeRxKB: 1024.5,
20+
PCIeTxKB: 512.3,
21+
},
22+
}
23+
gpuConfigs := []api.GPUStatus{
24+
{GPUID: "gpu-001", Vendor: "nvidia", Model: "RTX 4090", VRAMMb: 24576},
25+
}
26+
workers := []api.WorkerStatus{
27+
{WorkerID: "w-1", Status: "running", Restarts: 2, Connections: []api.ConnectionInfo{{ClientIP: "1.2.3.4"}}},
28+
}
29+
sysMetrics := &api.SystemMetrics{CPUUsage: 45.2, MemoryUsedMb: 8000, MemoryTotalMb: 16384}
30+
ts := int64(1700000000000)
31+
32+
result := buildMetricsLineProtocol("agent-abc", "my-host", gpuMetrics, gpuConfigs, workers, sysMetrics, ts)
33+
34+
lines := strings.Split(result, "\n")
35+
assert.Len(t, lines, 3)
36+
37+
// GPU line
38+
assert.Contains(t, lines[0], "gpu_metrics,agent_id=agent-abc,gpu_id=gpu-001,model=RTX\\ 4090,vendor=nvidia")
39+
assert.Contains(t, lines[0], "utilization=75.50,vram_used_mb=8192i,vram_total_mb=24576i,temperature=72.0,power_usage_w=250.0,pcie_rx_kb=1024.5,pcie_tx_kb=512.3")
40+
assert.True(t, strings.HasSuffix(lines[0], " 1700000000000"))
41+
42+
// System line
43+
assert.Contains(t, lines[1], "system_metrics,agent_id=agent-abc,hostname=my-host")
44+
assert.Contains(t, lines[1], "cpu_usage=45.20,memory_used_mb=8000i,memory_total_mb=16384i")
45+
46+
// Worker line
47+
assert.Contains(t, lines[2], "worker_metrics,agent_id=agent-abc,worker_id=w-1")
48+
assert.Contains(t, lines[2], "status=1i,connections=1i,restarts=2i")
49+
}
50+
51+
func TestBuildMetricsLineProtocol_MultipleGPUs(t *testing.T) {
52+
gpuMetrics := map[string]*api.GPUMetrics{
53+
"gpu-001": {GPUID: "gpu-001", Utilization: 50.0, VRAMUsedMb: 4000, Temperature: 65.0, PowerUsageW: 200.0},
54+
"gpu-002": {GPUID: "gpu-002", Utilization: 80.0, VRAMUsedMb: 6000, Temperature: 70.0, PowerUsageW: 300.0},
55+
}
56+
gpuConfigs := []api.GPUStatus{
57+
{GPUID: "gpu-001", Vendor: "nvidia", Model: "RTX 3090", VRAMMb: 24576},
58+
{GPUID: "gpu-002", Vendor: "nvidia", Model: "RTX 4090", VRAMMb: 24576},
59+
}
60+
61+
result := buildMetricsLineProtocol("agent-1", "host1", gpuMetrics, gpuConfigs, nil, nil, 1700000000000)
62+
63+
lines := strings.Split(result, "\n")
64+
assert.Len(t, lines, 2, "should have exactly 2 GPU metric lines")
65+
66+
// Check both GPUs are present (map iteration order is non-deterministic)
67+
combined := result
68+
assert.Contains(t, combined, "gpu_id=gpu-001")
69+
assert.Contains(t, combined, "gpu_id=gpu-002")
70+
assert.Contains(t, combined, "model=RTX\\ 3090")
71+
assert.Contains(t, combined, "model=RTX\\ 4090")
72+
}
73+
74+
func TestBuildMetricsLineProtocol_TagEscaping(t *testing.T) {
75+
gpuMetrics := map[string]*api.GPUMetrics{
76+
"gpu=special": {GPUID: "gpu=special", Utilization: 10.0, VRAMUsedMb: 100, Temperature: 50.0, PowerUsageW: 100.0},
77+
}
78+
gpuConfigs := []api.GPUStatus{
79+
{GPUID: "gpu=special", Vendor: "vendor,test", Model: "Model With Spaces", VRAMMb: 8192},
80+
}
81+
82+
result := buildMetricsLineProtocol("agent,id=1", "host name", gpuMetrics, gpuConfigs, nil, nil, 1700000000000)
83+
84+
assert.Contains(t, result, "agent_id=agent\\,id\\=1")
85+
assert.Contains(t, result, "gpu_id=gpu\\=special")
86+
assert.Contains(t, result, "model=Model\\ With\\ Spaces")
87+
assert.Contains(t, result, "vendor=vendor\\,test")
88+
}
89+
90+
func TestBuildMetricsLineProtocol_EmptyMetrics(t *testing.T) {
91+
result := buildMetricsLineProtocol("agent-1", "host1", nil, nil, nil, nil, 1700000000000)
92+
assert.Empty(t, result)
93+
}
94+
95+
func TestBuildMetricsLineProtocol_WorkerStatusMapping(t *testing.T) {
96+
workers := []api.WorkerStatus{
97+
{WorkerID: "w-running", Status: "running"},
98+
{WorkerID: "w-stopped", Status: "stopped"},
99+
{WorkerID: "w-pending", Status: "pending"},
100+
{WorkerID: "w-stopping", Status: "stopping"},
101+
{WorkerID: "w-unknown", Status: "weird"},
102+
}
103+
104+
result := buildMetricsLineProtocol("agent-1", "host1", nil, nil, workers, nil, 1700000000000)
105+
106+
lines := strings.Split(result, "\n")
107+
assert.Len(t, lines, 5)
108+
109+
// Verify status integer mapping
110+
assert.Contains(t, lines[0], "worker_id=w-running")
111+
assert.Contains(t, lines[0], "status=1i")
112+
113+
assert.Contains(t, lines[1], "worker_id=w-stopped")
114+
assert.Contains(t, lines[1], "status=0i")
115+
116+
assert.Contains(t, lines[2], "worker_id=w-pending")
117+
assert.Contains(t, lines[2], "status=2i")
118+
119+
assert.Contains(t, lines[3], "worker_id=w-stopping")
120+
assert.Contains(t, lines[3], "status=3i")
121+
122+
assert.Contains(t, lines[4], "worker_id=w-unknown")
123+
assert.Contains(t, lines[4], "status=-1i")
124+
}
125+
126+
func TestBuildMetricsLineProtocol_OnlySystemMetrics(t *testing.T) {
127+
sysMetrics := &api.SystemMetrics{CPUUsage: 25.0, MemoryUsedMb: 4096, MemoryTotalMb: 8192}
128+
129+
result := buildMetricsLineProtocol("agent-1", "host1", nil, nil, nil, sysMetrics, 1700000000000)
130+
131+
assert.Contains(t, result, "system_metrics")
132+
assert.NotContains(t, result, "gpu_metrics")
133+
assert.NotContains(t, result, "worker_metrics")
134+
}
135+
136+
func TestBuildMetricsLineProtocol_TimestampFormat(t *testing.T) {
137+
gpuMetrics := map[string]*api.GPUMetrics{
138+
"gpu-001": {GPUID: "gpu-001", Utilization: 50.0, VRAMUsedMb: 4000, Temperature: 65.0, PowerUsageW: 200.0},
139+
}
140+
gpuConfigs := []api.GPUStatus{
141+
{GPUID: "gpu-001", Vendor: "nvidia", Model: "RTX4090", VRAMMb: 24576},
142+
}
143+
ts := int64(1700000000123) // millisecond precision
144+
145+
result := buildMetricsLineProtocol("agent-1", "host1", gpuMetrics, gpuConfigs, nil, nil, ts)
146+
147+
assert.True(t, strings.HasSuffix(result, "1700000000123"), "timestamp should be in milliseconds")
148+
}
149+
150+
func TestEscapeTagValue(t *testing.T) {
151+
tests := []struct {
152+
input string
153+
expected string
154+
}{
155+
{"simple", "simple"},
156+
{"has space", "has\\ space"},
157+
{"has,comma", "has\\,comma"},
158+
{"has=equals", "has\\=equals"},
159+
{"all three, = mixed", "all\\ three\\,\\ \\=\\ mixed"},
160+
{"", ""},
161+
}
162+
for _, tc := range tests {
163+
assert.Equal(t, tc.expected, escapeTagValue(tc.input), "escapeTagValue(%q)", tc.input)
164+
}
165+
}
166+
167+
func TestWorkerStatusToInt(t *testing.T) {
168+
assert.Equal(t, 1, workerStatusToInt("running"))
169+
assert.Equal(t, 0, workerStatusToInt("stopped"))
170+
assert.Equal(t, 2, workerStatusToInt("pending"))
171+
assert.Equal(t, 3, workerStatusToInt("stopping"))
172+
assert.Equal(t, -1, workerStatusToInt("unknown"))
173+
}
174+
175+
func TestBuildMetricsLineProtocol_GPUConfigMismatch(t *testing.T) {
176+
// GPU has metrics but no matching config — vendor/model should be empty
177+
gpuMetrics := map[string]*api.GPUMetrics{
178+
"gpu-orphan": {GPUID: "gpu-orphan", Utilization: 30.0, VRAMUsedMb: 1000, Temperature: 55.0, PowerUsageW: 150.0},
179+
}
180+
181+
result := buildMetricsLineProtocol("agent-1", "host1", gpuMetrics, nil, nil, nil, 1700000000000)
182+
183+
assert.Contains(t, result, "gpu_id=gpu-orphan")
184+
assert.Contains(t, result, "model=,vendor=") // empty but present
185+
assert.Contains(t, result, "vram_total_mb=0i")
186+
}

0 commit comments

Comments
 (0)