Skip to content

Commit 59044af

Browse files
[ACI-4111] Add a log interceptor (#283)
* Add a log interceptor Singles out [Bitrise ...] logs from the rest and forwards them to stdout * Close intercepted writer * Log writer and scanner errors to stderr * Update loginterceptor/loginterceptor.go Co-authored-by: bitrise-ip-bot <95076763+bitrise-ip-bot@users.noreply.github.com> * Fix regex Co-authored-by: bitrise-ip-bot <95076763+bitrise-ip-bot@users.noreply.github.com> * Close pipe reader on coroutine end Co-authored-by: bitrise-ip-bot <95076763+bitrise-ip-bot@users.noreply.github.com> * Fix AI review suggestions * Remove pr close on run, Close() handles it * Add test for interceptor and close chained writers on interceptor close * Make closing optional for interceptor writers * Update variable names, introduce logger, update closing mechanism * Close reader too, restructure after goroutine closing * Remove superfuous writer close --------- Co-authored-by: bitrise-ip-bot <95076763+bitrise-ip-bot@users.noreply.github.com>
1 parent 0fd4e57 commit 59044af

File tree

3 files changed

+192
-3
lines changed

3 files changed

+192
-3
lines changed

loginterceptor/loginterceptor.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package loginterceptor
2+
3+
import (
4+
"bufio"
5+
"io"
6+
"regexp"
7+
"sync"
8+
9+
"github.com/bitrise-io/go-utils/v2/log"
10+
)
11+
12+
// PrefixInterceptor intercept writes: if a line begins with prefix, it will be written to
13+
// both writers. Partial writes without newline are buffered until a newline.
14+
type PrefixInterceptor struct {
15+
prefixRegexp *regexp.Regexp
16+
intercepted io.Writer
17+
target io.Writer
18+
logger log.Logger
19+
20+
// internal pipe and goroutine to scan and route
21+
internalReader *io.PipeReader
22+
internalWriter *io.PipeWriter
23+
24+
// close once
25+
closeOnce sync.Once
26+
closeErr error
27+
}
28+
29+
// NewPrefixInterceptor returns an io.WriteCloser. Writes are based on line prefix.
30+
func NewPrefixInterceptor(prefixRegexp *regexp.Regexp, intercepted, target io.Writer, logger log.Logger) *PrefixInterceptor {
31+
pipeReader, pipeWriter := io.Pipe()
32+
interceptor := &PrefixInterceptor{
33+
prefixRegexp: prefixRegexp,
34+
intercepted: intercepted,
35+
target: target,
36+
logger: logger,
37+
internalReader: pipeReader,
38+
internalWriter: pipeWriter,
39+
}
40+
go interceptor.run()
41+
return interceptor
42+
}
43+
44+
// Write implements io.Writer. It writes into an internal pipe which the interceptor goroutine consumes.
45+
func (i *PrefixInterceptor) Write(p []byte) (int, error) {
46+
return i.internalWriter.Write(p)
47+
}
48+
49+
// Close stops the interceptor and closes the pipe.
50+
func (i *PrefixInterceptor) Close() error {
51+
i.closeOnce.Do(func() {
52+
i.closeErr = i.internalWriter.Close()
53+
})
54+
return i.closeErr
55+
}
56+
57+
func (i *PrefixInterceptor) closeAfterRun() {
58+
// Close writers if able
59+
if interceptedCloser, ok := i.intercepted.(io.Closer); ok {
60+
if err := interceptedCloser.Close(); err != nil {
61+
i.logger.Errorf("closing intercepted writer: %v", err)
62+
}
63+
}
64+
if originalCloser, ok := i.target.(io.Closer); ok {
65+
if err := originalCloser.Close(); err != nil {
66+
i.logger.Errorf("closing original writer: %v", err)
67+
}
68+
}
69+
70+
if err := i.internalReader.Close(); err != nil {
71+
i.logger.Errorf("internal reader: %v", err)
72+
}
73+
}
74+
75+
// run reads lines (and partial final chunk) and writes them.
76+
func (i *PrefixInterceptor) run() {
77+
defer i.closeAfterRun()
78+
79+
// Use a scanner but with a large buffer to handle long lines.
80+
scanner := bufio.NewScanner(i.internalReader)
81+
const maxTokenSize = 10 * 1024 * 1024
82+
buf := make([]byte, 0, 64*1024)
83+
scanner.Buffer(buf, maxTokenSize)
84+
85+
for scanner.Scan() {
86+
line := scanner.Text() // note: newline removed
87+
// re-append newline to preserve same output format
88+
outLine := line + "\n"
89+
90+
// Write to intercepted channel if matching regexp
91+
if i.prefixRegexp.MatchString(line) {
92+
if _, err := io.WriteString(i.intercepted, outLine); err != nil {
93+
i.logger.Errorf("intercept writer error: %v", err)
94+
}
95+
}
96+
// Always write to target channel
97+
if _, err := io.WriteString(i.target, outLine); err != nil {
98+
i.logger.Errorf("writer error: %v", err)
99+
}
100+
}
101+
102+
// handle any scanner error
103+
if err := scanner.Err(); err != nil {
104+
i.logger.Errorf("router scanner error: %v\n", err)
105+
}
106+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package loginterceptor_test
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"regexp"
7+
"sync"
8+
"testing"
9+
10+
"github.com/bitrise-io/go-utils/v2/log"
11+
"github.com/bitrise-io/go-xcode/v2/loginterceptor"
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
func TestPrefixInterceptor(t *testing.T) {
16+
interceptReader, interceptWriter := io.Pipe()
17+
targetReader, targetWriter := io.Pipe()
18+
re := regexp.MustCompile(`^\[Bitrise.*\].*`)
19+
20+
sut := loginterceptor.NewPrefixInterceptor(re, interceptWriter, targetWriter, log.NewLogger())
21+
22+
msg1 := "Log message without prefix\n"
23+
msg2 := "[Bitrise Analytics] Log message with prefix\n"
24+
msg3 := "[Bitrise Build Cache] Log message with prefix\n"
25+
msg4 := "Stuff [Bitrise Build Cache] Log message without prefix\n"
26+
27+
go func() {
28+
//nolint:errCheck
29+
defer sut.Close()
30+
31+
_, _ = sut.Write([]byte(msg1))
32+
_, _ = sut.Write([]byte(msg2))
33+
_, _ = sut.Write([]byte(msg3))
34+
_, _ = sut.Write([]byte(msg4))
35+
}()
36+
37+
intercepted, target, err := readTwo(interceptReader, targetReader)
38+
assert.NoError(t, err)
39+
assert.Equal(t, msg2+msg3, string(intercepted))
40+
assert.Equal(t, msg1+msg2+msg3+msg4, string(target))
41+
}
42+
43+
func readTwo(r1, r2 io.Reader) (out1, out2 []byte, err error) {
44+
var (
45+
wg sync.WaitGroup
46+
e1, e2 error
47+
)
48+
wg.Add(2)
49+
50+
var b1, b2 bytes.Buffer
51+
52+
go func() {
53+
defer wg.Done()
54+
_, e1 = io.Copy(&b1, r1)
55+
}()
56+
57+
go func() {
58+
defer wg.Done()
59+
_, e2 = io.Copy(&b2, r2)
60+
}()
61+
62+
wg.Wait()
63+
64+
// prefer to return the first non-nil error
65+
if e1 != nil {
66+
return b1.Bytes(), b2.Bytes(), e1
67+
}
68+
if e2 != nil {
69+
return b1.Bytes(), b2.Bytes(), e2
70+
}
71+
return b1.Bytes(), b2.Bytes(), nil
72+
}

xcpretty/xcpretty.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,21 @@ import (
55
"fmt"
66
"io"
77
"os"
8+
"regexp"
89

910
"github.com/bitrise-io/go-steputils/v2/ruby"
1011
loggerV1 "github.com/bitrise-io/go-utils/log"
1112
"github.com/bitrise-io/go-utils/v2/command"
1213
"github.com/bitrise-io/go-utils/v2/env"
1314
"github.com/bitrise-io/go-utils/v2/log"
15+
"github.com/bitrise-io/go-xcode/v2/loginterceptor"
1416
"github.com/bitrise-io/go-xcode/v2/xcodebuild"
1517
"github.com/hashicorp/go-version"
1618
)
1719

1820
const (
1921
toolName = "xcpretty"
22+
prefixed = `^\[Bitrise.*\].*`
2023
)
2124

2225
// CommandModel ...
@@ -53,17 +56,25 @@ func (c CommandModel) PrintableCmd() string {
5356

5457
// Run ...
5558
func (c CommandModel) Run() (string, error) {
56-
5759
// Configure cmd in- and outputs
5860
pipeReader, pipeWriter := io.Pipe()
5961

6062
var outBuffer bytes.Buffer
6163
outWriter := io.MultiWriter(&outBuffer, pipeWriter)
6264

65+
logger := log.NewLogger()
66+
re := regexp.MustCompile(prefixed)
67+
interceptor := loginterceptor.NewPrefixInterceptor(re, os.Stdout, outWriter, logger)
68+
defer func() {
69+
if err := interceptor.Close(); err != nil {
70+
logger.Warnf("Failed to close log interceptor, error: %s", err)
71+
}
72+
}()
73+
6374
xcodebuildCmd := c.xcodebuildCommand.Command(&command.Opts{
6475
Stdin: nil,
65-
Stdout: outWriter,
66-
Stderr: outWriter,
76+
Stdout: interceptor,
77+
Stderr: interceptor,
6778
})
6879

6980
prettyCmd := c.Command(&command.Opts{

0 commit comments

Comments
 (0)