Skip to content

Commit 446a1e5

Browse files
committed
feat: job metrics report
1 parent 4447855 commit 446a1e5

File tree

6 files changed

+590
-41
lines changed

6 files changed

+590
-41
lines changed

.semaphore/semaphore.yml

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,21 @@ agent:
44
machine:
55
type: e2-standard-2
66
os_image: ubuntu2004
7+
8+
global_job_config:
9+
prologue:
10+
commands:
11+
- artifact pull project bin/test-results.v0.8.0-rc.0 -d /usr/local/bin/test-results --force
12+
- sudo chmod +x /usr/local/bin/test-results
13+
epilogue:
14+
always:
15+
commands:
16+
- test-results command-metrics report.md
17+
- test-results job-metrics report.md
18+
- artifact push job -d .semaphore/REPORT.md report.md
719
blocks:
820
- name: Unit tests
9-
dependencies: []
21+
dependencies: [Build]
1022
task:
1123
prologue:
1224
commands:
@@ -29,7 +41,7 @@ blocks:
2941
commands:
3042
- '[[ -f results.xml ]] && test-results publish --name "$SUITE_NAME" results.xml'
3143
- name: Security checks
32-
dependencies: []
44+
dependencies: [Build]
3345
task:
3446
secrets:
3547
- name: security-toolbox-shared-read-access
@@ -43,18 +55,14 @@ blocks:
4355
commands:
4456
- "export SUITE_NAME=\"\U0001F6E1️ Check dependencies\""
4557
- make check.deps
58+
- '[[ -f results.xml ]] && test-results publish --name "$SUITE_NAME" results.xml'
4659
- name: "\U0001F6E1️ Check code"
4760
commands:
4861
- "export SUITE_NAME=\"\U0001F6E1️ Check code\""
4962
- make check.static
50-
epilogue:
51-
always:
52-
commands:
5363
- '[[ -f results.xml ]] && test-results publish --name "$SUITE_NAME" results.xml'
5464
- name: Build
55-
dependencies:
56-
- Security checks
57-
- Unit tests
65+
dependencies: []
5866
task:
5967
prologue:
6068
commands:
@@ -67,6 +75,7 @@ blocks:
6775
commands:
6876
- make build
6977
- artifact push workflow bin/test-results -d bin/test-results
78+
- artifact push project bin/test-results -d bin/test-results.v0.8.0-rc.0 --force
7079
after_pipeline:
7180
task:
7281
jobs:

