diff --git a/end-to-end-tests/gochi/expect.ref b/end-to-end-tests/gochi/expect.ref new file mode 100644 index 0000000..d2eba6c --- /dev/null +++ b/end-to-end-tests/gochi/expect.ref @@ -0,0 +1,71 @@ +--- a/main.go ++++ b/main.go +@@ -4,9 +4,12 @@ + "io" + "log/slog" + "net/http" ++ "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" ++ "github.com/newrelic/go-agent/v3/integrations/nrgochi" ++ "github.com/newrelic/go-agent/v3/newrelic" + ) + + func endpoint404(w http.ResponseWriter, r *http.Request) { +@@ -15,9 +15,16 @@ + } + + func basicExternal(w http.ResponseWriter, r *http.Request) { ++ nrTxn := newrelic.FromContext(r.Context()) ++ ++ // the "http.Get()" net/http method can not be instrumented and its outbound traffic can not be traced ++ // please see these examples of code patterns for external http calls that can be instrumented: ++ // https://docs.newrelic.com/docs/apm/agents/go-agent/configuration/distributed-tracing-go-agent/#make-http-requests ++ // + // Make an http request to an external address + resp, err := http.Get("https://example.com") + if err != nil { ++ nrTxn.NoticeError(err) + slog.Error(err.Error()) + io.WriteString(w, err.Error()) + return +@@ -28,15 +32,31 @@ + } + + func main() { ++ NewRelicAgent, agentInitError := newrelic.NewApplication(newrelic.ConfigFromEnvironment()) ++ if agentInitError != nil { ++ panic(agentInitError) ++ } ++ + r := chi.NewRouter() ++ r.Use(nrgochi.Middleware(NewRelicAgent)) + r.Use(middleware.Logger) + r.Get("/", func(w http.ResponseWriter, r *http.Request) { ++ nrTxn := newrelic.FromContext(r.Context()) ++ ++ defer nrTxn.StartSegment("GET:/").End() ++ + w.Write([]byte("welcome")) + }) + r.Get("/404", endpoint404) + r.Get("/external", basicExternal) + r.Get("/literal", func(w http.ResponseWriter, r *http.Request) { ++ nrTxn := newrelic.FromContext(r.Context()) + ++ defer nrTxn.StartSegment("GET:/literal").End() ++ ++ // the "http.Get()" net/http method can not be instrumented and its outbound traffic can not be traced ++ // please see these examples of code patterns for external http calls that can be instrumented: ++ // https://docs.newrelic.com/docs/apm/agents/go-agent/configuration/distributed-tracing-go-agent/#make-http-requests + _, err := http.Get("https://newrelic.com") + if err != nil { + slog.Error(err.Error()) +@@ -44,4 +55,6 @@ + w.Write([]byte("function literal example")) + }) + http.ListenAndServe(":3000", r) ++ ++ NewRelicAgent.Shutdown(5 * time.Second) + } diff --git a/end-to-end-tests/gochi/go.mod b/end-to-end-tests/gochi/go.mod new file mode 100644 index 0000000..b7768ff --- /dev/null +++ b/end-to-end-tests/gochi/go.mod @@ -0,0 +1,5 @@ +module gochi + +go 1.25 + +require github.com/go-chi/chi/v5 v5.2.2 diff --git a/end-to-end-tests/gochi/go.sum b/end-to-end-tests/gochi/go.sum new file mode 100644 index 0000000..ed9ec89 --- /dev/null +++ b/end-to-end-tests/gochi/go.sum @@ -0,0 +1,2 @@ +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= diff --git a/end-to-end-tests/gochi/main.go b/end-to-end-tests/gochi/main.go new file mode 100644 index 0000000..f0845cd --- /dev/null +++ b/end-to-end-tests/gochi/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "io" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func endpoint404(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + w.Write([]byte("returning 404")) +} + +func basicExternal(w http.ResponseWriter, r *http.Request) { + // Make an http request to an external address + resp, err := http.Get("https://example.com") + if err != nil { + slog.Error(err.Error()) + io.WriteString(w, err.Error()) + return + } + + defer resp.Body.Close() + io.Copy(w, resp.Body) +} + +func main() { + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("welcome")) + }) + r.Get("/404", endpoint404) + r.Get("/external", basicExternal) + r.Get("/literal", func(w http.ResponseWriter, r *http.Request) { + + _, err := http.Get("https://newrelic.com") + if err != nil { + slog.Error(err.Error()) + } + w.Write([]byte("function literal example")) + }) + http.ListenAndServe(":3000", r) +} diff --git a/end-to-end-tests/testcases.json b/end-to-end-tests/testcases.json index dd23b3f..e51ec31 100644 --- a/end-to-end-tests/testcases.json +++ b/end-to-end-tests/testcases.json @@ -1,31 +1,36 @@ { - "tests": [ - { - "name": "http web app", - "dir": "end-to-end-tests/http-app" - }, - { - "name": "http-mux web app", - "dir": "end-to-end-tests/http-mux-app" - }, - { - "name": "grpc app", - "dir": "end-to-end-tests/grpc", - "builds": [ - "end-to-end-tests/grpc/server", - "end-to-end-tests/grpc/client" - ] - }, - { - "name": "gin - basic", - "dir": "end-to-end-tests/gin-examples/basic" - }, - { - "name": "gin - multiple services", - "dir": "end-to-end-tests/gin-examples/multiple-service" - }, - { - "name": "unit tests", - "dir": "end-to-end-tests/unit-tests" - } -]} + "tests": [ + { + "name": "http web app", + "dir": "end-to-end-tests/http-app" + }, + { + "name": "http-mux web app", + "dir": "end-to-end-tests/http-mux-app" + }, + { + "name": "grpc app", + "dir": "end-to-end-tests/grpc", + "builds": [ + "end-to-end-tests/grpc/server", + "end-to-end-tests/grpc/client" + ] + }, + { + "name": "gin - basic", + "dir": "end-to-end-tests/gin-examples/basic" + }, + { + "name": "gin - multiple services", + "dir": "end-to-end-tests/gin-examples/multiple-service" + }, + { + "name": "unit tests", + "dir": "end-to-end-tests/unit-tests" + }, + { + "name": "gochi app", + "dir": "end-to-end-tests/gochi" + } + ] +} diff --git a/internal/codegen/gochi.go b/internal/codegen/gochi.go new file mode 100644 index 0000000..5500b3a --- /dev/null +++ b/internal/codegen/gochi.go @@ -0,0 +1,34 @@ +package codegen + +import "github.com/dave/dst" + +const ( + NrChiImportPath = "github.com/newrelic/go-agent/v3/integrations/nrgochi" +) + +// Inject NR Middleware instrumentation logic to the Chi application via the `Use` directive. +// Ex: +// +// router := chi.NewRouter() +// router.Use(nrgochi.Middleware(app)) <--- Midddleware injection +func NrChiMiddleware(routerName string, agentVariableName dst.Expr) (*dst.ExprStmt, string) { + return &dst.ExprStmt{ + X: &dst.CallExpr{ + Fun: &dst.SelectorExpr{ + X: &dst.Ident{Name: routerName}, + Sel: &dst.Ident{Name: "Use"}, + }, + Args: []dst.Expr{ + &dst.CallExpr{ + Fun: &dst.Ident{ + Name: "Middleware", + Path: NrChiImportPath, + }, + Args: []dst.Expr{ + agentVariableName, + }, + }, + }, + }, + }, NrChiImportPath +} diff --git a/internal/codegen/gochi_test.go b/internal/codegen/gochi_test.go new file mode 100644 index 0000000..332837f --- /dev/null +++ b/internal/codegen/gochi_test.go @@ -0,0 +1,79 @@ +package codegen + +import ( + "reflect" + "testing" + + "github.com/dave/dst" +) + +const ( + ChiImportPath = "github.com/go-chi/chi/v5" +) + +func Test_NrChiMiddleware(t *testing.T) { + type args struct { + call *dst.CallExpr + routerName string + agentVariableName dst.Expr + } + + type test struct { + name string + args args + want *dst.ExprStmt + } + + tests := []test{ + { + name: "inject_nrgochi_middleware", + args: args{ + call: &dst.CallExpr{ + Fun: &dst.SelectorExpr{ + X: &dst.Ident{ + Name: "NewRouter", + Path: ChiImportPath, + }, + }, + }, + routerName: "router", + agentVariableName: &dst.Ident{Name: "NewRelicApplication"}, + }, + want: &dst.ExprStmt{ + X: &dst.CallExpr{ + Fun: &dst.SelectorExpr{ + X: &dst.Ident{ + Name: "router", + }, + Sel: &dst.Ident{ + Name: "Use", + }, + }, + Args: []dst.Expr{ + &dst.CallExpr{ + Fun: &dst.Ident{ + Name: "Middleware", + Path: NrChiImportPath, + }, + Args: []dst.Expr{ + &dst.Ident{Name: "NewRelicApplication"}, + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, imp := NrChiMiddleware(tt.args.routerName, tt.args.agentVariableName) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("NrChiMiddleware() = %v, want %v", got, tt.want) + } + if imp != NrChiImportPath { + t.Errorf("NrChiMiddleware() = %v, want %v", imp, NrChiImportPath) + } + }) + } +} diff --git a/parser/gochi.go b/parser/gochi.go new file mode 100644 index 0000000..3f7a161 --- /dev/null +++ b/parser/gochi.go @@ -0,0 +1,155 @@ +package parser + +import ( + "go/token" + "strconv" + "strings" + + "github.com/dave/dst" + "github.com/dave/dst/dstutil" + "github.com/newrelic/go-easy-instrumentation/internal/codegen" + "github.com/newrelic/go-easy-instrumentation/parser/tracestate" +) + +const ( + gochiImportPath = "github.com/go-chi/chi/v5" +) + +// Return the variable name of the Chi router object. +// Ex: +// +// router := chi.NewRouter() +// ^^^^^^ +func getChiRouterName(stmt dst.Stmt) string { + // Verify we're dealing with an assignment operation + v, ok := stmt.(*dst.AssignStmt) + if !ok || len(v.Rhs) != 1 { + return "" + } + + if v.Lhs == nil { + return "" + } + + // Verify the Rhs of the assignment is a Call Expression + call, ok := v.Rhs[0].(*dst.CallExpr) + if !ok { + return "" + } + + // Verify the name and path of the function being called + ident, ok := call.Fun.(*dst.Ident) + if !ok { + return "" + } + + // Reject calls that are not to the `NewRouter` Fn. Verify Chi relationship with the import path. + if ident.Name != "NewRouter" || ident.Path != gochiImportPath { + return "" + } + + return v.Lhs[0].(*dst.Ident).Name +} + +// Extract the HTTP method type and CallExpr node from the current cursor node +// +// router.Get("/", func(w, r){...}) +// _______^^^ +func getChiHTTPMethod(node dst.Node) (string, *dst.CallExpr) { + switch v := node.(type) { + case *dst.ExprStmt: + call, ok := v.X.(*dst.CallExpr) + if !ok { + return "", nil + } + + selExpr, ok := call.Fun.(*dst.SelectorExpr) + if !ok { + return "", nil + } + + method := selExpr.Sel.Name + switch strings.ToUpper(method) { + case "GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "TRACE", "CONNECT", "PATCH": + return strings.ToUpper(method), call + default: + return "", nil + } + } + return "", nil +} + +// Get the name of the route being registered to the handler for naming purposes +// +// router.Get("/routename", func(w, r){...}) +// ____________^^^^^^^^^^ +func getChiHTTPHandlerRouteName(callExpr *dst.CallExpr) (string, *dst.FuncLit) { + if callExpr == nil { + return "", nil + } + + if len(callExpr.Args) != 2 { + return "", nil + } + + routeName, ok := callExpr.Args[0].(*dst.BasicLit) + if !ok || routeName.Kind != token.STRING { + return "", nil + } + + fnLit, ok := callExpr.Args[1].(*dst.FuncLit) + if !ok { + return "", nil + } + + return routeName.Value, fnLit +} + +// InstrumentChiMiddleware detects whether a Chi Router has been initialized +// and adds New Relic Go Agent Middleware via the router.Use() method to +// instrument the routes registered to the router. +func InstrumentChiMiddleware(manager *InstrumentationManager, stmt dst.Stmt, c *dstutil.Cursor, tracing *tracestate.State) bool { + routerName := getChiRouterName(stmt) + if routerName == "" { + return false + } + + // Append at the current stmt location + middleware, goGet := codegen.NrChiMiddleware(routerName, tracing.AgentVariable()) + c.InsertAfter(middleware) + manager.addImport(goGet) + return true +} + +// InstrumentChiRouterLiteral detects if a Chi Router route uses a function +// literal and adds Txn/Segment tracing logic directly to the function literal +// block. +func InstrumentChiRouterLiteral(manager *InstrumentationManager, stmt dst.Stmt, c *dstutil.Cursor, tracing *tracestate.State) bool { + methodName, callExpr := getChiHTTPMethod(c.Node()) + if methodName == "" || callExpr == nil { + return false + } + + routeName, fnLit := getChiHTTPHandlerRouteName(callExpr) + routeName, err := strconv.Unquote(routeName) + if routeName == "" || fnLit == nil || err != nil { + return false + } + + ok, reqArgName := getHTTPRequestArgName(fnLit) + if reqArgName == "" || !ok { + return false + } + + txn := codegen.TxnFromContext(codegen.DefaultTransactionVariable, codegen.HttpRequestContext(reqArgName)) + if txn == nil { + return false + } + + segmentName := methodName + ":" + routeName + + codegen.PrependStatementToFunctionLit(fnLit, codegen.DeferSegment(segmentName, tracing.TransactionVariable())) + codegen.PrependStatementToFunctionLit(fnLit, txn) + + return true +} diff --git a/parser/gochi_test.go b/parser/gochi_test.go new file mode 100644 index 0000000..dd98c70 --- /dev/null +++ b/parser/gochi_test.go @@ -0,0 +1,330 @@ +package parser + +import ( + "go/token" + "testing" + + "github.com/dave/dst" + "github.com/stretchr/testify/assert" +) + +func TestInstrumentChiRouter(t *testing.T) { + tests := []struct { + name string + code string + expect string + }{ + { + name: "detect and trace chi router in main function", + code: `package main +import ( + "net/http" + + chi "github.com/go-chi/chi/v5" +) + +func main() { + router := chi.NewRouter() + http.ListenAndServe(":3000", router) +} +`, + expect: `package main + +import ( + "net/http" + "time" + + chi "github.com/go-chi/chi/v5" + "github.com/newrelic/go-agent/v3/integrations/nrgochi" + "github.com/newrelic/go-agent/v3/newrelic" +) + +func main() { + NewRelicAgent, agentInitError := newrelic.NewApplication(newrelic.ConfigFromEnvironment()) + if agentInitError != nil { + panic(agentInitError) + } + + router := chi.NewRouter() + router.Use(nrgochi.Middleware(NewRelicAgent)) + http.ListenAndServe(":3000", router) + + NewRelicAgent.Shutdown(5 * time.Second) +} +`, + }, + { + name: "detect and trace chi router in setup function", + code: `package main +import ( + "net/http" + + chi "github.com/go-chi/chi/v5" +) + +func setupRouter() { + router := chi.NewRouter() + http.ListenAndServe(":3000", router) +} + +func main() { + setupRouter() +} +`, + + expect: `package main + +import ( + "net/http" + "time" + + chi "github.com/go-chi/chi/v5" + "github.com/newrelic/go-agent/v3/integrations/nrgochi" + "github.com/newrelic/go-agent/v3/newrelic" +) + +func setupRouter(nrTxn *newrelic.Transaction) { + defer nrTxn.StartSegment("setupRouter").End() + + router := chi.NewRouter() + router.Use(nrgochi.Middleware(nrTxn.Application())) + http.ListenAndServe(":3000", router) +} + +func main() { + NewRelicAgent, agentInitError := newrelic.NewApplication(newrelic.ConfigFromEnvironment()) + if agentInitError != nil { + panic(agentInitError) + } + + nrTxn := NewRelicAgent.StartTransaction("setupRouter") + setupRouter(nrTxn) + nrTxn.End() + + NewRelicAgent.Shutdown(5 * time.Second) +} +`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer panicRecovery(t) + got := testStatelessTracingFunction(t, tt.code, InstrumentMain, InstrumentChiMiddleware) + assert.Equal(t, tt.expect, got) + }) + } +} + +func TestChiMiddlewareCall(t *testing.T) { + tests := []struct { + name string + stmt dst.Stmt + want string + }{ + { + name: "detect chi middleware call", + stmt: &dst.AssignStmt{ + Lhs: []dst.Expr{ + &dst.Ident{ + Name: "router", + }, + }, + Tok: token.DEFINE, + Rhs: []dst.Expr{ + &dst.CallExpr{ + Fun: &dst.Ident{ + Name: "NewRouter", + Path: gochiImportPath, + }, + }, + }, + }, + want: "router", + }, + { + name: "detect chi middleware call - Incorrect Import Path", + stmt: &dst.AssignStmt{ + Lhs: []dst.Expr{ + &dst.Ident{ + Name: "router", + }, + }, + Tok: token.DEFINE, + Rhs: []dst.Expr{ + &dst.CallExpr{ + Fun: &dst.Ident{ + Name: "New", + Path: "blah", + }, + }, + }, + }, + want: "", + }, + { + name: "detect chi middleware call - Incorrect Function", + stmt: &dst.AssignStmt{ + Lhs: []dst.Expr{ + &dst.Ident{ + Name: "router", + }, + }, + Tok: token.DEFINE, + Rhs: []dst.Expr{ + &dst.CallExpr{ + Fun: &dst.Ident{ + Name: "New", + Path: gochiImportPath, + }, + }, + }, + }, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer panicRecovery(t) + got := getChiRouterName(tt.stmt) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestInstrumentChiRouterLiteral(t *testing.T) { + tests := []struct { + name string + code string + expect string + }{ + { + name: "Instrument Chi Literal in http method handler", + code: `package main +import ( + "net/http" + + chi "github.com/go-chi/chi/v5" +) + +func main() { + router := chi.NewRouter() + router.Get("/literal", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("welcome")) + }) + http.ListenAndServe(":3000", router) +}`, + expect: `package main + +import ( + "net/http" + "time" + + chi "github.com/go-chi/chi/v5" + "github.com/newrelic/go-agent/v3/newrelic" +) + +func main() { + NewRelicAgent, agentInitError := newrelic.NewApplication(newrelic.ConfigFromEnvironment()) + if agentInitError != nil { + panic(agentInitError) + } + + router := chi.NewRouter() + router.Get("/literal", func(w http.ResponseWriter, r *http.Request) { + nrTxn := newrelic.FromContext(r.Context()) + + defer nrTxn.StartSegment("GET:/literal").End() + + w.Write([]byte("welcome")) + }) + http.ListenAndServe(":3000", router) + + NewRelicAgent.Shutdown(5 * time.Second) +} +`, + }, { + name: "Instrument Chi Liter in non-Main function", + code: `package main + +import ( + "net/http" + + chi "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func endpoint404(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + w.Write([]byte("returning 404")) +} + +func setupRouter(r *chi.Mux) { + r.Get("/literal", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("welcome")) + }) + r.Get("/404", endpoint404) +} + +func main() { + r := chi.NewRouter() + r.Use(middleware.Logger) + setupRouter(r) + http.ListenAndServe(":3000", r) +}`, + expect: `package main + +import ( + "net/http" + "time" + + chi "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/newrelic/go-agent/v3/newrelic" +) + +func endpoint404(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + w.Write([]byte("returning 404")) +} + +func setupRouter(r *chi.Mux, nrTxn *newrelic.Transaction) { + defer nrTxn.StartSegment("setupRouter").End() + + r.Get("/literal", func(w http.ResponseWriter, r *http.Request) { + nrTxn := newrelic.FromContext(r.Context()) + + defer nrTxn.StartSegment("GET:/literal").End() + + w.Write([]byte("welcome")) + }) + r.Get("/404", endpoint404) +} + +func main() { + NewRelicAgent, agentInitError := newrelic.NewApplication(newrelic.ConfigFromEnvironment()) + if agentInitError != nil { + panic(agentInitError) + } + + r := chi.NewRouter() + r.Use(middleware.Logger) + nrTxn := NewRelicAgent.StartTransaction("setupRouter") + setupRouter(r, nrTxn) + nrTxn.End() + http.ListenAndServe(":3000", r) + + NewRelicAgent.Shutdown(5 * time.Second) +} +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer panicRecovery(t) + got := testStatelessTracingFunction(t, tt.code, InstrumentMain, InstrumentChiRouterLiteral) + assert.Equal(t, tt.expect, got) + }) + } + +} diff --git a/parser/manager.go b/parser/manager.go index d4d8c1d..8f1fca1 100644 --- a/parser/manager.go +++ b/parser/manager.go @@ -87,7 +87,7 @@ func NewInstrumentationManager(pkgs []*decorator.Package, appName, agentVariable // DetectDependencyIntegrations func (m *InstrumentationManager) DetectDependencyIntegrations() error { m.loadStatelessTracingFunctions(InstrumentMain, InstrumentHandleFunction, InstrumentHttpClient, CannotInstrumentHttpMethod, InstrumentGrpcDial, InstrumentGinFunction, InstrumentGrpcServerMethod) - m.loadStatefulTracingFunctions(ExternalHttpCall, WrapNestedHandleFunction, InstrumentGrpcServer, InstrumentGinMiddleware) + m.loadStatefulTracingFunctions(ExternalHttpCall, WrapNestedHandleFunction, InstrumentGrpcServer, InstrumentGinMiddleware, InstrumentChiMiddleware, InstrumentChiRouterLiteral) m.loadDependencyScans(FindGrpcServerObject) return nil }