From d0a430505c7f87a0270754a165e1903290908caa Mon Sep 17 00:00:00 2001 From: Ryan D McGuire Date: Sat, 4 Jan 2025 12:24:42 -0500 Subject: [PATCH] Go app framework --- README.md | 60 ++++++++++- go.mod | 49 +++++++++ go.sum | 112 ++++++++++++++++++++ pkg/app/app.go | 93 +++++++++++++++++ pkg/app/run.go | 67 ++++++++++++ pkg/app/setup.go | 25 +++++ pkg/config/config.go | 69 +++++++++++++ pkg/config/ctx.go | 36 +++++++ pkg/config/types.go | 70 +++++++++++++ pkg/logging/logging.go | 72 +++++++++++++ pkg/otel/ctx.go | 54 ++++++++++ pkg/otel/otel.go | 227 +++++++++++++++++++++++++++++++++++++++++ pkg/otel/settings.go | 38 +++++++ pkg/otel/util.go | 30 ++++++ pkg/srv/http.go | 131 ++++++++++++++++++++++++ pkg/srv/http_health.go | 67 ++++++++++++ 16 files changed, 1199 insertions(+), 1 deletion(-) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 pkg/app/app.go create mode 100644 pkg/app/run.go create mode 100644 pkg/app/setup.go create mode 100644 pkg/config/config.go create mode 100644 pkg/config/ctx.go create mode 100644 pkg/config/types.go create mode 100644 pkg/logging/logging.go create mode 100644 pkg/otel/ctx.go create mode 100644 pkg/otel/otel.go create mode 100644 pkg/otel/settings.go create mode 100644 pkg/otel/util.go create mode 100644 pkg/srv/http.go create mode 100644 pkg/srv/http_health.go diff --git a/README.md b/README.md index aa7a104..3c615d3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,61 @@ # go-app -A simple app framework for bootstrapping logging, otel, and servers from config and environment \ No newline at end of file +A simple app framework for bootstrapping logging, otel, and servers from config and environment + +## Usage + +- Use the helper function to load config file (if given with -config) and apply +configuration through environment. This will prepare your app config and logging. + +- Load up HTTP routes and health checks. + +- Start the app. + +- Wait for it to be done and do any extra cleanup. + +Here is a reference main.go + +```go +package main + +import ( + "context" + "os" + "os/signal" + + "gitea.libretechconsulting.com/rmcguire/go-app/pkg/app" + "golang.org/x/sys/unix" +) + +func main() { + ctx, cncl := signal.NotifyContext(context.Background(), os.Kill, unix.SIGTERM) + defer cncl() + + ctx = app.MustSetupConfigAndLogging(ctx) + + awApp := app.App{ + AppContext: ctx, + HTTP: &app.AppHTTP{ + // Load up your HTTP methods here + Funcs: []srv.HTTPFunc{ + { + Path: "/test", + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) + w.Write([]byte("unimplemented test method")) + }, + }, + }, + // Load up health checks here + HealthChecks: []srv.HealthCheckFunc{ + func(ctx context.Context) error { + return errors.New("health check unimplemented") + }, + }, + }, + } + + awApp.MustRun() + <-awApp.Done() +} +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ae160d8 --- /dev/null +++ b/go.mod @@ -0,0 +1,49 @@ +module gitea.libretechconsulting.com/rmcguire/go-app + +go 1.23.4 + +require ( + github.com/caarlos0/env/v9 v9.0.0 + github.com/prometheus/client_golang v1.20.5 + github.com/rs/zerolog v1.33.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 + go.opentelemetry.io/otel v1.33.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.33.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 + go.opentelemetry.io/otel/exporters/prometheus v0.55.0 + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.33.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.33.0 + go.opentelemetry.io/otel/metric v1.33.0 + go.opentelemetry.io/otel/sdk v1.33.0 + go.opentelemetry.io/otel/sdk/metric v1.33.0 + go.opentelemetry.io/otel/trace v1.33.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.61.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect + go.opentelemetry.io/proto/otlp v1.4.0 // indirect + golang.org/x/net v0.32.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect + google.golang.org/grpc v1.68.1 // indirect + google.golang.org/protobuf v1.35.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..550d8a1 --- /dev/null +++ b/go.sum @@ -0,0 +1,112 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/caarlos0/env/v9 v9.0.0 h1:SI6JNsOA+y5gj9njpgybykATIylrRMklbs5ch6wO6pc= +github.com/caarlos0/env/v9 v9.0.0/go.mod h1:ye5mlCVMYh6tZ+vCgrs/B95sj88cg5Tlnc0XIzgZ020= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ= +github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= +go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= +go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.33.0 h1:7F29RDmnlqk6B5d+sUqemt8TBfDqxryYW5gX6L74RFA= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.33.0/go.mod h1:ZiGDq7xwDMKmWDrN1XsXAj0iC7hns+2DhxBFSncNHSE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= +go.opentelemetry.io/otel/exporters/prometheus v0.55.0 h1:sSPw658Lk2NWAv74lkD3B/RSDb+xRFx46GjkrL3VUZo= +go.opentelemetry.io/otel/exporters/prometheus v0.55.0/go.mod h1:nC00vyCmQixoeaxF6KNyP42II/RHa9UdruK02qBmHvI= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.33.0 h1:FiOTYABOX4tdzi8A0+mtzcsTmi6WBOxk66u0f1Mj9Gs= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.33.0/go.mod h1:xyo5rS8DgzV0Jtsht+LCEMwyiDbjpsxBpWETwFRF0/4= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.33.0 h1:W5AWUn/IVe8RFb5pZx1Uh9Laf/4+Qmm4kJL5zPuvR+0= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.33.0/go.mod h1:mzKxJywMNBdEX8TSJais3NnsVZUaJ+bAy6UxPTng2vk= +go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= +go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= +go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= +go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= +go.opentelemetry.io/otel/sdk/metric v1.33.0 h1:Gs5VK9/WUJhNXZgn8MR6ITatvAmKeIuCtNbsP3JkNqU= +go.opentelemetry.io/otel/sdk/metric v1.33.0/go.mod h1:dL5ykHZmm1B1nVRk9dDjChwDmt81MjVp3gLkQRwKf/Q= +go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= +go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= +go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= +go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= +golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= +google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= +google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/app/app.go b/pkg/app/app.go new file mode 100644 index 0000000..c70bc9a --- /dev/null +++ b/pkg/app/app.go @@ -0,0 +1,93 @@ +package app + +import ( + "context" + "errors" + + "github.com/rs/zerolog" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + + "gitea.libretechconsulting.com/rmcguire/go-app/pkg/config" + "gitea.libretechconsulting.com/rmcguire/go-app/pkg/otel" + "gitea.libretechconsulting.com/rmcguire/go-app/pkg/srv" +) + +type App struct { + AppContext context.Context + HTTP *AppHTTP + cfg *config.AppConfig + l *zerolog.Logger + tracer trace.Tracer + shutdownFuncs []shutdownFunc + appDone chan interface{} +} + +type AppHTTP struct { + Funcs []srv.HTTPFunc + HealthChecks []srv.HealthCheckFunc + httpDone <-chan interface{} +} + +type ( + healthCheckFunc func(context.Context) error + shutdownFunc func(context.Context) error +) + +func (a *App) Done() <-chan interface{} { + return a.appDone +} + +func (a *App) MustRun() { + if a.cfg != nil { + panic(errors.New("already ran app trying to run")) + } + + // Set up app + a.cfg = config.MustFromCtx(a.AppContext) + a.l = zerolog.Ctx(a.AppContext) + a.shutdownFuncs = make([]shutdownFunc, 0) + a.appDone = make(chan interface{}) + a.HTTP.httpDone = make(chan interface{}) + + if len(a.HTTP.Funcs) < 1 { + a.l.Warn().Msg("no http funcs provided, only serving health and metrics") + } + + // Start OTEL + a.initOTEL() + var initSpan trace.Span + _, initSpan = a.tracer.Start(a.AppContext, "init") + + // Start HTTP + a.initHTTP() + + // Monitor app lifecycle + go a.run() + + // Startup Complete + a.l.Info(). + Str("name", a.cfg.Name). + Str("version", a.cfg.Version). + Str("logLevel", a.cfg.Logging.Level). + Msg("app initialized") + initSpan.SetStatus(codes.Ok, "") + initSpan.End() +} + +func (a *App) initHTTP() { + var httpShutdown shutdownFunc + httpShutdown, a.HTTP.httpDone = srv.MustInitHTTPServer( + a.AppContext, + a.HTTP.Funcs, + a.HTTP.HealthChecks..., + ) + a.shutdownFuncs = append(a.shutdownFuncs, httpShutdown) +} + +func (a *App) initOTEL() { + var otelShutdown shutdownFunc + a.AppContext, otelShutdown = otel.Init(a.AppContext) + a.shutdownFuncs = append(a.shutdownFuncs, otelShutdown) + a.tracer = otel.MustTracerFromCtx(a.AppContext) +} diff --git a/pkg/app/run.go b/pkg/app/run.go new file mode 100644 index 0000000..59c85fb --- /dev/null +++ b/pkg/app/run.go @@ -0,0 +1,67 @@ +package app + +import ( + "context" + "sync" + "time" + + "go.opentelemetry.io/otel/codes" +) + +// Watches contexts and channels for the +// app to be finished and calls shutdown once +// the app is done +func (a *App) run() { + select { + case <-a.AppContext.Done(): + a.l.Warn().Str("reason", a.AppContext.Err().Error()). + Msg("shutting down on context done") + case <-a.HTTP.httpDone: + a.l.Warn().Msg("shutting down early on http server done") + } + a.Shutdown() + + a.appDone <- nil +} + +// Typically invoked when AppContext is done +// or Server has exited. Not intended to be called +// manually +func (a *App) Shutdown() { + now := time.Now() + + doneCtx, cncl := context.WithTimeout(context.Background(), 15*time.Second) + defer func() { + if doneCtx.Err() == context.DeadlineExceeded { + a.l.Err(doneCtx.Err()). + Dur("shutdownTime", time.Since(now)). + Msg("app shutdown aborted") + } else { + a.l.Info(). + Int("shutdownFuncsCalled", len(a.shutdownFuncs)). + Dur("shutdownTime", time.Since(now)). + Msg("app shutdown normally") + } + cncl() + }() + + doneCtx, span := a.tracer.Start(doneCtx, "shutdown") + defer span.End() + + var wg sync.WaitGroup + wg.Add(len(a.shutdownFuncs)) + + for _, f := range a.shutdownFuncs { + go func() { + defer wg.Done() + err := f(doneCtx) + if err != nil { + span.SetStatus(codes.Error, "shutdown failed") + span.RecordError(err) + a.l.Err(err).Send() + } + }() + } + + wg.Wait() +} diff --git a/pkg/app/setup.go b/pkg/app/setup.go new file mode 100644 index 0000000..8797312 --- /dev/null +++ b/pkg/app/setup.go @@ -0,0 +1,25 @@ +package app + +import ( + "context" + + "github.com/rs/zerolog" + + "gitea.libretechconsulting.com/rmcguire/go-app/pkg/config" + "gitea.libretechconsulting.com/rmcguire/go-app/pkg/logging" +) + +// Helper function to return a context loaded up with +// config.AppConfig and a logger +func MustSetupConfigAndLogging(ctx context.Context) context.Context { + ctx, err := config.LoadConfig(ctx) + if err != nil { + panic(err) + } + + cfg := config.MustFromCtx(ctx) + ctx = logging.MustInitLogging(ctx) + + zerolog.Ctx(ctx).Trace().Any("config", *cfg).Send() + return ctx +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..e919b1b --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,69 @@ +package config + +import ( + "context" + "flag" + "fmt" + "os" + "runtime/debug" + + "github.com/caarlos0/env/v9" + "gopkg.in/yaml.v3" +) + +// To be set by ldflags in go build command or +// retrieved from build meta below +var Version = "(devel)" + +// Calling this will try to load from config if -config is +// provided as a file, and will apply any environment overrides +// on-top of configuration defaults. +// Config is stored in returned context, and can be retrieved +// using config.FromCtx(ctx) +func LoadConfig(ctx context.Context) (context.Context, error) { + configPath := flag.String("config", "", "Path to the configuration file") + flag.Parse() + + // Start with defaults + // Load from config if provided + // Layer on environment + cfg, err := loadConfig(*configPath) + if err != nil { + return ctx, err + } + + // Add config to context, and return + // an updated context + ctx = cfg.AddToCtx(ctx) + return ctx, nil +} + +func loadConfig(configPath string) (*AppConfig, error) { + cfg := newAppConfig() + + if configPath != "" { + file, err := os.Open(configPath) + if err != nil { + return nil, fmt.Errorf("could not open config file: %w", err) + } + defer file.Close() + + decoder := yaml.NewDecoder(file) + if err := decoder.Decode(cfg); err != nil { + return nil, fmt.Errorf("could not decode config file: %w", err) + } + } + + if err := env.Parse(cfg); err != nil { + return nil, fmt.Errorf("could not parse environment variables: %w", err) + } + + return cfg, nil +} + +func getVersion() string { + if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "(devel)" { + return info.Main.Version + } + return Version +} diff --git a/pkg/config/ctx.go b/pkg/config/ctx.go new file mode 100644 index 0000000..b451469 --- /dev/null +++ b/pkg/config/ctx.go @@ -0,0 +1,36 @@ +package config + +import ( + "context" + "errors" +) + +type appConfigKey uint8 + +const appConfigCtxKey appConfigKey = iota + +func (a *AppConfig) AddToCtx(ctx context.Context) context.Context { + return context.WithValue(ctx, appConfigCtxKey, a) +} + +func MustFromCtx(ctx context.Context) *AppConfig { + cfg, err := FromCtx(ctx) + if err != nil { + panic(err) + } + return cfg +} + +func FromCtx(ctx context.Context) (*AppConfig, error) { + ctxData := ctx.Value(appConfigCtxKey) + if ctxData == nil { + return nil, errors.New("no config found in context") + } + + cfg, ok := ctxData.(*AppConfig) + if !ok { + return nil, errors.New("invalid config stored in context") + } + + return cfg, nil +} diff --git a/pkg/config/types.go b/pkg/config/types.go new file mode 100644 index 0000000..039921d --- /dev/null +++ b/pkg/config/types.go @@ -0,0 +1,70 @@ +package config + +func newAppConfig() *AppConfig { + return &AppConfig{ + Version: getVersion(), + Logging: &LogConfig{}, + HTTP: &HTTPConfig{}, + OTEL: &OTELConfig{}, + } +} + +type AppConfig struct { + Name string `yaml:"name" env:"APP_NAME" envDefault:"go-http-server-with-otel"` + Environment string `yaml:"environment" env:"APP_ENVIRONMENT" envDefault:"development"` + // This should either be set by ldflags, such as with + // go build -ldflags "-X gitea.libretechconsulting.com/rmcguire/go-app/pkg/config.Version=$(VERSION)" + // or allow this to use build meta. Will default to (devel) + Version string `yaml:"version" env:"APP_VERSION"` + Logging *LogConfig `yaml:"logging"` + HTTP *HTTPConfig `yaml:"http"` + OTEL *OTELConfig `yaml:"otel"` +} + +// Logging Configuration +type LogConfig struct { + Enabled bool `yaml:"enabled" env:"APP_LOG_ENABLED" envDefault:"true"` + Level string `yaml:"level" env:"APP_LOG_LEVEL" envDefault:"info"` + Format LogFormat `yaml:"format" env:"APP_LOG_FORMAT" envDefault:"json"` + Output LogOutput `yaml:"output" env:"APP_LOG_OUTPUT" envDefault:"stderr"` + TimeFormat TimeFormat `yaml:"timeFormat" env:"APP_LOG_TIME_FORMAT" envDefault:"short"` +} + +type LogFormat string + +const ( + LogFormatConsole LogFormat = "console" + LogFormatJSON LogFormat = "json" +) + +type TimeFormat string + +const ( + TimeFormatShort TimeFormat = "short" + TimeFormatLong TimeFormat = "long" + TimeFormatUnix TimeFormat = "unix" + TimeFormatRFC3339 TimeFormat = "rfc3339" + TimeFormatOff TimeFormat = "off" +) + +type LogOutput string + +const ( + LogOutputStdout LogOutput = "stdout" + LogOutputStderr LogOutput = "stderr" +) + +// HTTP Configuration +type HTTPConfig struct { + Listen string `yaml:"listen" env:"APP_HTTP_LISTEN" envDefault:"127.0.0.1:8080"` + RequestTimeout int `yaml:"requestTimeout" env:"APP_HTTP_REQUEST_TIMEOUT" envDefault:"30"` +} + +// OTEL Configuration +type OTELConfig struct { + Enabled bool `yaml:"enabled" env:"APP_OTEL_ENABLED" envDefault:"true"` + PrometheusEnabled bool `yaml:"prometheusEnabled" env:"APP_OTEL_PROMETHEUS_ENABLED" envDefault:"true"` + PrometheusPath string `yaml:"prometheusPath" env:"APP_OTEL_PROMETHEUS_PATH" envDefault:"/metrics"` + StdoutEnabled bool `yaml:"stdoutEnabled" env:"APP_OTEL_STDOUT_ENABLED" envDefault:"false"` + MetricIntervalSecs int `yaml:"metricIntervalSecs" env:"APP_OTEL_METRIC_INTERVAL_SECS" envDefault:"15"` +} diff --git a/pkg/logging/logging.go b/pkg/logging/logging.go new file mode 100644 index 0000000..2c8b047 --- /dev/null +++ b/pkg/logging/logging.go @@ -0,0 +1,72 @@ +package logging + +import ( + "context" + "os" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + "gitea.libretechconsulting.com/rmcguire/go-app/pkg/config" +) + +func MustInitLogging(ctx context.Context) context.Context { + cfg := config.MustFromCtx(ctx) + + logger, err := configureLogger(cfg.Logging) + if err != nil { + panic(err) + } + + return logger.WithContext(ctx) +} + +func configureLogger(cfg *config.LogConfig) (*zerolog.Logger, error) { + setTimeFormat(cfg.TimeFormat) + + // Default JSON logger + logger := zerolog.New(os.Stderr) + if cfg.TimeFormat != config.TimeFormatOff { + logger = logger.With().Timestamp().Logger() + } + + // Pretty console logger + if cfg.Format == config.LogFormatConsole { + consoleWriter := zerolog.ConsoleWriter{ + Out: os.Stderr, + TimeFormat: zerolog.TimeFieldFormat, + } + if cfg.TimeFormat == config.TimeFormatOff { + consoleWriter.FormatTimestamp = func(_ interface{}) string { + return "" + } + } + logger = log.Output(consoleWriter) + } + + level, err := zerolog.ParseLevel(cfg.Level) + if err != nil { + level = zerolog.InfoLevel + } + + logger = logger.Level(level) + zerolog.SetGlobalLevel(level) + + return &logger, err +} + +func setTimeFormat(format config.TimeFormat) { + switch format { + case config.TimeFormatShort: + zerolog.TimeFieldFormat = time.Kitchen + case config.TimeFormatUnix: + zerolog.TimeFieldFormat = zerolog.TimeFormatUnix + case config.TimeFormatLong: + zerolog.TimeFieldFormat = time.DateTime + case config.TimeFormatRFC3339: + zerolog.TimeFieldFormat = time.RFC3339 + case config.TimeFormatOff: + zerolog.TimeFieldFormat = "" + } +} diff --git a/pkg/otel/ctx.go b/pkg/otel/ctx.go new file mode 100644 index 0000000..4597ea7 --- /dev/null +++ b/pkg/otel/ctx.go @@ -0,0 +1,54 @@ +package otel + +import ( + "context" + "errors" + + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" +) + +type otelCtxKey uint8 + +const ( + ctxKeyTracer otelCtxKey = iota + ctxKeyMeter +) + +func MustTracerFromCtx(ctx context.Context) trace.Tracer { + ctxData := ctx.Value(ctxKeyTracer) + if ctxData == nil { + panic(errors.New("no tracer found in context")) + } + + tracer, ok := ctxData.(trace.Tracer) + if !ok { + panic(errors.New("invalid tracer found in context")) + } + + return tracer +} + +func AddTracerToCtx(ctx context.Context, tracer trace.Tracer) context.Context { + ctx = context.WithValue(ctx, ctxKeyTracer, tracer) + return ctx +} + +func MustMeterFromCtx(ctx context.Context) metric.Meter { + ctxData := ctx.Value(ctxKeyMeter) + if ctxData == nil { + panic(errors.New("no meter found in context")) + } + + meter, ok := ctxData.(metric.Meter) + if !ok { + panic(errors.New("invalid meter found in context")) + } + + return meter +} + +func AddMeterToCtx(ctx context.Context, meter metric.Meter) context.Context { + ctx = context.WithValue(ctx, ctxKeyMeter, meter) + return ctx +} diff --git a/pkg/otel/otel.go b/pkg/otel/otel.go new file mode 100644 index 0000000..cb6cb32 --- /dev/null +++ b/pkg/otel/otel.go @@ -0,0 +1,227 @@ +package otel + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + opentelemetry "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/exporters/prometheus" + "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric" + "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" + + "gitea.libretechconsulting.com/rmcguire/go-app/pkg/config" + + noopMetric "go.opentelemetry.io/otel/metric/noop" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + traceSDK "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.24.0" + trace "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" +) + +// OTEL Options +var ( + EnableStdoutExporter Option = enableStdoutExporter{} + EnablePrometheusExporter Option = enablePrometheusExporter{} + // Overide the default metric export interval + WithMetricExportInterval = func(interval time.Duration) Option { + return exportInterval{interval: interval} + } +) + +const defMetricInterval = 15 * time.Second + +// Context must carry config.AppConfig +func Init(ctx context.Context) (context.Context, func(context.Context) error) { + cfg := config.MustFromCtx(ctx) + + // Nothing to do here if not enabled + if !cfg.OTEL.Enabled { + opentelemetry.SetMeterProvider(noopMetric.NewMeterProvider()) + opentelemetry.SetTracerProvider(noop.NewTracerProvider()) + return ctx, func(context.Context) error { + return nil + } + } + + var ( + metricInterval = defMetricInterval + options = make([]Option, 0) + s = &settings{} + shutdownFuncs []func(context.Context) error + ) + + // Prepare settings for OTEL from configuration + if cfg.OTEL.StdoutEnabled { + options = append(options, EnableStdoutExporter) + } + if cfg.OTEL.MetricIntervalSecs > 0 { + metricInterval = time.Duration(cfg.OTEL.MetricIntervalSecs) * time.Second + } + if cfg.OTEL.PrometheusEnabled { + options = append(options, EnablePrometheusExporter) + } + options = append(options, + WithMetricExportInterval(metricInterval)) + + // Apply settings + for _, opt := range options { + opt.apply(s) + } + + // shutdown calls cleanup functions registered via shutdownFuncs. + // The errors from the calls are joined. + // Each registered cleanup will be invoked once. + shutdown := func(ctx context.Context) error { + var err error + for _, fn := range shutdownFuncs { + err = errors.Join(err, fn(ctx)) + } + shutdownFuncs = nil + return err + } + + // handleErr calls shutdown for cleanup and makes sure that all errors are returned. + handleErr := func(inErr error) { + if err := errors.Join(inErr, shutdown(ctx)); err != nil { + fmt.Fprintln(os.Stderr, "OTEL Error:", err) + } + } + + // Set up meter provider. + meterProvider, err := s.newMeterProvider(ctx) + if err != nil { + handleErr(err) + return ctx, shutdown + } + + shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown) + opentelemetry.SetMeterProvider(meterProvider) + + meter := opentelemetry.Meter(cfg.Name) + ctx = AddMeterToCtx(ctx, meter) + + // Set up tracing + opentelemetry.SetTextMapPropagator(newPropagator()) + var tracerProvider *traceSDK.TracerProvider + tracerProvider, err = s.newTracerProvider(ctx) + if err != nil { + handleErr(err) + return ctx, shutdown + } + shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown) + opentelemetry.SetTracerProvider(tracerProvider) + + tracer := opentelemetry.Tracer(cfg.Name) + ctx = AddTracerToCtx(ctx, tracer) + + return ctx, shutdown +} + +func newPropagator() propagation.TextMapPropagator { + return propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ) +} + +func (s *settings) newTracerProvider(ctx context.Context) (traceProvider *traceSDK.TracerProvider, err error) { + traceOpts := []traceSDK.TracerProviderOption{ + traceSDK.WithResource(newResource()), + } + + host, set := os.LookupEnv("OTEL_EXPORTER_OTLP_ENDPOINT") + if set && host != "" { + exporter, err := otlptracegrpc.New(ctx) + if err != nil { + return nil, err + } + traceOpts = append(traceOpts, traceSDK.WithBatcher(exporter)) + } + + if s.EnableStdoutExporter { + stdoutExporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint()) + if err != nil { + return nil, err + } + traceOpts = append(traceOpts, traceSDK.WithBatcher(stdoutExporter)) + } + + traceProvider = traceSDK.NewTracerProvider(traceOpts...) + + return +} + +func newResource() *resource.Resource { + return resource.NewWithAttributes(semconv.SchemaURL) +} + +// Configures meter provider +// Always provides a prometheus metrics exporter +// Conditionally provides an OTLP metrics exporter +func (s *settings) newMeterProvider(ctx context.Context) (*metric.MeterProvider, error) { + // OTEL Prometheus Exporter + exporter, err := prometheus.New() + if err != nil { + return nil, err + } + + metricOptions := make([]metric.Option, 0, 5) + if s.EnableStdoutExporter { + stdoutMetricExporter, err := stdoutmetric.New() + if err != nil { + return nil, err + } + metricOptions = append(metricOptions, + metric.WithReader(metric.NewPeriodicReader(stdoutMetricExporter)), + ) + } + + host, set := os.LookupEnv("OTEL_EXPORTER_OTLP_ENDPOINT") + var otlpExporter *otlpmetricgrpc.Exporter + if set && host != "" { + if exp, err := otlpmetricgrpc.New(ctx, otlpmetricgrpc.WithInsecure()); err != nil { + return nil, fmt.Errorf("otlpmetricgrpc.New: %w", err) + } else { + otlpExporter = exp + } + } + + var meterProvider *metric.MeterProvider + if otlpExporter != nil { + metricOptions = append(metricOptions, + metric.WithReader(exporter), + metric.WithReader( + metric.NewPeriodicReader( + otlpExporter, + metric.WithInterval(s.MetricExportInterval), + ), + ), + metric.WithResource(newResource()), + ) + } else { + metricOptions = append(metricOptions, + metric.WithReader(exporter), + metric.WithResource(newResource()), + ) + } + + meterProvider = metric.NewMeterProvider(metricOptions...) + + return meterProvider, nil +} + +// Creates a new tracer from the global opentelemetry provider +func NewTracer(options ...trace.TracerOption) trace.Tracer { + return opentelemetry.GetTracerProvider().Tracer( + os.Getenv("OTEL_SERVICE_NAME"), + options..., + ) +} diff --git a/pkg/otel/settings.go b/pkg/otel/settings.go new file mode 100644 index 0000000..3f12f2c --- /dev/null +++ b/pkg/otel/settings.go @@ -0,0 +1,38 @@ +package otel + +import "time" + +type settings struct { + EnableStdoutExporter bool + EnablePrometheusExporter bool + MetricExportInterval time.Duration +} + +type Option interface { + apply(*settings) +} + +type enableStdoutExporter struct { + Option +} + +func (setting enableStdoutExporter) apply(o *settings) { + o.EnableStdoutExporter = true +} + +type enablePrometheusExporter struct { + Option +} + +func (setting enablePrometheusExporter) apply(o *settings) { + o.EnablePrometheusExporter = true +} + +type exportInterval struct { + Option + interval time.Duration +} + +func (setting exportInterval) apply(o *settings) { + o.MetricExportInterval = setting.interval +} diff --git a/pkg/otel/util.go b/pkg/otel/util.go new file mode 100644 index 0000000..be0866c --- /dev/null +++ b/pkg/otel/util.go @@ -0,0 +1,30 @@ +package otel + +import ( + "context" + "strings" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" + + "gitea.libretechconsulting.com/rmcguire/go-app/pkg/config" +) + +func GetTracer(ctx context.Context, components ...string) trace.Tracer { + return otel.Tracer(getName(ctx, components...)) +} + +func GetMeter(ctx context.Context, components ...string) metric.Meter { + return otel.Meter(getName(ctx, components...)) +} + +func getName(ctx context.Context, components ...string) string { + cfg := config.MustFromCtx(ctx) + + path := make([]string, 0, len(components)+1) + path = append(path, cfg.Name) + path = append(path, components...) + + return strings.Join(path, ".") +} diff --git a/pkg/srv/http.go b/pkg/srv/http.go new file mode 100644 index 0000000..3fec8fe --- /dev/null +++ b/pkg/srv/http.go @@ -0,0 +1,131 @@ +package srv + +import ( + "context" + "net" + "net/http" + "time" + + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/rs/zerolog" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" + + "gitea.libretechconsulting.com/rmcguire/go-app/pkg/config" + "gitea.libretechconsulting.com/rmcguire/go-app/pkg/otel" +) + +var ( + httpMeter metric.Meter + httpTracer trace.Tracer + readTimeout = 10 * time.Second + writeTimeout = 10 * time.Second + idleTimeout = 15 * time.Second +) + +type HTTPFunc struct { + Path string + HandlerFunc http.HandlerFunc +} + +func prepHTTPServer(ctx context.Context, handleFuncs []HTTPFunc, hcFuncs ...HealthCheckFunc) *http.Server { + var ( + cfg = config.MustFromCtx(ctx) + l = zerolog.Ctx(ctx) + mux = &http.ServeMux{} + ) + + // NOTE: Wraps handle func with otelhttp handler and + // inserts route tag + otelHandleFunc := func(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) { + handler := otelhttp.WithRouteTag(pattern, http.HandlerFunc(handlerFunc)) + mux.Handle(pattern, handler) // Associate pattern with handler + } + + healthChecks := handleHealthCheckFunc(ctx, hcFuncs...) + otelHandleFunc("/health", healthChecks) + otelHandleFunc("/", healthChecks) + + for _, f := range handleFuncs { + otelHandleFunc(f.Path, f.HandlerFunc) + } + + // Prometheus metrics endpoint + if cfg.OTEL.PrometheusEnabled { + mux.Handle(cfg.OTEL.PrometheusPath, promhttp.Handler()) + l.Info().Str("prometheusPath", cfg.OTEL.PrometheusPath). + Msg("mounted prometheus metrics endpoint") + } + + // Add OTEL, skip health-check spans + // NOTE: Add any other span exclusions here + handler := otelhttp.NewHandler(mux, "/", + otelhttp.WithFilter(func(r *http.Request) bool { + switch r.URL.Path { + case "/health": + return false + case cfg.OTEL.PrometheusPath: + return false + default: + return true + } + })) + + return &http.Server{ + Addr: cfg.HTTP.Listen, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + Handler: handler, + BaseContext: func(_ net.Listener) context.Context { + return ctx + }, + } +} + +// Returns a shutdown func and a done channel if the +// server aborts abnormally. Panics on error. +func MustInitHTTPServer(ctx context.Context, funcs []HTTPFunc, hcFuncs ...HealthCheckFunc) ( + func(context.Context) error, <-chan interface{}, +) { + shutdownFunc, doneChan, err := InitHTTPServer(ctx, funcs, hcFuncs...) + if err != nil { + panic(err) + } + return shutdownFunc, doneChan +} + +// Returns a shutdown func and a done channel if the +// server aborts abnormally. Returns error on failure to start +func InitHTTPServer(ctx context.Context, funcs []HTTPFunc, hcFuncs ...HealthCheckFunc) ( + func(context.Context) error, <-chan interface{}, error, +) { + l := zerolog.Ctx(ctx) + doneChan := make(chan interface{}) + + var server *http.Server + + httpMeter = otel.GetMeter(ctx, "http") + httpTracer = otel.GetTracer(ctx, "http") + + server = prepHTTPServer(ctx, funcs, hcFuncs...) + + go func() { + l.Debug().Msg("HTTP Server Started") + err := server.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + l.Err(err).Msg("HTTP server error") + } else { + l.Info().Msg("HTTP server shut down") + } + doneChan <- nil + }() + + // Shut down http server with a deadline + return func(shutdownCtx context.Context) error { + l.Debug().Msg("stopping http server") + server.Shutdown(shutdownCtx) + return nil + }, doneChan, nil +} diff --git a/pkg/srv/http_health.go b/pkg/srv/http_health.go new file mode 100644 index 0000000..8a31a90 --- /dev/null +++ b/pkg/srv/http_health.go @@ -0,0 +1,67 @@ +package srv + +import ( + "context" + "errors" + "math/rand" + "net/http" + "sync" + "time" + + "github.com/rs/zerolog" +) + +type HealthCheckFunc func(context.Context) error + +func handleHealthCheckFunc(ctx context.Context, hcFuncs ...HealthCheckFunc) func(w http.ResponseWriter, r *http.Request) { + // Return http handle func + return func(w http.ResponseWriter, r *http.Request) { + var ( + healthChecksFailed bool + errs error + hcWg sync.WaitGroup + ) + + if len(hcFuncs) < 1 { + zerolog.Ctx(ctx).Warn().Msg("no health checks given responding with dummy 200") + hcFuncs = append(hcFuncs, dummyHealthCheck) + } + + // Run all health check funcs concurrently + // log all errors + hcWg.Add(len(hcFuncs)) + for _, hc := range hcFuncs { + go func() { + defer hcWg.Done() + errs = errors.Join(errs, hc(ctx)) + }() + } + hcWg.Wait() + + if errs != nil { + healthChecksFailed = true + } + + if healthChecksFailed { + w.WriteHeader(http.StatusInternalServerError) + } + + if errs != nil { + w.Write([]byte(errs.Error())) + } else { + w.Write([]byte("ok")) + } + } +} + +func dummyHealthCheck(ctx context.Context) error { + workFor := rand.Intn(750) + ticker := time.NewTicker(time.Duration(time.Duration(workFor) * time.Millisecond)) + + select { + case <-ticker.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +}