Skip to content

Commit 3892728

Browse files
committed
wip: upload logs from GHA
1 parent 02ae646 commit 3892728

File tree

5 files changed

+291
-0
lines changed

5 files changed

+291
-0
lines changed

framework/cmd/logs.go

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"encoding/json"
7+
"fmt"
8+
"github.com/smartcontractkit/chainlink-testing-framework/framework"
9+
"io"
10+
"net/http"
11+
"os"
12+
"path/filepath"
13+
"sync"
14+
"time"
15+
16+
"go.uber.org/ratelimit"
17+
)
18+
19+
var L = framework.L
20+
21+
// LokiPushRequest represents the payload format expected by Loki's push API.
22+
type LokiPushRequest struct {
23+
Streams []LokiStream `json:"streams"`
24+
}
25+
26+
// LokiStream represents one log stream.
27+
type LokiStream struct {
28+
Stream map[string]string `json:"stream"`
29+
Values [][2]string `json:"values"`
30+
}
31+
32+
const (
33+
lokiURL = "http://localhost:3030/loki/api/v1/push"
34+
grafanaURL = "http://localhost:3000/explore?panes=%7B%22V0P%22:%7B%22datasource%22:%22P8E80F9AEF21F6940%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%7Bjob%3D%5C%22"
35+
grafanaURL2 = "%5C%22%7D%22,%22queryType%22:%22range%22,%22datasource%22:%7B%22type%22:%22loki%22,%22uid%22:%22P8E80F9AEF21F6940%22%7D,%22editorMode%22:%22code%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&schemaVersion=1&orgId=1"
36+
)
37+
38+
// processAndUploadDir traverses the given directory recursively and
39+
// processes every file (ignoring directories) by calling processAndUploadLog.
40+
func processAndUploadDir(dirPath string, limiter ratelimit.Limiter, chunks int, jobID string) error {
41+
return filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
42+
if err != nil {
43+
L.Error().Err(err).Msgf("Error accessing %s", path)
44+
return nil
45+
}
46+
if info.IsDir() {
47+
return nil
48+
}
49+
L.Info().Msgf("Processing file: %s", path)
50+
f, err := os.Open(path)
51+
if err != nil {
52+
L.Error().Err(err).Msgf("Error opening file %s", path)
53+
return nil
54+
}
55+
defer f.Close()
56+
57+
if err := processAndUploadLog(path, f, limiter, chunks, jobID); err != nil {
58+
L.Error().Err(err).Msgf("Error processing file %s", path)
59+
// Continue processing other files even if one fails.
60+
}
61+
return nil
62+
})
63+
}
64+
65+
// processAndUploadLog reads log lines from the provided reader,
66+
// splits them into chunks (if more than 10,000 lines) and uploads each chunk concurrently.
67+
func processAndUploadLog(source string, r io.Reader, limiter ratelimit.Limiter, chunks int, jobID string) error {
68+
scanner := bufio.NewScanner(r)
69+
var values [][2]string
70+
baseTime := time.Now()
71+
72+
// Read all log lines; each line gets a unique timestamp.
73+
for scanner.Scan() {
74+
line := scanner.Text()
75+
ts := baseTime.UnixNano()
76+
values = append(values, [2]string{fmt.Sprintf("%d", ts), line})
77+
baseTime = baseTime.Add(time.Nanosecond)
78+
}
79+
if err := scanner.Err(); err != nil {
80+
return fmt.Errorf("error scanning logs from %s: %w", source, err)
81+
}
82+
83+
totalLines := len(values)
84+
if totalLines == 0 {
85+
L.Info().Msgf("No log lines found in %s", source)
86+
return nil
87+
}
88+
// Use one chunk if there are 10,000 or fewer lines.
89+
if totalLines <= 10000 {
90+
chunks = 1
91+
}
92+
if chunks > totalLines {
93+
chunks = totalLines
94+
}
95+
chunkSize := totalLines / chunks
96+
remainder := totalLines % chunks
97+
L.Debug().Int("total_lines", totalLines).
98+
Int("chunks", chunks).
99+
Msgf("Starting chunk processing for %s", source)
100+
var wg sync.WaitGroup
101+
errCh := make(chan error, chunks)
102+
start := 0
103+
for i := 0; i < chunks; i++ {
104+
extra := 0
105+
if i < remainder {
106+
extra = 1
107+
}
108+
end := start + chunkSize + extra
109+
chunkValues := values[start:end]
110+
startLine := start + 1
111+
endLine := end
112+
start = end
113+
114+
// Use the unique jobID as the "job" label.
115+
labels := map[string]string{
116+
"job": jobID,
117+
"chunk": fmt.Sprintf("%d", i+1),
118+
"source": source,
119+
}
120+
reqBody := LokiPushRequest{
121+
Streams: []LokiStream{
122+
{
123+
Stream: labels,
124+
Values: chunkValues,
125+
},
126+
},
127+
}
128+
data, err := json.Marshal(reqBody)
129+
if err != nil {
130+
return fmt.Errorf("error marshaling JSON for chunk %d: %w", i+1, err)
131+
}
132+
chunkMB := float64(len(data)) / (1024 * 1024)
133+
L.Debug().Int("chunk", i+1).
134+
Float64("chunk_size_MB", chunkMB).
135+
Int("start_line", startLine).
136+
Int("end_line", endLine).
137+
Msg("Prepared chunk for upload")
138+
139+
wg.Add(1)
140+
go func(chunkNum, sLine, eLine int, payload []byte, sizeMB float64) {
141+
defer wg.Done()
142+
const maxRetries = 50
143+
const retryDelay = 1 * time.Second
144+
145+
var resp *http.Response
146+
var attempt int
147+
var err error
148+
for attempt = 1; attempt <= maxRetries; attempt++ {
149+
limiter.Take()
150+
resp, err = http.Post(lokiURL, "application/json", bytes.NewReader(payload))
151+
if err != nil {
152+
L.Error().Err(err).Int("attempt", attempt).
153+
Int("chunk", chunkNum).
154+
Float64("chunk_size_MB", sizeMB).
155+
Msg("Error sending POST request")
156+
time.Sleep(retryDelay)
157+
continue
158+
}
159+
160+
body, _ := io.ReadAll(resp.Body)
161+
resp.Body.Close()
162+
163+
if resp.StatusCode == 429 {
164+
L.Debug().Int("attempt", attempt).
165+
Int("chunk", chunkNum).
166+
Float64("chunk_size_MB", sizeMB).
167+
Msg("Received 429, retrying...")
168+
time.Sleep(retryDelay)
169+
continue
170+
}
171+
172+
if resp.StatusCode/100 != 2 {
173+
err = fmt.Errorf("loki error: %s - %s", resp.Status, body)
174+
L.Error().Err(err).Int("chunk", chunkNum).
175+
Float64("chunk_size_MB", sizeMB).
176+
Msg("Chunk upload failed")
177+
time.Sleep(retryDelay)
178+
continue
179+
}
180+
181+
L.Info().Int("chunk", chunkNum).
182+
Float64("chunk_size_MB", sizeMB).
183+
Msg("Successfully uploaded chunk")
184+
return
185+
}
186+
errCh <- fmt.Errorf("max retries reached for chunk %d; last error: %v", chunkNum, err)
187+
}(i+1, startLine, endLine, data, chunkMB)
188+
}
189+
190+
wg.Wait()
191+
close(errCh)
192+
if len(errCh) > 0 {
193+
return <-errCh
194+
}
195+
196+
return nil
197+
}

