Skip to content

Commit 3a154fb

Browse files
authored
feat(logging): split application/output logs using stderr/stdout (#607)
Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io>
1 parent f08fc0c commit 3a154fb

File tree

7 files changed

+458
-44
lines changed

7 files changed

+458
-44
lines changed

README.md

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,43 @@ transaction:
9999
}
100100
```
101101

102-
Each event is output individually. The log output prints each event to stdout
103-
using Go's standard `slog` logging library.
102+
Each event is output individually. The log output supports two formats:
103+
104+
- **text** (default) — human-readable, one line per event:
105+
106+
```text
107+
2026-02-07 09:18:40 BLOCK slot=12345678 block=9876543 hash=abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234 era=Conway txs=5 size=1234
108+
2026-02-07 09:18:41 TX slot=12345678 block=9876543 tx=deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef fee=180000 inputs=2 outputs=3
109+
2026-02-07 09:18:42 ROLLBACK slot=12345678 hash=aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd
110+
2026-02-07 09:18:43 GOVERNANCE slot=12345678 block=9876543 tx=1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd proposals=1 votes=2 certs=1
111+
```
112+
113+
- **json** — newline-delimited JSON, one JSON object per event (suitable for
114+
piping to `jq` or other tooling):
115+
116+
```json
117+
{"type":"chainsync.block","timestamp":"2026-02-07T09:18:40Z","context":{"blockNumber":9876543,"slotNumber":12345678},"payload":{"blockHash":"abc12345..."}}
118+
```
119+
120+
Select the format with `--output-log-format`:
121+
122+
```bash
123+
adder --output-log-format json
124+
```
125+
126+
Event data is written to **stdout** and application logs are written to
127+
**stderr**. This means you can capture only event output:
128+
129+
```bash
130+
# Save events to a file, see app logs in terminal
131+
adder > events.txt
132+
133+
# Pipe events to jq, suppress app logs
134+
adder --output-log-format json 2>/dev/null | jq .
135+
136+
# See only app logs, discard event data
137+
adder > /dev/null
138+
```
104139

105140
## Configuration
106141

@@ -123,6 +158,7 @@ Flags:
123158
specifies the TCP address of the node to connect to
124159
...
125160
--output string output plugin to use, 'list' to show available (default "log")
161+
--output-log-format string output format: "text" or "json" (default "text")
126162
--output-log-level string specifies the log level to use (default "info")
127163
-h, --help help for adder
128164
```
@@ -181,6 +217,7 @@ plugins:
181217
output:
182218
log:
183219
level: info
220+
format: text
184221
```
185222
186223
## Filtering

internal/logging/kugoCustomLogger.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ func convertKVs(kvs []ogmigo.KeyValue) []any {
6363
}
6464

6565
func NewKugoCustomLogger(level LogLevel) *KugoCustomLogger {
66-
// Create a new slog logger that logs to stdout using JSON format
67-
handler := slog.NewJSONHandler(os.Stdout, nil)
66+
// Create a new slog logger that logs to stderr using JSON format
67+
handler := slog.NewJSONHandler(os.Stderr, nil)
6868
logger := slog.New(handler)
6969

7070
return &KugoCustomLogger{

internal/logging/logging.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import (
2424

2525
// defaultLogger returns a non-nil logger so globalLogger is never nil at declaration (satisfies nilaway).
2626
func defaultLogger() *slog.Logger {
27-
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
27+
return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
2828
Level: slog.LevelInfo,
2929
})).With("component", "main")
3030
}
@@ -47,7 +47,7 @@ func Configure() {
4747
level = slog.LevelInfo
4848
}
4949

50-
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
50+
handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
5151
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
5252
if a.Key == slog.TimeKey {
5353
// Format the time attribute to use RFC3339 or your custom format

output/log/log.go

Lines changed: 100 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,73 +15,144 @@
1515
package log
1616

1717
import (
18+
"encoding/json"
1819
"fmt"
19-
"log/slog"
20+
"os"
2021

2122
"github.com/blinklabs-io/adder/event"
2223
"github.com/blinklabs-io/adder/internal/logging"
2324
"github.com/blinklabs-io/adder/plugin"
2425
)
2526

27+
const (
28+
FormatText = "text"
29+
FormatJSON = "json"
30+
)
31+
2632
type LogOutput struct {
27-
errorChan chan error
28-
eventChan chan event.Event
29-
logger plugin.Logger
30-
outputLogger *slog.Logger
31-
level string
33+
errorChan chan error
34+
eventChan chan event.Event
35+
doneChan chan struct{}
36+
logger plugin.Logger
37+
format string
3238
}
3339

3440
func New(options ...LogOptionFunc) *LogOutput {
3541
l := &LogOutput{
36-
level: "info",
42+
format: FormatText,
3743
}
3844
for _, option := range options {
3945
option(l)
4046
}
4147
if l.logger == nil {
4248
l.logger = logging.GetLogger()
4349
}
44-
45-
// Use the provided *slog.Logger if available, otherwise fall back to global logger
46-
if providedLogger, ok := l.logger.(*slog.Logger); ok {
47-
l.outputLogger = providedLogger.With("type", "event")
48-
} else {
49-
l.outputLogger = logging.GetLogger().With("type", "event")
50-
}
5150
return l
5251
}
5352

5453
// Start the log output
5554
func (l *LogOutput) Start() error {
5655
l.eventChan = make(chan event.Event, 10)
5756
l.errorChan = make(chan error)
57+
l.doneChan = make(chan struct{})
58+
// Capture channels locally to avoid races with Stop()
59+
eventChan := l.eventChan
60+
doneChan := l.doneChan
5861
go func() {
59-
for {
60-
evt, ok := <-l.eventChan
61-
// Channel has been closed, which means we're shutting down
62-
if !ok {
63-
return
64-
}
65-
switch l.level {
66-
case "info":
67-
l.outputLogger.Info("", "event", fmt.Sprintf("%+v", evt))
68-
case "warn":
69-
l.outputLogger.Warn("", "event", fmt.Sprintf("%+v", evt))
70-
case "error":
71-
l.outputLogger.Error("", "event", fmt.Sprintf("%+v", evt))
62+
defer close(doneChan)
63+
for evt := range eventChan {
64+
switch l.format {
65+
case FormatJSON:
66+
l.writeJSON(evt)
7267
default:
73-
// Use INFO level if log level isn't recognized
74-
l.outputLogger.Info("", "event", fmt.Sprintf("%+v", evt))
68+
l.writeText(evt)
7569
}
7670
}
7771
}()
7872
return nil
7973
}
8074

75+
// writeText writes events in a human-readable format to stdout.
76+
func (l *LogOutput) writeText(evt event.Event) {
77+
ts := evt.Timestamp.Format("2006-01-02 15:04:05")
78+
79+
var line string
80+
switch payload := evt.Payload.(type) {
81+
case event.BlockEvent:
82+
ctx, _ := evt.Context.(event.BlockContext)
83+
line = fmt.Sprintf(
84+
"%s %-12s slot=%-10d block=%-8d hash=%s era=%-7s txs=%d size=%d",
85+
ts, "BLOCK",
86+
ctx.SlotNumber, ctx.BlockNumber,
87+
payload.BlockHash,
88+
ctx.Era,
89+
payload.TransactionCount,
90+
payload.BlockBodySize,
91+
)
92+
case event.TransactionEvent:
93+
ctx, _ := evt.Context.(event.TransactionContext)
94+
line = fmt.Sprintf(
95+
"%s %-12s slot=%-10d block=%-8d tx=%s fee=%d inputs=%d outputs=%d",
96+
ts, "TX",
97+
ctx.SlotNumber, ctx.BlockNumber,
98+
ctx.TransactionHash,
99+
payload.Fee,
100+
len(payload.Inputs), len(payload.Outputs),
101+
)
102+
case event.RollbackEvent:
103+
line = fmt.Sprintf(
104+
"%s %-12s slot=%-10d hash=%s",
105+
ts, "ROLLBACK",
106+
payload.SlotNumber,
107+
payload.BlockHash,
108+
)
109+
case event.GovernanceEvent:
110+
ctx, _ := evt.Context.(event.GovernanceContext)
111+
certs := len(payload.DRepCertificates) +
112+
len(payload.VoteDelegationCertificates) +
113+
len(payload.CommitteeCertificates)
114+
line = fmt.Sprintf(
115+
"%s %-12s slot=%-10d block=%-8d tx=%s proposals=%d votes=%d certs=%d",
116+
ts, "GOVERNANCE",
117+
ctx.SlotNumber, ctx.BlockNumber,
118+
ctx.TransactionHash,
119+
len(payload.ProposalProcedures),
120+
len(payload.VotingProcedures),
121+
certs,
122+
)
123+
default:
124+
line = fmt.Sprintf(
125+
"%s %-12s %+v",
126+
ts, evt.Type, evt.Payload,
127+
)
128+
}
129+
130+
fmt.Fprintln(os.Stdout, line)
131+
}
132+
133+
// writeJSON writes events as newline-delimited JSON to stdout.
134+
// Errors are written to stderr to avoid corrupting the JSON stream.
135+
func (l *LogOutput) writeJSON(evt event.Event) {
136+
data, err := json.Marshal(evt)
137+
if err != nil {
138+
fmt.Fprintf(
139+
os.Stderr,
140+
"error: failed to marshal event: %v\n",
141+
err,
142+
)
143+
return
144+
}
145+
os.Stdout.Write(append(data, '\n'))
146+
}
147+
81148
// Stop the log output
82149
func (l *LogOutput) Stop() error {
83150
if l.eventChan != nil {
84151
close(l.eventChan)
152+
// Wait for the goroutine to finish processing
153+
if l.doneChan != nil {
154+
<-l.doneChan
155+
}
85156
l.eventChan = nil
86157
}
87158
if l.errorChan != nil {

0 commit comments

Comments
 (0)