cmd/command-metrics.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
10+
"github.com/spf13/cobra"
11+
)
12+
13+
var commandMetricsCmd = &cobra.Command{
14+
Use: "command-metrics",
15+
Short: "Generates a command summary markdown report from agent metrics",
16+
Long: `Generates a command summary markdown report from agent metrics`,
17+
Args: cobra.ExactArgs(1),
18+
RunE: func(cmd *cobra.Command, args []string) error {
19+
out := ""
20+
21+
srcFile, err := cmd.Flags().GetString("src")
22+
if err != nil {
23+
return fmt.Errorf("src cannot be parsed: %w", err)
24+
}
25+
26+
matches, err := filepath.Glob(srcFile)
27+
if err != nil || len(matches) == 0 {
28+
return fmt.Errorf("failed to find job log file: %w", err)
29+
}
30+
31+
type CmdFinished struct {
32+
Event string `json:"event"`
33+
Directive string `json:"directive"`
34+
StartedAt int64 `json:"started_at"`
35+
FinishedAt int64 `json:"finished_at"`
36+
}
37+
38+
lines, err := os.ReadFile(matches[0])
39+
if err != nil {
40+
return fmt.Errorf("could not read job log: %w", err)
41+
}
42+
43+
var flowNodes []CmdFinished
44+
for _, raw := range strings.Split(string(lines), "\n") {
45+
if strings.TrimSpace(raw) == "" {
46+
continue
47+
}
48+
var entry CmdFinished
49+
if err := json.Unmarshal([]byte(raw), &entry); err != nil {
50+
continue
51+
}
52+
if entry.Event == "cmd_finished" {
53+
flowNodes = append(flowNodes, entry)
54+
}
55+
}
56+
57+
out += "## 🧭 Job Timeline\n\n```mermaid\ngantt\n title Job Command Timeline\n dateFormat X\n axisFormat %X\n"
58+
for i, node := range flowNodes {
59+
duration := node.FinishedAt - node.StartedAt
60+
if duration < 1 {
61+
duration = 1
62+
}
63+
out += fmt.Sprintf(" %s[%ds] :step%d, %d, %ds\n", node.Directive, duration, i, node.StartedAt, duration)
64+
}
65+
out += "```\n"
66+
67+
f, err := os.OpenFile(args[0], os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
68+
if err != nil {
69+
return fmt.Errorf("failed to open output file: %w", err)
70+
}
71+
defer f.Close()
72+
73+
if _, err := f.WriteString(out); err != nil {
74+
return fmt.Errorf("failed to append to output file: %w", err)
75+
}
76+
return nil
77+
},
78+
}
79+
80+
func init() {
81+
commandMetricsCmd.Flags().String("src", "/tmp/job_log_*.json", "source file to read system metrics from")
82+
rootCmd.AddCommand(commandMetricsCmd)
83+
}

cmd/job-metrics.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
package cmd
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"regexp"
9+
"strings"
10+
"time"
11+
12+
"github.com/spf13/cobra"
13+
)
14+
15+
var jobMetricsCmd = &cobra.Command{
16+
Use: "job-metrics",
17+
Short: "Generates a job resource utilization summary markdown report from agent metrics",
18+
Long: `Generates a job resource utilization summary markdown report from agent metrics`,
19+
Args: cobra.ExactArgs(1),
20+
RunE: func(cmd *cobra.Command, args []string) error {
21+
type JobMetric struct {
22+
Timestamp string
23+
CPU float64
24+
Memory float64
25+
SystemDisk float64
26+
DockerDisk float64
27+
}
28+
29+
srcFile, err := cmd.Flags().GetString("src")
30+
if err != nil {
31+
return fmt.Errorf("src cannot be parsed: %w", err)
32+
}
33+
34+
file, err := os.Open(filepath.Clean(srcFile))
35+
if err != nil {
36+
return fmt.Errorf("failed to open log file: %w", err)
37+
}
38+
defer file.Close()
39+
40+
metricLineRegex := regexp.MustCompile(`^(.*?) \| cpu:(.*)%, mem:\s*(.*)%, system_disk:\s*(.*)%, docker_disk:\s*(.*)%,(.*)$`)
41+
42+
var metrics []JobMetric
43+
scanner := bufio.NewScanner(file)
44+
for scanner.Scan() {
45+
line := scanner.Text()
46+
matches := metricLineRegex.FindStringSubmatch(line)
47+
if len(matches) != 7 {
48+
continue
49+
}
50+
var m JobMetric
51+
m.Timestamp = matches[1]
52+
_, err := fmt.Sscanf(matches[2], "%f", &m.CPU)
53+
if err != nil {
54+
continue
55+
}
56+
_, err = fmt.Sscanf(matches[3], "%f", &m.Memory)
57+
if err != nil {
58+
continue
59+
}
60+
_, err = fmt.Sscanf(matches[4], "%f", &m.SystemDisk)
61+
if err != nil {
62+
continue
63+
}
64+
_, err = fmt.Sscanf(matches[5], "%f", &m.DockerDisk)
65+
if err != nil {
66+
continue
67+
}
68+
metrics = append(metrics, m)
69+
}
70+
71+
if err := scanner.Err(); err != nil {
72+
return fmt.Errorf("error reading file: %w", err)
73+
}
74+
75+
if len(metrics) == 0 {
76+
return fmt.Errorf("no valid data found")
77+
}
78+
79+
step := 1
80+
if len(metrics) > 100 {
81+
step = len(metrics) / 100
82+
}
83+
84+
var (
85+
xLabels []string
86+
cpuSeries []string
87+
memSeries []string
88+
sysDiskSeries []string
89+
dockerDiskSeries []string
90+
)
91+
92+
layout := "Mon 02 Jan 2006 03:04:05 PM MST"
93+
startTime, err := time.Parse(layout, metrics[0].Timestamp)
94+
if err != nil {
95+
return fmt.Errorf("failed to parse start time: %w", err)
96+
}
97+
98+
min := func(f1, f2 float64) float64 {
99+
if f1 < f2 {
100+
return f1
101+
}
102+
return f2
103+
}
104+
105+
max := func(f1, f2 float64) float64 {
106+
if f1 > f2 {
107+
return f1
108+
}
109+
return f2
110+
}
111+
112+
cpuMin, cpuMax := metrics[0].CPU, metrics[0].CPU
113+
memMin, memMax := metrics[0].Memory, metrics[0].Memory
114+
diskMin, diskMax := metrics[0].SystemDisk, metrics[0].SystemDisk
115+
dockerMin, dockerMax := metrics[0].DockerDisk, metrics[0].DockerDisk
116+
117+
for i := 0; i < len(metrics); i += step {
118+
m := metrics[i]
119+
cpuMin = min(cpuMin, m.CPU)
120+
cpuMax = max(cpuMax, m.CPU)
121+
memMin = min(memMin, m.Memory)
122+
memMax = max(memMax, m.Memory)
123+
diskMin = min(diskMin, m.SystemDisk)
124+
diskMax = max(diskMax, m.SystemDisk)
125+
dockerMin = min(dockerMin, m.DockerDisk)
126+
dockerMax = max(dockerMax, m.DockerDisk)
127+
128+
t, err := time.Parse(layout, m.Timestamp)
129+
if err != nil {
130+
xLabels = append(xLabels, "\"??:??\"")
131+
} else {
132+
duration := t.Sub(startTime)
133+
seconds := int(duration.Seconds())
134+
xLabels = append(xLabels, fmt.Sprintf("\"%02d:%02d\"", seconds/60, seconds%60))
135+
}
136+
cpuSeries = append(cpuSeries, fmt.Sprintf("%.2f", m.CPU))
137+
memSeries = append(memSeries, fmt.Sprintf("%.2f", m.Memory))
138+
sysDiskSeries = append(sysDiskSeries, fmt.Sprintf("%.2f", m.SystemDisk))
139+
dockerDiskSeries = append(dockerDiskSeries, fmt.Sprintf("%.2f", m.DockerDisk))
140+
}
141+
142+
out := "## 🎯 System Metrics Summary\n\n"
143+
out += fmt.Sprintf("**Total datapoints:** `%d` \n", len(metrics))
144+
out += fmt.Sprintf("**🕒 Time Range:** `%s` → `%s` \n\n", metrics[0].Timestamp, metrics[len(metrics)-1].Timestamp)
145+
out += fmt.Sprintf("- **🔥 CPU:** `min: %.2f%%`, `max: %.2f%%` \n", cpuMin, cpuMax)
146+
out += fmt.Sprintf("- **🧠 Memory:** `min: %.2f%%`, `max: %.2f%%` \n", memMin, memMax)
147+
out += fmt.Sprintf("- **💽 System Disk:** `min: %.2f%%`, `max: %.2f%%` \n", diskMin, diskMax)
148+
out += fmt.Sprintf("- **🐳 Docker Disk:** `min: %.2f%%`, `max: %.2f%%`\n\n", dockerMin, dockerMax)
149+
out += "---\n\n"
150+
151+
out += "```mermaid\n"
152+
out += "xychart-beta\n"
153+
out += "title \"CPU Usage\"\n"
154+
out += fmt.Sprintf("x-axis [%s]\n", strings.Join(xLabels, ", "))
155+
out += "y-axis \"Usage (%)\"\n"
156+
out += fmt.Sprintf("line [%s]\n", strings.Join(cpuSeries, ", "))
157+
out += fmt.Sprintf("bar [%s]\n", strings.Join(cpuSeries, ", "))
158+
out += "```\n\n"
159+
160+
out += "```mermaid\n"
161+
out += "xychart-beta\n"
162+
out += "title \"Memory Usage\"\n"
163+
out += fmt.Sprintf("x-axis [%s]\n", strings.Join(xLabels, ", "))
164+
out += "y-axis \"Usage (%)\"\n"
165+
out += fmt.Sprintf("line [%s]\n", strings.Join(memSeries, ", "))
166+
out += fmt.Sprintf("bar [%s]\n", strings.Join(memSeries, ", "))
167+
out += "```\n\n"
168+
169+
out += "```mermaid\n"
170+
out += "xychart-beta\n"
171+
out += "title \"System Disk Usage\"\n"
172+
out += fmt.Sprintf("x-axis [%s]\n", strings.Join(xLabels, ", "))
173+
out += "y-axis \"Disk Usage (%)\"\n"
174+
out += fmt.Sprintf("line [%s]\n", strings.Join(sysDiskSeries, ", "))
175+
out += fmt.Sprintf("bar [%s]\n", strings.Join(sysDiskSeries, ", "))
176+
out += "```\n"
177+
178+
out += "```mermaid\n"
179+
out += "xychart-beta\n"
180+
out += "title \"Docker Disk Usage\"\n"
181+
out += fmt.Sprintf("x-axis [%s]\n", strings.Join(xLabels, ", "))
182+
out += "y-axis \"Disk Usage (%)\"\n"
183+
out += fmt.Sprintf("line [%s]\n", strings.Join(dockerDiskSeries, ", "))
184+
out += fmt.Sprintf("bar [%s]\n", strings.Join(dockerDiskSeries, ", "))
185+
out += "```\n"
186+
187+
f, err := os.OpenFile(args[0], os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
188+
if err != nil {
189+
return fmt.Errorf("failed to open output file: %w", err)
190+
}
191+
defer f.Close()
192+
193+
if _, err := f.WriteString(out); err != nil {
194+
return fmt.Errorf("failed to append to output file: %w", err)
195+
}
196+
return nil
197+
},
198+
}
199+
200+
func init() {
201+
jobMetricsCmd.Flags().String("src", "/tmp/system-metrics", "source file to read system metrics from")
202+
rootCmd.AddCommand(jobMetricsCmd)
203+
}

0 commit comments

Comments
 (0)