228 lines
6.7 KiB
Go
228 lines
6.7 KiB
Go
package middleware
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
|
|
model "github.com/perfect-panel/server/internal/model/user"
|
|
"github.com/perfect-panel/server/pkg/constant"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/codes"
|
|
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
|
|
oteltrace "go.opentelemetry.io/otel/trace"
|
|
|
|
"github.com/perfect-panel/server/internal/svc"
|
|
"github.com/perfect-panel/server/pkg/trace"
|
|
)
|
|
|
|
// bodyLogWriter is a wrapper for gin.ResponseWriter to capture response body
|
|
type bodyLogWriter struct {
|
|
gin.ResponseWriter
|
|
body *bytes.Buffer
|
|
}
|
|
|
|
func (w bodyLogWriter) Write(b []byte) (int, error) {
|
|
w.body.Write(b)
|
|
return w.ResponseWriter.Write(b)
|
|
}
|
|
|
|
// inviteCodeRegex matches invite code patterns in URLs like:
|
|
// /v1/common/client/download/file/Hi快VPN-mac-1.0.0-ic-uuSo11uy.dmg
|
|
// Matches: ic-XXXXX or ic_XXXXX before file extension
|
|
var inviteCodeRegex = regexp.MustCompile(`[-_]ic[-_]([a-zA-Z0-9]+)\.[a-zA-Z0-9]+$`)
|
|
|
|
// extractInviteCode extracts invite code from URL path
|
|
// Returns empty string if no invite code found
|
|
func extractInviteCode(path string) string {
|
|
matches := inviteCodeRegex.FindStringSubmatch(path)
|
|
if len(matches) >= 2 {
|
|
return matches[1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// statusByWriter returns a span status code and message for an HTTP status code
|
|
// value returned by a server. Status codes in the 400-499 range are not
|
|
// returned as errors.
|
|
func statusByWriter(code int) (codes.Code, string) {
|
|
if code < 100 || code >= 600 {
|
|
return codes.Error, fmt.Sprintf("Invalid HTTP status code %d", code)
|
|
}
|
|
if code >= 500 {
|
|
return codes.Error, ""
|
|
}
|
|
return codes.Unset, ""
|
|
}
|
|
|
|
func requestAttributes(req *http.Request) []attribute.KeyValue {
|
|
protoN := strings.SplitN(req.Proto, "/", 2)
|
|
remoteAddrN := strings.SplitN(req.RemoteAddr, ":", 2)
|
|
|
|
attrs := []attribute.KeyValue{
|
|
semconv.HTTPRequestMethodKey.String(req.Method),
|
|
semconv.HTTPUserAgentKey.String(req.UserAgent()),
|
|
semconv.HTTPRequestContentLengthKey.Int64(req.ContentLength),
|
|
|
|
semconv.URLFullKey.String(req.URL.String()),
|
|
semconv.URLSchemeKey.String(req.URL.Scheme),
|
|
semconv.URLFragmentKey.String(req.URL.Fragment),
|
|
semconv.URLPathKey.String(req.URL.Path),
|
|
semconv.URLQueryKey.String(req.URL.RawQuery),
|
|
|
|
semconv.NetworkProtocolNameKey.String(strings.ToLower(protoN[0])),
|
|
semconv.NetworkProtocolVersionKey.String(protoN[1]),
|
|
|
|
semconv.ClientAddressKey.String(remoteAddrN[0]),
|
|
semconv.ClientPortKey.String(remoteAddrN[1]),
|
|
}
|
|
|
|
// Extract invite code from URL path (e.g., /v1/common/client/download/file/Hi快VPN-mac-1.0.0-ic-uuSo11uy.dmg)
|
|
if inviteCode := extractInviteCode(req.URL.Path); inviteCode != "" {
|
|
attrs = append(attrs, attribute.String("affiliate.invite_code", inviteCode))
|
|
attrs = append(attrs, attribute.String("affiliate.source", "download_link"))
|
|
}
|
|
|
|
// Also check query parameter for invite code (e.g., ?ic=uuSo11uy)
|
|
if ic := req.URL.Query().Get("ic"); ic != "" {
|
|
attrs = append(attrs, attribute.String("affiliate.invite_code", ic))
|
|
attrs = append(attrs, attribute.String("affiliate.source", "query_param"))
|
|
}
|
|
|
|
return attrs
|
|
}
|
|
|
|
// userAttributes extracts user information from context and returns span attributes
|
|
func userAttributes(ctx context.Context) []attribute.KeyValue {
|
|
var attrs []attribute.KeyValue
|
|
|
|
// Get user info from context (set by authMiddleware)
|
|
if userInfo := ctx.Value(constant.CtxKeyUser); userInfo != nil {
|
|
if user, ok := userInfo.(*model.User); ok {
|
|
var email string
|
|
for _, method := range user.AuthMethods {
|
|
if method.AuthType == "email" {
|
|
email = method.AuthIdentifier
|
|
break
|
|
}
|
|
}
|
|
attrs = append(attrs,
|
|
attribute.Int64("user.id", user.Id),
|
|
attribute.String("user.email", email),
|
|
attribute.Bool("user.is_admin", *user.IsAdmin),
|
|
)
|
|
}
|
|
}
|
|
|
|
// Get session ID from context
|
|
if sessionID := ctx.Value(constant.CtxKeySessionID); sessionID != nil {
|
|
if sid, ok := sessionID.(string); ok {
|
|
attrs = append(attrs, attribute.String("user.session_id", sid))
|
|
}
|
|
}
|
|
|
|
// Get device ID from context
|
|
if deviceID := ctx.Value(constant.CtxKeyDeviceID); deviceID != nil {
|
|
if did, ok := deviceID.(int64); ok {
|
|
attrs = append(attrs, attribute.Int64("user.device_id", did))
|
|
}
|
|
}
|
|
|
|
// Get login type from context
|
|
if loginType := ctx.Value(constant.LoginType); loginType != nil {
|
|
if lt, ok := loginType.(string); ok {
|
|
attrs = append(attrs, attribute.String("user.login_type", lt))
|
|
}
|
|
}
|
|
|
|
return attrs
|
|
}
|
|
|
|
func TraceMiddleware(_ *svc.ServiceContext) func(ctx *gin.Context) {
|
|
return func(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
tracer := trace.TracerFromContext(ctx)
|
|
|
|
// Capture Request Body
|
|
var reqBody []byte
|
|
if c.Request.Body != nil {
|
|
reqBody, _ = io.ReadAll(c.Request.Body)
|
|
c.Request.Body = io.NopCloser(bytes.NewBuffer(reqBody)) // Restore body
|
|
}
|
|
|
|
spanName := c.FullPath()
|
|
method := c.Request.Method
|
|
|
|
ctx, span := tracer.Start(
|
|
ctx,
|
|
fmt.Sprintf("%s %s", method, spanName),
|
|
oteltrace.WithSpanKind(oteltrace.SpanKindServer),
|
|
)
|
|
defer span.End()
|
|
|
|
requestId := trace.TraceIDFromContext(ctx)
|
|
|
|
c.Header(trace.RequestIdKey, requestId)
|
|
|
|
span.SetAttributes(requestAttributes(c.Request)...)
|
|
span.SetAttributes(
|
|
attribute.String("http.request_id", requestId),
|
|
semconv.HTTPRouteKey.String(c.FullPath()),
|
|
)
|
|
|
|
// Add user attributes from context (set by authMiddleware)
|
|
span.SetAttributes(userAttributes(ctx)...)
|
|
|
|
// Record Request Body (limit to 1MB)
|
|
if len(reqBody) > 0 {
|
|
limit := 1048576
|
|
if len(reqBody) > limit {
|
|
span.SetAttributes(attribute.String("http.request.body", string(reqBody[:limit])+"...(truncated)"))
|
|
} else {
|
|
span.SetAttributes(attribute.String("http.request.body", string(reqBody)))
|
|
}
|
|
}
|
|
|
|
// context with request host
|
|
ctx = context.WithValue(ctx, constant.CtxKeyRequestHost, c.Request.Host)
|
|
// restructure context
|
|
c.Request = c.Request.WithContext(ctx)
|
|
|
|
// Wrap ResponseWriter to capture Response Body
|
|
blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer}
|
|
c.Writer = blw
|
|
|
|
c.Next()
|
|
|
|
// Record Response Body (limit to 1MB)
|
|
respBody := blw.body.String()
|
|
if len(respBody) > 0 {
|
|
limit := 1048576
|
|
if len(respBody) > limit {
|
|
span.SetAttributes(attribute.String("http.response.body", respBody[:limit]+"...(truncated)"))
|
|
} else {
|
|
span.SetAttributes(attribute.String("http.response.body", respBody))
|
|
}
|
|
}
|
|
|
|
// handle response related attributes
|
|
status := c.Writer.Status()
|
|
span.SetStatus(statusByWriter(status))
|
|
if status > 0 {
|
|
span.SetAttributes(semconv.HTTPResponseStatusCodeKey.Int(status))
|
|
}
|
|
if len(c.Errors) > 0 {
|
|
span.SetStatus(codes.Error, c.Errors.String())
|
|
for _, err := range c.Errors {
|
|
span.RecordError(err.Err)
|
|
}
|
|
}
|
|
}
|
|
}
|