framework/cmd/main.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,44 @@ func main() {
118118
Description: "Removes local observability stack",
119119
Action: func(c *cli.Context) error { return observabilityDown() },
120120
},
121+
{
122+
Name: "load",
123+
Usage: "ctf obs l",
124+
Aliases: []string{"l"},
125+
Description: "Loads logs to Loki",
126+
Flags: []cli.Flag{
127+
&cli.StringFlag{
128+
Name: "raw-url",
129+
Aliases: []string{"u"},
130+
Usage: "URL to GitHub raw log data",
131+
},
132+
&cli.StringFlag{
133+
Name: "dir",
134+
Aliases: []string{"d"},
135+
Usage: "Directory to logs, output of 'gh run download $run_id'",
136+
},
137+
&cli.IntFlag{
138+
Name: "rps",
139+
Aliases: []string{"r"},
140+
Usage: "RPS for uploading log chunks",
141+
Value: 30,
142+
},
143+
&cli.IntFlag{
144+
Name: "chunk",
145+
Aliases: []string{"c"},
146+
Usage: "Amount of chunks the files will be split in",
147+
Value: 100,
148+
},
149+
},
150+
Action: func(c *cli.Context) error {
151+
return loadLogs(
152+
c.String("raw-url"),
153+
c.String("dir"),
154+
c.Int("rps"),
155+
c.Int("chunk"),
156+
)
157+
},
158+
},
121159
},
122160
},
123161
{

framework/cmd/observability.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,57 @@ package main
22

33
import (
44
"fmt"
5+
"github.com/google/uuid"
56
"github.com/smartcontractkit/chainlink-testing-framework/framework"
7+
"go.uber.org/ratelimit"
8+
"net/http"
69
)
710

11+
func loadLogs(rawURL, dirPath string, rps, chunks int) error {
12+
framework.L.Info().Msg("Loading logs into Loki")
13+
sources := 0
14+
if rawURL != "" {
15+
sources++
16+
}
17+
if dirPath != "" {
18+
sources++
19+
}
20+
if sources != 1 {
21+
L.Error().Msg("Usage: provide exactly one of -raw-url or -dir")
22+
return nil
23+
}
24+
jobID := uuid.New().String()[0:5]
25+
L.Info().Msgf("Using unique job identifier: %s", jobID)
26+
limiter := ratelimit.New(rps)
27+
if rawURL != "" {
28+
L.Info().Msg("Downloading raw logs from URL")
29+
resp, err := http.Get(rawURL)
30+
if err != nil {
31+
L.Error().Err(err).Msg("Error downloading raw logs")
32+
return nil
33+
}
34+
defer resp.Body.Close()
35+
36+
if resp.StatusCode/100 != 2 {
37+
L.Error().Msgf("Non-success response downloading raw logs: %s", resp.Status)
38+
return nil
39+
}
40+
41+
if err := processAndUploadLog(rawURL, resp.Body, limiter, chunks, jobID); err != nil {
42+
L.Error().Err(err).Msg("Error processing raw logs")
43+
return nil
44+
}
45+
} else if dirPath != "" {
46+
L.Info().Msgf("Processing directory: %s", dirPath)
47+
if err := processAndUploadDir(dirPath, limiter, chunks, jobID); err != nil {
48+
L.Error().Err(err).Msg("Error processing directory")
49+
return nil
50+
}
51+
}
52+
framework.L.Info().Str("JobID", jobID).Str("URL", grafanaURL+jobID+grafanaURL2).Msg("Upload complete")
53+
return nil
54+
}
55+
856
func observabilityUp() error {
957
framework.L.Info().Msg("Creating local observability stack")
1058
if err := extractAllFiles("observability"); err != nil {

framework/go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ require (
2525
github.com/stretchr/testify v1.9.0
2626
github.com/testcontainers/testcontainers-go v0.35.0
2727
github.com/urfave/cli/v2 v2.27.5
28+
go.uber.org/ratelimit v0.3.1
2829
golang.org/x/sync v0.10.0
2930
gopkg.in/guregu/null.v4 v4.0.0
3031
)
@@ -48,6 +49,7 @@ require (
4849
github.com/aws/aws-sdk-go-v2/service/sts v1.31.3 // indirect
4950
github.com/aws/smithy-go v1.21.0 // indirect
5051
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
52+
github.com/benbjohnson/clock v1.3.0 // indirect
5153
github.com/bits-and-blooms/bitset v1.13.0 // indirect
5254
github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect
5355
github.com/bytedance/sonic v1.12.3 // indirect

framework/go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ github.com/aws/smithy-go v1.21.0 h1:H7L8dtDRk0P1Qm6y0ji7MCYMQObJ5R9CRpyPhRUkLYA=
4848
github.com/aws/smithy-go v1.21.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
4949
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
5050
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
51+
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
52+
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
5153
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
5254
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
5355
github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE=
@@ -415,6 +417,10 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
415417
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
416418
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
417419
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
420+
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
421+
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
422+
go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0=
423+
go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk=
418424
golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
419425
golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
420426
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=

0 commit comments

Comments
 (0)