09/10/2024 - GO
In this example we are going to use NewRelic in a Postgres driven Golang client-server APIs. The features we are going to focus on is the most on-demand ones which are transactions, segments and events. Along with this, Golang's slog logger will integrate with NewRelic so each logs are pushed to NewRelic too. You can see some high-level information here. Here's some properties of our setup.
SELECT * FROM `external:auth`
data.*
at runtime.global.attribute
.xtrace.NewTrace
.xtrace.NewSpan
.function:*
, external:*
and database:*
.attr.*
.├── api
│ └── user
│ ├── handle.go
│ ├── request.go
│ └── service.go
├── db.sql
├── docker-compose.yaml
├── main.go
├── pkg
│ ├── xerror
│ │ └── error.go
│ ├── xhttp
│ │ └── client.go
│ ├── xlogger
│ │ └── logger.go
│ ├── xmiddleware
│ │ └── panic_recovery.go
│ ├── xnewrelic
│ │ └── newrelic.go
│ └── xtrace
│ ├── event.go
│ ├── middleware.go
│ ├── span.go
│ ├── trace.go
│ └── util.go
├── server
│ └── main.go
└── storage
├── model.go
└── postgres.go
All packages and files are open for improvements. They are meant to be helping you kick-start your own work but you can use them as they are if you want to.
Given this is a client-server application, I am adding a dummy server app under the server
directory. You will run this separately while testing the HTTP client. It will randomly return success or failure as response.
package main
import (
"net/http"
"math/rand/v2"
)
func main() {
rtr := http.NewServeMux()
rtr.HandleFunc("GET /auth", func(w http.ResponseWriter, r *http.Request) {
statuses := []int{200, 400}
status := statuses[rand.IntN(len(statuses))]
msg := "good job"
if status == 400 {
msg = "bad job"
}
w.WriteHeader(status)
w.Write([]byte(msg))
})
http.ListenAndServe(":8090", rtr)
}
package main
import (
"context"
"log/slog"
"net/http"
"time"
"newrelic/api/user"
"newrelic/pkg/xhttp"
"newrelic/pkg/xlogger"
"newrelic/pkg/xmiddleware"
"newrelic/pkg/xnewrelic"
"newrelic/pkg/xtrace"
"newrelic/storage"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
start := time.Now()
ctx := context.Background()
// NEWRELIC ----------------------------------------------------------------
newRelic, err := xnewrelic.New(xnewrelic.Config{
Service: "new-relic",
Licence: "qwerty1234567890..............",
ForwardLogs: true,
})
if err != nil {
slog.Error("Setup newrelic", "error", err)
return
}
if err := newRelic.Application().WaitForConnection(5 * time.Second); nil != err {
slog.Error("Connect to newrelic", "error", err)
return
}
// LOGGER ------------------------------------------------------------------
err = xlogger.Setup(xlogger.Config{
Level: "INFO",
Name: "new-relic",
Environment: "loc",
Version: "v0.0.1",
Writer: newRelic.LogWriter(),
})
if err != nil {
slog.Error("Setup logger", "error", err)
return
}
// TRACER ------------------------------------------------------------------
xtraceMiddleware := xtrace.Middleware{
Client: newRelic.Application(),
Attributes: map[string]any{"global.attribute": "value"},
}
// POSTGRES ----------------------------------------------------------------
postgresClient, err := pgxpool.New(ctx, "postgres://postgres:postgres@0.0.0.0:5432/postgres?sslmode=disable")
if err != nil {
slog.Error("Setup postgres client", "error", err)
return
}
postgresStore := storage.Postgres{Client: postgresClient}
// API ---------------------------------------------------------------------
userHandle := user.Handle{Service: user.Service{Database: postgresStore, Client: xhttp.NewClient()}}
// HTTP ROUTER -------------------------------------------------------------
// You can use any HTTP router to accomplish standard behaviour below.
router := xhttp.NewRouter()
router.Use((xmiddleware.PanicRecovery{}).Handle)
router.Use(xtraceMiddleware.Handle)
router.Add("POST", "/api/v1/users", userHandle.Create)
// HTTP SERVER -------------------------------------------------------------
slog.Info("Service has started", "data.duration_ms", time.Since(start).Milliseconds())
http.ListenAndServe(":8080", router.Handler())
}
package user
import (
"errors"
"fmt"
"log/slog"
"net/http"
"newrelic/pkg/xerror"
)
type Handle struct {
Service Service
}
func (h Handle) Create(w http.ResponseWriter, r *http.Request) {
var req createRequest
if err := req.parse(r); err != nil {
slog.ErrorContext(r.Context(), "Unable to parse request",
"error", err.Error(),
)
w.WriteHeader(http.StatusInternalServerError)
return
}
if errs := req.validate(r.Context()); len(errs) != 0 {
fmt.Println(errs)
w.WriteHeader(http.StatusBadRequest)
return
}
id, err := h.Service.create(r.Context(), req)
switch {
case err == nil:
w.Write([]byte(id))
case errors.Is(err, xerror.ErrResourceConflict):
w.WriteHeader(http.StatusConflict)
case errors.Is(err, xerror.ErrUpstreamService):
w.WriteHeader(http.StatusBadGateway)
default:
w.WriteHeader(http.StatusInternalServerError)
}
}
package user
import (
"context"
"encoding/json"
"net/http"
"newrelic/pkg/xtrace"
)
type createRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
func (c *createRequest) parse(r *http.Request) error {
defer xtrace.NewTrace(r.Context(), xtrace.Function("request:parse")).End()
return json.NewDecoder(r.Body).Decode(c)
}
func (c *createRequest) validate(ctx context.Context) map[string]string {
defer xtrace.NewTrace(ctx, xtrace.Function("request:validate")).End()
if c.Name == "" {
return map[string]string{"name": "Invalid value"}
}
if c.Email == "" {
return map[string]string{"email": "Invalid value"}
}
return nil
}
package user
import (
"context"
"log/slog"
"time"
"newrelic/pkg/xerror"
"newrelic/pkg/xhttp"
"newrelic/pkg/xtrace"
"newrelic/storage"
"github.com/google/uuid"
)
type Service struct {
Database storage.Postgres
Client xhttp.Client
}
func (s Service) create(ctx context.Context, req createRequest) (string, error) {
trc := xtrace.NewTrace(ctx, xtrace.Function("service:create"))
defer trc.End()
crq := xhttp.ClientRequest{
Method: "GET",
Endpoint: "http://0.0.0.0:8090/auth",
}
spn := xtrace.NewSpan(trc, xtrace.External("auth:register"),
xtrace.Arg("method", crq.Method),
xtrace.Arg("endpoint", crq.Endpoint),
)
res, err := s.Client.Request(ctx, crq)
spn.End()
if err != nil {
slog.ErrorContext(ctx, "Unable to communicate with a service",
"error", err.Error(),
"data.service", "auth",
)
xtrace.NewEvent(ctx, xtrace.External("auth"),
xtrace.Arg("action", "register"),
xtrace.Arg("outcome", "failure"),
)
return "", xerror.ErrUpstreamService
}
if res.StatusCode != 200 {
slog.ErrorContext(ctx, "Unexpected response status from a service",
"data.service", "auth",
"data.status", res.StatusCode,
)
xtrace.NewEvent(ctx, xtrace.External("auth"),
xtrace.Arg("action", "register"),
xtrace.Arg("outcome", "failure"),
xtrace.Arg("status", res.StatusCode),
)
return "", xerror.ErrUpstreamService
}
xtrace.NewEvent(ctx, xtrace.External("auth"),
xtrace.Arg("action", "register"),
xtrace.Arg("outcome", "success"),
xtrace.Arg("status", res.StatusCode),
)
id := uuid.NewString()
model := storage.User{
ID: id,
Name: req.Name,
Email: req.Email,
CreatedAt: time.Now(),
}
if err := s.Database.CreateUser(ctx, model); err != nil {
slog.ErrorContext(ctx, "Unable to create user",
"error", err.Error(),
"data.user_id", id,
)
xtrace.NewEvent(ctx, xtrace.Database("postgres"),
xtrace.Arg("action", "create_user"),
xtrace.Arg("outcome", "failure"),
)
return "", err
}
xtrace.NewEvent(ctx, xtrace.Database("postgres"),
xtrace.Arg("action", "create_user"),
xtrace.Arg("outcome", "success"),
)
return id, nil
}
CREATE TABLE IF NOT EXISTS users
(
id uuid NOT NULL,
name character varying(100) NOT NULL,
email character varying(100) NOT NULL,
created_at timestamp with time zone NOT NULL,
deleted_at timestamp with time zone,
CONSTRAINT users_prk_id PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS users_idx_created_at ON users USING btree (
created_at
);
CREATE UNIQUE INDEX IF NOT EXISTS users_unq_email ON users USING btree (
) WHERE deleted_at IS NULL;
version: "3.8"
services:
newrelic-postgres:
container_name: "newrelic-postgres"
image: "postgres:15.0-alpine"
ports:
- "5432:5432"
environment:
POSTGRES_DB: "postgres"
POSTGRES_USER: "postgres"
POSTGRES_PASSWORD: "postgres"
volumes:
- "./db.sql:/docker-entrypoint-initdb.d/db.sql"
command: >
postgres -c log_statement=all -c log_destination=stderr
package xerror
import (
"errors"
)
var (
ErrInternal = errors.New("internal")
ErrResourceConflict = errors.New("resource conflict")
ErrResourceNotFound = errors.New("resource not found")
ErrOutdatedResourceState = errors.New("outdated resource state")
ErrUpstreamService = errors.New("unexpected upstream service response")
)
package xhttp
import (
"context"
"io"
"net/http"
)
type ClientRequest struct {
Method string
Endpoint string
Body io.Reader
Headers map[string]string
}
type Client struct {
client http.RoundTripper
}
func NewClient() Client {
return Client{
client: http.DefaultTransport,
}
}
func (c Client) Request(ctx context.Context, req ClientRequest) (*http.Response, error) {
crq, err := http.NewRequestWithContext(ctx, req.Method, req.Endpoint, req.Body)
if err != nil {
return nil, err
}
for k, v := range req.Headers {
crq.Header.Add(k, v)
}
return c.client.RoundTrip(crq)
}
package xlogger
import (
"io"
"log/slog"
"os"
"time"
)
type Config struct {
Level string
Name string
Environment string
Version string
Writer io.Writer
}
func Setup(config Config) error {
var level slog.Level
if err := level.UnmarshalText([]byte(config.Level)); err != nil {
return err
}
opts := slog.HandlerOptions{
Level: level,
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
if attr.Key == slog.TimeKey {
attr.Value = slog.StringValue(attr.Value.Time().Format(time.RFC3339))
}
if attr.Key == slog.MessageKey {
attr.Key = "message"
}
return attr
},
}
atrs := []slog.Attr{
slog.String("svc.name", config.Name),
slog.String("svc.env", config.Environment),
slog.String("svc.ver", config.Version),
}
var writer io.Writer = os.Stderr
if config.Writer != nil {
writer = config.Writer
}
slog.SetDefault(slog.New(slog.NewJSONHandler(writer, &opts).WithAttrs(atrs)))
return nil
}
package xmiddleware
import (
"fmt"
"log/slog"
"net/http"
"os"
"runtime"
"strings"
)
// PanicRecovery recovers panics to prevent the application from crashing. It
// logs error message as well as the panic stack trace to ease debugging before
// returning 500 response.
type PanicRecovery struct{}
func (p PanicRecovery) Handle(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
slog.ErrorContext(r.Context(), "Recover from panic", p.attributes(err)...)
w.WriteHeader(http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func (p PanicRecovery) attributes(err any) []any {
var (
attrs []any
traces []string
size [32]uintptr
count = runtime.Callers(3, size[:])
frames = runtime.CallersFrames(size[:count])
goroot = runtime.GOROOT()
)
attrs = append(attrs, slog.String("panic.error", fmt.Sprintf("%v", err)))
dir, err := os.Getwd()
if err != nil {
dir = ""
}
for {
frame, more := frames.Next()
if !more {
break
}
if strings.HasPrefix(frame.File, goroot) {
continue
}
traces = append(traces, fmt.Sprintf("%s:%d", strings.TrimPrefix(frame.File, dir), frame.Line))
}
attrs = append(attrs, slog.Any("panic.trace", traces))
return attrs
}
package xnewrelic
import (
"io"
"os"
"github.com/newrelic/go-agent/v3/integrations/logcontext-v2/logWriter"
"github.com/newrelic/go-agent/v3/newrelic"
)
type Config struct {
Service string
Licence string
ForwardLogs bool
}
type NewRelic struct {
client *newrelic.Application
writer io.Writer
}
func New(cfg Config) (NewRelic, error) {
app, err := newrelic.NewApplication(
newrelic.ConfigAppName(cfg.Service),
newrelic.ConfigLicense(cfg.Licence),
newrelic.ConfigAppLogForwardingEnabled(cfg.ForwardLogs),
)
if err != nil {
return NewRelic{}, err
}
var writer io.Writer = os.Stderr
if cfg.ForwardLogs {
writer = logWriter.New(os.Stderr, app)
}
return NewRelic{
client: app,
writer: writer,
}, nil
}
func (n NewRelic) Application() *newrelic.Application {
return n.client
}
func (n NewRelic) LogWriter() io.Writer {
return n.writer
}
package xtrace
import (
"context"
"errors"
)
func NewEvent(ctx context.Context, name string, args ...arg) error {
app := client(ctx)
if app == nil {
return errors.New("client not found in context")
}
params := make(map[string]any, len(args))
for _, arg := range args {
params[arg.k] = arg.v
}
app.RecordCustomEvent(name, params)
return nil
}
package xtrace
import (
"context"
"net/http"
"github.com/newrelic/go-agent/v3/newrelic"
)
type ctxKey string
const clientKey ctxKey = "xtrace_client"
type Middleware struct {
Client *newrelic.Application
Attributes map[string]any
}
func (m Middleware) Handle(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
txn := m.Client.StartTransaction(r.Pattern)
defer txn.End()
for k, v := range m.Attributes {
txn.AddAttribute(k, v)
}
txn.SetWebRequestHTTP(r)
w = txn.SetWebResponse(w)
r = newrelic.RequestWithTransactionContext(r, txn)
ctx := r.Context()
ctx = context.WithValue(ctx, clientKey, m.Client)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
func client(ctx context.Context) *newrelic.Application {
val := ctx.Value(clientKey)
if val == nil {
return nil
}
clt, ok := val.(*newrelic.Application)
if !ok {
return nil
}
return clt
}
package xtrace
import "github.com/newrelic/go-agent/v3/newrelic"
type Span struct {
sgm *newrelic.Segment
}
func NewSpan(trace Trace, name string, args ...arg) Span {
sgm := trace.txn.StartSegment(name)
for _, arg := range args {
sgm.AddAttribute(arg.k, arg.v)
}
return Span{
sgm: sgm,
}
}
func (s Span) End() {
s.sgm.End()
}
package xtrace
import (
"context"
"github.com/newrelic/go-agent/v3/newrelic"
)
type Trace struct {
txn *newrelic.Transaction
sgm *newrelic.Segment
}
func NewTrace(ctx context.Context, name string, args ...arg) Trace {
txn := newrelic.FromContext(ctx)
sgm := txn.StartSegment(name)
for _, arg := range args {
sgm.AddAttribute(arg.k, arg.v)
}
return Trace{
txn: txn,
sgm: sgm,
}
}
func (t Trace) End() {
t.sgm.End()
}
package xtrace
func Function(name string) string {
return "function:" + name
}
func Database(name string) string {
return "database:" + name
}
func External(name string) string {
return "external:" + name
}
type arg struct {
k string
v any
}
func Arg(k string, v any) arg {
return arg{
k: "arg." + k,
v: v,
}
}
package storage
import "time"
type User struct {
ID string
Name string
Email string
CreatedAt time.Time
}
package storage
import (
"context"
"newrelic/pkg/xerror"
"newrelic/pkg/xtrace"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/pkg/errors"
)
type Postgres struct {
Client *pgxpool.Pool
}
func (p Postgres) CreateUser(ctx context.Context, model User) error {
defer xtrace.NewTrace(ctx, xtrace.Database("postgres:create_user")).End()
sql := `
INSERT INTO users
(id, name, email, created_at)
VALUES
($1, $2, $3, $4)`
_, err := p.Client.Exec(ctx, sql,
model.ID,
model.Name,
model.Email,
model.CreatedAt,
)
if err == nil {
return nil
}
var pge *pgconn.PgError
if errors.As(err, &pge) && pge.Code == "23505" {
return errors.Wrap(xerror.ErrResourceConflict, err.Error())
}
return errors.Wrap(xerror.ErrInternal, err.Error())
}
// Run database
$ docker-compose up
// Run server
$ go run -race server/main.go
// Run client
$ go run -race main.go
// Send dummy requests to client
curl --location --request POST 'http://localhost:8080/api/v1/users' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "User",
"email": "user@example.com"
}'
After sending requests, head to NewRelic UI and check what it looks like. You should have transactions, traces, logs, events, spans so on.