diff --git a/Dockerfile b/Dockerfile index 5b8abae..a6579e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,7 @@ FROM golang:1.26 AS build WORKDIR /app -#COPY go.mod go.sum ./ -COPY go.mod ./ +COPY go.mod go.sum ./ RUN go mod download diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index a3c3449..f859cac 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -3,7 +3,7 @@ package main import ( "context" "errors" - "log" + "log/slog" "net/http" "os" "os/signal" @@ -11,9 +11,44 @@ import ( "time" "github.com/robindittmar/dttmr-api/internal/api/router" + "github.com/robindittmar/dttmr-api/internal/telemetry" ) func main() { + serviceName := "dttmr-api" + serviceVersion := "0.1.0" + + if err := run(serviceName, serviceVersion); err != nil { + slog.Error("Service crashed", slog.Any("error", err)) + os.Exit(1) + } +} + +func run(serviceName string, serviceVersion string) error { + baseHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }) + traceHandler := &telemetry.TraceHandler{Handler: baseHandler} + + logger := slog.New(traceHandler) + slog.SetDefault(logger) + + slog.Info("Starting service", slog.String("service", serviceName), slog.String("version", serviceVersion)) + + shutdownTelemetry, err := telemetry.Init(context.Background(), serviceName, serviceVersion) + if err != nil { + slog.Error("Failed to initialize telemetry", err) + return err + } + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := shutdownTelemetry(shutdownCtx); err != nil { + slog.Error("Failed to shutdown telemetry", err) + } + }() + cfg := router.Config{} mux := router.NewMux(cfg) @@ -26,22 +61,26 @@ func main() { } go func() { - log.Printf("Listening on %s\n", srv.Addr) + slog.Info("Starting http server", "addr", srv.Addr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("Failed to start server: %v", err) + slog.Error("Failed to start http server", err) + os.Exit(1) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit - log.Println("Shutting down server...") + sig := <-quit + slog.Info("Shutting down server...", slog.String("signal", sig.String())) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { - log.Fatalf("Server forced to shutdown: %v", err) + slog.Error("Server forced to shutdown", slog.Any("error", err)) + return err } - log.Println("Server shutdown successful!") + + slog.Info("Service shutdown successful!") + return nil } diff --git a/go.mod b/go.mod index ac74bcb..7bd873a 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,32 @@ module github.com/robindittmar/dttmr-api go 1.26 + +require ( + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 + go.opentelemetry.io/otel/sdk v1.44.0 +) + +require ( + github.com/cenkalti/backoff/v5 v5.0.3 // 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.3 // 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.29.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/internal/api/handler/default.go b/internal/api/handler/default.go index 43dc8e9..8b4855d 100644 --- a/internal/api/handler/default.go +++ b/internal/api/handler/default.go @@ -1,7 +1,7 @@ package handler import ( - "log" + "log/slog" "net/http" "github.com/robindittmar/dttmr-api/internal/api/response" @@ -18,15 +18,17 @@ type apiResponse struct { } func DefaultHandler(w http.ResponseWriter, r *http.Request) { - var resp apiResponse + ctx := r.Context() - resp.Method = r.Method - resp.Url = r.URL.String() - resp.Proto = r.Proto - resp.Header = make(map[string]string) - resp.Host = r.Host - resp.RemoteAddr = r.RemoteAddr - resp.Form = make(map[string]string) + resp := apiResponse{ + Method: r.Method, + Url: r.URL.String(), + Proto: r.Proto, + Header: make(map[string]string), + Host: r.Host, + RemoteAddr: r.RemoteAddr, + Form: make(map[string]string), + } for k, v := range r.Header { resp.Header[k] = v[0] @@ -37,8 +39,8 @@ func DefaultHandler(w http.ResponseWriter, r *http.Request) { resp.Form[k] = v[0] } } else { - log.Println(err) + slog.ErrorContext(ctx, "Error parsing form", slog.Any("error", err)) } - response.JSON(w, http.StatusOK, resp) + response.JSON(ctx, w, http.StatusOK, resp) } diff --git a/internal/api/handler/health.go b/internal/api/handler/health.go index 9c1561e..b56ca58 100644 --- a/internal/api/handler/health.go +++ b/internal/api/handler/health.go @@ -11,9 +11,10 @@ type healthResponse struct { } func HealthHandler(w http.ResponseWriter, r *http.Request) { - _ = r + ctx := r.Context() + resp := healthResponse{ Status: "ok", } - response.JSON(w, http.StatusOK, resp) + response.JSON(ctx, w, http.StatusOK, resp) } diff --git a/internal/api/middleware/telemetry.go b/internal/api/middleware/telemetry.go index 41859c0..e0f7fee 100644 --- a/internal/api/middleware/telemetry.go +++ b/internal/api/middleware/telemetry.go @@ -2,9 +2,10 @@ package middleware import ( "net/http" + + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) func WithTelemetry(next http.Handler) http.Handler { - //return otelhttp.NewHandler(next, "dttmr-api") - return next + return otelhttp.NewHandler(next, "dttmr-api") } diff --git a/internal/api/response/json.go b/internal/api/response/json.go index 43ba2db..e73f217 100644 --- a/internal/api/response/json.go +++ b/internal/api/response/json.go @@ -1,19 +1,20 @@ package response import ( + "context" "encoding/json" - "log" + "log/slog" "net/http" ) -func JSON(w http.ResponseWriter, status int, data any) { +func JSON(ctx context.Context, w http.ResponseWriter, status int, data any) { payload, err := json.Marshal(data) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) _, err := w.Write([]byte(`{"error": "internal server error: failed to marshal response"}`)) if err != nil { - log.Println(err) + slog.ErrorContext(ctx, "Failed to write JSON", slog.Any("error", err)) return } return @@ -23,11 +24,11 @@ func JSON(w http.ResponseWriter, status int, data any) { w.WriteHeader(status) _, err = w.Write(payload) if err != nil { - log.Println(err) + slog.ErrorContext(ctx, "Failed to write response", slog.Any("error", err)) return } } -func Error(w http.ResponseWriter, status int, message string) { - JSON(w, status, map[string]string{"error": message}) +func Error(ctx context.Context, w http.ResponseWriter, status int, message string) { + JSON(ctx, w, status, map[string]string{"error": message}) } diff --git a/internal/telemetry/.gitkeep b/internal/telemetry/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/telemetry/logger.go b/internal/telemetry/logger.go new file mode 100644 index 0000000..2e85ada --- /dev/null +++ b/internal/telemetry/logger.go @@ -0,0 +1,32 @@ +package telemetry + +import ( + "context" + "log/slog" + + "go.opentelemetry.io/otel/trace" +) + +type TraceHandler struct { + slog.Handler +} + +func (h *TraceHandler) Handle(ctx context.Context, r slog.Record) error { + spanCtx := trace.SpanContextFromContext(ctx) + if spanCtx.HasTraceID() { + r.AddAttrs( + slog.String("trace_id", spanCtx.TraceID().String()), + slog.String("span_id", spanCtx.SpanID().String()), + ) + } + + return h.Handler.Handle(ctx, r) +} + +func (h *TraceHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &TraceHandler{Handler: h.Handler.WithAttrs(attrs)} +} + +func (h *TraceHandler) WithGroup(name string) slog.Handler { + return &TraceHandler{Handler: h.Handler.WithGroup(name)} +} diff --git a/internal/telemetry/otel.go b/internal/telemetry/otel.go new file mode 100644 index 0000000..8e48cec --- /dev/null +++ b/internal/telemetry/otel.go @@ -0,0 +1,57 @@ +package telemetry + +import ( + "context" + "errors" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.24.0" +) + +func Init(ctx context.Context, serviceName string, version string) (func(context.Context) error, error) { + var shutdownFns []func(context.Context) error + + shutdown := func(ctx context.Context) error { + var err error + for _, fn := range shutdownFns { + err = errors.Join(err, fn(ctx)) + } + shutdownFns = nil + return err + } + + res, err := resource.New(ctx, + resource.WithAttributes( + semconv.ServiceName(serviceName), + semconv.ServiceVersion(version), + ), + ) + if err != nil { + return shutdown, err + } + + otel.SetTextMapPropagator( + propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ), + ) + + traceExporter, err := otlptracegrpc.New(ctx) + if err != nil { + return shutdown, err + } + + tracerProvider := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(traceExporter), + sdktrace.WithResource(res), + ) + otel.SetTracerProvider(tracerProvider) + shutdownFns = append(shutdownFns, tracerProvider.Shutdown) + + return shutdown, nil +}