فهرست منبع

monitoring instance (#1546)

Victor Sokolov 4 روز پیش
والد
کامیت
05a413b8a2

+ 17 - 0
CHANGELOG.v4.md

@@ -1,5 +1,22 @@
 # 📑 Changelog (version/4 dev)
 
+## 2025-10-01
+
+### 🆕 Added
+
+- `DD_TRACE_AGENT_PORT` (default: 8126) as a default DataDog trace agent port.
+
+## 2025-09-30
+
+### ❌ Removed
+
+- Deprecated `IMGPROXY_OPEN_TELEMETRY_ENDPOINT` is removed.
+- Deprecated `IMGPROXY_OPEN_TELEMETRY_PROTOCOL` is removed.
+- Deprecated `IMGPROXY_OPEN_TELEMETRY_GRPC_INSECURE` is removed.
+- Deprecated `IMGPROXY_OPEN_TELEMETRY_SERVICE_NAME` is removed.
+- Deprecated `IMGPROXY_OPEN_TELEMETRY_PROPAGATORS` is removed.
+- Deprecated `IMGPROXY_OPEN_TELEMETRY_CONNECTION_TIMEOUT` is removed.
+
 ## 2025-09-26
 
 ### ❌ Removed

+ 1 - 0
cli/main.go

@@ -38,6 +38,7 @@ func run(ctx context.Context, cmd *cli.Command) error {
 	if err != nil {
 		return err
 	}
+	defer instance.Close(ctx)
 
 	if err := instance.StartServer(ctx, nil); err != nil {
 		return err

+ 17 - 0
config.go

@@ -7,6 +7,8 @@ import (
 	"github.com/imgproxy/imgproxy/v3/fetcher"
 	processinghandler "github.com/imgproxy/imgproxy/v3/handlers/processing"
 	streamhandler "github.com/imgproxy/imgproxy/v3/handlers/stream"
+	"github.com/imgproxy/imgproxy/v3/monitoring"
+	"github.com/imgproxy/imgproxy/v3/monitoring/prometheus"
 	optionsparser "github.com/imgproxy/imgproxy/v3/options/parser"
 	"github.com/imgproxy/imgproxy/v3/processing"
 	"github.com/imgproxy/imgproxy/v3/security"
@@ -32,6 +34,7 @@ type Config struct {
 	Processing     processing.Config
 	OptionsParser  optionsparser.Config
 	Cookies        cookies.Config
+	Monitoring     monitoring.Config
 }
 
 // NewDefaultConfig creates a new default configuration
@@ -49,6 +52,8 @@ func NewDefaultConfig() Config {
 		Security:      security.NewDefaultConfig(),
 		Processing:    processing.NewDefaultConfig(),
 		OptionsParser: optionsparser.NewDefaultConfig(),
+		Cookies:       cookies.NewDefaultConfig(),
+		Monitoring:    monitoring.NewDefaultConfig(),
 	}
 }
 
@@ -102,5 +107,17 @@ func LoadConfigFromEnv(c *Config) (*Config, error) {
 		return nil, err
 	}
 
+	if _, err = monitoring.LoadConfigFromEnv(&c.Monitoring); err != nil {
+		return nil, err
+	}
+
 	return c, nil
 }
+
+func (c *Config) Validate() error {
+	if c.Monitoring.Prometheus.Enabled() && c.Monitoring.Prometheus.Bind == c.Server.Bind {
+		return prometheus.IMGPROXY_PROMETHEUS_BIND.Errorf("should be different than IMGPROXY_BIND: %s", c.Server.Bind)
+	}
+
+	return nil
+}

+ 4 - 1
env/aws.go

@@ -46,11 +46,14 @@ func loadAWSSecret(ctx context.Context) error {
 		return fmt.Errorf("can't load AWS Secrets Manager config: %s", err)
 	}
 
-	conf.Region = defaultAWSRegion
 	if len(secretRegion) > 0 {
 		conf.Region = secretRegion
 	}
 
+	if len(conf.Region) == 0 {
+		conf.Region = defaultAWSRegion
+	}
+
 	// Let's create secrets manager client
 	client := secretsmanager.NewFromConfig(conf)
 

+ 39 - 3
env/parsers.go

@@ -3,6 +3,7 @@ package env
 import (
 	"bufio"
 	"encoding/hex"
+	"fmt"
 	"os"
 	"regexp"
 	"strconv"
@@ -55,13 +56,13 @@ func MegaInt(f *int, desc Desc) error {
 	if err != nil {
 		return desc.ErrorParse(err)
 	}
-	*f = int(value) * 1000000
+	*f = int(value) * 1_000_000
 
 	return nil
 }
 
-// Duration parses a duration (in seconds) from the environment variable
-func Duration(d *time.Duration, desc Desc) error {
+// duration parses a duration (in resolution) from the environment variable
+func duration(d *time.Duration, desc Desc, resolution time.Duration) error {
 	env, ok := desc.Get()
 	if !ok {
 		return nil
@@ -76,6 +77,16 @@ func Duration(d *time.Duration, desc Desc) error {
 	return nil
 }
 
+// Duration parses a duration (in seconds) from the environment variable
+func Duration(d *time.Duration, desc Desc) error {
+	return duration(d, desc, time.Second)
+}
+
+// DurationMils parses a duration (in milliseconds) from the environment variable
+func DurationMils(d *time.Duration, desc Desc) error {
+	return duration(d, desc, time.Millisecond)
+}
+
 // String sets the string from the environment variable. Empty value is allowed.
 func String(s *string, desc Desc) error {
 	if env, ok := desc.Get(); ok {
@@ -301,6 +312,7 @@ func HexSlice(b *[][]byte, desc Desc) error {
 	return nil
 }
 
+// FromMap sets a value from a enum map based on the environment variable
 func FromMap[T any](v *T, m map[string]T, desc Desc) error {
 	env, ok := desc.Get()
 	if !ok {
@@ -315,3 +327,27 @@ func FromMap[T any](v *T, m map[string]T, desc Desc) error {
 
 	return nil
 }
+
+// StringMap parses a map of string key-value pairs from the environment variable
+func StringMap(m *map[string]string, desc Desc) error {
+	env, ok := desc.Get()
+	if !ok {
+		return nil
+	}
+
+	mm := make(map[string]string)
+
+	keyvalues := strings.SplitSeq(env, ";")
+
+	for keyvalue := range keyvalues {
+		parts := strings.SplitN(keyvalue, "=", 2)
+		if len(parts) != 2 {
+			return fmt.Errorf("invalid key/value: %s", keyvalue)
+		}
+		mm[parts[0]] = parts[1]
+	}
+
+	*m = mm
+
+	return nil
+}

+ 4 - 4
handlers/processing/handler.go

@@ -13,7 +13,6 @@ import (
 	"github.com/imgproxy/imgproxy/v3/ierrors"
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/monitoring"
-	"github.com/imgproxy/imgproxy/v3/monitoring/stats"
 	"github.com/imgproxy/imgproxy/v3/options"
 	"github.com/imgproxy/imgproxy/v3/options/keys"
 	optionsparser "github.com/imgproxy/imgproxy/v3/options/parser"
@@ -32,6 +31,7 @@ type HandlerContext interface {
 	OptionsParser() *optionsparser.Parser
 	Processor() *processing.Processor
 	Cookies() *cookies.Cookies
+	Monitoring() *monitoring.Monitoring
 }
 
 // Handler handles image processing requests
@@ -66,8 +66,8 @@ func (h *Handler) Execute(
 	req *http.Request,
 ) error {
 	// Increment the number of requests in progress
-	stats.IncRequestsInProgress()
-	defer stats.DecRequestsInProgress()
+	h.Monitoring().Stats().IncRequestsInProgress()
+	defer h.Monitoring().Stats().DecRequestsInProgress()
 
 	ctx := req.Context()
 
@@ -134,7 +134,7 @@ func (h *Handler) newRequest(
 	errorreport.SetMetadata(req, "Source Image Origin", imageOrigin)
 	errorreport.SetMetadata(req, "Options", o.NestedMap())
 
-	monitoring.SetMetadata(ctx, mm)
+	h.Monitoring().SetMetadata(ctx, mm)
 
 	// verify that image URL came from the valid source
 	err = h.Security().VerifySourceURL(imageURL)

+ 2 - 3
handlers/processing/request.go

@@ -10,7 +10,6 @@ import (
 	"github.com/imgproxy/imgproxy/v3/ierrors"
 	"github.com/imgproxy/imgproxy/v3/imagetype"
 	"github.com/imgproxy/imgproxy/v3/monitoring"
-	"github.com/imgproxy/imgproxy/v3/monitoring/stats"
 	"github.com/imgproxy/imgproxy/v3/options"
 	"github.com/imgproxy/imgproxy/v3/options/keys"
 	"github.com/imgproxy/imgproxy/v3/security"
@@ -53,8 +52,8 @@ func (r *request) execute(ctx context.Context) error {
 	defer releaseWorker()
 
 	// Deal with processing image counter
-	stats.IncImagesInProgress()
-	defer stats.DecImagesInProgress()
+	r.Monitoring().Stats().IncImagesInProgress()
+	defer r.Monitoring().Stats().DecImagesInProgress()
 
 	// Response status code is OK by default
 	statusCode := http.StatusOK

+ 4 - 4
handlers/processing/request_methods.go

@@ -37,7 +37,7 @@ func (r *request) makeImageRequestHeaders() http.Header {
 
 // acquireWorker acquires the processing worker
 func (r *request) acquireWorker(ctx context.Context) (context.CancelFunc, error) {
-	defer monitoring.StartQueueSegment(ctx)()
+	defer r.Monitoring().StartQueueSegment(ctx)()
 
 	fn, err := r.Workers().Acquire(ctx)
 	if err != nil {
@@ -58,7 +58,7 @@ func (r *request) acquireWorker(ctx context.Context) (context.CancelFunc, error)
 
 // makeDownloadOptions creates a new default download options
 func (r *request) makeDownloadOptions(ctx context.Context, h http.Header) imagedata.DownloadOptions {
-	downloadFinished := monitoring.StartDownloadingSegment(ctx, r.monitoringMeta.Filter(
+	downloadFinished := r.Monitoring().StartDownloadingSegment(ctx, r.monitoringMeta.Filter(
 		monitoring.MetaSourceImageURL,
 		monitoring.MetaSourceImageOrigin,
 	))
@@ -96,7 +96,7 @@ func (r *request) handleDownloadError(
 	}
 
 	// Just send error
-	monitoring.SendError(ctx, handlers.CategoryDownload, err)
+	r.Monitoring().SendError(ctx, handlers.CategoryDownload, err)
 
 	// We didn't return, so we have to report error
 	if err.ShouldReport() {
@@ -153,7 +153,7 @@ func (r *request) getFallbackImage(ctx context.Context) (imagedata.ImageData, ht
 
 // processImage calls actual image processing
 func (r *request) processImage(ctx context.Context, originData imagedata.ImageData) (*processing.Result, error) {
-	defer monitoring.StartProcessingSegment(ctx, r.monitoringMeta.Filter(monitoring.MetaOptions))()
+	defer r.Monitoring().StartProcessingSegment(ctx, r.monitoringMeta.Filter(monitoring.MetaOptions))()
 	return r.Processor().ProcessImage(ctx, originData, r.opts, r.secops)
 }
 

+ 27 - 19
handlers/stream/handler.go

@@ -12,7 +12,6 @@ import (
 	"github.com/imgproxy/imgproxy/v3/httpheaders"
 	"github.com/imgproxy/imgproxy/v3/ierrors"
 	"github.com/imgproxy/imgproxy/v3/monitoring"
-	"github.com/imgproxy/imgproxy/v3/monitoring/stats"
 	"github.com/imgproxy/imgproxy/v3/options"
 	"github.com/imgproxy/imgproxy/v3/options/keys"
 	"github.com/imgproxy/imgproxy/v3/server"
@@ -33,15 +32,24 @@ var (
 	}
 )
 
+// HandlerContext provides access to shared handler dependencies
+type HandlerContext interface {
+	Fetcher() *fetcher.Fetcher
+	Cookies() *cookies.Cookies
+	Monitoring() *monitoring.Monitoring
+}
+
 // Handler handles image passthrough requests, allowing images to be streamed directly
 type Handler struct {
-	config  *Config          // Configuration for the streamer
-	fetcher *fetcher.Fetcher // Fetcher instance to handle image fetching
-	cookies *cookies.Cookies // Cookies manager
+	HandlerContext
+
+	config *Config // Configuration for the streamer
 }
 
 // request holds the parameters and state for a single streaming request
 type request struct {
+	HandlerContext
+
 	handler      *Handler
 	imageRequest *http.Request
 	imageURL     string
@@ -51,15 +59,14 @@ type request struct {
 }
 
 // New creates new handler object
-func New(config *Config, fetcher *fetcher.Fetcher, cookies *cookies.Cookies) (*Handler, error) {
+func New(hCtx HandlerContext, config *Config) (*Handler, error) {
 	if err := config.Validate(); err != nil {
 		return nil, err
 	}
 
 	return &Handler{
-		fetcher: fetcher,
-		config:  config,
-		cookies: cookies,
+		HandlerContext: hCtx,
+		config:         config,
 	}, nil
 }
 
@@ -73,12 +80,13 @@ func (s *Handler) Execute(
 	rw server.ResponseWriter,
 ) error {
 	stream := &request{
-		handler:      s,
-		imageRequest: userRequest,
-		imageURL:     imageURL,
-		reqID:        reqID,
-		opts:         o,
-		rw:           rw,
+		HandlerContext: s.HandlerContext,
+		handler:        s,
+		imageRequest:   userRequest,
+		imageURL:       imageURL,
+		reqID:          reqID,
+		opts:           o,
+		rw:             rw,
 	}
 
 	return stream.execute(ctx)
@@ -86,9 +94,9 @@ func (s *Handler) Execute(
 
 // execute handles the actual streaming logic
 func (s *request) execute(ctx context.Context) error {
-	stats.IncImagesInProgress()
-	defer stats.DecImagesInProgress()
-	defer monitoring.StartStreamingSegment(ctx)()
+	s.Monitoring().Stats().IncImagesInProgress()
+	defer s.Monitoring().Stats().DecImagesInProgress()
+	defer s.Monitoring().StartStreamingSegment(ctx)()
 
 	// Passthrough request headers from the original request
 	requestHeaders := s.getImageRequestHeaders()
@@ -98,7 +106,7 @@ func (s *request) execute(ctx context.Context) error {
 	}
 
 	// Build the request to fetch the image
-	r, err := s.handler.fetcher.BuildRequest(ctx, s.imageURL, requestHeaders, cookieJar)
+	r, err := s.Fetcher().BuildRequest(ctx, s.imageURL, requestHeaders, cookieJar)
 	if r != nil {
 		defer r.Cancel()
 	}
@@ -136,7 +144,7 @@ func (s *request) execute(ctx context.Context) error {
 
 // getCookieJar returns non-empty cookie jar if cookie passthrough is enabled
 func (s *request) getCookieJar() (http.CookieJar, error) {
-	return s.handler.cookies.JarFromRequest(s.imageRequest)
+	return s.Cookies().JarFromRequest(s.imageRequest)
 }
 
 // getImageRequestHeaders returns a new http.Header containing only

+ 48 - 11
handlers/stream/handler_test.go

@@ -16,12 +16,31 @@ import (
 	"github.com/imgproxy/imgproxy/v3/fetcher"
 	"github.com/imgproxy/imgproxy/v3/httpheaders"
 	"github.com/imgproxy/imgproxy/v3/logger"
+	"github.com/imgproxy/imgproxy/v3/monitoring"
 	"github.com/imgproxy/imgproxy/v3/options"
 	"github.com/imgproxy/imgproxy/v3/options/keys"
 	"github.com/imgproxy/imgproxy/v3/server/responsewriter"
 	"github.com/imgproxy/imgproxy/v3/testutil"
 )
 
+type Ctx struct {
+	fetcher    *fetcher.Fetcher
+	monitoring *monitoring.Monitoring
+	cookies    *cookies.Cookies
+}
+
+func (c *Ctx) Fetcher() *fetcher.Fetcher {
+	return c.fetcher
+}
+
+func (c *Ctx) Monitoring() *monitoring.Monitoring {
+	return c.monitoring
+}
+
+func (c *Ctx) Cookies() *cookies.Cookies {
+	return c.cookies
+}
+
 type HandlerTestSuite struct {
 	testutil.LazySuite
 
@@ -31,7 +50,8 @@ type HandlerTestSuite struct {
 	rwFactory testutil.LazyObj[*responsewriter.Factory]
 
 	cookieConf testutil.LazyObj[*cookies.Config]
-	cookies    testutil.LazyObj[*cookies.Cookies]
+
+	ctx testutil.LazyObj[*Ctx]
 
 	config  testutil.LazyObj[*Config]
 	handler testutil.LazyObj[*Handler]
@@ -67,10 +87,33 @@ func (s *HandlerTestSuite) SetupSuite() {
 		},
 	)
 
-	s.cookies, _ = testutil.NewLazySuiteObj(
+	s.ctx, _ = testutil.NewLazySuiteObj(
 		s,
-		func() (*cookies.Cookies, error) {
-			return cookies.New(s.cookieConf())
+		func() (*Ctx, error) {
+			fc := fetcher.NewDefaultConfig()
+			fc.Transport.HTTP.AllowLoopbackSourceAddresses = true
+
+			fetcher, err := fetcher.New(&fc)
+			if err != nil {
+				return nil, err
+			}
+
+			mc := monitoring.NewDefaultConfig()
+			monitoring, err := monitoring.New(s.T().Context(), &mc, 1)
+			if err != nil {
+				return nil, err
+			}
+
+			cookies, err := cookies.New(s.cookieConf())
+			if err != nil {
+				return nil, err
+			}
+
+			return &Ctx{
+				fetcher:    fetcher,
+				monitoring: monitoring,
+				cookies:    cookies,
+			}, nil
 		},
 	)
 
@@ -85,13 +128,7 @@ func (s *HandlerTestSuite) SetupSuite() {
 	s.handler, _ = testutil.NewLazySuiteObj(
 		s,
 		func() (*Handler, error) {
-			fc := fetcher.NewDefaultConfig()
-			fc.Transport.HTTP.AllowLoopbackSourceAddresses = true
-
-			fetcher, err := fetcher.New(&fc)
-			s.Require().NoError(err)
-
-			return New(s.config(), fetcher, s.cookies())
+			return New(s.ctx(), s.config())
 		},
 	)
 

+ 28 - 4
imgproxy.go

@@ -14,7 +14,7 @@ import (
 	streamhandler "github.com/imgproxy/imgproxy/v3/handlers/stream"
 	"github.com/imgproxy/imgproxy/v3/imagedata"
 	"github.com/imgproxy/imgproxy/v3/memory"
-	"github.com/imgproxy/imgproxy/v3/monitoring/prometheus"
+	"github.com/imgproxy/imgproxy/v3/monitoring"
 	optionsparser "github.com/imgproxy/imgproxy/v3/options/parser"
 	"github.com/imgproxy/imgproxy/v3/processing"
 	"github.com/imgproxy/imgproxy/v3/security"
@@ -47,11 +47,16 @@ type Imgproxy struct {
 	optionsParser    *optionsparser.Parser
 	processor        *processing.Processor
 	cookies          *cookies.Cookies
+	monitoring       *monitoring.Monitoring
 	config           *Config
 }
 
 // New creates a new imgproxy instance
 func New(ctx context.Context, config *Config) (*Imgproxy, error) {
+	if err := config.Validate(); err != nil {
+		return nil, err
+	}
+
 	fetcher, err := fetcher.New(&config.Fetcher)
 	if err != nil {
 		return nil, err
@@ -94,6 +99,11 @@ func New(ctx context.Context, config *Config) (*Imgproxy, error) {
 		return nil, err
 	}
 
+	monitoring, err := monitoring.New(ctx, &config.Monitoring, config.Workers.WorkersNumber)
+	if err != nil {
+		return nil, err
+	}
+
 	imgproxy := &Imgproxy{
 		workers:          workers,
 		fallbackImage:    fallbackImage,
@@ -105,12 +115,13 @@ func New(ctx context.Context, config *Config) (*Imgproxy, error) {
 		optionsParser:    optionsParser,
 		processor:        processor,
 		cookies:          cookies,
+		monitoring:       monitoring,
 	}
 
 	imgproxy.handlers.Health = healthhandler.New()
 	imgproxy.handlers.Landing = landinghandler.New()
 
-	imgproxy.handlers.Stream, err = streamhandler.New(&config.Handlers.Stream, fetcher, cookies)
+	imgproxy.handlers.Stream, err = streamhandler.New(imgproxy, &config.Handlers.Stream)
 	if err != nil {
 		return nil, err
 	}
@@ -127,7 +138,7 @@ func New(ctx context.Context, config *Config) (*Imgproxy, error) {
 
 // BuildRouter sets up the HTTP routes and middleware
 func (i *Imgproxy) BuildRouter() (*server.Router, error) {
-	r, err := server.NewRouter(&i.config.Server)
+	r, err := server.NewRouter(&i.config.Server, i.monitoring)
 	if err != nil {
 		return nil, err
 	}
@@ -160,7 +171,7 @@ func (i *Imgproxy) StartServer(ctx context.Context, hasStarted chan net.Addr) er
 
 	ctx, cancel := context.WithCancel(ctx)
 
-	if err := prometheus.StartServer(cancel); err != nil {
+	if err := i.monitoring.StartPrometheus(cancel); err != nil {
 		return err
 	}
 
@@ -185,6 +196,11 @@ func (i *Imgproxy) StartServer(ctx context.Context, hasStarted chan net.Addr) er
 	return nil
 }
 
+// Close gracefully shuts down the imgproxy instance
+func (i *Imgproxy) Close(ctx context.Context) {
+	i.monitoring.Stop(ctx)
+}
+
 // startMemoryTicker starts a ticker that periodically frees memory and optionally logs memory stats
 func (i *Imgproxy) startMemoryTicker(ctx context.Context) {
 	ticker := time.NewTicker(i.config.Server.FreeMemoryInterval)
@@ -204,6 +220,10 @@ func (i *Imgproxy) startMemoryTicker(ctx context.Context) {
 	}
 }
 
+func (i *Imgproxy) Fetcher() *fetcher.Fetcher {
+	return i.fetcher
+}
+
 func (i *Imgproxy) Workers() *workers.Workers {
 	return i.workers
 }
@@ -235,3 +255,7 @@ func (i *Imgproxy) Processor() *processing.Processor {
 func (i *Imgproxy) Cookies() *cookies.Cookies {
 	return i.cookies
 }
+
+func (i *Imgproxy) Monitoring() *monitoring.Monitoring {
+	return i.monitoring
+}

+ 0 - 6
init.go

@@ -13,7 +13,6 @@ import (
 	"github.com/imgproxy/imgproxy/v3/env"
 	"github.com/imgproxy/imgproxy/v3/errorreport"
 	"github.com/imgproxy/imgproxy/v3/logger"
-	"github.com/imgproxy/imgproxy/v3/monitoring"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
@@ -46,10 +45,6 @@ func Init() error {
 		slog.Debug(fmt.Sprintf(msg, args...))
 	}))
 
-	if err := monitoring.Init(); err != nil {
-		return err
-	}
-
 	vipsCfg, err := vips.LoadConfigFromEnv(nil)
 	if err != nil {
 		return err
@@ -72,7 +67,6 @@ func Init() error {
 
 // Shutdown performs global cleanup
 func Shutdown() {
-	monitoring.Stop()
 	vips.Shutdown()
 	errorreport.Close()
 }

+ 83 - 153
monitoring/cloudwatch/cloudwatch.go

@@ -4,7 +4,6 @@ import (
 	"context"
 	"fmt"
 	"log/slog"
-	"sync"
 	"time"
 
 	"github.com/aws/aws-sdk-go-v2/aws"
@@ -12,250 +11,181 @@ import (
 	"github.com/aws/aws-sdk-go-v2/service/cloudwatch"
 	cloudwatchTypes "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/monitoring/stats"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
-type bufferStats struct {
-	count         int
-	sum, min, max int
-}
+const (
+	// AWS CloudWatch PutMetrics timeout
+	putMetricsTimeout = 30 * time.Second
+
+	// default AWS region to set if neither aws env region nor config region are set
+	defaultAwsRegion = "us-west-1"
+)
 
-var (
-	enabled bool
+// CloudWatch holds CloudWatch client and configuration
+type CloudWatch struct {
+	config *Config
+	stats  *stats.Stats
 
 	client *cloudwatch.Client
 
 	collectorCtx       context.Context
 	collectorCtxCancel context.CancelFunc
+}
 
-	bufferDefaultSizes = make(map[string]int)
-	bufferMaxSizes     = make(map[string]int)
-	bufferSizeStats    = make(map[string]*bufferStats)
-	bufferStatsMutex   sync.Mutex
-)
-
-func Init() error {
-	if len(config.CloudWatchServiceName) == 0 {
-		return nil
+// New creates a new CloudWatch instance
+func New(ctx context.Context, config *Config, stats *stats.Stats) (*CloudWatch, error) {
+	cw := &CloudWatch{
+		config: config,
+		stats:  stats,
 	}
 
-	conf, err := awsConfig.LoadDefaultConfig(context.Background())
-	if err != nil {
-		return fmt.Errorf("can't load CloudWatch config: %s", err)
+	if !config.Enabled() {
+		return cw, nil
 	}
 
-	if len(config.CloudWatchRegion) != 0 {
-		conf.Region = config.CloudWatchRegion
+	if err := config.Validate(); err != nil {
+		return nil, err
 	}
 
-	if len(conf.Region) == 0 {
-		conf.Region = "us-west-1"
+	conf, err := awsConfig.LoadDefaultConfig(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("can't load CloudWatch config: %s", err)
 	}
 
-	client = cloudwatch.NewFromConfig(conf)
-
-	collectorCtx, collectorCtxCancel = context.WithCancel(context.Background())
-
-	go runMetricsCollector()
-
-	enabled = true
-
-	return nil
-}
-
-func Stop() {
-	if enabled {
-		collectorCtxCancel()
+	if len(config.Region) > 0 {
+		conf.Region = config.Region
 	}
-}
-
-func Enabled() bool {
-	return enabled
-}
 
-func ObserveBufferSize(t string, size int) {
-	if enabled {
-		bufferStatsMutex.Lock()
-		defer bufferStatsMutex.Unlock()
+	if len(conf.Region) == 0 {
+		conf.Region = defaultAwsRegion
+	}
 
-		sizef := size
+	cw.client = cloudwatch.NewFromConfig(conf)
+	cw.collectorCtx, cw.collectorCtxCancel = context.WithCancel(ctx)
 
-		stats, ok := bufferSizeStats[t]
-		if !ok {
-			stats = &bufferStats{count: 1, sum: sizef, min: sizef, max: sizef}
-			bufferSizeStats[t] = stats
-			return
-		}
+	go cw.runMetricsCollector()
 
-		stats.count += 1
-		stats.sum += sizef
-		stats.min = min(stats.min, sizef)
-		stats.max = max(stats.max, sizef)
-	}
+	return cw, nil
 }
 
-func SetBufferDefaultSize(t string, size int) {
-	if enabled {
-		bufferStatsMutex.Lock()
-		defer bufferStatsMutex.Unlock()
-
-		bufferDefaultSizes[t] = size
-	}
+// Enabled returns true if CloudWatch is enabled
+func (cw *CloudWatch) Enabled() bool {
+	return cw.config.Enabled()
 }
 
-func SetBufferMaxSize(t string, size int) {
-	if enabled {
-		bufferStatsMutex.Lock()
-		defer bufferStatsMutex.Unlock()
-
-		bufferMaxSizes[t] = size
+// Stop stops the CloudWatch metrics collection
+func (cw *CloudWatch) Stop() {
+	if cw.collectorCtxCancel != nil {
+		cw.collectorCtxCancel()
 	}
 }
 
-func runMetricsCollector() {
-	tick := time.NewTicker(10 * time.Second)
+// runMetricsCollector collects and sends metrics to CloudWatch
+func (cw *CloudWatch) runMetricsCollector() {
+	tick := time.NewTicker(cw.config.MetricsInterval)
 	defer tick.Stop()
 
 	dimension := cloudwatchTypes.Dimension{
 		Name:  aws.String("ServiceName"),
-		Value: aws.String(config.CloudWatchServiceName),
+		Value: aws.String(cw.config.ServiceName),
 	}
 
-	bufferDimensions := make(map[string]cloudwatchTypes.Dimension)
-	bufferDimension := func(t string) cloudwatchTypes.Dimension {
-		if d, ok := bufferDimensions[t]; ok {
-			return d
-		}
+	dimensions := []cloudwatchTypes.Dimension{dimension}
 
-		d := cloudwatchTypes.Dimension{
-			Name:  aws.String("BufferType"),
-			Value: aws.String(t),
-		}
+	namespace := aws.String(cw.config.Namespace)
 
-		bufferDimensions[t] = d
-
-		return d
-	}
+	// metric names
+	metricNameWorkers := aws.String("Workers")
+	metricNameRequestsInProgress := aws.String("RequestsInProgress")
+	metricNameImagesInProgress := aws.String("ImagesInProgress")
+	metricNameConcurrencyUtilization := aws.String("ConcurrencyUtilization")
+	metricNameWorkersUtilization := aws.String("WorkersUtilization")
+	metricNameVipsMemory := aws.String("VipsMemory")
+	metricNameVipsMaxMemory := aws.String("VipsMaxMemory")
+	metricNameVipsAllocs := aws.String("VipsAllocs")
 
 	for {
 		select {
 		case <-tick.C:
-			metricsCount := len(bufferDefaultSizes) + len(bufferMaxSizes) + len(bufferSizeStats) + 8
-			metrics := make([]cloudwatchTypes.MetricDatum, 0, metricsCount)
-
-			func() {
-				bufferStatsMutex.Lock()
-				defer bufferStatsMutex.Unlock()
-
-				for t, size := range bufferDefaultSizes {
-					metrics = append(metrics, cloudwatchTypes.MetricDatum{
-						Dimensions: []cloudwatchTypes.Dimension{dimension, bufferDimension(t)},
-						MetricName: aws.String("BufferDefaultSize"),
-						Unit:       cloudwatchTypes.StandardUnitBytes,
-						Value:      aws.Float64(float64(size)),
-					})
-				}
-
-				for t, size := range bufferMaxSizes {
-					metrics = append(metrics, cloudwatchTypes.MetricDatum{
-						Dimensions: []cloudwatchTypes.Dimension{dimension, bufferDimension(t)},
-						MetricName: aws.String("BufferMaximumSize"),
-						Unit:       cloudwatchTypes.StandardUnitBytes,
-						Value:      aws.Float64(float64(size)),
-					})
-				}
-
-				for t, stats := range bufferSizeStats {
-					metrics = append(metrics, cloudwatchTypes.MetricDatum{
-						Dimensions: []cloudwatchTypes.Dimension{dimension, bufferDimension(t)},
-						MetricName: aws.String("BufferSize"),
-						Unit:       cloudwatchTypes.StandardUnitBytes,
-						StatisticValues: &cloudwatchTypes.StatisticSet{
-							SampleCount: aws.Float64(float64(stats.count)),
-							Sum:         aws.Float64(float64(stats.sum)),
-							Minimum:     aws.Float64(float64(stats.min)),
-							Maximum:     aws.Float64(float64(stats.max)),
-						},
-					})
-				}
-			}()
+			// 8 is the number of metrics we send
+			metrics := make([]cloudwatchTypes.MetricDatum, 0, 8)
 
 			metrics = append(metrics, cloudwatchTypes.MetricDatum{
-				Dimensions: []cloudwatchTypes.Dimension{dimension},
-				MetricName: aws.String("Workers"),
+				Dimensions: dimensions,
+				MetricName: metricNameWorkers,
 				Unit:       cloudwatchTypes.StandardUnitCount,
-				Value:      aws.Float64(float64(config.Workers)),
+				Value:      aws.Float64(float64(cw.stats.WorkersNumber)),
 			})
 
 			metrics = append(metrics, cloudwatchTypes.MetricDatum{
-				Dimensions: []cloudwatchTypes.Dimension{dimension},
-				MetricName: aws.String("RequestsInProgress"),
+				Dimensions: dimensions,
+				MetricName: metricNameRequestsInProgress,
 				Unit:       cloudwatchTypes.StandardUnitCount,
-				Value:      aws.Float64(stats.RequestsInProgress()),
+				Value:      aws.Float64(cw.stats.RequestsInProgress()),
 			})
 
 			metrics = append(metrics, cloudwatchTypes.MetricDatum{
-				Dimensions: []cloudwatchTypes.Dimension{dimension},
-				MetricName: aws.String("ImagesInProgress"),
+				Dimensions: dimensions,
+				MetricName: metricNameImagesInProgress,
 				Unit:       cloudwatchTypes.StandardUnitCount,
-				Value:      aws.Float64(stats.ImagesInProgress()),
+				Value:      aws.Float64(cw.stats.ImagesInProgress()),
 			})
 
 			metrics = append(metrics, cloudwatchTypes.MetricDatum{
-				Dimensions: []cloudwatchTypes.Dimension{dimension},
-				MetricName: aws.String("ConcurrencyUtilization"),
+				Dimensions: dimensions,
+				MetricName: metricNameConcurrencyUtilization,
 				Unit:       cloudwatchTypes.StandardUnitPercent,
 				Value: aws.Float64(
-					stats.WorkersUtilization(),
+					cw.stats.WorkersUtilization(),
 				),
 			})
 
 			metrics = append(metrics, cloudwatchTypes.MetricDatum{
-				Dimensions: []cloudwatchTypes.Dimension{dimension},
-				MetricName: aws.String("WorkersUtilization"),
+				Dimensions: dimensions,
+				MetricName: metricNameWorkersUtilization,
 				Unit:       cloudwatchTypes.StandardUnitPercent,
 				Value: aws.Float64(
-					stats.WorkersUtilization(),
+					cw.stats.WorkersUtilization(),
 				),
 			})
 
 			metrics = append(metrics, cloudwatchTypes.MetricDatum{
-				Dimensions: []cloudwatchTypes.Dimension{dimension},
-				MetricName: aws.String("VipsMemory"),
+				Dimensions: dimensions,
+				MetricName: metricNameVipsMemory,
 				Unit:       cloudwatchTypes.StandardUnitBytes,
 				Value:      aws.Float64(vips.GetMem()),
 			})
 
 			metrics = append(metrics, cloudwatchTypes.MetricDatum{
-				Dimensions: []cloudwatchTypes.Dimension{dimension},
-				MetricName: aws.String("VipsMaxMemory"),
+				Dimensions: dimensions,
+				MetricName: metricNameVipsMaxMemory,
 				Unit:       cloudwatchTypes.StandardUnitBytes,
 				Value:      aws.Float64(vips.GetMemHighwater()),
 			})
 
 			metrics = append(metrics, cloudwatchTypes.MetricDatum{
-				Dimensions: []cloudwatchTypes.Dimension{dimension},
-				MetricName: aws.String("VipsAllocs"),
+				Dimensions: dimensions,
+				MetricName: metricNameVipsAllocs,
 				Unit:       cloudwatchTypes.StandardUnitCount,
 				Value:      aws.Float64(vips.GetAllocs()),
 			})
 
 			input := cloudwatch.PutMetricDataInput{
-				Namespace:  aws.String(config.CloudWatchNamespace),
+				Namespace:  namespace,
 				MetricData: metrics,
 			}
 
 			func() {
-				ctx, cancel := context.WithTimeout(collectorCtx, 30*time.Second)
+				ctx, cancel := context.WithTimeout(cw.collectorCtx, putMetricsTimeout)
 				defer cancel()
 
-				if _, err := client.PutMetricData(ctx, &input); err != nil {
-					slog.Warn(fmt.Sprintf("Can't send CloudWatch metrics: %s", err))
+				if _, err := cw.client.PutMetricData(ctx, &input); err != nil {
+					slog.Warn(fmt.Sprintf("can't send CloudWatch metrics: %s", err))
 				}
 			}()
-		case <-collectorCtx.Done():
+		case <-cw.collectorCtx.Done():
 			return
 		}
 	}

+ 61 - 0
monitoring/cloudwatch/config.go

@@ -0,0 +1,61 @@
+package cloudwatch
+
+import (
+	"errors"
+	"time"
+
+	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_CLOUD_WATCH_SERVICE_NAME = env.Describe("IMGPROXY_CLOUD_WATCH_SERVICE_NAME", "string")
+	IMGPROXY_CLOUD_WATCH_NAMESPACE    = env.Describe("IMGPROXY_CLOUD_WATCH_NAMESPACE", "string")
+	IMGPROXY_CLOUD_WATCH_REGION       = env.Describe("IMGPROXY_CLOUD_WATCH_REGION", "string")
+)
+
+// Config holds the configuration for CloudWatch monitoring
+type Config struct {
+	ServiceName     string        // CloudWatch service name (also used to enable/disable CloudWatch)
+	Namespace       string        // CloudWatch metrics namespace
+	Region          string        // AWS region for CloudWatch
+	MetricsInterval time.Duration // Interval between metrics collections
+}
+
+// NewDefaultConfig returns a new default configuration for CloudWatch monitoring
+func NewDefaultConfig() Config {
+	return Config{
+		ServiceName:     "",         // CloudWatch service name, enabled if not empty
+		Namespace:       "imgproxy", // CloudWatch metrics namespace
+		Region:          "",
+		MetricsInterval: 10 * time.Second, // Metrics collection interval
+	}
+}
+
+// LoadConfigFromEnv loads configuration from environment variables
+func LoadConfigFromEnv(c *Config) (*Config, error) {
+	c = ensure.Ensure(c, NewDefaultConfig)
+
+	err := errors.Join(
+		env.String(&c.ServiceName, IMGPROXY_CLOUD_WATCH_SERVICE_NAME),
+		env.String(&c.Namespace, IMGPROXY_CLOUD_WATCH_NAMESPACE),
+		env.String(&c.Region, IMGPROXY_CLOUD_WATCH_REGION),
+	)
+
+	return c, err
+}
+
+// Enabled returns true if CloudWatch is enabled
+func (c *Config) Enabled() bool {
+	return len(c.ServiceName) > 0
+}
+
+// Validate checks the configuration for errors
+func (c *Config) Validate() error {
+	// If service name is not set, CloudWatch is disabled, so no need to validate other fields
+	if !c.Enabled() {
+		return nil
+	}
+
+	return nil
+}

+ 53 - 0
monitoring/config.go

@@ -0,0 +1,53 @@
+package monitoring
+
+import (
+	"errors"
+
+	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/monitoring/cloudwatch"
+	"github.com/imgproxy/imgproxy/v3/monitoring/datadog"
+	"github.com/imgproxy/imgproxy/v3/monitoring/newrelic"
+	"github.com/imgproxy/imgproxy/v3/monitoring/otel"
+	"github.com/imgproxy/imgproxy/v3/monitoring/prometheus"
+)
+
+// Config holds the configuration for all monitoring services
+type Config struct {
+	Prometheus    prometheus.Config // Prometheus metrics configuration
+	NewRelic      newrelic.Config   // New Relic configuration
+	DataDog       datadog.Config    // DataDog configuration
+	OpenTelemetry otel.Config       // OpenTelemetry configuration
+	CloudWatch    cloudwatch.Config // CloudWatch configuration
+}
+
+// NewDefaultConfig returns a new default configuration for all monitoring services
+func NewDefaultConfig() Config {
+	return Config{
+		Prometheus:    prometheus.NewDefaultConfig(),
+		NewRelic:      newrelic.NewDefaultConfig(),
+		DataDog:       datadog.NewDefaultConfig(),
+		OpenTelemetry: otel.NewDefaultConfig(),
+		CloudWatch:    cloudwatch.NewDefaultConfig(),
+	}
+}
+
+// LoadConfigFromEnv loads configuration for all monitoring services from environment variables
+func LoadConfigFromEnv(c *Config) (*Config, error) {
+	c = ensure.Ensure(c, NewDefaultConfig)
+
+	var promErr, nrErr, ddErr, otelErr, cwErr error
+
+	_, promErr = prometheus.LoadConfigFromEnv(&c.Prometheus)
+	_, nrErr = newrelic.LoadConfigFromEnv(&c.NewRelic)
+	_, ddErr = datadog.LoadConfigFromEnv(&c.DataDog)
+	_, otelErr = otel.LoadConfigFromEnv(&c.OpenTelemetry)
+	_, cwErr = cloudwatch.LoadConfigFromEnv(&c.CloudWatch)
+
+	err := errors.Join(promErr, nrErr, ddErr, otelErr, cwErr)
+
+	return c, err
+}
+
+func (c *Config) Validate() error {
+	return nil
+}

+ 92 - 0
monitoring/datadog/config.go

@@ -0,0 +1,92 @@
+package datadog
+
+import (
+	"errors"
+	"time"
+
+	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_DATADOG_ENABLE                    = env.Describe("IMGPROXY_DATADOG_ENABLE", "boolean")
+	IMGPROXY_DATADOG_ENABLE_ADDITIONAL_METRICS = env.Describe("IMGPROXY_DATADOG_ENABLE_ADDITIONAL_METRICS", "boolean")
+
+	DD_SERVICE            = env.Describe("DD_SERVICE", "string")
+	DD_TRACE_STARTUP_LOGS = env.Describe("DD_TRACE_STARTUP_LOGS", "boolean")
+	DD_AGENT_HOST         = env.Describe("DD_AGENT_HOST", "host")
+	DD_TRACE_AGENT_PORT   = env.Describe("DD_TRACE_AGENT_PORT", "port")
+	DD_DOGSTATSD_PORT     = env.Describe("DD_DOGSTATSD_PORT", "port")
+)
+
+// Config holds the configuration for DataDog monitoring
+type Config struct {
+	Enable           bool          // Enable DataDog tracing
+	EnableMetrics    bool          // Enable DataDog metrics collection
+	Service          string        // DataDog service name
+	TraceStartupLogs bool          // Enable trace startup logs
+	AgentHost        string        // DataDog agent host
+	TracePort        int           // DataDog tracer port
+	StatsDPort       int           // DataDog StatsD port
+	MetricsInterval  time.Duration // Interval for sending metrics to DataDog
+}
+
+// NewDefaultConfig returns a new default configuration for DataDog monitoring
+func NewDefaultConfig() Config {
+	return Config{
+		Enable:           false,
+		EnableMetrics:    false,
+		Service:          "imgproxy",
+		TraceStartupLogs: false,
+		AgentHost:        "localhost",
+		TracePort:        8126,
+		StatsDPort:       8125,
+		MetricsInterval:  10 * time.Second,
+	}
+}
+
+// LoadConfigFromEnv loads configuration from environment variables
+func LoadConfigFromEnv(c *Config) (*Config, error) {
+	c = ensure.Ensure(c, NewDefaultConfig)
+
+	err := errors.Join(
+		env.Bool(&c.Enable, IMGPROXY_DATADOG_ENABLE),
+		env.Bool(&c.EnableMetrics, IMGPROXY_DATADOG_ENABLE_ADDITIONAL_METRICS),
+		env.String(&c.Service, DD_SERVICE),
+		env.Bool(&c.TraceStartupLogs, DD_TRACE_STARTUP_LOGS),
+		env.String(&c.AgentHost, DD_AGENT_HOST),
+		env.Int(&c.TracePort, DD_TRACE_AGENT_PORT),
+		env.Int(&c.StatsDPort, DD_DOGSTATSD_PORT),
+	)
+
+	return c, err
+}
+
+// Enabled returns true if DataDog is enabled
+func (c *Config) Enabled() bool {
+	return c.Enable
+}
+
+// Validate checks the configuration for errors
+func (c *Config) Validate() error {
+	// If DataDog is not enabled, no need to validate further
+	if !c.Enabled() {
+		return nil
+	}
+
+	// Service name is required
+	if len(c.Service) == 0 {
+		return DD_SERVICE.ErrorEmpty()
+	}
+
+	if c.TracePort <= 0 || c.TracePort > 65535 {
+		return DD_TRACE_AGENT_PORT.ErrorRange()
+	}
+
+	// StatsD port must be in the valid range
+	if c.StatsDPort <= 0 || c.StatsDPort > 65535 {
+		return DD_DOGSTATSD_PORT.ErrorRange()
+	}
+
+	return nil
+}

+ 70 - 92
monitoring/datadog/datadog.go

@@ -6,7 +6,6 @@ import (
 	"log/slog"
 	"net"
 	"net/http"
-	"os"
 	"reflect"
 	"strconv"
 	"time"
@@ -16,93 +15,96 @@ import (
 	"github.com/DataDog/dd-trace-go/v2/ddtrace/tracer"
 	"github.com/felixge/httpsnoop"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/monitoring/errformat"
 	"github.com/imgproxy/imgproxy/v3/monitoring/stats"
 	"github.com/imgproxy/imgproxy/v3/version"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
+// spanCtxKey is the context key type for storing the root span in the request context
 type spanCtxKey struct{}
 
-var (
-	enabled        bool
-	enabledMetrics bool
+// dataDogLogger is a custom logger for DataDog
+type dataDogLogger struct{}
+
+func (l dataDogLogger) Log(msg string) {
+	slog.Info(msg)
+}
+
+// DataDog holds DataDog client and configuration
+type DataDog struct {
+	stats  *stats.Stats
+	config *Config
 
 	statsdClient     *statsd.Client
 	statsdClientStop chan struct{}
-)
-
-func Init() {
-	if !config.DataDogEnable {
-		return
-	}
+}
 
-	name := os.Getenv("DD_SERVICE")
-	if len(name) == 0 {
-		name = "imgproxy"
+// New creates a new DataDog instance
+func New(config *Config, stats *stats.Stats) (*DataDog, error) {
+	dd := &DataDog{
+		stats:  stats,
+		config: config,
 	}
 
-	logStartup := false
-	if b, err := strconv.ParseBool(os.Getenv("DD_TRACE_STARTUP_LOGS")); err == nil {
-		logStartup = b
+	if !config.Enabled() {
+		return dd, nil
 	}
 
 	tracer.Start(
-		tracer.WithService(name),
+		tracer.WithService(config.Service),
 		tracer.WithServiceVersion(version.Version),
 		tracer.WithLogger(dataDogLogger{}),
-		tracer.WithLogStartup(logStartup),
+		tracer.WithLogStartup(config.TraceStartupLogs),
+		tracer.WithAgentAddr(net.JoinHostPort(config.AgentHost, strconv.Itoa(config.TracePort))),
 	)
 
-	enabled = true
-
-	statsdHost, statsdPort := os.Getenv("DD_AGENT_HOST"), os.Getenv("DD_DOGSTATSD_PORT")
-	if len(statsdHost) == 0 {
-		statsdHost = "localhost"
-	}
-	if len(statsdPort) == 0 {
-		statsdPort = "8125"
-	}
-
-	if !config.DataDogEnableMetrics {
-		return
+	// If additional metrics collection is not enabled, return early
+	if !config.EnableMetrics {
+		return dd, nil
 	}
 
 	var err error
-	statsdClient, err = statsd.New(
-		net.JoinHostPort(statsdHost, statsdPort),
+
+	dd.statsdClient, err = statsd.New(
+		net.JoinHostPort(config.AgentHost, strconv.Itoa(config.StatsDPort)),
 		statsd.WithTags([]string{
-			"service:" + name,
+			"service:" + config.Service,
 			"version:" + version.Version,
 		}),
 	)
+
 	if err == nil {
-		statsdClientStop = make(chan struct{})
-		enabledMetrics = true
-		go runMetricsCollector()
+		dd.statsdClientStop = make(chan struct{})
+		go dd.runMetricsCollector()
 	} else {
-		slog.Warn(fmt.Sprintf("Can't initialize DogStatsD client: %s", err))
+		slog.Warn(fmt.Sprintf("can't initialize DogStatsD client: %s", err))
 	}
+
+	return dd, nil
 }
 
-func Stop() {
-	if enabled {
-		tracer.Stop()
+// Enabled returns true if DataDog is enabled
+func (dd *DataDog) Enabled() bool {
+	return dd.config.Enabled()
+}
 
-		if statsdClient != nil {
-			close(statsdClientStop)
-			statsdClient.Close()
-		}
+// Stop stops the DataDog tracer and metrics collection
+func (dd *DataDog) Stop() {
+	if !dd.Enabled() {
+		return
 	}
-}
 
-func Enabled() bool {
-	return enabled
+	tracer.Stop()
+
+	if dd.statsdClient != nil {
+		close(dd.statsdClientStop)
+		dd.statsdClient.Close()
+	}
 }
 
-func StartRootSpan(ctx context.Context, rw http.ResponseWriter, r *http.Request) (context.Context, context.CancelFunc, http.ResponseWriter) {
-	if !enabled {
+func (dd *DataDog) StartRootSpan(ctx context.Context, rw http.ResponseWriter, r *http.Request) (context.Context, context.CancelFunc, http.ResponseWriter) {
+	if !dd.Enabled() {
 		return ctx, func() {}, rw
 	}
 
@@ -142,8 +144,8 @@ func setMetadata(span *tracer.Span, key string, value any) {
 	span.SetTag(key, value)
 }
 
-func SetMetadata(ctx context.Context, key string, value any) {
-	if !enabled {
+func (dd *DataDog) SetMetadata(ctx context.Context, key string, value any) {
+	if !dd.Enabled() {
 		return
 	}
 
@@ -152,8 +154,8 @@ func SetMetadata(ctx context.Context, key string, value any) {
 	}
 }
 
-func StartSpan(ctx context.Context, name string, meta map[string]any) context.CancelFunc {
-	if !enabled {
+func (dd *DataDog) StartSpan(ctx context.Context, name string, meta map[string]any) context.CancelFunc {
+	if !dd.Enabled() {
 		return func() {}
 	}
 
@@ -170,8 +172,8 @@ func StartSpan(ctx context.Context, name string, meta map[string]any) context.Ca
 	return func() {}
 }
 
-func SendError(ctx context.Context, errType string, err error) {
-	if !enabled {
+func (dd *DataDog) SendError(ctx context.Context, errType string, err error) {
+	if !dd.Enabled() {
 		return
 	}
 
@@ -181,47 +183,23 @@ func SendError(ctx context.Context, errType string, err error) {
 	}
 }
 
-func ObserveBufferSize(t string, size int) {
-	if enabledMetrics {
-		statsdClient.Histogram("imgproxy.buffer.size", float64(size), []string{"type:" + t}, 1)
-	}
-}
-
-func SetBufferDefaultSize(t string, size int) {
-	if enabledMetrics {
-		statsdClient.Gauge("imgproxy.buffer.default_size", float64(size), []string{"type:" + t}, 1)
-	}
-}
-
-func SetBufferMaxSize(t string, size int) {
-	if enabledMetrics {
-		statsdClient.Gauge("imgproxy.buffer.max_size", float64(size), []string{"type:" + t}, 1)
-	}
-}
-
-func runMetricsCollector() {
-	tick := time.NewTicker(10 * time.Second)
+func (dd *DataDog) runMetricsCollector() {
+	tick := time.NewTicker(dd.config.MetricsInterval)
 	defer tick.Stop()
+
 	for {
 		select {
 		case <-tick.C:
-			statsdClient.Gauge("imgproxy.workers", float64(config.Workers), nil, 1)
-			statsdClient.Gauge("imgproxy.requests_in_progress", stats.RequestsInProgress(), nil, 1)
-			statsdClient.Gauge("imgproxy.images_in_progress", stats.ImagesInProgress(), nil, 1)
-			statsdClient.Gauge("imgproxy.workers_utilization", stats.WorkersUtilization(), nil, 1)
-
-			statsdClient.Gauge("imgproxy.vips.memory", vips.GetMem(), nil, 1)
-			statsdClient.Gauge("imgproxy.vips.max_memory", vips.GetMemHighwater(), nil, 1)
-			statsdClient.Gauge("imgproxy.vips.allocs", vips.GetAllocs(), nil, 1)
-		case <-statsdClientStop:
+			dd.statsdClient.Gauge("imgproxy.workers", float64(dd.stats.WorkersNumber), nil, 1)
+			dd.statsdClient.Gauge("imgproxy.requests_in_progress", dd.stats.RequestsInProgress(), nil, 1)
+			dd.statsdClient.Gauge("imgproxy.images_in_progress", dd.stats.ImagesInProgress(), nil, 1)
+			dd.statsdClient.Gauge("imgproxy.workers_utilization", dd.stats.WorkersUtilization(), nil, 1)
+
+			dd.statsdClient.Gauge("imgproxy.vips.memory", vips.GetMem(), nil, 1)
+			dd.statsdClient.Gauge("imgproxy.vips.max_memory", vips.GetMemHighwater(), nil, 1)
+			dd.statsdClient.Gauge("imgproxy.vips.allocs", vips.GetAllocs(), nil, 1)
+		case <-dd.statsdClientStop:
 			return
 		}
 	}
 }
-
-type dataDogLogger struct {
-}
-
-func (l dataDogLogger) Log(msg string) {
-	slog.Info(msg)
-}

+ 22 - 0
monitoring/meta.go

@@ -0,0 +1,22 @@
+package monitoring
+
+// Metadata key names
+const (
+	MetaSourceImageURL    = "imgproxy.source_image_url"
+	MetaSourceImageOrigin = "imgproxy.source_image_origin"
+	MetaOptions           = "imgproxy.options"
+)
+
+// Meta represents a set of metadata key-value pairs.
+type Meta map[string]any
+
+// Filter creates a copy of Meta with only the specified keys.
+func (m Meta) Filter(only ...string) Meta {
+	filtered := make(Meta)
+	for _, key := range only {
+		if value, ok := m[key]; ok {
+			filtered[key] = value
+		}
+	}
+	return filtered
+}

+ 0 - 0
monitoring/monitoring_test.go → monitoring/meta_test.go


+ 84 - 97
monitoring/monitoring.go

@@ -2,6 +2,7 @@ package monitoring
 
 import (
 	"context"
+	"errors"
 	"net/http"
 
 	"github.com/imgproxy/imgproxy/v3/monitoring/cloudwatch"
@@ -9,67 +10,77 @@ import (
 	"github.com/imgproxy/imgproxy/v3/monitoring/newrelic"
 	"github.com/imgproxy/imgproxy/v3/monitoring/otel"
 	"github.com/imgproxy/imgproxy/v3/monitoring/prometheus"
+	"github.com/imgproxy/imgproxy/v3/monitoring/stats"
 )
 
-const (
-	MetaSourceImageURL    = "imgproxy.source_image_url"
-	MetaSourceImageOrigin = "imgproxy.source_image_origin"
-	MetaOptions           = "imgproxy.options"
-)
-
-type Meta map[string]any
+// Monitoring holds all monitoring service instances
+type Monitoring struct {
+	config *Config
+	stats  *stats.Stats
 
-// Filter creates a copy of Meta with only the specified keys.
-func (m Meta) Filter(only ...string) Meta {
-	filtered := make(Meta)
-	for _, key := range only {
-		if value, ok := m[key]; ok {
-			filtered[key] = value
-		}
-	}
-	return filtered
+	prometheus *prometheus.Prometheus
+	newrelic   *newrelic.NewRelic
+	datadog    *datadog.DataDog
+	otel       *otel.Otel
+	cloudwatch *cloudwatch.CloudWatch
 }
 
-func Init() error {
-	prometheus.Init()
+// New creates a new Monitoring instance
+func New(ctx context.Context, config *Config, workersNumber int) (*Monitoring, error) {
+	if err := config.Validate(); err != nil {
+		return nil, err
+	}
 
-	if err := newrelic.Init(); err != nil {
-		return nil
+	m := &Monitoring{
+		config: config,
+		stats:  stats.New(workersNumber),
 	}
 
-	datadog.Init()
+	var prErr, nlErr, ddErr, otelErr, cwErr error
 
-	if err := otel.Init(); err != nil {
-		return err
-	}
+	m.prometheus, prErr = prometheus.New(&config.Prometheus, m.stats)
+	m.newrelic, nlErr = newrelic.New(&config.NewRelic, m.stats)
+	m.datadog, ddErr = datadog.New(&config.DataDog, m.stats)
+	m.otel, otelErr = otel.New(&config.OpenTelemetry, m.stats)
+	m.cloudwatch, cwErr = cloudwatch.New(ctx, &config.CloudWatch, m.stats)
 
-	if err := cloudwatch.Init(); err != nil {
-		return err
-	}
+	err := errors.Join(prErr, nlErr, ddErr, otelErr, cwErr)
+
+	return m, err
+}
 
-	return nil
+// Enabled returns true if at least one monitoring service is enabled
+func (m *Monitoring) Enabled() bool {
+	return m.prometheus.Enabled() ||
+		m.newrelic.Enabled() ||
+		m.datadog.Enabled() ||
+		m.otel.Enabled() ||
+		m.cloudwatch.Enabled()
 }
 
-func Stop() {
-	newrelic.Stop()
-	datadog.Stop()
-	otel.Stop()
-	cloudwatch.Stop()
+// Stats returns the stats instance
+func (m *Monitoring) Stats() *stats.Stats {
+	return m.stats
 }
 
-func Enabled() bool {
-	return prometheus.Enabled() ||
-		newrelic.Enabled() ||
-		datadog.Enabled() ||
-		otel.Enabled() ||
-		cloudwatch.Enabled()
+// Stop stops all monitoring services
+func (m *Monitoring) Stop(ctx context.Context) {
+	m.newrelic.Stop(ctx)
+	m.datadog.Stop()
+	m.otel.Stop(ctx)
+	m.cloudwatch.Stop()
 }
 
-func StartRequest(ctx context.Context, rw http.ResponseWriter, r *http.Request) (context.Context, context.CancelFunc, http.ResponseWriter) {
-	promCancel, rw := prometheus.StartRequest(rw)
-	ctx, nrCancel, rw := newrelic.StartTransaction(ctx, rw, r)
-	ctx, ddCancel, rw := datadog.StartRootSpan(ctx, rw, r)
-	ctx, otelCancel, rw := otel.StartRootSpan(ctx, rw, r)
+// StartPrometheus starts the Prometheus metrics server
+func (m *Monitoring) StartPrometheus(cancel context.CancelFunc) error {
+	return m.prometheus.StartServer(cancel)
+}
+
+func (m *Monitoring) StartRequest(ctx context.Context, rw http.ResponseWriter, r *http.Request) (context.Context, context.CancelFunc, http.ResponseWriter) {
+	promCancel, rw := m.prometheus.StartRequest(rw)
+	ctx, nrCancel, rw := m.newrelic.StartTransaction(ctx, rw, r)
+	ctx, ddCancel, rw := m.datadog.StartRootSpan(ctx, rw, r)
+	ctx, otelCancel, rw := m.otel.StartRootSpan(ctx, rw, r)
 
 	cancel := func() {
 		promCancel()
@@ -81,19 +92,19 @@ func StartRequest(ctx context.Context, rw http.ResponseWriter, r *http.Request)
 	return ctx, cancel, rw
 }
 
-func SetMetadata(ctx context.Context, meta Meta) {
+func (m *Monitoring) SetMetadata(ctx context.Context, meta Meta) {
 	for key, value := range meta {
-		newrelic.SetMetadata(ctx, key, value)
-		datadog.SetMetadata(ctx, key, value)
-		otel.SetMetadata(ctx, key, value)
+		m.newrelic.SetMetadata(ctx, key, value)
+		m.datadog.SetMetadata(ctx, key, value)
+		m.otel.SetMetadata(ctx, key, value)
 	}
 }
 
-func StartQueueSegment(ctx context.Context) context.CancelFunc {
-	promCancel := prometheus.StartQueueSegment()
-	nrCancel := newrelic.StartSegment(ctx, "Queue", nil)
-	ddCancel := datadog.StartSpan(ctx, "queue", nil)
-	otelCancel := otel.StartSpan(ctx, "queue", nil)
+func (m *Monitoring) StartQueueSegment(ctx context.Context) context.CancelFunc {
+	promCancel := m.prometheus.StartQueueSegment()
+	nrCancel := m.newrelic.StartSegment(ctx, "Queue", nil)
+	ddCancel := m.datadog.StartSpan(ctx, "queue", nil)
+	otelCancel := m.otel.StartSpan(ctx, "queue", nil)
 
 	cancel := func() {
 		promCancel()
@@ -105,11 +116,11 @@ func StartQueueSegment(ctx context.Context) context.CancelFunc {
 	return cancel
 }
 
-func StartDownloadingSegment(ctx context.Context, meta Meta) context.CancelFunc {
-	promCancel := prometheus.StartDownloadingSegment()
-	nrCancel := newrelic.StartSegment(ctx, "Downloading image", meta)
-	ddCancel := datadog.StartSpan(ctx, "downloading_image", meta)
-	otelCancel := otel.StartSpan(ctx, "downloading_image", meta)
+func (m *Monitoring) StartDownloadingSegment(ctx context.Context, meta Meta) context.CancelFunc {
+	promCancel := m.prometheus.StartDownloadingSegment()
+	nrCancel := m.newrelic.StartSegment(ctx, "Downloading image", meta)
+	ddCancel := m.datadog.StartSpan(ctx, "downloading_image", meta)
+	otelCancel := m.otel.StartSpan(ctx, "downloading_image", meta)
 
 	cancel := func() {
 		promCancel()
@@ -121,11 +132,11 @@ func StartDownloadingSegment(ctx context.Context, meta Meta) context.CancelFunc
 	return cancel
 }
 
-func StartProcessingSegment(ctx context.Context, meta Meta) context.CancelFunc {
-	promCancel := prometheus.StartProcessingSegment()
-	nrCancel := newrelic.StartSegment(ctx, "Processing image", meta)
-	ddCancel := datadog.StartSpan(ctx, "processing_image", meta)
-	otelCancel := otel.StartSpan(ctx, "processing_image", meta)
+func (m *Monitoring) StartProcessingSegment(ctx context.Context, meta Meta) context.CancelFunc {
+	promCancel := m.prometheus.StartProcessingSegment()
+	nrCancel := m.newrelic.StartSegment(ctx, "Processing image", meta)
+	ddCancel := m.datadog.StartSpan(ctx, "processing_image", meta)
+	otelCancel := m.otel.StartSpan(ctx, "processing_image", meta)
 
 	cancel := func() {
 		promCancel()
@@ -137,11 +148,11 @@ func StartProcessingSegment(ctx context.Context, meta Meta) context.CancelFunc {
 	return cancel
 }
 
-func StartStreamingSegment(ctx context.Context) context.CancelFunc {
-	promCancel := prometheus.StartStreamingSegment()
-	nrCancel := newrelic.StartSegment(ctx, "Streaming image", nil)
-	ddCancel := datadog.StartSpan(ctx, "streaming_image", nil)
-	otelCancel := otel.StartSpan(ctx, "streaming_image", nil)
+func (m *Monitoring) StartStreamingSegment(ctx context.Context) context.CancelFunc {
+	promCancel := m.prometheus.StartStreamingSegment()
+	nrCancel := m.newrelic.StartSegment(ctx, "Streaming image", nil)
+	ddCancel := m.datadog.StartSpan(ctx, "streaming_image", nil)
+	otelCancel := m.otel.StartSpan(ctx, "streaming_image", nil)
 
 	cancel := func() {
 		promCancel()
@@ -153,33 +164,9 @@ func StartStreamingSegment(ctx context.Context) context.CancelFunc {
 	return cancel
 }
 
-func SendError(ctx context.Context, errType string, err error) {
-	prometheus.IncrementErrorsTotal(errType)
-	newrelic.SendError(ctx, errType, err)
-	datadog.SendError(ctx, errType, err)
-	otel.SendError(ctx, errType, err)
-}
-
-func ObserveBufferSize(t string, size int) {
-	prometheus.ObserveBufferSize(t, size)
-	newrelic.ObserveBufferSize(t, size)
-	datadog.ObserveBufferSize(t, size)
-	otel.ObserveBufferSize(t, size)
-	cloudwatch.ObserveBufferSize(t, size)
-}
-
-func SetBufferDefaultSize(t string, size int) {
-	prometheus.SetBufferDefaultSize(t, size)
-	newrelic.SetBufferDefaultSize(t, size)
-	datadog.SetBufferDefaultSize(t, size)
-	otel.SetBufferDefaultSize(t, size)
-	cloudwatch.SetBufferDefaultSize(t, size)
-}
-
-func SetBufferMaxSize(t string, size int) {
-	prometheus.SetBufferMaxSize(t, size)
-	newrelic.SetBufferMaxSize(t, size)
-	datadog.SetBufferMaxSize(t, size)
-	otel.SetBufferMaxSize(t, size)
-	cloudwatch.SetBufferMaxSize(t, size)
+func (m *Monitoring) SendError(ctx context.Context, errType string, err error) {
+	m.prometheus.IncrementErrorsTotal(errType)
+	m.newrelic.SendError(ctx, errType, err)
+	m.datadog.SendError(ctx, errType, err)
+	m.otel.SendError(ctx, errType, err)
 }

+ 66 - 0
monitoring/newrelic/config.go

@@ -0,0 +1,66 @@
+package newrelic
+
+import (
+	"errors"
+	"time"
+
+	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_NEW_RELIC_APP_NAME = env.Describe("IMGPROXY_NEW_RELIC_APP_NAME", "string")
+	IMGPROXY_NEW_RELIC_KEY      = env.Describe("IMGPROXY_NEW_RELIC_KEY", "string")
+	IMGPROXY_NEW_RELIC_LABELS   = env.Describe("IMGPROXY_NEW_RELIC_LABELS", "semicolon-separated list of key=value pairs")
+)
+
+// Config holds the configuration for New Relic monitoring
+type Config struct {
+	AppName         string            // New Relic application name
+	Key             string            // New Relic license key (non-empty value enables New Relic)
+	Labels          map[string]string // New Relic labels/tags
+	MetricsInterval time.Duration     // Interval for sending metrics to New Relic
+}
+
+// NewDefaultConfig returns a new default configuration for New Relic monitoring
+func NewDefaultConfig() Config {
+	return Config{
+		AppName:         "imgproxy",
+		Key:             "",
+		Labels:          make(map[string]string),
+		MetricsInterval: 10 * time.Second,
+	}
+}
+
+// LoadConfigFromEnv loads configuration from environment variables
+func LoadConfigFromEnv(c *Config) (*Config, error) {
+	c = ensure.Ensure(c, NewDefaultConfig)
+
+	err := errors.Join(
+		env.String(&c.AppName, IMGPROXY_NEW_RELIC_APP_NAME),
+		env.String(&c.Key, IMGPROXY_NEW_RELIC_KEY),
+		env.StringMap(&c.Labels, IMGPROXY_NEW_RELIC_LABELS),
+	)
+
+	return c, err
+}
+
+// Enabled returns true if New Relic is enabled
+func (c *Config) Enabled() bool {
+	return len(c.Key) > 0
+}
+
+// Validate checks the configuration for errors
+func (c *Config) Validate() error {
+	// If Key is empty, New Relic is disabled, so no need to validate further
+	if !c.Enabled() {
+		return nil
+	}
+
+	// AppName should not be empty if New Relic is enabled
+	if len(c.AppName) == 0 {
+		return IMGPROXY_NEW_RELIC_APP_NAME.ErrorEmpty()
+	}
+
+	return nil
+}

+ 74 - 136
monitoring/newrelic/newrelic.go

@@ -4,133 +4,134 @@ import (
 	"context"
 	"fmt"
 	"log/slog"
-	"math"
 	"net/http"
 	"reflect"
 	"regexp"
-	"sync"
 	"time"
 
 	"github.com/newrelic/go-agent/v3/newrelic"
 	"github.com/newrelic/newrelic-telemetry-sdk-go/telemetry"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/monitoring/errformat"
 	"github.com/imgproxy/imgproxy/v3/monitoring/stats"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
+// transactionCtxKey context key for storing New Relic transaction in context
 type transactionCtxKey struct{}
 
+// attributable is an interface for New Relic entities that can have attributes set on them
 type attributable interface {
-	AddAttribute(key string, value interface{})
+	AddAttribute(key string, value any)
 }
 
 const (
+	// Metric API endpoints. NOTE: Possibly, this should be configurable?
 	defaultMetricURL = "https://metric-api.newrelic.com/metric/v1"
 	euMetricURL      = "https://metric-api.eu.newrelic.com/metric/v1"
 )
 
-var (
-	enabled          = false
-	enabledHarvester = false
+type NewRelic struct {
+	stats  *stats.Stats
+	config *Config
 
 	app       *newrelic.Application
 	harvester *telemetry.Harvester
 
 	harvesterCtx       context.Context
 	harvesterCtxCancel context.CancelFunc
+}
 
-	bufferSummaries      = make(map[string]*telemetry.Summary)
-	bufferSummariesMutex sync.RWMutex
-
-	interval = 10 * time.Second
-
-	licenseEuRegex = regexp.MustCompile(`(^eu.+?)x`)
-)
-
-func Init() error {
-	if len(config.NewRelicKey) == 0 {
-		return nil
+func New(config *Config, stats *stats.Stats) (*NewRelic, error) {
+	nl := &NewRelic{
+		config: config,
+		stats:  stats,
 	}
 
-	name := config.NewRelicAppName
-	if len(name) == 0 {
-		name = "imgproxy"
+	if !config.Enabled() {
+		return nl, nil
 	}
 
 	var err error
 
-	app, err = newrelic.NewApplication(
-		newrelic.ConfigAppName(name),
-		newrelic.ConfigLicense(config.NewRelicKey),
+	// Initialize New Relic APM agent
+	nl.app, err = newrelic.NewApplication(
+		newrelic.ConfigAppName(config.AppName),
+		newrelic.ConfigLicense(config.Key),
 		func(c *newrelic.Config) {
-			if len(config.NewRelicLabels) > 0 {
-				c.Labels = config.NewRelicLabels
+			if len(config.Labels) > 0 {
+				c.Labels = config.Labels
 			}
 		},
 	)
 
 	if err != nil {
-		return fmt.Errorf("Can't init New Relic agent: %s", err)
+		return nil, fmt.Errorf("can't init New Relic agent: %s", err)
 	}
 
-	harvesterAttributes := map[string]interface{}{"appName": name}
-	for k, v := range config.NewRelicLabels {
+	// Initialize New Relic Telemetry SDK harvester
+	harvesterAttributes := map[string]any{"appName": config.AppName}
+	for k, v := range config.Labels {
 		harvesterAttributes[k] = v
 	}
 
+	// Choose metrics endpoint based on license key pattern
+	licenseEuRegex := regexp.MustCompile(`(^eu.+?)x`)
+
 	metricsURL := defaultMetricURL
-	if licenseEuRegex.MatchString(config.NewRelicKey) {
+	if licenseEuRegex.MatchString(config.Key) {
 		metricsURL = euMetricURL
 	}
 
+	// Initialize error logger
 	errLogger := slog.NewLogLogger(
 		slog.With("source", "newrelic").Handler(),
 		slog.LevelWarn,
 	)
 
-	harvester, err = telemetry.NewHarvester(
-		telemetry.ConfigAPIKey(config.NewRelicKey),
+	// Create harvester
+	harvester, err := telemetry.NewHarvester(
+		telemetry.ConfigAPIKey(config.Key),
 		telemetry.ConfigCommonAttributes(harvesterAttributes),
 		telemetry.ConfigHarvestPeriod(0), // Don't harvest automatically
 		telemetry.ConfigMetricsURLOverride(metricsURL),
 		telemetry.ConfigBasicErrorLogger(errLogger.Writer()),
 	)
 	if err == nil {
-		harvesterCtx, harvesterCtxCancel = context.WithCancel(context.Background())
-		enabledHarvester = true
-		go runMetricsCollector()
+		// In case, there were no errors while starting the harvester, start the metrics collector
+		nl.harvester = harvester
+		nl.harvesterCtx, nl.harvesterCtxCancel = context.WithCancel(context.Background())
+		go nl.runMetricsCollector()
 	} else {
 		slog.Warn(fmt.Sprintf("Can't init New Relic telemetry harvester: %s", err))
 	}
 
-	enabled = true
-
-	return nil
+	return nl, nil
 }
 
-func Stop() {
-	if enabled {
-		app.Shutdown(5 * time.Second)
+// Enabled returns true if New Relic is enabled
+func (nl *NewRelic) Enabled() bool {
+	return nl.config.Enabled()
+}
 
-		if enabledHarvester {
-			harvesterCtxCancel()
-			harvester.HarvestNow(context.Background())
-		}
+// Stop stops the New Relic APM agent and Telemetry SDK harvester
+func (nl *NewRelic) Stop(ctx context.Context) {
+	if nl.app != nil {
+		nl.app.Shutdown(5 * time.Second)
 	}
-}
 
-func Enabled() bool {
-	return enabled
+	if nl.harvester != nil {
+		nl.harvesterCtxCancel()
+		nl.harvester.HarvestNow(ctx)
+	}
 }
 
-func StartTransaction(ctx context.Context, rw http.ResponseWriter, r *http.Request) (context.Context, context.CancelFunc, http.ResponseWriter) {
-	if !enabled {
+func (nl *NewRelic) StartTransaction(ctx context.Context, rw http.ResponseWriter, r *http.Request) (context.Context, context.CancelFunc, http.ResponseWriter) {
+	if !nl.Enabled() {
 		return ctx, func() {}, rw
 	}
 
-	txn := app.StartTransaction("request")
+	txn := nl.app.StartTransaction("request")
 	txn.SetWebRequestHTTP(r)
 	newRw := txn.SetWebResponse(rw)
 	cancel := func() { txn.End() }
@@ -166,8 +167,8 @@ func setMetadata(span attributable, key string, value interface{}) {
 	}
 }
 
-func SetMetadata(ctx context.Context, key string, value interface{}) {
-	if !enabled {
+func (nl *NewRelic) SetMetadata(ctx context.Context, key string, value interface{}) {
+	if !nl.Enabled() {
 		return
 	}
 
@@ -176,8 +177,8 @@ func SetMetadata(ctx context.Context, key string, value interface{}) {
 	}
 }
 
-func StartSegment(ctx context.Context, name string, meta map[string]any) context.CancelFunc {
-	if !enabled {
+func (nl *NewRelic) StartSegment(ctx context.Context, name string, meta map[string]any) context.CancelFunc {
+	if !nl.Enabled() {
 		return func() {}
 	}
 
@@ -194,8 +195,8 @@ func StartSegment(ctx context.Context, name string, meta map[string]any) context
 	return func() {}
 }
 
-func SendError(ctx context.Context, errType string, err error) {
-	if !enabled {
+func (nl *NewRelic) SendError(ctx context.Context, errType string, err error) {
+	if !nl.Enabled() {
 		return
 	}
 
@@ -207,120 +208,57 @@ func SendError(ctx context.Context, errType string, err error) {
 	}
 }
 
-func ObserveBufferSize(t string, size int) {
-	if enabledHarvester {
-		bufferSummariesMutex.Lock()
-		defer bufferSummariesMutex.Unlock()
-
-		summary, ok := bufferSummaries[t]
-		if !ok {
-			summary = &telemetry.Summary{
-				Name:       "imgproxy.buffer.size",
-				Attributes: map[string]interface{}{"buffer_type": t},
-				Timestamp:  time.Now(),
-			}
-			bufferSummaries[t] = summary
-		}
-
-		sizef := float64(size)
-
-		summary.Count += 1
-		summary.Sum += sizef
-		summary.Min = math.Min(summary.Min, sizef)
-		summary.Max = math.Max(summary.Max, sizef)
-	}
-}
-
-func SetBufferDefaultSize(t string, size int) {
-	if enabledHarvester {
-		harvester.RecordMetric(telemetry.Gauge{
-			Name:       "imgproxy.buffer.default_size",
-			Value:      float64(size),
-			Attributes: map[string]interface{}{"buffer_type": t},
-			Timestamp:  time.Now(),
-		})
-	}
-}
-
-func SetBufferMaxSize(t string, size int) {
-	if enabledHarvester {
-		harvester.RecordMetric(telemetry.Gauge{
-			Name:       "imgproxy.buffer.max_size",
-			Value:      float64(size),
-			Attributes: map[string]interface{}{"buffer_type": t},
-			Timestamp:  time.Now(),
-		})
-	}
-}
-
-func runMetricsCollector() {
-	tick := time.NewTicker(interval)
+func (nl *NewRelic) runMetricsCollector() {
+	tick := time.NewTicker(nl.config.MetricsInterval)
 	defer tick.Stop()
+
 	for {
 		select {
 		case <-tick.C:
-			func() {
-				bufferSummariesMutex.RLock()
-				defer bufferSummariesMutex.RUnlock()
-
-				now := time.Now()
-
-				for _, summary := range bufferSummaries {
-					summary.Interval = now.Sub(summary.Timestamp)
-					harvester.RecordMetric(*summary)
-
-					summary.Timestamp = now
-					summary.Count = 0
-					summary.Sum = 0
-					summary.Min = 0
-					summary.Max = 0
-				}
-			}()
-
-			harvester.RecordMetric(telemetry.Gauge{
+			nl.harvester.RecordMetric(telemetry.Gauge{
 				Name:      "imgproxy.workers",
-				Value:     float64(config.Workers),
+				Value:     float64(nl.stats.WorkersNumber),
 				Timestamp: time.Now(),
 			})
 
-			harvester.RecordMetric(telemetry.Gauge{
+			nl.harvester.RecordMetric(telemetry.Gauge{
 				Name:      "imgproxy.requests_in_progress",
-				Value:     stats.RequestsInProgress(),
+				Value:     nl.stats.RequestsInProgress(),
 				Timestamp: time.Now(),
 			})
 
-			harvester.RecordMetric(telemetry.Gauge{
+			nl.harvester.RecordMetric(telemetry.Gauge{
 				Name:      "imgproxy.images_in_progress",
-				Value:     stats.ImagesInProgress(),
+				Value:     nl.stats.ImagesInProgress(),
 				Timestamp: time.Now(),
 			})
 
-			harvester.RecordMetric(telemetry.Gauge{
+			nl.harvester.RecordMetric(telemetry.Gauge{
 				Name:      "imgproxy.workers_utilization",
-				Value:     stats.WorkersUtilization(),
+				Value:     nl.stats.WorkersUtilization(),
 				Timestamp: time.Now(),
 			})
 
-			harvester.RecordMetric(telemetry.Gauge{
+			nl.harvester.RecordMetric(telemetry.Gauge{
 				Name:      "imgproxy.vips.memory",
 				Value:     vips.GetMem(),
 				Timestamp: time.Now(),
 			})
 
-			harvester.RecordMetric(telemetry.Gauge{
+			nl.harvester.RecordMetric(telemetry.Gauge{
 				Name:      "imgproxy.vips.max_memory",
 				Value:     vips.GetMemHighwater(),
 				Timestamp: time.Now(),
 			})
 
-			harvester.RecordMetric(telemetry.Gauge{
+			nl.harvester.RecordMetric(telemetry.Gauge{
 				Name:      "imgproxy.vips.allocs",
 				Value:     vips.GetAllocs(),
 				Timestamp: time.Now(),
 			})
 
-			harvester.HarvestNow(harvesterCtx)
-		case <-harvesterCtx.Done():
+			nl.harvester.HarvestNow(nl.harvesterCtx)
+		case <-nl.harvesterCtx.Done():
 			return
 		}
 	}

+ 154 - 0
monitoring/otel/builders.go

@@ -0,0 +1,154 @@
+package otel
+
+import (
+	"context"
+	"crypto/tls"
+	"crypto/x509"
+	"errors"
+	"fmt"
+	"time"
+
+	"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
+	"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
+	"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
+	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
+	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
+	sdkmetric "go.opentelemetry.io/otel/sdk/metric"
+	"google.golang.org/grpc/credentials"
+)
+
+// buildProtocolExporter builds trace and metric exporters based on the provided configuration.
+func buildProtocolExporter(config *Config) (te *otlptrace.Exporter, me sdkmetric.Exporter, err error) {
+	switch config.Protocol {
+	case "grpc":
+		te, me, err = buildGRPCExporters(config)
+	case "http/protobuf", "http", "https":
+		te, me, err = buildHTTPExporters(config)
+	default:
+		err = fmt.Errorf("unsupported OpenTelemetry protocol: %s", config.Protocol)
+	}
+
+	return
+}
+
+// buildGRPCExporters builds GRPC exporters based on the provided configuration.
+func buildGRPCExporters(config *Config) (*otlptrace.Exporter, sdkmetric.Exporter, error) {
+	tracerOpts := []otlptracegrpc.Option{}
+	meterOpts := []otlpmetricgrpc.Option{}
+
+	if tlsConf, err := buildTLSConfig(config); tlsConf != nil && err == nil {
+		creds := credentials.NewTLS(tlsConf)
+		tracerOpts = append(tracerOpts, otlptracegrpc.WithTLSCredentials(creds))
+		meterOpts = append(meterOpts, otlpmetricgrpc.WithTLSCredentials(creds))
+	} else if err != nil {
+		return nil, nil, err
+	}
+
+	// This context limits connect timeout, not the whole lifetime of the exporter
+	trctx, trcancel := context.WithTimeout(
+		context.Background(), withDefaultTimeout(config, config.TracesConnTimeout),
+	)
+	defer trcancel()
+
+	traceExporter, err := otlptracegrpc.New(trctx, tracerOpts...)
+	if err != nil {
+		err = fmt.Errorf("can't connect to OpenTelemetry collector: %s", err)
+	}
+
+	if !config.EnableMetrics {
+		return traceExporter, nil, err
+	}
+
+	// This context limits connect timeout, not the whole lifetime of the exporter
+	mtctx, mtcancel := context.WithTimeout(
+		context.Background(), withDefaultTimeout(config, config.MetricsConnTimeout),
+	)
+	defer mtcancel()
+
+	metricExporter, err := otlpmetricgrpc.New(mtctx, meterOpts...)
+	if err != nil {
+		err = fmt.Errorf("can't connect to OpenTelemetry collector: %s", err)
+	}
+
+	return traceExporter, metricExporter, err
+}
+
+// buildHTTPExporters builds HTTP exporters based on the provided configuration.
+func buildHTTPExporters(config *Config) (*otlptrace.Exporter, sdkmetric.Exporter, error) {
+	tracerOpts := []otlptracehttp.Option{}
+	meterOpts := []otlpmetrichttp.Option{}
+
+	if tlsConf, err := buildTLSConfig(config); tlsConf != nil && err == nil {
+		tracerOpts = append(tracerOpts, otlptracehttp.WithTLSClientConfig(tlsConf))
+		meterOpts = append(meterOpts, otlpmetrichttp.WithTLSClientConfig(tlsConf))
+	} else if err != nil {
+		return nil, nil, err
+	}
+
+	// This context limits connect timeout, not the whole lifetime of the exporter
+	trctx, trcancel := context.WithTimeout(
+		context.Background(), withDefaultTimeout(config, config.TracesConnTimeout),
+	)
+	defer trcancel()
+
+	traceExporter, err := otlptracehttp.New(trctx, tracerOpts...)
+	if err != nil {
+		err = fmt.Errorf("can't connect to OpenTelemetry collector: %s", err)
+	}
+
+	if !config.EnableMetrics {
+		return traceExporter, nil, err
+	}
+
+	// This context limits connect timeout, not the whole lifetime of the exporter
+	mtctx, mtcancel := context.WithTimeout(
+		context.Background(), withDefaultTimeout(config, config.MetricsConnTimeout),
+	)
+	defer mtcancel()
+
+	metricExporter, err := otlpmetrichttp.New(mtctx, meterOpts...)
+	if err != nil {
+		err = fmt.Errorf("can't connect to OpenTelemetry collector: %s", err)
+	}
+
+	return traceExporter, metricExporter, err
+}
+
+// buildTLSConfig constructs a tls.Config based on the provided configuration.
+func buildTLSConfig(config *Config) (*tls.Config, error) {
+	// If no server certificate is provided, we assume no TLS is needed
+	if len(config.ServerCert) == 0 {
+		return nil, nil
+	}
+
+	// Attach root CAs
+	certPool := x509.NewCertPool()
+	if !certPool.AppendCertsFromPEM(config.ServerCert) {
+		return nil, errors.New("can't load OpenTelemetry server cert")
+	}
+
+	tlsConf := tls.Config{RootCAs: certPool}
+
+	// If there is not client cert or key, return the config with only root CAs
+	if len(config.ClientCert) == 0 || len(config.ClientKey) == 0 {
+		return &tlsConf, nil
+	}
+
+	cert, err := tls.X509KeyPair(config.ClientCert, config.ClientKey)
+	if err != nil {
+		return nil, fmt.Errorf("can't load OpenTelemetry client cert/key pair: %s", err)
+	}
+
+	tlsConf.Certificates = []tls.Certificate{cert}
+
+	return &tlsConf, nil
+}
+
+func withDefaultTimeout(config *Config, timeout time.Duration) time.Duration {
+	// In case, timeout is zero or negative we assume it was not set
+	// (or was set to invalid value) and use the default timeout
+	if timeout <= 0 {
+		return config.ConnTimeout
+	}
+	return timeout
+}

+ 112 - 0
monitoring/otel/config.go

@@ -0,0 +1,112 @@
+package otel
+
+import (
+	"errors"
+	"strings"
+	"time"
+
+	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_OPEN_TELEMETRY_ENABLE             = env.Describe("IMGPROXY_OPEN_TELEMETRY_ENABLE", "boolean")
+	IMGPROXY_OPEN_TELEMETRY_ENABLE_METRICS     = env.Describe("IMGPROXY_OPEN_TELEMETRY_ENABLE_METRICS", "boolean")
+	IMGPROXY_OPEN_TELEMETRY_SERVER_CERT        = env.Describe("IMGPROXY_OPEN_TELEMETRY_SERVER_CERT", "string")
+	IMGPROXY_OPEN_TELEMETRY_CLIENT_CERT        = env.Describe("IMGPROXY_OPEN_TELEMETRY_CLIENT_CERT", "string")
+	IMGPROXY_OPEN_TELEMETRY_CLIENT_KEY         = env.Describe("IMGPROXY_OPEN_TELEMETRY_CLIENT_KEY", "string")
+	IMGPROXY_OPEN_TELEMETRY_TRACE_ID_GENERATOR = env.Describe("IMGPROXY_OPEN_TELEMETRY_TRACE_ID_GENERATOR", "xray|random")
+
+	// Those are OpenTelemetry SDK environment variables
+	OTEL_EXPORTER_OTLP_PROTOCOL        = env.Describe("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc|http/protobuf|http|https")
+	OTEL_EXPORTER_OTLP_TIMEOUT         = env.Describe("OTEL_EXPORTER_OTLP_TIMEOUT", "milliseconds")
+	OTEL_EXPORTER_OTLP_TRACES_TIMEOUT  = env.Describe("OTEL_EXPORTER_OTLP_TRACES_TIMEOUT", "milliseconds")
+	OTEL_EXPORTER_OTLP_METRICS_TIMEOUT = env.Describe("OTEL_EXPORTER_OTLP_METRICS_TIMEOUT", "milliseconds")
+	OTEL_PROPAGATORS                   = env.Describe("OTEL_PROPAGATORS", "comma-separated list of propagators")
+	OTEL_SERVICE_NAME                  = env.Describe("OTEL_SERVICE_NAME", "string") // This is used during initialization
+)
+
+// Config holds the configuration for OpenTelemetry monitoring
+type Config struct {
+	Enable           bool   // Enable OpenTelemetry tracing and metrics
+	EnableMetrics    bool   // Enable OpenTelemetry metrics collection
+	ServerCert       []byte // Server certificate for TLS connection
+	ClientCert       []byte // Client certificate for TLS connection
+	ClientKey        []byte // Client key for TLS connection
+	TraceIDGenerator string // Trace ID generator type (e.g., "xray", "random")
+
+	Protocol           string        // Protocol to use for OTLP exporter (grpc, http/protobuf, http, https)
+	ConnTimeout        time.Duration // Connection timeout for OTLP exporter
+	MetricsConnTimeout time.Duration // Connection timeout for metrics exporter
+	TracesConnTimeout  time.Duration // Connection timeout for traces exporter
+	Propagators        []string      // List of propagators to use
+
+	MetricsInterval time.Duration // Interval for sending metrics to OpenTelemetry collector
+}
+
+// NewDefaultConfig returns a new default configuration for OpenTelemetry monitoring
+func NewDefaultConfig() Config {
+	return Config{
+		Enable:             false,
+		EnableMetrics:      false,
+		ServerCert:         nil,
+		ClientCert:         nil,
+		ClientKey:          nil,
+		TraceIDGenerator:   "xray",
+		Protocol:           "grpc",
+		ConnTimeout:        10_000 * time.Millisecond,
+		MetricsConnTimeout: 0,
+		TracesConnTimeout:  0,
+		Propagators:        []string{},
+		MetricsInterval:    10 * time.Second,
+	}
+}
+
+// LoadConfigFromEnv loads configuration from environment variables
+func LoadConfigFromEnv(c *Config) (*Config, error) {
+	c = ensure.Ensure(c, NewDefaultConfig)
+
+	var serverCert, clientCert, clientKey string
+
+	err := errors.Join(
+		env.Bool(&c.Enable, IMGPROXY_OPEN_TELEMETRY_ENABLE),
+		env.Bool(&c.EnableMetrics, IMGPROXY_OPEN_TELEMETRY_ENABLE_METRICS),
+		env.String(&serverCert, IMGPROXY_OPEN_TELEMETRY_SERVER_CERT),
+		env.String(&clientCert, IMGPROXY_OPEN_TELEMETRY_CLIENT_CERT),
+		env.String(&clientKey, IMGPROXY_OPEN_TELEMETRY_CLIENT_KEY),
+		env.String(&c.TraceIDGenerator, IMGPROXY_OPEN_TELEMETRY_TRACE_ID_GENERATOR),
+		env.String(&c.Protocol, OTEL_EXPORTER_OTLP_PROTOCOL),
+		env.DurationMils(&c.ConnTimeout, OTEL_EXPORTER_OTLP_TIMEOUT),
+		env.DurationMils(&c.TracesConnTimeout, OTEL_EXPORTER_OTLP_TRACES_TIMEOUT),
+		env.DurationMils(&c.MetricsConnTimeout, OTEL_EXPORTER_OTLP_METRICS_TIMEOUT),
+		env.StringSlice(&c.Propagators, OTEL_PROPAGATORS),
+	)
+
+	c.ServerCert = prepareKeyCert(serverCert)
+	c.ClientCert = prepareKeyCert(clientCert)
+	c.ClientKey = prepareKeyCert(clientKey)
+
+	return c, err
+}
+
+func (c *Config) Enabled() bool {
+	return c.Enable
+}
+
+// Validate checks the configuration for errors
+func (c *Config) Validate() error {
+	if !c.Enabled() {
+		return nil
+	}
+
+	// Timeout should be valid
+	if c.ConnTimeout <= 0 {
+		return OTEL_EXPORTER_OTLP_TIMEOUT.ErrorZeroOrNegative()
+	}
+
+	return nil
+}
+
+func prepareKeyCert(str string) []byte {
+	return []byte(strings.ReplaceAll(str, `\n`, "\n"))
+}

+ 123 - 397
monitoring/otel/otel.go

@@ -2,18 +2,12 @@ package otel
 
 import (
 	"context"
-	"crypto/tls"
-	"crypto/x509"
-	"errors"
 	"fmt"
 	"log/slog"
 	"net/http"
 	"os"
 	"reflect"
 	"runtime"
-	"strconv"
-	"strings"
-	"sync"
 	"time"
 
 	"github.com/felixge/httpsnoop"
@@ -26,11 +20,6 @@ import (
 	"go.opentelemetry.io/otel"
 	"go.opentelemetry.io/otel/attribute"
 	"go.opentelemetry.io/otel/codes"
-	"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
-	"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
-	"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
-	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
-	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
 	"go.opentelemetry.io/otel/metric"
 	"go.opentelemetry.io/otel/propagation"
 	sdkmetric "go.opentelemetry.io/otel/sdk/metric"
@@ -39,10 +28,7 @@ import (
 	semconv "go.opentelemetry.io/otel/semconv/v1.20.0"
 	"go.opentelemetry.io/otel/semconv/v1.20.0/httpconv"
 	"go.opentelemetry.io/otel/trace"
-	"google.golang.org/grpc/credentials"
 
-	"github.com/imgproxy/imgproxy/v3/config"
-	"github.com/imgproxy/imgproxy/v3/config/configurators"
 	"github.com/imgproxy/imgproxy/v3/ierrors"
 	"github.com/imgproxy/imgproxy/v3/monitoring/errformat"
 	"github.com/imgproxy/imgproxy/v3/monitoring/stats"
@@ -50,11 +36,28 @@ import (
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
+const (
+	// stopTimeout is the maximum time to wait for the shutdown of the tracer and meter providers
+	stopTimeout = 5 * time.Second
+
+	// defaultOtelServiceName is the default service name for OpenTelemetry if none is set
+	defaultOtelServiceName = "imgproxy"
+)
+
+// hasSpanCtxKey is a context key to mark that there is a span in the context
 type hasSpanCtxKey struct{}
 
-var (
-	enabled        bool
-	enabledMetrics bool
+// errorHandler is an implementation of the OpenTelemetry error handler interface
+type errorHandler struct{}
+
+func (h errorHandler) Handle(err error) {
+	slog.Warn(err.Error(), "source", "opentelemetry")
+}
+
+// Otel holds OpenTelemetry tracer and meter providers and configuration
+type Otel struct {
+	config *Config
+	stats  *stats.Stats
 
 	tracerProvider *sdktrace.TracerProvider
 	tracer         trace.Tracer
@@ -63,52 +66,39 @@ var (
 	meter         metric.Meter
 
 	propagator propagation.TextMapPropagator
+}
 
-	bufferSizeHist     metric.Int64Histogram
-	bufferDefaultSizes = make(map[string]int)
-	bufferMaxSizes     = make(map[string]int)
-	bufferStatsMutex   sync.Mutex
-)
+// New creates a new Otel instance
+func New(config *Config, stats *stats.Stats) (*Otel, error) {
+	o := &Otel{
+		config: config,
+		stats:  stats,
+	}
 
-func Init() error {
-	mapDeprecatedConfig()
+	if !config.Enabled() {
+		return o, nil
+	}
 
-	if !config.OpenTelemetryEnable {
-		return nil
+	if err := config.Validate(); err != nil {
+		return nil, err
 	}
 
 	otel.SetErrorHandler(errorHandler{})
 
-	var (
-		traceExporter  *otlptrace.Exporter
-		metricExporter sdkmetric.Exporter
-		err            error
-	)
-
-	protocol := "grpc"
-	configurators.String(&protocol, "OTEL_EXPORTER_OTLP_PROTOCOL")
-
-	switch protocol {
-	case "grpc":
-		traceExporter, metricExporter, err = buildGRPCExporters()
-	case "http/protobuf", "http", "https":
-		traceExporter, metricExporter, err = buildHTTPExporters()
-	default:
-		return fmt.Errorf("Unsupported OpenTelemetry protocol: %s", protocol)
-	}
-
+	traceExporter, metricExporter, err := buildProtocolExporter(config)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
-	if len(os.Getenv("OTEL_SERVICE_NAME")) == 0 {
-		os.Setenv("OTEL_SERVICE_NAME", "imgproxy")
+	// If no service name is set, use "imgproxy" as default, and write it into the environment
+	if n, _ := OTEL_SERVICE_NAME.Get(); len(n) == 0 {
+		os.Setenv(OTEL_SERVICE_NAME.Name, defaultOtelServiceName)
 	}
 
 	res, _ := resource.Merge(
 		resource.Default(),
 		resource.NewSchemaless(
-			semconv.ServiceVersionKey.String(version.Version),
+			semconv.ServiceVersion(version.Version),
 		),
 	)
 
@@ -122,7 +112,7 @@ func Init() error {
 	if merged, merr := resource.Merge(awsRes, res); merr == nil {
 		res = merged
 	} else {
-		slog.Warn(fmt.Sprintf("Can't add AWS attributes to OpenTelemetry: %s", merr))
+		slog.Warn(fmt.Sprintf("can't add AWS attributes to OpenTelemetry: %s", merr))
 	}
 
 	opts := []sdktrace.TracerProviderOption{
@@ -130,271 +120,76 @@ func Init() error {
 		sdktrace.WithBatcher(traceExporter),
 	}
 
-	switch g := config.OpenTelemetryTraceIDGenerator; g {
+	switch g := config.TraceIDGenerator; g {
 	case "xray":
 		idg := xray.NewIDGenerator()
 		opts = append(opts, sdktrace.WithIDGenerator(idg))
 	case "random":
 		// Do nothing. OTel uses random generator by default
 	default:
-		return fmt.Errorf("Unknown Trace ID generator: %s", g)
+		return nil, fmt.Errorf("unknown Trace ID generator: %s", g)
 	}
 
-	tracerProvider = sdktrace.NewTracerProvider(opts...)
-
-	tracer = tracerProvider.Tracer("imgproxy")
-
-	var propagatorNames []string
-	configurators.StringSlice(&propagatorNames, "OTEL_PROPAGATORS")
+	o.tracerProvider = sdktrace.NewTracerProvider(opts...)
+	o.tracer = o.tracerProvider.Tracer("imgproxy")
 
-	if len(propagatorNames) > 0 {
-		propagator, err = autoprop.TextMapPropagator(propagatorNames...)
+	if len(config.Propagators) > 0 {
+		o.propagator, err = autoprop.TextMapPropagator(config.Propagators...)
 		if err != nil {
-			return err
+			return nil, err
 		}
 	}
 
-	enabled = true
-
 	if metricExporter == nil {
-		return nil
+		return o, nil
 	}
 
 	metricReader := sdkmetric.NewPeriodicReader(
 		metricExporter,
-		sdkmetric.WithInterval(5*time.Second),
+		sdkmetric.WithInterval(config.MetricsInterval),
 	)
 
-	meterProvider = sdkmetric.NewMeterProvider(
+	o.meterProvider = sdkmetric.NewMeterProvider(
 		sdkmetric.WithResource(res),
 		sdkmetric.WithReader(metricReader),
 	)
 
-	meter = meterProvider.Meter("imgproxy")
-
-	if err = addDefaultMetrics(); err != nil {
-		return err
-	}
-
-	enabledMetrics = true
-
-	return nil
-}
-
-func mapDeprecatedConfig() {
-	endpoint := os.Getenv("IMGPROXY_OPEN_TELEMETRY_ENDPOINT")
-	if len(endpoint) > 0 {
-		slog.Warn("The IMGPROXY_OPEN_TELEMETRY_ENDPOINT config is deprecated. Use IMGPROXY_OPEN_TELEMETRY_ENABLE and OTEL_EXPORTER_OTLP_ENDPOINT instead. See https://docs.imgproxy.net/latest/monitoring/open_telemetry#deprecated-environment-variables")
-		config.OpenTelemetryEnable = true
-	}
-
-	if !config.OpenTelemetryEnable {
-		return
-	}
-
-	protocol := "grpc"
-
-	if prot := os.Getenv("IMGPROXY_OPEN_TELEMETRY_PROTOCOL"); len(prot) > 0 {
-		slog.Warn("The IMGPROXY_OPEN_TELEMETRY_PROTOCOL config is deprecated. Use OTEL_EXPORTER_OTLP_PROTOCOL instead. See https://docs.imgproxy.net/latest/monitoring/open_telemetry#deprecated-environment-variables")
-		protocol = prot
-		os.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", protocol)
-	}
-
-	if len(endpoint) > 0 {
-		schema := "https"
-
-		switch protocol {
-		case "grpc":
-			if insecure, _ := strconv.ParseBool(os.Getenv("IMGPROXY_OPEN_TELEMETRY_GRPC_INSECURE")); insecure {
-				slog.Warn("The IMGPROXY_OPEN_TELEMETRY_GRPC_INSECURE config is deprecated. Use OTEL_EXPORTER_OTLP_ENDPOINT with the `http://` schema instead. See https://docs.imgproxy.net/latest/monitoring/open_telemetry#deprecated-environment-variables")
-				schema = "http"
-			}
-		case "http":
-			schema = "http"
-		}
-
-		os.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", fmt.Sprintf("%s://%s", schema, endpoint))
-	}
-
-	if serviceName := os.Getenv("IMGPROXY_OPEN_TELEMETRY_SERVICE_NAME"); len(serviceName) > 0 {
-		slog.Warn("The IMGPROXY_OPEN_TELEMETRY_SERVICE_NAME config is deprecated. Use OTEL_SERVICE_NAME instead. See https://docs.imgproxy.net/latest/monitoring/open_telemetry#deprecated-environment-variables")
-		os.Setenv("OTEL_SERVICE_NAME", serviceName)
-	}
-
-	if propagators := os.Getenv("IMGPROXY_OPEN_TELEMETRY_PROPAGATORS"); len(propagators) > 0 {
-		slog.Warn("The IMGPROXY_OPEN_TELEMETRY_PROPAGATORS config is deprecated. Use OTEL_PROPAGATORS instead. See https://docs.imgproxy.net/latest/monitoring/open_telemetry#deprecated-environment-variables")
-		os.Setenv("OTEL_PROPAGATORS", propagators)
-	}
-
-	if timeout := os.Getenv("IMGPROXY_OPEN_TELEMETRY_CONNECTION_TIMEOUT"); len(timeout) > 0 {
-		slog.Warn("The IMGPROXY_OPEN_TELEMETRY_CONNECTION_TIMEOUT config is deprecated. Use OTEL_EXPORTER_OTLP_TIMEOUT instead. See https://docs.imgproxy.net/latest/monitoring/open_telemetry#deprecated-environment-variables")
-
-		if to, _ := strconv.Atoi(timeout); to > 0 {
-			os.Setenv("OTEL_EXPORTER_OTLP_TIMEOUT", strconv.Itoa(to*1000))
-		}
-	}
-}
-
-func buildGRPCExporters() (*otlptrace.Exporter, sdkmetric.Exporter, error) {
-	tracerOpts := []otlptracegrpc.Option{}
-	meterOpts := []otlpmetricgrpc.Option{}
-
-	if tlsConf, err := buildTLSConfig(); tlsConf != nil && err == nil {
-		creds := credentials.NewTLS(tlsConf)
-		tracerOpts = append(tracerOpts, otlptracegrpc.WithTLSCredentials(creds))
-		meterOpts = append(meterOpts, otlpmetricgrpc.WithTLSCredentials(creds))
-	} else if err != nil {
-		return nil, nil, err
-	}
-
-	tracesConnTimeout, metricsConnTimeout, err := getConnectionTimeouts()
-	if err != nil {
-		return nil, nil, err
-	}
-
-	trctx, trcancel := context.WithTimeout(context.Background(), tracesConnTimeout)
-	defer trcancel()
-
-	traceExporter, err := otlptracegrpc.New(trctx, tracerOpts...)
-	if err != nil {
-		err = fmt.Errorf("Can't connect to OpenTelemetry collector: %s", err)
-	}
-
-	if !config.OpenTelemetryEnableMetrics {
-		return traceExporter, nil, err
-	}
-
-	mtctx, mtcancel := context.WithTimeout(context.Background(), metricsConnTimeout)
-	defer mtcancel()
-
-	metricExporter, err := otlpmetricgrpc.New(mtctx, meterOpts...)
-	if err != nil {
-		err = fmt.Errorf("Can't connect to OpenTelemetry collector: %s", err)
-	}
-
-	return traceExporter, metricExporter, err
-}
-
-func buildHTTPExporters() (*otlptrace.Exporter, sdkmetric.Exporter, error) {
-	tracerOpts := []otlptracehttp.Option{}
-	meterOpts := []otlpmetrichttp.Option{}
-
-	if tlsConf, err := buildTLSConfig(); tlsConf != nil && err == nil {
-		tracerOpts = append(tracerOpts, otlptracehttp.WithTLSClientConfig(tlsConf))
-		meterOpts = append(meterOpts, otlpmetrichttp.WithTLSClientConfig(tlsConf))
-	} else if err != nil {
-		return nil, nil, err
-	}
-
-	tracesConnTimeout, metricsConnTimeout, err := getConnectionTimeouts()
-	if err != nil {
-		return nil, nil, err
-	}
-
-	trctx, trcancel := context.WithTimeout(context.Background(), tracesConnTimeout)
-	defer trcancel()
-
-	traceExporter, err := otlptracehttp.New(trctx, tracerOpts...)
-	if err != nil {
-		err = fmt.Errorf("Can't connect to OpenTelemetry collector: %s", err)
-	}
-
-	if !config.OpenTelemetryEnableMetrics {
-		return traceExporter, nil, err
-	}
-
-	mtctx, mtcancel := context.WithTimeout(context.Background(), metricsConnTimeout)
-	defer mtcancel()
-
-	metricExporter, err := otlpmetrichttp.New(mtctx, meterOpts...)
-	if err != nil {
-		err = fmt.Errorf("Can't connect to OpenTelemetry collector: %s", err)
-	}
-
-	return traceExporter, metricExporter, err
-}
-
-func getConnectionTimeouts() (time.Duration, time.Duration, error) {
-	connTimeout := 10000
-	configurators.Int(&connTimeout, "OTEL_EXPORTER_OTLP_TIMEOUT")
-
-	tracesConnTimeout := connTimeout
-	configurators.Int(&tracesConnTimeout, "OTEL_EXPORTER_OTLP_TRACES_TIMEOUT")
-
-	metricsConnTimeout := connTimeout
-	configurators.Int(&metricsConnTimeout, "OTEL_EXPORTER_OTLP_METRICS_TIMEOUT")
+	o.meter = o.meterProvider.Meter("imgproxy")
 
-	if tracesConnTimeout <= 0 {
-		return 0, 0, errors.New("Opentelemetry traces timeout should be greater than 0")
+	if err = o.addDefaultMetrics(); err != nil {
+		return nil, err
 	}
 
-	if metricsConnTimeout <= 0 {
-		return 0, 0, errors.New("Opentelemetry metrics timeout should be greater than 0")
-	}
-
-	return time.Duration(tracesConnTimeout) * time.Millisecond,
-		time.Duration(metricsConnTimeout) * time.Millisecond,
-		nil
-}
-
-func buildTLSConfig() (*tls.Config, error) {
-	if len(config.OpenTelemetryServerCert) == 0 {
-		return nil, nil
-	}
-
-	certPool := x509.NewCertPool()
-	if !certPool.AppendCertsFromPEM(prepareKeyCert(config.OpenTelemetryServerCert)) {
-		return nil, errors.New("Can't load OpenTelemetry server cert")
-	}
-
-	tlsConf := tls.Config{RootCAs: certPool}
-
-	if len(config.OpenTelemetryClientCert) > 0 && len(config.OpenTelemetryClientKey) > 0 {
-		cert, err := tls.X509KeyPair(
-			prepareKeyCert(config.OpenTelemetryClientCert),
-			prepareKeyCert(config.OpenTelemetryClientKey),
-		)
-		if err != nil {
-			return nil, fmt.Errorf("Can't load OpenTelemetry client cert/key pair: %s", err)
-		}
-
-		tlsConf.Certificates = []tls.Certificate{cert}
-	}
-
-	return &tlsConf, nil
+	return o, nil
 }
 
-func prepareKeyCert(str string) []byte {
-	return []byte(strings.ReplaceAll(str, `\n`, "\n"))
+func (o *Otel) Enabled() bool {
+	return o.config.Enabled()
 }
 
-func Stop() {
-	if enabled {
-		trctx, trcancel := context.WithTimeout(context.Background(), 5*time.Second)
+func (o *Otel) Stop(ctx context.Context) {
+	if o.tracerProvider != nil {
+		trctx, trcancel := context.WithTimeout(ctx, stopTimeout)
 		defer trcancel()
 
-		tracerProvider.Shutdown(trctx)
+		o.tracerProvider.Shutdown(trctx)
+	}
 
-		if meterProvider != nil {
-			mtctx, mtcancel := context.WithTimeout(context.Background(), 5*time.Second)
-			defer mtcancel()
+	if o.meterProvider != nil {
+		mtctx, mtcancel := context.WithTimeout(ctx, stopTimeout)
+		defer mtcancel()
 
-			meterProvider.Shutdown(mtctx)
-		}
+		o.meterProvider.Shutdown(mtctx)
 	}
 }
 
-func Enabled() bool {
-	return enabled
-}
-
-func StartRootSpan(ctx context.Context, rw http.ResponseWriter, r *http.Request) (context.Context, context.CancelFunc, http.ResponseWriter) {
-	if !enabled {
+func (o *Otel) StartRootSpan(ctx context.Context, rw http.ResponseWriter, r *http.Request) (context.Context, context.CancelFunc, http.ResponseWriter) {
+	if !o.Enabled() {
 		return ctx, func() {}, rw
 	}
 
-	if propagator != nil {
-		ctx = propagator.Extract(ctx, propagation.HeaderCarrier(r.Header))
+	if o.propagator != nil {
+		ctx = o.propagator.Extract(ctx, propagation.HeaderCarrier(r.Header))
 	}
 
 	server := r.Host
@@ -402,7 +197,7 @@ func StartRootSpan(ctx context.Context, rw http.ResponseWriter, r *http.Request)
 		server = "imgproxy"
 	}
 
-	ctx, span := tracer.Start(
+	ctx, span := o.tracer.Start(
 		ctx, "/request",
 		trace.WithSpanKind(trace.SpanKindServer),
 		trace.WithAttributes(httpconv.ServerRequest(server, r)...),
@@ -459,8 +254,8 @@ func setMetadata(span trace.Span, key string, value interface{}) {
 	}
 }
 
-func SetMetadata(ctx context.Context, key string, value interface{}) {
-	if !enabled {
+func (o *Otel) SetMetadata(ctx context.Context, key string, value interface{}) {
+	if !o.Enabled() {
 		return
 	}
 
@@ -471,13 +266,13 @@ func SetMetadata(ctx context.Context, key string, value interface{}) {
 	}
 }
 
-func StartSpan(ctx context.Context, name string, meta map[string]any) context.CancelFunc {
-	if !enabled {
+func (o *Otel) StartSpan(ctx context.Context, name string, meta map[string]any) context.CancelFunc {
+	if !o.Enabled() {
 		return func() {}
 	}
 
 	if ctx.Value(hasSpanCtxKey{}) != nil {
-		_, span := tracer.Start(ctx, name, trace.WithSpanKind(trace.SpanKindInternal))
+		_, span := o.tracer.Start(ctx, name, trace.WithSpanKind(trace.SpanKindInternal))
 
 		for k, v := range meta {
 			setMetadata(span, k, v)
@@ -489,8 +284,8 @@ func StartSpan(ctx context.Context, name string, meta map[string]any) context.Ca
 	return func() {}
 }
 
-func SendError(ctx context.Context, errType string, err error) {
-	if !enabled {
+func (o *Otel) SendError(ctx context.Context, errType string, err error) {
+	if !o.Enabled() {
 		return
 	}
 
@@ -510,195 +305,167 @@ func SendError(ctx context.Context, errType string, err error) {
 	span.AddEvent(semconv.ExceptionEventName, trace.WithAttributes(attributes...))
 }
 
-func addDefaultMetrics() error {
+func (o *Otel) addDefaultMetrics() error {
 	proc, err := process.NewProcess(int32(os.Getpid()))
 	if err != nil {
-		return fmt.Errorf("Can't initialize process data for OpenTelemetry: %s", err)
+		return fmt.Errorf("can't initialize process data for OpenTelemetry: %s", err)
 	}
 
-	processResidentMemory, err := meter.Int64ObservableGauge(
+	processResidentMemory, err := o.meter.Int64ObservableGauge(
 		"process_resident_memory_bytes",
 		metric.WithUnit("By"),
 		metric.WithDescription("Resident memory size in bytes."),
 	)
 	if err != nil {
-		return fmt.Errorf("Can't add process_resident_memory_bytes gauge to OpenTelemetry: %s", err)
+		return fmt.Errorf("can't add process_resident_memory_bytes gauge to OpenTelemetry: %s", err)
 	}
 
-	processVirtualMemory, err := meter.Int64ObservableGauge(
+	processVirtualMemory, err := o.meter.Int64ObservableGauge(
 		"process_virtual_memory_bytes",
 		metric.WithUnit("By"),
 		metric.WithDescription("Virtual memory size in bytes."),
 	)
 	if err != nil {
-		return fmt.Errorf("Can't add process_virtual_memory_bytes gauge to OpenTelemetry: %s", err)
+		return fmt.Errorf("can't add process_virtual_memory_bytes gauge to OpenTelemetry: %s", err)
 	}
 
-	goMemstatsSys, err := meter.Int64ObservableGauge(
+	goMemstatsSys, err := o.meter.Int64ObservableGauge(
 		"go_memstats_sys_bytes",
 		metric.WithUnit("By"),
 		metric.WithDescription("Number of bytes obtained from system."),
 	)
 	if err != nil {
-		return fmt.Errorf("Can't add go_memstats_sys_bytes gauge to OpenTelemetry: %s", err)
+		return fmt.Errorf("can't add go_memstats_sys_bytes gauge to OpenTelemetry: %s", err)
 	}
 
-	goMemstatsHeapIdle, err := meter.Int64ObservableGauge(
+	goMemstatsHeapIdle, err := o.meter.Int64ObservableGauge(
 		"go_memstats_heap_idle_bytes",
 		metric.WithUnit("By"),
 		metric.WithDescription("Number of heap bytes waiting to be used."),
 	)
 	if err != nil {
-		return fmt.Errorf("Can't add go_memstats_heap_idle_bytes gauge to OpenTelemetry: %s", err)
+		return fmt.Errorf("can't add go_memstats_heap_idle_bytes gauge to OpenTelemetry: %s", err)
 	}
 
-	goMemstatsHeapInuse, err := meter.Int64ObservableGauge(
+	goMemstatsHeapInuse, err := o.meter.Int64ObservableGauge(
 		"go_memstats_heap_inuse_bytes",
 		metric.WithUnit("By"),
 		metric.WithDescription("Number of heap bytes that are in use."),
 	)
 	if err != nil {
-		return fmt.Errorf("Can't add go_memstats_heap_inuse_bytes gauge to OpenTelemetry: %s", err)
+		return fmt.Errorf("can't add go_memstats_heap_inuse_bytes gauge to OpenTelemetry: %s", err)
 	}
 
-	goGoroutines, err := meter.Int64ObservableGauge(
+	goGoroutines, err := o.meter.Int64ObservableGauge(
 		"go_goroutines",
 		metric.WithUnit("1"),
 		metric.WithDescription("Number of goroutines that currently exist."),
 	)
 	if err != nil {
-		return fmt.Errorf("Can't add go_goroutines gauge to OpenTelemetry: %s", err)
+		return fmt.Errorf("can't add go_goroutines gauge to OpenTelemetry: %s", err)
 	}
 
-	goThreads, err := meter.Int64ObservableGauge(
+	goThreads, err := o.meter.Int64ObservableGauge(
 		"go_threads",
 		metric.WithUnit("1"),
 		metric.WithDescription("Number of OS threads created."),
 	)
 	if err != nil {
-		return fmt.Errorf("Can't add go_threads gauge to OpenTelemetry: %s", err)
+		return fmt.Errorf("can't add go_threads gauge to OpenTelemetry: %s", err)
 	}
 
-	workersGauge, err := meter.Int64ObservableGauge(
+	workersGauge, err := o.meter.Int64ObservableGauge(
 		"workers",
 		metric.WithUnit("1"),
 		metric.WithDescription("A gauge of the number of running workers."),
 	)
 	if err != nil {
-		return fmt.Errorf("Can't add workets gauge to OpenTelemetry: %s", err)
+		return fmt.Errorf("can't add workers gauge to OpenTelemetry: %s", err)
 	}
 
-	requestsInProgressGauge, err := meter.Float64ObservableGauge(
+	requestsInProgressGauge, err := o.meter.Float64ObservableGauge(
 		"requests_in_progress",
 		metric.WithUnit("1"),
 		metric.WithDescription("A gauge of the number of requests currently being in progress."),
 	)
 	if err != nil {
-		return fmt.Errorf("Can't add requests_in_progress gauge to OpenTelemetry: %s", err)
+		return fmt.Errorf("can't add requests_in_progress gauge to OpenTelemetry: %s", err)
 	}
 
-	imagesInProgressGauge, err := meter.Float64ObservableGauge(
+	imagesInProgressGauge, err := o.meter.Float64ObservableGauge(
 		"images_in_progress",
 		metric.WithUnit("1"),
 		metric.WithDescription("A gauge of the number of images currently being in progress."),
 	)
 	if err != nil {
-		return fmt.Errorf("Can't add images_in_progress gauge to OpenTelemetry: %s", err)
+		return fmt.Errorf("can't add images_in_progress gauge to OpenTelemetry: %s", err)
 	}
 
-	workersUtilizationGauge, err := meter.Float64ObservableGauge(
+	workersUtilizationGauge, err := o.meter.Float64ObservableGauge(
 		"workers_utilization",
 		metric.WithUnit("%"),
 		metric.WithDescription("A gauge of the workers utilization in percents."),
 	)
 	if err != nil {
-		return fmt.Errorf("Can't add workers_utilization gauge to OpenTelemetry: %s", err)
+		return fmt.Errorf("can't add workers_utilization gauge to OpenTelemetry: %s", err)
 	}
 
-	bufferDefaultSizeGauge, err := meter.Int64ObservableGauge(
-		"buffer_default_size_bytes",
-		metric.WithUnit("By"),
-		metric.WithDescription("A gauge of the buffer default size in bytes."),
-	)
-	if err != nil {
-		return fmt.Errorf("Can't add buffer_default_size_bytes gauge to OpenTelemetry: %s", err)
-	}
-
-	bufferMaxSizeGauge, err := meter.Int64ObservableGauge(
-		"buffer_max_size_bytes",
-		metric.WithUnit("By"),
-		metric.WithDescription("A gauge of the buffer max size in bytes."),
-	)
-	if err != nil {
-		return fmt.Errorf("Can't add buffer_max_size_bytes gauge to OpenTelemetry: %s", err)
-	}
-
-	vipsMemory, err := meter.Float64ObservableGauge(
+	vipsMemory, err := o.meter.Float64ObservableGauge(
 		"vips_memory_bytes",
 		metric.WithUnit("By"),
 		metric.WithDescription("A gauge of the vips tracked memory usage in bytes."),
 	)
 	if err != nil {
-		return fmt.Errorf("Can't add vips_memory_bytes gauge to OpenTelemetry: %s", err)
+		return fmt.Errorf("can't add vips_memory_bytes gauge to OpenTelemetry: %s", err)
 	}
 
-	vipsMaxMemory, err := meter.Float64ObservableGauge(
+	vipsMaxMemory, err := o.meter.Float64ObservableGauge(
 		"vips_max_memory_bytes",
 		metric.WithUnit("By"),
 		metric.WithDescription("A gauge of the max vips tracked memory usage in bytes."),
 	)
 	if err != nil {
-		return fmt.Errorf("Can't add vips_max_memory_bytes gauge to OpenTelemetry: %s", err)
+		return fmt.Errorf("can't add vips_max_memory_bytes gauge to OpenTelemetry: %s", err)
 	}
 
-	vipsAllocs, err := meter.Float64ObservableGauge(
+	vipsAllocs, err := o.meter.Float64ObservableGauge(
 		"vips_allocs",
 		metric.WithUnit("1"),
 		metric.WithDescription("A gauge of the number of active vips allocations."),
 	)
 	if err != nil {
-		return fmt.Errorf("Can't add vips_allocs gauge to OpenTelemetry: %s", err)
+		return fmt.Errorf("can't add vips_allocs gauge to OpenTelemetry: %s", err)
 	}
 
-	_, err = meter.RegisterCallback(
-		func(ctx context.Context, o metric.Observer) error {
+	_, err = o.meter.RegisterCallback(
+		func(ctx context.Context, ob metric.Observer) error {
 			memStats, merr := proc.MemoryInfo()
 			if merr != nil {
 				return merr
 			}
 
-			o.ObserveInt64(processResidentMemory, int64(memStats.RSS))
-			o.ObserveInt64(processVirtualMemory, int64(memStats.VMS))
+			ob.ObserveInt64(processResidentMemory, int64(memStats.RSS))
+			ob.ObserveInt64(processVirtualMemory, int64(memStats.VMS))
 
 			goMemStats := &runtime.MemStats{}
 			runtime.ReadMemStats(goMemStats)
 
-			o.ObserveInt64(goMemstatsSys, int64(goMemStats.Sys))
-			o.ObserveInt64(goMemstatsHeapIdle, int64(goMemStats.HeapIdle))
-			o.ObserveInt64(goMemstatsHeapInuse, int64(goMemStats.HeapInuse))
+			ob.ObserveInt64(goMemstatsSys, int64(goMemStats.Sys))
+			ob.ObserveInt64(goMemstatsHeapIdle, int64(goMemStats.HeapIdle))
+			ob.ObserveInt64(goMemstatsHeapInuse, int64(goMemStats.HeapInuse))
 
 			threadsNum, _ := runtime.ThreadCreateProfile(nil)
-			o.ObserveInt64(goGoroutines, int64(runtime.NumGoroutine()))
-			o.ObserveInt64(goThreads, int64(threadsNum))
-
-			o.ObserveInt64(workersGauge, int64(config.Workers))
-			o.ObserveFloat64(requestsInProgressGauge, stats.RequestsInProgress())
-			o.ObserveFloat64(imagesInProgressGauge, stats.ImagesInProgress())
-			o.ObserveFloat64(workersUtilizationGauge, stats.WorkersUtilization())
-
-			bufferStatsMutex.Lock()
-			defer bufferStatsMutex.Unlock()
+			ob.ObserveInt64(goGoroutines, int64(runtime.NumGoroutine()))
+			ob.ObserveInt64(goThreads, int64(threadsNum))
 
-			for t, v := range bufferDefaultSizes {
-				o.ObserveInt64(bufferDefaultSizeGauge, int64(v), metric.WithAttributes(attribute.String("type", t)))
-			}
-			for t, v := range bufferMaxSizes {
-				o.ObserveInt64(bufferMaxSizeGauge, int64(v), metric.WithAttributes(attribute.String("type", t)))
-			}
+			ob.ObserveInt64(workersGauge, int64(o.stats.WorkersNumber))
+			ob.ObserveFloat64(requestsInProgressGauge, o.stats.RequestsInProgress())
+			ob.ObserveFloat64(imagesInProgressGauge, o.stats.ImagesInProgress())
+			ob.ObserveFloat64(workersUtilizationGauge, o.stats.WorkersUtilization())
 
-			o.ObserveFloat64(vipsMemory, vips.GetMem())
-			o.ObserveFloat64(vipsMaxMemory, vips.GetMemHighwater())
-			o.ObserveFloat64(vipsAllocs, vips.GetAllocs())
+			ob.ObserveFloat64(vipsMemory, vips.GetMem())
+			ob.ObserveFloat64(vipsMaxMemory, vips.GetMemHighwater())
+			ob.ObserveFloat64(vipsAllocs, vips.GetAllocs())
 
 			return nil
 		},
@@ -713,54 +480,13 @@ func addDefaultMetrics() error {
 		requestsInProgressGauge,
 		imagesInProgressGauge,
 		workersUtilizationGauge,
-		bufferDefaultSizeGauge,
-		bufferMaxSizeGauge,
 		vipsMemory,
 		vipsMaxMemory,
 		vipsAllocs,
 	)
 	if err != nil {
-		return fmt.Errorf("Can't register OpenTelemetry callbacks: %s", err)
-	}
-
-	bufferSizeHist, err = meter.Int64Histogram(
-		"buffer_size_bytes",
-		metric.WithUnit("By"),
-		metric.WithDescription("A histogram of the buffer size in bytes."),
-	)
-	if err != nil {
-		return fmt.Errorf("Can't add buffer_size_bytes histogram to OpenTelemetry: %s", err)
+		return fmt.Errorf("can't register OpenTelemetry callbacks: %s", err)
 	}
 
 	return nil
 }
-
-func ObserveBufferSize(t string, size int) {
-	if enabledMetrics {
-		bufferSizeHist.Record(context.Background(), int64(size), metric.WithAttributes(attribute.String("type", t)))
-	}
-}
-
-func SetBufferDefaultSize(t string, size int) {
-	if enabledMetrics {
-		bufferStatsMutex.Lock()
-		defer bufferStatsMutex.Unlock()
-
-		bufferDefaultSizes[t] = size
-	}
-}
-
-func SetBufferMaxSize(t string, size int) {
-	if enabledMetrics {
-		bufferStatsMutex.Lock()
-		defer bufferStatsMutex.Unlock()
-
-		bufferMaxSizes[t] = size
-	}
-}
-
-type errorHandler struct{}
-
-func (h errorHandler) Handle(err error) {
-	slog.Warn(err.Error(), "source", "opentelemetry")
-}

+ 0 - 119
monitoring/otel/otel_test.go

@@ -1,119 +0,0 @@
-package otel
-
-import (
-	"os"
-	"strings"
-	"testing"
-
-	"github.com/stretchr/testify/suite"
-
-	"github.com/imgproxy/imgproxy/v3/config"
-	"github.com/imgproxy/imgproxy/v3/logger"
-)
-
-type OtelTestSuite struct{ suite.Suite }
-
-func (s *OtelTestSuite) SetupSuite() {
-	logger.Mute()
-}
-
-func (s *OtelTestSuite) TearDownSuite() {
-	logger.Unmute()
-}
-
-func (s *OtelTestSuite) SetupTest() {
-	for _, env := range os.Environ() {
-		keyVal := strings.Split(env, "=")
-		if strings.HasPrefix(keyVal[0], "OTEL_") || strings.HasPrefix(keyVal[0], "IMGPROXY_OPEN_TELEMETRY_") {
-			os.Unsetenv(keyVal[0])
-		}
-	}
-
-	config.Reset()
-}
-
-func (s *OtelTestSuite) TestMapDeprecatedConfigEndpointNoProtocol() {
-	os.Setenv("IMGPROXY_OPEN_TELEMETRY_ENDPOINT", "otel_endpoint:1234")
-
-	mapDeprecatedConfig()
-
-	s.Require().True(config.OpenTelemetryEnable)
-	s.Require().Equal("https://otel_endpoint:1234", os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"))
-	s.Require().Empty(os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL"))
-}
-
-func (s *OtelTestSuite) TestMapDeprecatedConfigEndpointGrpcProtocol() {
-	os.Setenv("IMGPROXY_OPEN_TELEMETRY_ENDPOINT", "otel_endpoint:1234")
-	os.Setenv("IMGPROXY_OPEN_TELEMETRY_PROTOCOL", "grpc")
-
-	mapDeprecatedConfig()
-
-	s.Require().True(config.OpenTelemetryEnable)
-	s.Require().Equal("https://otel_endpoint:1234", os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"))
-	s.Require().Equal("grpc", os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL"))
-}
-
-func (s *OtelTestSuite) TestMapDeprecatedConfigEndpointGrpcProtocolInsecure() {
-	os.Setenv("IMGPROXY_OPEN_TELEMETRY_ENDPOINT", "otel_endpoint:1234")
-	os.Setenv("IMGPROXY_OPEN_TELEMETRY_PROTOCOL", "grpc")
-	os.Setenv("IMGPROXY_OPEN_TELEMETRY_GRPC_INSECURE", "1")
-
-	mapDeprecatedConfig()
-
-	s.Require().True(config.OpenTelemetryEnable)
-	s.Require().Equal("http://otel_endpoint:1234", os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"))
-	s.Require().Equal("grpc", os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL"))
-}
-
-func (s *OtelTestSuite) TestMapDeprecatedConfigEndpointHttpsProtocol() {
-	os.Setenv("IMGPROXY_OPEN_TELEMETRY_ENDPOINT", "otel_endpoint:1234")
-	os.Setenv("IMGPROXY_OPEN_TELEMETRY_PROTOCOL", "https")
-
-	mapDeprecatedConfig()
-
-	s.Require().True(config.OpenTelemetryEnable)
-	s.Require().Equal("https://otel_endpoint:1234", os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"))
-	s.Require().Equal("https", os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL"))
-}
-
-func (s *OtelTestSuite) TestMapDeprecatedConfigEndpointHttpProtocol() {
-	os.Setenv("IMGPROXY_OPEN_TELEMETRY_ENDPOINT", "otel_endpoint:1234")
-	os.Setenv("IMGPROXY_OPEN_TELEMETRY_PROTOCOL", "http")
-
-	mapDeprecatedConfig()
-
-	s.Require().True(config.OpenTelemetryEnable)
-	s.Require().Equal("http://otel_endpoint:1234", os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"))
-	s.Require().Equal("http", os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL"))
-}
-
-func (s *OtelTestSuite) TestMapDeprecatedConfigServiceName() {
-	os.Setenv("IMGPROXY_OPEN_TELEMETRY_SERVICE_NAME", "testtest")
-
-	config.OpenTelemetryEnable = true
-	mapDeprecatedConfig()
-
-	s.Require().Equal("testtest", os.Getenv("OTEL_SERVICE_NAME"))
-}
-
-func (s *OtelTestSuite) TestMapDeprecatedConfigPropagators() {
-	os.Setenv("IMGPROXY_OPEN_TELEMETRY_PROPAGATORS", "testtest")
-
-	config.OpenTelemetryEnable = true
-	mapDeprecatedConfig()
-
-	s.Require().Equal("testtest", os.Getenv("OTEL_PROPAGATORS"))
-}
-
-func (s *OtelTestSuite) TestMapDeprecatedConfigConnectionTimeout() {
-	os.Setenv("IMGPROXY_OPEN_TELEMETRY_CONNECTION_TIMEOUT", "15")
-
-	config.OpenTelemetryEnable = true
-	mapDeprecatedConfig()
-
-	s.Require().Equal("15000", os.Getenv("OTEL_EXPORTER_OTLP_TIMEOUT"))
-}
-
-func TestPresets(t *testing.T) {
-	suite.Run(t, new(OtelTestSuite))
-}

+ 53 - 0
monitoring/prometheus/config.go

@@ -0,0 +1,53 @@
+package prometheus
+
+import (
+	"errors"
+
+	"github.com/imgproxy/imgproxy/v3/ensure"
+	"github.com/imgproxy/imgproxy/v3/env"
+)
+
+var (
+	IMGPROXY_PROMETHEUS_BIND      = env.Describe("IMGPROXY_PROMETHEUS_BIND", "string")
+	IMGPROXY_PROMETHEUS_NAMESPACE = env.Describe("IMGPROXY_PROMETHEUS_NAMESPACE", "string")
+)
+
+// Config holds the configuration for Prometheus monitoring
+type Config struct {
+	Bind      string // Prometheus server bind address
+	Namespace string // Prometheus metrics namespace
+}
+
+// NewDefaultConfig returns a new default configuration for Prometheus monitoring
+func NewDefaultConfig() Config {
+	return Config{
+		Bind:      "",
+		Namespace: "",
+	}
+}
+
+// LoadConfigFromEnv loads configuration from environment variables
+func LoadConfigFromEnv(c *Config) (*Config, error) {
+	c = ensure.Ensure(c, NewDefaultConfig)
+
+	err := errors.Join(
+		env.String(&c.Bind, IMGPROXY_PROMETHEUS_BIND),
+		env.String(&c.Namespace, IMGPROXY_PROMETHEUS_NAMESPACE),
+	)
+
+	return c, err
+}
+
+// Enabled returns true if Prometheus monitoring is enabled
+func (c *Config) Enabled() bool {
+	return len(c.Bind) > 0
+}
+
+// Validate checks the configuration for errors
+func (c *Config) Validate() error {
+	if !c.Enabled() {
+		return nil
+	}
+
+	return nil
+}

+ 85 - 113
monitoring/prometheus/prometheus.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 	"log/slog"
+	"net"
 	"net/http"
 	"strconv"
 	"time"
@@ -12,14 +13,14 @@ import (
 	"github.com/prometheus/client_golang/prometheus"
 	"github.com/prometheus/client_golang/prometheus/promhttp"
 
-	"github.com/imgproxy/imgproxy/v3/config"
 	"github.com/imgproxy/imgproxy/v3/monitoring/stats"
-	"github.com/imgproxy/imgproxy/v3/reuseport"
 	"github.com/imgproxy/imgproxy/v3/vips"
 )
 
-var (
-	enabled = false
+// Prometheus holds Prometheus metrics and configuration
+type Prometheus struct {
+	config *Config
+	stats  *stats.Stats
 
 	requestsTotal    prometheus.Counter
 	statusCodesTotal *prometheus.CounterVec
@@ -30,134 +31,118 @@ var (
 	downloadDuration    prometheus.Histogram
 	processingDuration  prometheus.Histogram
 
-	bufferSize        *prometheus.HistogramVec
-	bufferDefaultSize *prometheus.GaugeVec
-	bufferMaxSize     *prometheus.GaugeVec
-
 	workers prometheus.Gauge
-)
+}
 
-func Init() {
-	if len(config.PrometheusBind) == 0 {
-		return
+// New creates a new Prometheus instance
+func New(config *Config, stats *stats.Stats) (*Prometheus, error) {
+	p := &Prometheus{
+		config: config,
+		stats:  stats,
+	}
+
+	if !config.Enabled() {
+		return p, nil
 	}
 
-	requestsTotal = prometheus.NewCounter(prometheus.CounterOpts{
-		Namespace: config.PrometheusNamespace,
+	if err := config.Validate(); err != nil {
+		return nil, err
+	}
+
+	p.requestsTotal = prometheus.NewCounter(prometheus.CounterOpts{
+		Namespace: config.Namespace,
 		Name:      "requests_total",
 		Help:      "A counter of the total number of HTTP requests imgproxy processed.",
 	})
 
-	statusCodesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
-		Namespace: config.PrometheusNamespace,
+	p.statusCodesTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
+		Namespace: config.Namespace,
 		Name:      "status_codes_total",
 		Help:      "A counter of the response status codes.",
 	}, []string{"status"})
 
-	errorsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
-		Namespace: config.PrometheusNamespace,
+	p.errorsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
+		Namespace: config.Namespace,
 		Name:      "errors_total",
 		Help:      "A counter of the occurred errors separated by type.",
 	}, []string{"type"})
 
-	requestDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
-		Namespace: config.PrometheusNamespace,
+	p.requestDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
+		Namespace: config.Namespace,
 		Name:      "request_duration_seconds",
 		Help:      "A histogram of the response latency.",
 	})
 
-	requestSpanDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
-		Namespace: config.PrometheusNamespace,
+	p.requestSpanDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
+		Namespace: config.Namespace,
 		Name:      "request_span_duration_seconds",
 		Help:      "A histogram of the queue latency.",
 	}, []string{"span"})
 
-	downloadDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
-		Namespace: config.PrometheusNamespace,
+	p.downloadDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
+		Namespace: config.Namespace,
 		Name:      "download_duration_seconds",
 		Help:      "A histogram of the source image downloading latency.",
 	})
 
-	processingDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
-		Namespace: config.PrometheusNamespace,
+	p.processingDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
+		Namespace: config.Namespace,
 		Name:      "processing_duration_seconds",
 		Help:      "A histogram of the image processing latency.",
 	})
 
-	bufferSize = prometheus.NewHistogramVec(prometheus.HistogramOpts{
-		Namespace: config.PrometheusNamespace,
-		Name:      "buffer_size_bytes",
-		Help:      "A histogram of the buffer size in bytes.",
-		Buckets:   prometheus.ExponentialBuckets(1024, 2, 14),
-	}, []string{"type"})
-
-	bufferDefaultSize = prometheus.NewGaugeVec(prometheus.GaugeOpts{
-		Namespace: config.PrometheusNamespace,
-		Name:      "buffer_default_size_bytes",
-		Help:      "A gauge of the buffer default size in bytes.",
-	}, []string{"type"})
-
-	bufferMaxSize = prometheus.NewGaugeVec(prometheus.GaugeOpts{
-		Namespace: config.PrometheusNamespace,
-		Name:      "buffer_max_size_bytes",
-		Help:      "A gauge of the buffer max size in bytes.",
-	}, []string{"type"})
-
-	workers = prometheus.NewGauge(prometheus.GaugeOpts{
-		Namespace: config.PrometheusNamespace,
+	p.workers = prometheus.NewGauge(prometheus.GaugeOpts{
+		Namespace: config.Namespace,
 		Name:      "workers",
 		Help:      "A gauge of the number of running workers.",
 	})
-	workers.Set(float64(config.Workers))
+	p.workers.Set(float64(stats.WorkersNumber))
 
 	requestsInProgress := prometheus.NewGaugeFunc(prometheus.GaugeOpts{
-		Namespace: config.PrometheusNamespace,
+		Namespace: config.Namespace,
 		Name:      "requests_in_progress",
 		Help:      "A gauge of the number of requests currently being in progress.",
 	}, stats.RequestsInProgress)
 
 	imagesInProgress := prometheus.NewGaugeFunc(prometheus.GaugeOpts{
-		Namespace: config.PrometheusNamespace,
+		Namespace: config.Namespace,
 		Name:      "images_in_progress",
 		Help:      "A gauge of the number of images currently being in progress.",
 	}, stats.ImagesInProgress)
 
 	workersUtilization := prometheus.NewGaugeFunc(prometheus.GaugeOpts{
-		Namespace: config.PrometheusNamespace,
+		Namespace: config.Namespace,
 		Name:      "workers_utilization",
 		Help:      "A gauge of the workers utilization in percents.",
 	}, stats.WorkersUtilization)
 
 	vipsMemoryBytes := prometheus.NewGaugeFunc(prometheus.GaugeOpts{
-		Namespace: config.PrometheusNamespace,
+		Namespace: config.Namespace,
 		Name:      "vips_memory_bytes",
 		Help:      "A gauge of the vips tracked memory usage in bytes.",
 	}, vips.GetMem)
 
 	vipsMaxMemoryBytes := prometheus.NewGaugeFunc(prometheus.GaugeOpts{
-		Namespace: config.PrometheusNamespace,
+		Namespace: config.Namespace,
 		Name:      "vips_max_memory_bytes",
 		Help:      "A gauge of the max vips tracked memory usage in bytes.",
 	}, vips.GetMemHighwater)
 
 	vipsAllocs := prometheus.NewGaugeFunc(prometheus.GaugeOpts{
-		Namespace: config.PrometheusNamespace,
+		Namespace: config.Namespace,
 		Name:      "vips_allocs",
 		Help:      "A gauge of the number of active vips allocations.",
 	}, vips.GetAllocs)
 
 	prometheus.MustRegister(
-		requestsTotal,
-		statusCodesTotal,
-		errorsTotal,
-		requestDuration,
-		requestSpanDuration,
-		downloadDuration,
-		processingDuration,
-		bufferSize,
-		bufferDefaultSize,
-		bufferMaxSize,
-		workers,
+		p.requestsTotal,
+		p.statusCodesTotal,
+		p.errorsTotal,
+		p.requestDuration,
+		p.requestSpanDuration,
+		p.downloadDuration,
+		p.processingDuration,
+		p.workers,
 		requestsInProgress,
 		imagesInProgress,
 		workersUtilization,
@@ -166,27 +151,30 @@ func Init() {
 		vipsAllocs,
 	)
 
-	enabled = true
+	return p, nil
 }
 
-func Enabled() bool {
-	return enabled
+// Enabled returns true if Prometheus monitoring is enabled
+func (p *Prometheus) Enabled() bool {
+	return p.config.Enabled()
 }
 
-func StartServer(cancel context.CancelFunc) error {
-	if !enabled {
+// StartServer starts the Prometheus metrics server
+func (p *Prometheus) StartServer(cancel context.CancelFunc) error {
+	// If not enabled, do nothing
+	if !p.Enabled() {
 		return nil
 	}
 
 	s := http.Server{Handler: promhttp.Handler()}
 
-	l, err := reuseport.Listen("tcp", config.PrometheusBind, config.SoReuseport)
+	l, err := net.Listen("tcp", p.config.Bind)
 	if err != nil {
-		return fmt.Errorf("Can't start Prometheus metrics server: %s", err)
+		return fmt.Errorf("can't start Prometheus metrics server: %s", err)
 	}
 
 	go func() {
-		slog.Info(fmt.Sprintf("Starting Prometheus server at %s", config.PrometheusBind))
+		slog.Info(fmt.Sprintf("Starting Prometheus server at %s", p.config.Bind))
 		if err := s.Serve(l); err != nil && err != http.ErrServerClosed {
 			slog.Error(err.Error())
 		}
@@ -196,40 +184,40 @@ func StartServer(cancel context.CancelFunc) error {
 	return nil
 }
 
-func StartRequest(rw http.ResponseWriter) (context.CancelFunc, http.ResponseWriter) {
-	if !enabled {
+func (p *Prometheus) StartRequest(rw http.ResponseWriter) (context.CancelFunc, http.ResponseWriter) {
+	if !p.Enabled() {
 		return func() {}, rw
 	}
 
-	requestsTotal.Inc()
+	p.requestsTotal.Inc()
 
 	newRw := httpsnoop.Wrap(rw, httpsnoop.Hooks{
 		WriteHeader: func(next httpsnoop.WriteHeaderFunc) httpsnoop.WriteHeaderFunc {
 			return func(statusCode int) {
-				statusCodesTotal.With(prometheus.Labels{"status": strconv.Itoa(statusCode)}).Inc()
+				p.statusCodesTotal.With(prometheus.Labels{"status": strconv.Itoa(statusCode)}).Inc()
 				next(statusCode)
 			}
 		},
 	})
 
-	return startDuration(requestDuration), newRw
+	return p.startDuration(p.requestDuration), newRw
 }
 
-func StartQueueSegment() context.CancelFunc {
-	if !enabled {
+func (p *Prometheus) StartQueueSegment() context.CancelFunc {
+	if !p.Enabled() {
 		return func() {}
 	}
 
-	return startDuration(requestSpanDuration.With(prometheus.Labels{"span": "queue"}))
+	return p.startDuration(p.requestSpanDuration.With(prometheus.Labels{"span": "queue"}))
 }
 
-func StartDownloadingSegment() context.CancelFunc {
-	if !enabled {
+func (p *Prometheus) StartDownloadingSegment() context.CancelFunc {
+	if !p.Enabled() {
 		return func() {}
 	}
 
-	cancel := startDuration(requestSpanDuration.With(prometheus.Labels{"span": "downloading"}))
-	cancelLegacy := startDuration(downloadDuration)
+	cancel := p.startDuration(p.requestSpanDuration.With(prometheus.Labels{"span": "downloading"}))
+	cancelLegacy := p.startDuration(p.downloadDuration)
 
 	return func() {
 		cancel()
@@ -237,13 +225,13 @@ func StartDownloadingSegment() context.CancelFunc {
 	}
 }
 
-func StartProcessingSegment() context.CancelFunc {
-	if !enabled {
+func (p *Prometheus) StartProcessingSegment() context.CancelFunc {
+	if !p.Enabled() {
 		return func() {}
 	}
 
-	cancel := startDuration(requestSpanDuration.With(prometheus.Labels{"span": "processing"}))
-	cancelLegacy := startDuration(processingDuration)
+	cancel := p.startDuration(p.requestSpanDuration.With(prometheus.Labels{"span": "processing"}))
+	cancelLegacy := p.startDuration(p.processingDuration)
 
 	return func() {
 		cancel()
@@ -251,41 +239,25 @@ func StartProcessingSegment() context.CancelFunc {
 	}
 }
 
-func StartStreamingSegment() context.CancelFunc {
-	if !enabled {
+func (p *Prometheus) StartStreamingSegment() context.CancelFunc {
+	if !p.Enabled() {
 		return func() {}
 	}
 
-	return startDuration(requestSpanDuration.With(prometheus.Labels{"span": "streaming"}))
+	return p.startDuration(p.requestSpanDuration.With(prometheus.Labels{"span": "streaming"}))
 }
 
-func startDuration(m prometheus.Observer) context.CancelFunc {
+func (p *Prometheus) startDuration(m prometheus.Observer) context.CancelFunc {
 	t := time.Now()
 	return func() {
 		m.Observe(time.Since(t).Seconds())
 	}
 }
 
-func IncrementErrorsTotal(t string) {
-	if enabled {
-		errorsTotal.With(prometheus.Labels{"type": t}).Inc()
-	}
-}
-
-func ObserveBufferSize(t string, size int) {
-	if enabled {
-		bufferSize.With(prometheus.Labels{"type": t}).Observe(float64(size))
-	}
-}
-
-func SetBufferDefaultSize(t string, size int) {
-	if enabled {
-		bufferDefaultSize.With(prometheus.Labels{"type": t}).Set(float64(size))
+func (p *Prometheus) IncrementErrorsTotal(t string) {
+	if !p.Enabled() {
+		return
 	}
-}
 
-func SetBufferMaxSize(t string, size int) {
-	if enabled {
-		bufferMaxSize.With(prometheus.Labels{"type": t}).Set(float64(size))
-	}
+	p.errorsTotal.With(prometheus.Labels{"type": t}).Inc()
 }

+ 35 - 18
monitoring/stats/stats.go

@@ -2,39 +2,56 @@ package stats
 
 import (
 	"sync/atomic"
-
-	"github.com/imgproxy/imgproxy/v3/config"
 )
 
-var (
+// Stats holds statistics counters thread safely
+type Stats struct {
 	requestsInProgress int64
 	imagesInProgress   int64
-)
+	WorkersNumber      int
+}
+
+// New creates a new Stats instance
+func New(workersNumber int) *Stats {
+	return &Stats{
+		WorkersNumber: workersNumber,
+	}
+}
 
-func RequestsInProgress() float64 {
-	return float64(atomic.LoadInt64(&requestsInProgress))
+// RequestsInProgress returns the current number of requests in progress
+func (s *Stats) RequestsInProgress() float64 {
+	return float64(atomic.LoadInt64(&s.requestsInProgress))
 }
 
-func IncRequestsInProgress() {
-	atomic.AddInt64(&requestsInProgress, 1)
+// IncRequestsInProgress increments the requests in progress counter
+func (s *Stats) IncRequestsInProgress() {
+	atomic.AddInt64(&s.requestsInProgress, 1)
 }
 
-func DecRequestsInProgress() {
-	atomic.AddInt64(&requestsInProgress, -1)
+// DecRequestsInProgress decrements the requests in progress counter
+func (s *Stats) DecRequestsInProgress() {
+	atomic.AddInt64(&s.requestsInProgress, -1)
 }
 
-func ImagesInProgress() float64 {
-	return float64(atomic.LoadInt64(&imagesInProgress))
+// ImagesInProgress returns the current number of images being processed
+func (s *Stats) ImagesInProgress() float64 {
+	return float64(atomic.LoadInt64(&s.imagesInProgress))
 }
 
-func IncImagesInProgress() {
-	atomic.AddInt64(&imagesInProgress, 1)
+// IncImagesInProgress increments the images in progress counter
+func (s *Stats) IncImagesInProgress() {
+	atomic.AddInt64(&s.imagesInProgress, 1)
 }
 
-func DecImagesInProgress() {
-	atomic.AddInt64(&imagesInProgress, -1)
+// DecImagesInProgress decrements the images in progress counter
+func (s *Stats) DecImagesInProgress() {
+	atomic.AddInt64(&s.imagesInProgress, -1)
 }
 
-func WorkersUtilization() float64 {
-	return RequestsInProgress() / float64(config.Workers) * 100.0
+// WorkersUtilization returns the current workers utilization percentage
+func (s *Stats) WorkersUtilization() float64 {
+	if s.WorkersNumber == 0 {
+		return 0.0
+	}
+	return s.RequestsInProgress() / float64(s.WorkersNumber) * 100.0
 }

+ 3 - 4
server/middlewares.go

@@ -10,7 +10,6 @@ import (
 	"github.com/imgproxy/imgproxy/v3/errorreport"
 	"github.com/imgproxy/imgproxy/v3/httpheaders"
 	"github.com/imgproxy/imgproxy/v3/ierrors"
-	"github.com/imgproxy/imgproxy/v3/monitoring"
 )
 
 const (
@@ -19,12 +18,12 @@ const (
 
 // WithMonitoring wraps RouteHandler with monitoring handling.
 func (r *Router) WithMonitoring(h RouteHandler) RouteHandler {
-	if !monitoring.Enabled() {
+	if !r.monitoring.Enabled() {
 		return h
 	}
 
 	return func(reqID string, rw ResponseWriter, req *http.Request) error {
-		ctx, cancel, newRw := monitoring.StartRequest(req.Context(), rw.HTTPResponseWriter(), req)
+		ctx, cancel, newRw := r.monitoring.StartRequest(req.Context(), rw.HTTPResponseWriter(), req)
 		defer cancel()
 
 		// Replace rw.ResponseWriter with new one returned from monitoring
@@ -122,7 +121,7 @@ func (r *Router) WithReportError(h RouteHandler) RouteHandler {
 
 		// We do not need to send any canceled context
 		if !errors.Is(ierr, context.Canceled) {
-			monitoring.SendError(ctx, errCat, err)
+			r.monitoring.SendError(ctx, errCat, err)
 		}
 
 		// Report error to error collectors

+ 8 - 3
server/router.go

@@ -11,6 +11,7 @@ import (
 	nanoid "github.com/matoous/go-nanoid/v2"
 
 	"github.com/imgproxy/imgproxy/v3/httpheaders"
+	"github.com/imgproxy/imgproxy/v3/monitoring"
 	"github.com/imgproxy/imgproxy/v3/server/responsewriter"
 )
 
@@ -51,10 +52,13 @@ type Router struct {
 
 	// routes is the collection of all routes
 	routes []*route
+
+	// monitoring is the monitoring instance
+	monitoring *monitoring.Monitoring
 }
 
 // NewRouter creates a new Router instance
-func NewRouter(config *Config) (*Router, error) {
+func NewRouter(config *Config, monitoring *monitoring.Monitoring) (*Router, error) {
 	if err := config.Validate(); err != nil {
 		return nil, err
 	}
@@ -65,8 +69,9 @@ func NewRouter(config *Config) (*Router, error) {
 	}
 
 	return &Router{
-		rwFactory: rwf,
-		config:    config,
+		rwFactory:  rwf,
+		config:     config,
+		monitoring: monitoring,
 	}, nil
 }
 

+ 6 - 1
server/router_test.go

@@ -8,6 +8,7 @@ import (
 	"github.com/stretchr/testify/suite"
 
 	"github.com/imgproxy/imgproxy/v3/httpheaders"
+	"github.com/imgproxy/imgproxy/v3/monitoring"
 )
 
 type RouterTestSuite struct {
@@ -18,8 +19,12 @@ type RouterTestSuite struct {
 func (s *RouterTestSuite) SetupTest() {
 	c := NewDefaultConfig()
 
+	mc := monitoring.NewDefaultConfig()
+	m, err := monitoring.New(s.T().Context(), &mc, 1)
+	s.Require().NoError(err)
+
 	c.PathPrefix = "/api"
-	r, err := NewRouter(&c)
+	r, err := NewRouter(&c, m)
 	s.Require().NoError(err)
 
 	s.router = r

+ 11 - 4
server/server_test.go

@@ -10,12 +10,14 @@ import (
 	"time"
 
 	"github.com/imgproxy/imgproxy/v3/httpheaders"
+	"github.com/imgproxy/imgproxy/v3/monitoring"
 	"github.com/stretchr/testify/suite"
 )
 
 type ServerTestSuite struct {
 	suite.Suite
 	config      *Config
+	monitoring  *monitoring.Monitoring
 	blankRouter *Router
 }
 
@@ -25,7 +27,12 @@ func (s *ServerTestSuite) SetupTest() {
 	s.config = &c
 	s.config.Bind = "127.0.0.1:0" // Use port 0 for auto-assignment
 
-	r, err := NewRouter(s.config)
+	mc := monitoring.NewDefaultConfig()
+	m, err := monitoring.New(s.T().Context(), &mc, 1)
+	s.Require().NoError(err)
+	s.monitoring = m
+
+	r, err := NewRouter(s.config, m)
 	s.Require().NoError(err)
 	s.blankRouter = r
 }
@@ -51,7 +58,7 @@ func (s *ServerTestSuite) TestStartServerWithInvalidBind() {
 	invalidConfig := NewDefaultConfig()
 	invalidConfig.Bind = "-1.-1.-1.-1" // Invalid address
 
-	r, err := NewRouter(&invalidConfig)
+	r, err := NewRouter(&invalidConfig, s.monitoring)
 	s.Require().NoError(err)
 
 	server, err := Start(cancelWrapper, r)
@@ -118,7 +125,7 @@ func (s *ServerTestSuite) TestWithCORS() {
 			config := NewDefaultConfig()
 			config.CORSAllowOrigin = tt.corsAllowOrigin
 
-			router, err := NewRouter(&config)
+			router, err := NewRouter(&config, s.monitoring)
 			s.Require().NoError(err)
 
 			wrappedHandler := router.WithCORS(s.mockHandler)
@@ -164,7 +171,7 @@ func (s *ServerTestSuite) TestWithSecret() {
 			config := NewDefaultConfig()
 			config.Secret = tt.secret
 
-			router, err := NewRouter(&config)
+			router, err := NewRouter(&config, s.monitoring)
 			s.Require().NoError(err)
 
 			wrappedHandler := router.WithSecret(s.mockHandler)