Skip to content

Commit ebc7a1c

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

File tree

6 files changed

+585
-38
lines changed

6 files changed

+585
-38
lines changed

.semaphore/semaphore.yml

Lines changed: 16 additions & 5 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
@@ -52,9 +64,7 @@ blocks:
5264
commands:
5365
- '[[ -f results.xml ]] && test-results publish --name "$SUITE_NAME" results.xml'
5466
- name: Build
55-
dependencies:
56-
- Security checks
57-
- Unit tests
67+
dependencies: []
5868
task:
5969
prologue:
6070
commands:
@@ -67,6 +77,7 @@ blocks:
6777
commands:
6878
- make build
6979
- artifact push workflow bin/test-results -d bin/test-results
80+
- artifact push project bin/test-results -d bin/test-results.v0.8.0-rc.0 --force
7081
after_pipeline:
7182
task:
7283
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: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
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 += "```\n\n"
158+
159+
out += "```mermaid\n"
160+
out += "xychart-beta\n"
161+
out += "title \"Memory Usage\"\n"
162+
out += fmt.Sprintf("x-axis [%s]\n", strings.Join(xLabels, ", "))
163+
out += "y-axis \"Usage (%)\"\n"
164+
out += fmt.Sprintf("line [%s]\n", strings.Join(memSeries, ", "))
165+
out += "```\n\n"
166+
167+
out += "```mermaid\n"
168+
out += "xychart-beta\n"
169+
out += "title \"System Disk Usage\"\n"
170+
out += fmt.Sprintf("x-axis [%s]\n", strings.Join(xLabels, ", "))
171+
out += "y-axis \"Disk Usage (%)\"\n"
172+
out += fmt.Sprintf("line [%s]\n", strings.Join(sysDiskSeries, ", "))
173+
out += "```\n"
174+
175+
out += "```mermaid\n"
176+
out += "xychart-beta\n"
177+
out += "title \"Docker Disk Usage\"\n"
178+
out += fmt.Sprintf("x-axis [%s]\n", strings.Join(xLabels, ", "))
179+
out += "y-axis \"Disk Usage (%)\"\n"
180+
out += fmt.Sprintf("line [%s]\n", strings.Join(dockerDiskSeries, ", "))
181+
out += "```\n"
182+
183+
f, err := os.OpenFile(args[0], os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
184+
if err != nil {
185+
return fmt.Errorf("failed to open output file: %w", err)
186+
}
187+
defer f.Close()
188+
189+
if _, err := f.WriteString(out); err != nil {
190+
return fmt.Errorf("failed to append to output file: %w", err)
191+
}
192+
return nil
193+
},
194+
}
195+
196+
func init() {
197+
jobMetricsCmd.Flags().String("src", "/tmp/system-metrics", "source file to read system metrics from")
198+
rootCmd.AddCommand(jobMetricsCmd)
199+
}

0 commit comments

Comments
 (0)