@@ -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+ }
0 commit comments