Skip to content

Commit 5e8782a

Browse files
committed
otel stuff
1 parent 33fc1b2 commit 5e8782a

File tree

9 files changed

+570
-40
lines changed

9 files changed

+570
-40
lines changed

.claude/settings.local.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"Bash(GITHUB_TOKEN=invalid_token go run:*)",
1919
"Bash(sips:*)",
2020
"Bash(id=%s)",
21+
"Bash(python3:*)"
2122
]
2223
}
2324
}

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,30 @@ gha-analyzer <url> --perfetto=trace.json --open-in-perfetto
3333
gha-analyzer <url> --window=1h
3434
```
3535

36+
## OTel Output
37+
38+
```bash
39+
# JSON spans to stdout (agent-readable JSONL)
40+
gha-analyzer <url> --otel
41+
42+
# Export via OTLP/HTTP
43+
gha-analyzer <url> --otel=localhost:4318
44+
45+
# Export via OTLP/gRPC
46+
gha-analyzer <url> --otel-grpc
47+
gha-analyzer <url> --otel-grpc=localhost:4317
48+
```
49+
50+
## Webhook Input
51+
52+
Pipe a GitHub Actions webhook payload on stdin to analyze the associated commit:
53+
54+
```bash
55+
echo '{"workflow_run":{"head_sha":"abc123"},"repository":{"full_name":"owner/repo"}}' | gha-analyzer --otel
56+
```
57+
58+
Supports `workflow_run` and `workflow_job` events. When no URL arguments are given and stdin is piped, the webhook is read automatically.
59+
3660
## License
3761

3862
MIT

cmd/gha-analyzer/main.go

Lines changed: 114 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ import (
1414
otelexport "github.com/stefanpenner/gha-analyzer/pkg/export/otel"
1515
perfettoexport "github.com/stefanpenner/gha-analyzer/pkg/export/perfetto"
1616
"github.com/stefanpenner/gha-analyzer/pkg/export/terminal"
17-
"github.com/stefanpenner/gha-analyzer/pkg/perfetto"
1817
"github.com/stefanpenner/gha-analyzer/pkg/githubapi"
1918
"github.com/stefanpenner/gha-analyzer/pkg/ingest/polling"
19+
"github.com/stefanpenner/gha-analyzer/pkg/ingest/webhook"
2020
"github.com/stefanpenner/gha-analyzer/pkg/output"
21+
"github.com/stefanpenner/gha-analyzer/pkg/perfetto"
2122
"github.com/stefanpenner/gha-analyzer/pkg/tui"
2223
tuiresults "github.com/stefanpenner/gha-analyzer/pkg/tui/results"
2324
"github.com/stefanpenner/gha-analyzer/pkg/utils"
@@ -75,69 +76,100 @@ func printErrorMsg(message string) {
7576
fmt.Fprintf(os.Stderr, "%sError: %s%s\n", colorRed, message, colorReset)
7677
}
7778

78-
func main() {
79-
args := os.Args[1:]
80-
perfettoFile := ""
81-
openInPerfetto := false
82-
openInOTel := false
83-
otelEndpoint := ""
84-
tuiMode := isTerminal() // TUI only enabled if running in a terminal
85-
clearCache := false
86-
var window time.Duration
87-
88-
filtered := []string{}
79+
type config struct {
80+
urls []string
81+
perfettoFile string
82+
openInPerfetto bool
83+
openInOTel bool
84+
otelEndpoint string
85+
otelStdout bool
86+
otelGRPCEndpoint string
87+
tuiMode bool
88+
clearCache bool
89+
window time.Duration
90+
showHelp bool
91+
}
92+
93+
func parseArgs(args []string, terminal bool) (config, error) {
94+
cfg := config{
95+
tuiMode: terminal,
96+
}
97+
8998
for _, arg := range args {
9099
if arg == "help" || arg == "--help" || arg == "-h" {
91-
printUsage()
92-
os.Exit(0)
100+
cfg.showHelp = true
101+
continue
93102
}
94103
if strings.HasPrefix(arg, "--perfetto=") {
95-
perfettoFile = strings.TrimPrefix(arg, "--perfetto=")
104+
cfg.perfettoFile = strings.TrimPrefix(arg, "--perfetto=")
96105
continue
97106
}
98107
if strings.HasPrefix(arg, "--window=") {
99108
d, err := time.ParseDuration(strings.TrimPrefix(arg, "--window="))
100109
if err != nil {
101-
printError(err, fmt.Sprintf("invalid window duration %s", arg))
102-
os.Exit(1)
110+
return cfg, fmt.Errorf("invalid window duration %s: %w", arg, err)
103111
}
104-
window = d
112+
cfg.window = d
105113
continue
106114
}
107115
if arg == "--open-in-perfetto" {
108-
openInPerfetto = true
116+
cfg.openInPerfetto = true
109117
continue
110118
}
111119
if arg == "--open-in-otel" {
112-
openInOTel = true
120+
cfg.openInOTel = true
113121
continue
114122
}
115123
if strings.HasPrefix(arg, "--otel=") {
116-
otelEndpoint = strings.TrimPrefix(arg, "--otel=")
124+
cfg.otelEndpoint = strings.TrimPrefix(arg, "--otel=")
117125
continue
118126
}
119127
if arg == "--otel" {
120-
otelEndpoint = "localhost:4318"
128+
cfg.otelStdout = true
129+
continue
130+
}
131+
if strings.HasPrefix(arg, "--otel-grpc=") {
132+
cfg.otelGRPCEndpoint = strings.TrimPrefix(arg, "--otel-grpc=")
133+
continue
134+
}
135+
if arg == "--otel-grpc" {
136+
cfg.otelGRPCEndpoint = "localhost:4317"
121137
continue
122138
}
123139
if arg == "--tui" {
124-
tuiMode = true
140+
cfg.tuiMode = true
125141
continue
126142
}
127143
if arg == "--no-tui" || arg == "--notui" {
128-
tuiMode = false
144+
cfg.tuiMode = false
129145
continue
130146
}
131147
if arg == "--clear-cache" {
132-
clearCache = true
148+
cfg.clearCache = true
133149
continue
134150
}
135-
filtered = append(filtered, arg)
151+
cfg.urls = append(cfg.urls, arg)
152+
}
153+
154+
return cfg, nil
155+
}
156+
157+
func main() {
158+
cfg, err := parseArgs(os.Args[1:], isTerminal())
159+
if err != nil {
160+
printErrorMsg(err.Error())
161+
os.Exit(1)
162+
}
163+
164+
if cfg.showHelp {
165+
printUsage()
166+
os.Exit(0)
136167
}
137-
args = filtered
168+
169+
args := cfg.urls
138170

139171
// Handle --clear-cache flag
140-
if clearCache {
172+
if cfg.clearCache {
141173
cacheDir := githubapi.DefaultCacheDir()
142174
if err := os.RemoveAll(cacheDir); err != nil {
143175
printError(err, "failed to clear cache")
@@ -149,8 +181,26 @@ func main() {
149181
}
150182
}
151183

184+
// If no URL args and stdin is piped, read webhook from stdin
185+
if len(args) == 0 && !isStdinTerminal() {
186+
fmt.Fprintf(os.Stderr, "Reading webhook from stdin...\n")
187+
urls, err := webhook.ParseWebhook(os.Stdin)
188+
if err != nil {
189+
printError(err, "failed to parse webhook")
190+
os.Exit(1)
191+
}
192+
args = urls
193+
}
194+
195+
// When --otel stdout is used, disable TUI so output goes to stdout cleanly
196+
if cfg.otelStdout {
197+
cfg.tuiMode = false
198+
}
199+
200+
perfettoFile := cfg.perfettoFile
201+
152202
// Auto-generate perfetto file if --open-in-perfetto is used without --perfetto
153-
if openInPerfetto && perfettoFile == "" {
203+
if cfg.openInPerfetto && perfettoFile == "" {
154204
tmpFile, err := os.CreateTemp("", "gha-trace-*.json")
155205
if err == nil {
156206
perfettoFile = tmpFile.Name()
@@ -206,16 +256,30 @@ func main() {
206256
}
207257

208258
if perfettoFile != "" {
209-
exporters = append(exporters, perfettoexport.NewExporter(os.Stderr, perfettoFile, openInPerfetto))
259+
exporters = append(exporters, perfettoexport.NewExporter(os.Stderr, perfettoFile, cfg.openInPerfetto))
260+
}
261+
262+
if cfg.otelStdout {
263+
stdoutExporter, err := otelexport.NewStdoutExporter(os.Stdout)
264+
if err == nil {
265+
exporters = append(exporters, stdoutExporter)
266+
}
210267
}
211268

212-
if otelEndpoint != "" {
213-
otelExporter, err := otelexport.NewExporter(ctx, otelEndpoint)
269+
if cfg.otelEndpoint != "" {
270+
otelExporter, err := otelexport.NewExporter(ctx, cfg.otelEndpoint)
214271
if err == nil {
215272
exporters = append(exporters, otelExporter)
216273
}
217274
}
218275

276+
if cfg.otelGRPCEndpoint != "" {
277+
grpcExporter, err := otelexport.NewGRPCExporter(ctx, cfg.otelGRPCEndpoint)
278+
if err == nil {
279+
exporters = append(exporters, grpcExporter)
280+
}
281+
}
282+
219283
pipeline := core.NewPipeline(exporters...)
220284

221285
// 4. Setup Progress TUI
@@ -224,7 +288,7 @@ func main() {
224288

225289
// 5. Run Ingestor
226290
ingestor := polling.NewPollingIngestor(client, args, progress, analyzer.AnalyzeOptions{
227-
Window: window,
291+
Window: cfg.window,
228292
})
229293
results, globalEarliest, globalLatest, err := ingestor.Ingest(ctx)
230294

@@ -245,15 +309,15 @@ func main() {
245309
}
246310

247311
// If TUI mode is enabled, launch interactive TUI
248-
if tuiMode {
312+
if cfg.tuiMode {
249313
// Handle perfetto export before TUI starts (so it opens immediately)
250314
if perfettoFile != "" {
251315
combined := analyzer.CalculateCombinedMetrics(results, sumRuns(results), collectStarts(results), collectEnds(results))
252316
var allTraceEvents []analyzer.TraceEvent
253317
for _, res := range results {
254318
allTraceEvents = append(allTraceEvents, res.TraceEvents...)
255319
}
256-
if err := perfetto.WriteTrace(os.Stderr, results, combined, allTraceEvents, globalEarliest, perfettoFile, openInPerfetto, spans); err != nil {
320+
if err := perfetto.WriteTrace(os.Stderr, results, combined, allTraceEvents, globalEarliest, perfettoFile, cfg.openInPerfetto, spans); err != nil {
257321
printError(err, "writing perfetto trace failed")
258322
}
259323
}
@@ -296,7 +360,7 @@ func main() {
296360
// Re-run ingestion
297361
reloadClient := githubapi.NewClient(githubapi.NewContext(token))
298362
reloadIngestor := polling.NewPollingIngestor(reloadClient, args, progressReporter, analyzer.AnalyzeOptions{
299-
Window: window,
363+
Window: cfg.window,
300364
})
301365
_, reloadEarliest, reloadLatest, err := reloadIngestor.Ingest(ctx)
302366
if err != nil {
@@ -346,13 +410,13 @@ func main() {
346410
for _, res := range results {
347411
allTraceEvents = append(allTraceEvents, res.TraceEvents...)
348412
}
349-
output.OutputCombinedResults(os.Stderr, results, combined, allTraceEvents, globalEarliest, globalLatest, perfettoFile, openInPerfetto, spans)
413+
output.OutputCombinedResults(os.Stderr, results, combined, allTraceEvents, globalEarliest, globalLatest, perfettoFile, cfg.openInPerfetto, spans)
350414

351415
if err := pipeline.Finish(ctx); err != nil {
352416
printError(err, "finalizing pipeline failed")
353417
}
354418

355-
if openInOTel {
419+
if cfg.openInOTel {
356420
fmt.Println("Opening OTel Desktop Viewer...")
357421
_ = utils.OpenBrowser("http://localhost:8000")
358422
}
@@ -391,7 +455,9 @@ func printUsage() {
391455
fmt.Println(" --no-tui Disable interactive TUI, use CLI output instead")
392456
fmt.Println(" --perfetto=<file.json> Save trace for Perfetto.dev analysis")
393457
fmt.Println(" --open-in-perfetto Automatically open the generated trace in Perfetto UI")
394-
fmt.Println(" --otel[=<endpoint>] Export traces to OTel collector (default: localhost:4318)")
458+
fmt.Println(" --otel Write OTel spans as JSON to stdout")
459+
fmt.Println(" --otel=<endpoint> Export traces via OTLP/HTTP (default port: 4318)")
460+
fmt.Println(" --otel-grpc[=<endpoint>] Export traces via OTLP/gRPC (default: localhost:4317)")
395461
fmt.Println(" --open-in-otel Automatically open the OTel Desktop Viewer")
396462
fmt.Println(" --window=<duration> Only show events within <duration> of merge/latest activity (e.g. 24h, 2h)")
397463
fmt.Println(" --clear-cache Clear the HTTP cache (can be combined with other flags)")
@@ -414,3 +480,12 @@ func isTerminal() bool {
414480
}
415481
return (info.Mode() & os.ModeCharDevice) != 0
416482
}
483+
484+
// isStdinTerminal checks if stdin is connected to a terminal
485+
func isStdinTerminal() bool {
486+
info, err := os.Stdin.Stat()
487+
if err != nil {
488+
return false
489+
}
490+
return (info.Mode() & os.ModeCharDevice) != 0
491+
}

go.mod

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ require (
66
github.com/charmbracelet/bubbles v0.21.0
77
github.com/charmbracelet/bubbletea v1.3.10
88
github.com/charmbracelet/lipgloss v1.1.0
9+
github.com/cockroachdb/errors v1.12.0
910
github.com/stretchr/testify v1.11.1
1011
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0
1112
go.opentelemetry.io/otel v1.39.0
13+
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0
1214
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0
15+
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0
1316
go.opentelemetry.io/otel/sdk v1.39.0
1417
go.opentelemetry.io/otel/trace v1.39.0
1518
)
@@ -22,7 +25,6 @@ require (
2225
github.com/charmbracelet/x/ansi v0.10.1 // indirect
2326
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
2427
github.com/charmbracelet/x/term v0.2.1 // indirect
25-
github.com/cockroachdb/errors v1.12.0 // indirect
2628
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect
2729
github.com/cockroachdb/redact v1.1.5 // indirect
2830
github.com/davecgh/go-spew v1.1.1 // indirect

go.sum

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
3333
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
3434
github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
3535
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
36+
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
37+
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
3638
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
3739
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
3840
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -68,6 +70,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
6870
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
6971
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
7072
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
73+
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
74+
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
7175
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
7276
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
7377
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -95,8 +99,12 @@ go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
9599
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
96100
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
97101
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
102+
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM=
103+
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c=
98104
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4=
99105
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU=
106+
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0 h1:8UPA4IbVZxpsD76ihGOQiFml99GPAEZLohDXvqHdi6U=
107+
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0/go.mod h1:MZ1T/+51uIVKlRzGw1Fo46KEWThjlCBZKl2LzY5nv4g=
100108
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
101109
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
102110
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=

0 commit comments

Comments
 (0)