Skip to content

Commit eab67d0

Browse files
authored
CLI Improvements (#115)
* CLI Improvements * (feat): add bubble tea CUI * add debug logging * debug logging and CLI changes * fix: no terminal in ci/cd * add testing for new cli * added testing for TUI * Make File Exclusions be a flag + Testing
1 parent 541f9dc commit eab67d0

24 files changed

Lines changed: 1135 additions & 66 deletions

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@ This project, its code, and the UX are under heavy development, and should be ex
1919

2020
This tool doesn't interfere with your application's operation, and it doesn't make any changes to your code directly. Here's what happens when you run the tool:
2121

22-
* It analyzes your code and suggests changes that allow the Go agent to capture telemetry data.
23-
* You review the changes in the .diff file and decide which changes to add to your source code.
22+
* It analyzes your code and suggests changes that allow the Go agent to capture telemetry data.
23+
* You watch the progress in real-time as the tool scans and instruments your source files.
24+
* A diff file is generated with the proposed changes.
25+
* You apply the changes to your source code using `git apply <diff-file>`.
2426

2527
As part of the analysis, this tool may invoke `go get` or other Go language toolchain commands which may modify your `go.mod` file, but not your actual source code.
2628

27-
**IMPORTANT:** This tool can't detect if you already have New Relic instrumentation. Please only use this on applications without any instrumentation.
28-
2929
## What is instrumented?
3030

3131
The scope of what this tool can instrument in your application is limited to these actions:
@@ -68,6 +68,10 @@ Installation Steps have been moved to: https://docs.newrelic.com/docs/apm/agents
6868
```
6969
go run . instrument $MY_APP
7070
```
71+
5. Once the instrumentation is complete, the tool will output the path to a diff file. Apply the changes to your source code:
72+
```sh
73+
git apply <path-to-diff-file>
74+
```
7175

7276

7377
### Updating an existing version of Go Easy Instrumentation

cmd/instrument.go

Lines changed: 253 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@ import (
55
"fmt"
66
"os"
77
"path/filepath"
8+
"strings"
89

10+
"github.com/charmbracelet/bubbles/spinner"
11+
tea "github.com/charmbracelet/bubbletea"
12+
"github.com/charmbracelet/lipgloss"
913
"github.com/dave/dst/decorator"
1014
"github.com/newrelic/go-easy-instrumentation/internal/comment"
1115
"github.com/newrelic/go-easy-instrumentation/parser"
1216
"github.com/spf13/cobra"
17+
"golang.org/x/term"
1318
"golang.org/x/tools/go/packages"
1419
)
1520

@@ -20,12 +25,11 @@ const (
2025
defaultAppName = ""
2126
defaultOutputFilePath = ""
2227
defaultDiffFileName = "new-relic-instrumentation.diff"
23-
defaultDebug = false
2428
)
2529

2630
var (
27-
debug bool
28-
diffFile string
31+
diffFile string
32+
excludeDirs string
2933
)
3034

3135
var instrumentCmd = &cobra.Command{
@@ -73,56 +77,277 @@ func setOutputFilePath(outputFilePath, applicationPath string) (string, error) {
7377

7478
const LoadMode = packages.LoadSyntax | packages.NeedForTest
7579

76-
func Instrument(packagePath string) {
80+
// Bubble Tea Model
81+
type model struct {
82+
spinner spinner.Model
83+
stepDesc string
84+
totalSteps int
85+
currentStep int
86+
done bool
87+
err error
88+
packages []*decorator.Package
89+
pkgPath string
90+
sub chan tea.Msg
91+
outputFile string
92+
}
93+
94+
// Messages
95+
// Messages
96+
type progressMsg struct {
97+
desc string
98+
}
99+
type pkgLoadedMsg []*decorator.Package
100+
type errMsg error
101+
type completedMsg struct{}
102+
103+
func initialModel(pkgPath, outputFile string) model {
104+
s := spinner.New()
105+
s.Spinner = spinner.Dot
106+
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#1CE783"))
107+
return model{
108+
spinner: s,
109+
stepDesc: "Loading packages...",
110+
totalSteps: 8,
111+
pkgPath: pkgPath,
112+
outputFile: outputFile,
113+
}
114+
}
115+
116+
func (m model) Init() tea.Cmd {
117+
return tea.Batch(
118+
m.spinner.Tick,
119+
waitForNext(m.sub),
120+
)
121+
}
122+
123+
func Instrument(packagePath string, patterns ...string) {
77124
if packagePath == "" {
78125
cobra.CheckErr("path argument cannot be empty")
79126
}
80-
81127
if _, err := os.Stat(packagePath); err != nil {
82128
cobra.CheckErr(fmt.Errorf("path argument \"%s\" is invalid: %v", packagePath, err))
83129
}
84-
85130
outputFile, err := setOutputFilePath(diffFile, packagePath)
86131
cobra.CheckErr(err)
87132
if debug {
88133
comment.EnableConsolePrinter(packagePath)
89134
}
90135

91-
pkgs, err := decorator.Load(&packages.Config{Dir: packagePath, Mode: LoadMode, Tests: true}, defaultPackageName)
92-
cobra.CheckErr(err)
136+
// If debug mode is enabled or no terminal is available (CI/CD), run in text mode (no TUI)
137+
if debug || !term.IsTerminal(int(os.Stdout.Fd())) {
138+
runTextMode(packagePath, patterns, outputFile)
139+
return
140+
}
141+
142+
// Normal TUI mode
143+
runTUIMode(packagePath, patterns, outputFile)
144+
}
145+
146+
// runTextMode runs the instrumentation pipeline with plain text output to stdout.
147+
// This is used when the TUI is unavailable (e.g. CI/CD, piped output) or when
148+
// the --debug flag is enabled. It delegates to instrumentPackages for the core
149+
// logic and handles printing status and exit on error.
150+
func runTextMode(packagePath string, patterns []string, outputFile string) {
151+
fmt.Printf("Instrumentation started for %s\n", packagePath)
152+
fmt.Printf("Output file: %s\n\n", outputFile)
153+
154+
if err := instrumentPackages(packagePath, patterns, outputFile); err != nil {
155+
fmt.Printf("Error: %v\n", err)
156+
os.Exit(1)
157+
}
158+
159+
fmt.Printf("\nDone! Changes written to: %s\nTip: Apply these changes with: git apply %s\n", outputFile, outputFile)
160+
}
161+
162+
// instrumentPackages loads Go packages from packagePath, runs the full
163+
// instrumentation pipeline, and writes the resulting diff to outputFile.
164+
// It returns an error rather than exiting the process, making it safe to
165+
// call from both runTextMode (which handles os.Exit) and from tests.
166+
func instrumentPackages(packagePath string, patterns []string, outputFile string) error {
167+
loadPatterns := patterns
168+
if len(loadPatterns) == 0 {
169+
loadPatterns = []string{defaultPackageName}
170+
}
171+
172+
fmt.Println(" -> Loading packages...")
173+
pkgs, err := decorator.Load(&packages.Config{Dir: packagePath, Mode: LoadMode, Tests: true}, loadPatterns...)
174+
if err != nil {
175+
return fmt.Errorf("loading packages: %w", err)
176+
}
93177

94178
manager := parser.NewInstrumentationManager(pkgs, defaultAppName, defaultAgentVariableName, outputFile, packagePath)
95-
err = manager.CreateDiffFile()
96-
cobra.CheckErr(err)
97179

98-
err = manager.DetectDependencyIntegrations()
99-
cobra.CheckErr(err)
180+
steps := []struct {
181+
desc string
182+
fn func() error
183+
}{
184+
{"Creating diff file", manager.CreateDiffFile},
185+
{"Detecting dependencies", manager.DetectDependencyIntegrations},
186+
{"Tracing package calls", manager.TracePackageCalls},
187+
{"Scanning application", manager.ScanApplication},
188+
{"Instrumenting application", manager.InstrumentApplication},
189+
{"Resolving unit tests", manager.ResolveUnitTests},
190+
{"Adding required modules", manager.AddRequiredModules},
191+
{"Writing diff file", func() error {
192+
comment.WriteAll()
193+
return manager.WriteDiff(func(msg string) {})
194+
}},
195+
}
100196

101-
err = manager.TracePackageCalls()
102-
cobra.CheckErr(err)
197+
for _, step := range steps {
198+
if err := step.fn(); err != nil {
199+
return fmt.Errorf("%s: %w", step.desc, err)
200+
}
201+
}
103202

104-
err = manager.ScanApplication()
105-
cobra.CheckErr(err)
203+
return nil
204+
}
106205

107-
err = manager.InstrumentApplication()
108-
cobra.CheckErr(err)
206+
func runTUIMode(packagePath string, patterns []string, outputFile string) {
207+
// Channel to receive updates from the worker
208+
updates := make(chan tea.Msg)
109209

110-
err = manager.ResolveUnitTests()
111-
cobra.CheckErr(err)
210+
// Worker goroutine
211+
go func() {
212+
loadPatterns := patterns
213+
if len(loadPatterns) == 0 {
214+
loadPatterns = []string{defaultPackageName}
215+
}
112216

113-
err = manager.AddRequiredModules()
114-
cobra.CheckErr(err)
217+
pkgs, err := decorator.Load(&packages.Config{Dir: packagePath, Mode: LoadMode, Tests: true}, loadPatterns...)
218+
if err != nil {
219+
updates <- errMsg(err)
220+
return
221+
}
115222

116-
// write debug comments before writing diff so that
117-
// diff file console log is still easy to see
118-
comment.WriteAll()
119-
err = manager.WriteDiff()
120-
cobra.CheckErr(err)
223+
updates <- pkgLoadedMsg(pkgs)
224+
225+
manager := parser.NewInstrumentationManager(pkgs, defaultAppName, defaultAgentVariableName, outputFile, packagePath)
226+
227+
steps := []struct {
228+
desc string
229+
fn func() error
230+
}{
231+
{"Creating diff file", manager.CreateDiffFile},
232+
{"Detecting dependencies", manager.DetectDependencyIntegrations},
233+
{"Tracing package calls", manager.TracePackageCalls},
234+
{"Scanning application", manager.ScanApplication},
235+
{"Instrumenting application", manager.InstrumentApplication},
236+
{"Resolving unit tests", manager.ResolveUnitTests},
237+
{"Adding required modules", manager.AddRequiredModules},
238+
{"Writing diff file", func() error {
239+
comment.WriteAll()
240+
// Pass a callback to WriteDiff to receive granular progress updates.
241+
// This callback updates the UI with the name of the file currently being written,
242+
// avoiding a "stalled" UI during this potentially long-running step.
243+
return manager.WriteDiff(func(msg string) {
244+
updates <- progressMsg{desc: msg}
245+
})
246+
}},
247+
}
248+
249+
for _, step := range steps {
250+
updates <- progressMsg{desc: step.desc}
251+
if err := step.fn(); err != nil {
252+
updates <- errMsg(err)
253+
return
254+
}
255+
}
256+
257+
updates <- completedMsg{}
258+
close(updates)
259+
}()
260+
261+
initialM := initialModel(packagePath, outputFile)
262+
initialM.sub = updates
263+
264+
finalModel, err := tea.NewProgram(initialM).Run()
265+
if err != nil {
266+
fmt.Printf("Error running program: %v\n", err)
267+
os.Exit(1)
268+
}
269+
270+
if m, ok := finalModel.(model); ok {
271+
if m.err != nil {
272+
os.Exit(1)
273+
}
274+
if m.done {
275+
fmt.Printf("\nDone! Changes written to: %s\nTip: Apply these changes with: git apply %s\n", m.outputFile, m.outputFile)
276+
}
277+
}
278+
}
279+
280+
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
281+
switch msg := msg.(type) {
282+
case tea.KeyMsg:
283+
if msg.Type == tea.KeyCtrlC {
284+
return m, tea.Quit
285+
}
286+
case spinner.TickMsg:
287+
var cmd tea.Cmd
288+
m.spinner, cmd = m.spinner.Update(msg)
289+
return m, cmd
290+
case pkgLoadedMsg:
291+
m.packages = msg
292+
m.stepDesc = "Starting instrumentation..."
293+
return m, waitForNext(m.sub)
294+
case progressMsg:
295+
m.currentStep++
296+
m.stepDesc = msg.desc
297+
// We just update the description, no progress bar to update
298+
return m, waitForNext(m.sub)
299+
case errMsg:
300+
m.err = msg
301+
return m, tea.Quit
302+
case completedMsg:
303+
m.done = true
304+
return m, tea.Quit
305+
}
306+
307+
// If we are strictly in Init, we should return the initial batch.
308+
// But Update is called for every message.
309+
// If the message is none of the above (shouldn't happen usually), we return nil.
310+
311+
// Wait, we need to ensure the first waitForNext is called.
312+
// We can do it in a special "start" message or just include it in Init.
313+
return m, nil
314+
}
315+
316+
func waitForNext(sub chan tea.Msg) tea.Cmd {
317+
if sub == nil {
318+
return nil
319+
}
320+
return func() tea.Msg {
321+
msg, ok := <-sub
322+
if !ok {
323+
return nil
324+
}
325+
return msg
326+
}
327+
}
328+
329+
func (m model) View() string {
330+
if m.err != nil {
331+
return fmt.Sprintf("\nError: %v\n", m.err)
332+
}
333+
334+
pad := strings.Repeat(" ", padding(m.stepDesc, 30))
335+
336+
// Simple spinner view for all phases
337+
return fmt.Sprintf("\n %s %s%s\n\n", m.spinner.View(), m.stepDesc, pad)
338+
}
339+
340+
func padding(s string, width int) int {
341+
l := len(s)
342+
if l > width {
343+
return 0
344+
}
345+
return width - l
121346
}
122347

123348
func init() {
124-
instrumentCmd.Flags().BoolVarP(&debug, "debug", "D", defaultDebug, "enable debugging output")
125349
instrumentCmd.Flags().StringVarP(&diffFile, "output", "o", defaultOutputFilePath, "specify diff output file path")
350+
instrumentCmd.Flags().StringVarP(&excludeDirs, "exclude", "e", "", "comma-separated list of folders to exclude from instrumentation")
126351
cobra.MarkFlagFilename(instrumentCmd.Flags(), "output", ".diff") // for file completion
127352

128353
rootCmd.AddCommand(instrumentCmd)

0 commit comments

Comments
 (0)