diff --git a/Makefile b/Makefile index 6e72adc..7c211dc 100644 --- a/Makefile +++ b/Makefile @@ -13,4 +13,7 @@ lint: @golangci-lint run run: - @go run example/main.go \ No newline at end of file + @go run example/main.go + +mod: + @go mod tidy diff --git a/README.md b/README.md index 7f4be33..89050f9 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,21 @@ A comprehensive Go utility package providing common application framework compon - **Database Monitoring**: Connection tracking and replica status - **API Exposure**: Public/private API configuration tracking +### HTTP Client +- **Pre-configured Client**: HTTP client with automatic metrics and internal headers +- **Transport Wrappers**: Composable transport layers for metrics and header injection +- **Caller Tracking**: Context-based caller identification for metrics + +### Request Management +- **X-Request-ID**: Automatic generation, validation, and propagation +- **Context Integration**: Request ID stored in context for easy access +- **Header Management**: CORS and custom header support + +### Context Utilities +- **Metadata Context**: Store and retrieve request metadata (IP, User-Agent, Platform, Version, Country) +- **Debug Support**: Debug ID and SQL group tracking for logging +- **Integration Support**: Sentry Hub and method tracking + ## Usage Examples Please see example/main.go @@ -86,6 +101,22 @@ func callInternalService() { } ``` +### HTTP Client with Metrics + +```go +// Create pre-configured HTTP client for internal service calls +client := appkit.NewHTTPClient("externalsrv", appkit.Version(), 30*time.Second) + +// Set caller name in context for metrics tracking +ctx := appkit.NewCallerNameContext(context.Background(), "externalsrv") + +req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "http://api-service/endpoint", nil) +resp, _ := client.Do(req) + +// Usage with clients generated by vmkteam/rpcgen +arithsrvClient := arithsrv.NewClient(arithsrvUrl, appkit.NewHTTPClient("arithsrv", appkit.Version(), 5*time.Second)) +``` + ## API Reference ### Core Functions @@ -105,19 +136,67 @@ func callInternalService() { - `HTTPMetrics(serverName string) echo.MiddlewareFunc` - Prometheus HTTP metrics middleware - `MetadataManager` - Service metadata configuration and metrics +### HTTP Client + +- `NewHTTPClient(appName, version string, timeout time.Duration) *http.Client` - Creates HTTP client with metrics and internal headers +- `WithMetricsTransport(base http.RoundTripper) http.RoundTripper` - Wraps transport with Prometheus metrics +- `WithHeadersTransport(base http.RoundTripper, headers http.Header) http.RoundTripper` - Wraps transport to inject headers for each request +- `NewCallerNameContext(ctx context.Context, callerName string) context.Context` - Creates context with caller name for metrics +- `CallerNameFromContext(ctx context.Context) string` - Retrieves caller name from context + +### Request ID & HTTP Handlers + +- `XRequestIDFromContext(ctx context.Context) string` - Retrieves X-Request-ID from context +- `NewXRequestIDContext(ctx context.Context, requestID string) context.Context` - Creates context with X-Request-ID +- `SetXRequestIDFromCtx(ctx context.Context, req *http.Request)` - Adds X-Request-ID from context to request headers +- `CORS(next http.Handler, headers ...string) http.Handler` - CORS middleware for HTTP handlers +- `XRequestID(next http.Handler) http.Handler` - X-Request-ID middleware for HTTP handlers +- `EchoHandler(next http.Handler) echo.HandlerFunc` - Wraps HTTP handler as Echo handler +- `EchoSentryHubContext() echo.MiddlewareFunc` - Echo middleware to apply Sentry hub to context +- `EchoIPContext() echo.MiddlewareFunc` - Echo middleware to apply client IP to context + +### Context Utilities + +- `NewDebugIDContext(ctx context.Context, debugID uint64) context.Context` - Creates context with debug ID +- `DebugIDFromContext(ctx context.Context) uint64` - Retrieves debug ID from context +- `NewSQLGroupContext(ctx context.Context, group string) context.Context` - Creates context with SQL group for debug logging +- `SQLGroupFromContext(ctx context.Context) string` - Retrieves SQL group from context +- `NewSentryHubContext(ctx context.Context, sentryHub *sentry.Hub) context.Context` - Creates context with Sentry Hub +- `SentryHubFromContext(ctx context.Context) (*sentry.Hub, bool)` - Retrieves Sentry Hub from context +- `NewIPContext(ctx context.Context, ip string) context.Context` - Creates context with IP address +- `IPFromContext(ctx context.Context) string` - Retrieves IP address from context +- `NewUserAgentContext(ctx context.Context, ua string) context.Context` - Creates context with User-Agent +- `UserAgentFromContext(ctx context.Context) string` - Retrieves User-Agent from context +- `NewNotificationContext(ctx context.Context) context.Context` - Creates context with JSONRPC2 notification flag +- `NotificationFromContext(ctx context.Context) bool` - Retrieves JSONRPC2 notification flag from context +- `NewIsDevelContext(ctx context.Context, isDevel bool) context.Context` - Creates context with isDevel flag +- `IsDevelFromContext(ctx context.Context) bool` - Retrieves isDevel flag from context +- `NewPlatformContext(ctx context.Context, platform string) context.Context` - Creates context with platform +- `PlatformFromContext(ctx context.Context) string` - Retrieves platform from context +- `NewVersionContext(ctx context.Context, version string) context.Context` - Creates context with version +- `VersionFromContext(ctx context.Context) string` - Retrieves version from context +- `NewCountryContext(ctx context.Context, country string) context.Context` - Creates context with country +- `CountryFromContext(ctx context.Context) string` - Retrieves country from context +- `NewMethodContext(ctx context.Context, method string) context.Context` - Creates context with method +- `MethodFromContext(ctx context.Context) string` - Retrieves method from context + ## Dependencies - [Echo](https://echo.labstack.com/) - High performance HTTP framework - [Prometheus](https://prometheus.io/) - Metrics collection and monitoring - [Sentry](https://sentry.io/) - Error tracking and monitoring -- [zenrpc-middleware](https://github.com/vmkteam/zenrpc-middleware) - RPC middleware utilities ## Metrics Exported -### HTTP Metrics +### HTTP Server Metrics - `app_http_requests_total` - Total HTTP requests by method/path/status - `app_http_responses_duration_seconds` - Response time distribution +### HTTP Client Metrics +- `app_http_client_requests_total` - Total client requests by code/method/caller/origin +- `app_http_client_responses_duration_seconds` - Client response time distribution +- `app_http_client_requests_inflight` - Current inflight client requests by caller/origin + ### Service Metadata Metrics - `app_metadata_service` - Service configuration information - `app_metadata_db_connections_total` - Database connection counts diff --git a/appkit.go b/appkit.go index b25b6b7..a630c0b 100644 --- a/appkit.go +++ b/appkit.go @@ -6,6 +6,8 @@ import ( "runtime/debug" ) +type contextKey string + // Version returns app version from VCS info. func Version() string { result := "devel" diff --git a/client.go b/client.go new file mode 100644 index 0000000..80003c6 --- /dev/null +++ b/client.go @@ -0,0 +1,132 @@ +package appkit + +import ( + "context" + "net/http" + "strconv" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +const ( + ctxCallerName contextKey = "callerName" +) + +var ( + clientMetricsOnce sync.Once + clientRequests = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "app", + Subsystem: "http_client", + Name: "requests_total", + Help: "Requests count by code/method/client/origin.", + }, + []string{"code", "method", "caller", "origin"}, + ) + clientDurations = prometheus.NewSummaryVec( + prometheus.SummaryOpts{ + Namespace: "app", + Subsystem: "http_client", + Name: "responses_duration_seconds", + Help: "Response time by code/method/client/origin.", + }, + []string{"code", "method", "caller", "origin"}, + ) + clientInflights = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "app", + Subsystem: "http_client", + Name: "requests_inflight", + Help: "Gauge for inflight requests.", + }, + []string{"caller", "origin"}, + ) +) + +// NewCallerNameContext creates new context with caller name. +func NewCallerNameContext(ctx context.Context, callerName string) context.Context { + return context.WithValue(ctx, ctxCallerName, callerName) +} + +// CallerNameFromContext returns caller name from context. +func CallerNameFromContext(ctx context.Context) string { + r, _ := ctx.Value(ctxCallerName).(string) + return r +} + +type metricsRoundTripper struct { + base http.RoundTripper +} + +func (m *metricsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + var origin string + if req.URL != nil { + origin = req.URL.Scheme + "://" + req.URL.Host + } + labels := prometheus.Labels{ + "caller": CallerNameFromContext(req.Context()), + "origin": origin, + } + + start := time.Now() + clientInflights.With(labels).Inc() + resp, err := m.base.RoundTrip(req) + clientInflights.With(labels).Dec() + duration := time.Since(start).Seconds() + + labels["method"] = req.Method + if resp != nil { + labels["code"] = strconv.Itoa(resp.StatusCode) + } + + clientRequests.With(labels).Inc() + clientDurations.With(labels).Observe(duration) + + return resp, err +} + +// WithMetricsTransport wraps http transport, adds client metrics tracking. +func WithMetricsTransport(base http.RoundTripper) http.RoundTripper { + clientMetricsOnce.Do(func() { + prometheus.MustRegister(clientRequests, clientDurations, clientInflights) + }) + return &metricsRoundTripper{base: base} +} + +type headerRoundTripper struct { + base http.RoundTripper + headers http.Header +} + +func (h *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + reqClone := req.Clone(req.Context()) + + for key, values := range h.headers { + for _, value := range values { + reqClone.Header.Add(key, value) + } + } + + return h.base.RoundTrip(reqClone) +} + +// WithHeadersTransport wraps http transport, adds provided headers for each request. +func WithHeadersTransport(base http.RoundTripper, headers http.Header) http.RoundTripper { + return &headerRoundTripper{ + base: base, + headers: headers, + } +} + +// NewHTTPClient returns http client with metrics and headers for internal service calls. +func NewHTTPClient(appName, version string, timeout time.Duration) *http.Client { + transport := WithHeadersTransport(http.DefaultTransport, NewInternalHeaders(appName, version)) + transport = WithMetricsTransport(transport) + + return &http.Client{ + Timeout: timeout, + Transport: transport, + } +} diff --git a/echo.go b/echo.go index 428337c..37d029f 100644 --- a/echo.go +++ b/echo.go @@ -12,7 +12,6 @@ import ( sentryecho "github.com/getsentry/sentry-go/echo" "github.com/labstack/echo/v4" "github.com/prometheus/client_golang/prometheus" - zm "github.com/vmkteam/zenrpc-middleware" ) const DefaultServerName = "default" @@ -32,7 +31,7 @@ func NewEcho() *echo.Echo { })) // use zenrpc middlewares - e.Use(zm.EchoIPContext(), zm.EchoSentryHubContext()) + e.Use(EchoIPContext(), EchoSentryHubContext()) return e } diff --git a/go.mod b/go.mod index fb92da0..a585268 100644 --- a/go.mod +++ b/go.mod @@ -3,20 +3,17 @@ module github.com/vmkteam/appkit go 1.24.5 require ( + github.com/getsentry/sentry-go v0.35.3 github.com/getsentry/sentry-go/echo v0.35.3 github.com/labstack/echo/v4 v4.13.4 github.com/prometheus/client_golang v1.23.2 - github.com/vmkteam/zenrpc-middleware v1.2.2 + github.com/vmkteam/zenrpc/v2 v2.2.12 ) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/getsentry/sentry-go v0.35.3 // indirect - github.com/go-pg/pg/v10 v10.15.0 // indirect - github.com/go-pg/zerochecker v0.2.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect - github.com/jinzhu/inflection v1.0.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -25,19 +22,12 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.17.0 // indirect - github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - github.com/vmihailenco/bufpool v0.1.11 // indirect - github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect - github.com/vmihailenco/tagparser v0.1.2 // indirect - github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - github.com/vmkteam/zenrpc/v2 v2.2.12 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/crypto v0.42.0 // indirect golang.org/x/net v0.44.0 // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.29.0 // indirect google.golang.org/protobuf v1.36.9 // indirect - mellium.im/sasl v0.3.2 // indirect ) diff --git a/go.sum b/go.sum index 1eb906e..e76b64b 100644 --- a/go.sum +++ b/go.sum @@ -3,34 +3,26 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/getsentry/sentry-go v0.35.3 h1:u5IJaEqZyPdWqe/hKlBKBBnMTSxB/HenCqF3QLabeds= github.com/getsentry/sentry-go v0.35.3/go.mod h1:mdL49ixwT2yi57k5eh7mpnDyPybixPzlzEJFu0Z76QA= github.com/getsentry/sentry-go/echo v0.35.3 h1:aJ0e4kGuH7T1ggAd3LOYwAyQV0bq37AX36vNPr6JYnM= github.com/getsentry/sentry-go/echo v0.35.3/go.mod h1:zQn5wNGqJUwIlA6z/pi7CFeXiUGrWkzue28C0Mfbz/Q= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/go-pg/pg/v10 v10.15.0 h1:6DQwbaxJz/e4wvgzbxBkBLiL/Uuk87MGgHhkURtzx24= -github.com/go-pg/pg/v10 v10.15.0/go.mod h1:FIn/x04hahOf9ywQ1p68rXqaDVbTRLYlu4MQR0lhoB8= -github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU= -github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= -github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= @@ -41,12 +33,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/onsi/ginkgo v1.14.2 h1:8mVmC9kjFFmA8H4pKMUhcblgifdkOIXPvbhN1T36q1M= -github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= -github.com/onsi/gomega v1.10.3 h1:gph6h/qe9GSUw1NhH1gp+qb+h8rXD8Cy60Z32Qw3ELA= -github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -63,26 +49,12 @@ github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7D github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= -github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94= -github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ= -github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= -github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= -github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc= -github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= -github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= -github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/vmkteam/zenrpc-middleware v1.2.2 h1:o2n3O7aA9ehwY1pBbNfpw3MaXJKYUL3kpdYrHls3xN0= -github.com/vmkteam/zenrpc-middleware v1.2.2/go.mod h1:tL6LYWI+oEu7mMeS7183LRvTx705Ksj5lTX1l5JPWuw= github.com/vmkteam/zenrpc/v2 v2.2.12 h1:McYNxjJPqzyxaB+WGj9KlcEto7ldjvfx9NotzSGsv2o= github.com/vmkteam/zenrpc/v2 v2.2.12/go.mod h1:T/ZQlJbKThBNJtyN0313xEPcxjEyB19uNldTBr0o2KE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -98,20 +70,10 @@ golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0= -mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY= diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..2fe4feb --- /dev/null +++ b/handler.go @@ -0,0 +1,96 @@ +package appkit + +import ( + "context" + "net/http" + "slices" + "strings" + + sentryecho "github.com/getsentry/sentry-go/echo" + "github.com/labstack/echo/v4" +) + +var ( + defaultHeaders = []string{ + "Authorization", "Authorization2", "Origin", "X-Requested-With", "Content-Type", + "Accept", "Platform", "Version", "X-Request-ID", + } +) + +// CORS allows certain CORS headers. +func CORS(next http.Handler, headers ...string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", strings.Join(slices.Concat(defaultHeaders, headers), ", ")) + if r.Method == http.MethodOptions { + return + } + + next.ServeHTTP(w, r) + }) +} + +// SetXRequestIDFromCtx adds X-Request-ID to request headers from context. +func SetXRequestIDFromCtx(ctx context.Context, req *http.Request) { + xRequestID := XRequestIDFromContext(ctx) + if xRequestID != "" && req.Header.Get(headerXRequestID) == "" { + req.Header.Add(headerXRequestID, xRequestID) + } +} + +// XRequestID add X-Request-ID header if not exists. +func XRequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestID := r.Header.Get(headerXRequestID) + if !isValidXRequestID(requestID) { + requestID = generateXRequestID() + r.Header.Add(echo.HeaderXRequestID, requestID) + } + w.Header().Set(echo.HeaderXRequestID, requestID) + + next.ServeHTTP(w, r) + }) +} + +// EchoHandler is wrapper for Echo. +func EchoHandler(next http.Handler) echo.HandlerFunc { + return func(ctx echo.Context) error { + ctx = applySentryHubToContext(ctx) + ctx = applyIPToContext(ctx) + req := ctx.Request() + CORS(XRequestID(next)).ServeHTTP(ctx.Response(), req) + return nil + } +} + +// EchoSentryHubContext middleware applies sentry hub to context for zenrpc middleware. +func EchoSentryHubContext() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + return next(applySentryHubToContext(c)) + } + } +} + +// EchoIPContext middleware applies client ip to context for zenrpc middleware. +func EchoIPContext() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + return next(applyIPToContext(c)) + } + } +} + +func applySentryHubToContext(c echo.Context) echo.Context { + if hub := sentryecho.GetHubFromContext(c); hub != nil { + req := c.Request() + c.SetRequest(req.WithContext(NewSentryHubContext(req.Context(), hub))) + } + return c +} + +func applyIPToContext(c echo.Context) echo.Context { + req := c.Request() + c.SetRequest(req.WithContext(NewIPContext(req.Context(), c.RealIP()))) + return c +} diff --git a/metadata.go b/metadata.go index 3845949..4bfb737 100644 --- a/metadata.go +++ b/metadata.go @@ -32,11 +32,13 @@ type MetadataOpts struct { Services []ServiceMetadata // List of used services } +// ServiceMetadata describes used service. type ServiceMetadata struct { Name string // service name Type MetadataServiceType // sync, async, external } +// DBMetadata describes database configuration. type DBMetadata struct { Name string // database name Connections int // used connections @@ -60,6 +62,7 @@ func (d *MetadataManager) Handler(c echo.Context) error { return c.JSON(http.StatusOK, d.opts) } +// RegisterMetrics tracks metrics based on specified metadata. func (d *MetadataManager) RegisterMetrics() { appInfo := prometheus.NewGaugeVec( prometheus.GaugeOpts{ diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..b2d60b7 --- /dev/null +++ b/middleware.go @@ -0,0 +1,172 @@ +package appkit + +import ( + "context" + + "github.com/getsentry/sentry-go" +) + +const ( + isDevelCtx contextKey = "isDevel" + ctxPlatformKey contextKey = "platform" + ctxVersionKey contextKey = "version" + ctxMethodKey contextKey = "method" + ctxIPKey contextKey = "ip" + ctxUserAgentKey contextKey = "userAgent" + ctxCountryKey contextKey = "country" + ctxNotificationKey string = "JSONRPC2-Notification" + ctxSentryHubKey contextKey = "sentryHub" + debugIDCtx contextKey = "debugID" + sqlGroupCtx contextKey = "sqlGroup" + + maxUserAgentLength = 2048 + maxVersionLength = 64 + maxCountryLength = 16 + EmptyDebugID = 0 +) + +// DebugIDFromContext returns debug ID from context. +func DebugIDFromContext(ctx context.Context) uint64 { + if ctx == nil { + return EmptyDebugID + } + + if id, ok := ctx.Value(debugIDCtx).(uint64); ok { + return id + } + + return EmptyDebugID +} + +// NewDebugIDContext creates new context with debug ID. +func NewDebugIDContext(ctx context.Context, debugID uint64) context.Context { + return context.WithValue(ctx, debugIDCtx, debugID) +} + +// NewSQLGroupContext creates new context with SQL Group for debug SQL logging. +func NewSQLGroupContext(ctx context.Context, group string) context.Context { + groups, _ := ctx.Value(sqlGroupCtx).(string) + if groups != "" { + groups += ">" + } + groups += group + return context.WithValue(ctx, sqlGroupCtx, groups) +} + +// SQLGroupFromContext returns sql group from context. +func SQLGroupFromContext(ctx context.Context) string { + r, _ := ctx.Value(sqlGroupCtx).(string) + return r +} + +// NewSentryHubContext creates new context with Sentry Hub. +func NewSentryHubContext(ctx context.Context, sentryHub *sentry.Hub) context.Context { + if sentryHub == nil { + return ctx + } + return context.WithValue(ctx, ctxSentryHubKey, sentryHub) +} + +// SentryHubFromContext returns Sentry Hub from context. +func SentryHubFromContext(ctx context.Context) (*sentry.Hub, bool) { + r, ok := ctx.Value(ctxSentryHubKey).(*sentry.Hub) + return r, ok +} + +// NewIPContext creates new context with IP. +func NewIPContext(ctx context.Context, ip string) context.Context { + return context.WithValue(ctx, ctxIPKey, ip) +} + +// IPFromContext returns IP from context. +func IPFromContext(ctx context.Context) string { + r, _ := ctx.Value(ctxIPKey).(string) + return r +} + +// NewUserAgentContext creates new context with User-Agent. +func NewUserAgentContext(ctx context.Context, ua string) context.Context { + return context.WithValue(ctx, ctxUserAgentKey, cutString(ua, maxUserAgentLength)) +} + +// UserAgentFromContext returns userAgent from context. +func UserAgentFromContext(ctx context.Context) string { + r, _ := ctx.Value(ctxUserAgentKey).(string) + return r +} + +// NewNotificationContext creates new context with JSONRPC2 notification flag. +func NewNotificationContext(ctx context.Context) context.Context { + return context.WithValue(ctx, ctxNotificationKey, true) //nolint:staticcheck +} + +// NotificationFromContext returns JSONRPC2 notification flag from context. +func NotificationFromContext(ctx context.Context) bool { + r, _ := ctx.Value(ctxNotificationKey).(bool) + return r +} + +// NewIsDevelContext creates new context with isDevel flag. +func NewIsDevelContext(ctx context.Context, isDevel bool) context.Context { + return context.WithValue(ctx, isDevelCtx, isDevel) +} + +// IsDevelFromContext returns isDevel flag from context. +func IsDevelFromContext(ctx context.Context) bool { + if isDevel, ok := ctx.Value(isDevelCtx).(bool); ok { + return isDevel + } + return false +} + +// NewPlatformContext creates new context with platform. +func NewPlatformContext(ctx context.Context, platform string) context.Context { + return context.WithValue(ctx, ctxPlatformKey, cutString(platform, 64)) +} + +// PlatformFromContext returns platform from context. +func PlatformFromContext(ctx context.Context) string { + r, _ := ctx.Value(ctxPlatformKey).(string) + return r +} + +// NewVersionContext creates new context with version. +func NewVersionContext(ctx context.Context, version string) context.Context { + return context.WithValue(ctx, ctxVersionKey, cutString(version, maxVersionLength)) +} + +// VersionFromContext returns version from context. +func VersionFromContext(ctx context.Context) string { + r, _ := ctx.Value(ctxVersionKey).(string) + return r +} + +// NewCountryContext creates new context with country. +func NewCountryContext(ctx context.Context, country string) context.Context { + return context.WithValue(ctx, ctxCountryKey, cutString(country, maxCountryLength)) +} + +// CountryFromContext returns country from context. +func CountryFromContext(ctx context.Context) string { + r, _ := ctx.Value(ctxCountryKey).(string) + return r +} + +// NewMethodContext creates new context with Method. +func NewMethodContext(ctx context.Context, method string) context.Context { + return context.WithValue(ctx, ctxMethodKey, method) +} + +// MethodFromContext returns Method from context. +func MethodFromContext(ctx context.Context) string { + r, _ := ctx.Value(ctxMethodKey).(string) + return r +} + +// cutString cuts string with given length. +func cutString(s string, length int) string { + if len(s) > length { + return s[:length] + } + return s +} diff --git a/xrequestid.go b/xrequestid.go new file mode 100644 index 0000000..1a48dda --- /dev/null +++ b/xrequestid.go @@ -0,0 +1,38 @@ +package appkit + +import ( + "context" + "crypto/rand" + "encoding/hex" + "regexp" + + "github.com/labstack/echo/v4" +) + +const ( + ctxXRequestIDKey string = echo.HeaderXRequestID + headerXRequestID string = echo.HeaderXRequestID +) + +var xRequestIDre = regexp.MustCompile(`[a-zA-Z0-9-]+`) + +func generateXRequestID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func isValidXRequestID(requestID string) bool { + return requestID != "" && len(requestID) <= 32 && xRequestIDre.MatchString(requestID) +} + +// XRequestIDFromContext returns X-Request-ID from context. +func XRequestIDFromContext(ctx context.Context) string { + r, _ := ctx.Value(ctxXRequestIDKey).(string) + return r +} + +// NewXRequestIDContext creates new context with X-Request-ID. +func NewXRequestIDContext(ctx context.Context, requestID string) context.Context { + return context.WithValue(ctx, ctxXRequestIDKey, requestID) //nolint:staticcheck +}