fiumba
This commit is contained in:
347
src/common/jwttoken/jwt.go
Normal file
347
src/common/jwttoken/jwt.go
Normal file
@ -0,0 +1,347 @@
|
||||
// Package jwttoken encrypts and decrypts tokens using JWT
|
||||
package jwttoken
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/rs/zerolog"
|
||||
zlog "github.com/rs/zerolog/log"
|
||||
|
||||
"quantex.com/qfixdpl/src/app"
|
||||
"quantex.com/qfixdpl/src/app/version"
|
||||
"quantex.com/qfixdpl/src/common/tracerr"
|
||||
)
|
||||
|
||||
const (
|
||||
postFix = "_QUANTEX_SECRET_KEY"
|
||||
errAuthInvalidJWT = "invalid JWT token at %s"
|
||||
errAuthMissingTokenClaim = "missing token claim %s at %s"
|
||||
errAuthTokenNotSigned = "token could not be signed at Encrypt: %w"
|
||||
errAuthMissingToken = "missing authentication token for service at %s: %s"
|
||||
errAuthExpiredToken = "authentication token expired at %s"
|
||||
errAuthExpClaim = "expiration claim is missing or invalid at %s"
|
||||
errAuthSecretKeyNotFound = "secret key %s not found in environment"
|
||||
errAuthTokenNotParsable = "token could not be parsed at %s:%w "
|
||||
errAuthServiceNameEmpty = "service name cannot be empty at %s - received: %s"
|
||||
claimToken = "token"
|
||||
claimPermissions = "permissions"
|
||||
claimIss = "iss"
|
||||
claimExp = "exp"
|
||||
claimIat = "iat"
|
||||
)
|
||||
|
||||
var claimsToValidate = []string{claimToken, claimIss} //nolint:gochecknoglobals // Set claims to validate
|
||||
|
||||
var log zerolog.Logger //nolint
|
||||
|
||||
var secretCache sync.Map //nolint:gochecknoglobals // Cache secrets after first lookup
|
||||
|
||||
func init() {
|
||||
log = zlog.With().Str("gtag", "secure_token").Logger().Level(zerolog.InfoLevel)
|
||||
}
|
||||
|
||||
// Validate decrypts and validates a JWT token and its claims.
|
||||
// It returns the AuthorizedService associated with the token if valid.
|
||||
// An error is returned if the token is invalid or any validation step fails.
|
||||
// The service parameter specifies the expected issuer of the token.
|
||||
func Validate(service, token string, authServices map[string]app.AuthorizedService) (*app.AuthorizedService, error) {
|
||||
claims, err := decrypt(service, token)
|
||||
if err != nil {
|
||||
err := tracerr.Errorf("JWT Token could not be decrypted: %w", err)
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ok, err := validateJWTClaims(claims)
|
||||
if err != nil || !ok {
|
||||
err := tracerr.Errorf("invalid claims: %w", err)
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
auth, err := serviceAuth(claims, authServices)
|
||||
if err != nil {
|
||||
err := tracerr.Errorf("service auth validation failed: %w", err)
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t, ok := claims[claimToken].(string)
|
||||
if !ok {
|
||||
err := tracerr.Errorf("token claim is not a string")
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
auth.Token = &t
|
||||
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// Encrypt creates a JWT token string from the provided ExtAuth information.
|
||||
// It returns the signed JWT token string or an error if the process fails.
|
||||
// The auth parameter contains the necessary information for token creation.
|
||||
func Encrypt(auth app.ExtAuth) (string, error) {
|
||||
if auth.Name == "" {
|
||||
err := tracerr.Errorf("auth.Name cannot be empty at Encrypt")
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
claimToken: auth.Token,
|
||||
claimIss: version.AppName,
|
||||
claimIat: time.Now().Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
secret, err := findSecret(auth.Name)
|
||||
if secret == nil || len(secret) == 0 || err != nil {
|
||||
err := tracerr.Errorf(errAuthMissingToken, "Encrypt", auth.Name)
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
signedTkn, err := token.SignedString(secret)
|
||||
if err != nil {
|
||||
err = tracerr.Errorf(errAuthTokenNotSigned, err)
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
return signedTkn, nil
|
||||
}
|
||||
|
||||
// IsJWT checks if a token string is a valid JWT format.
|
||||
// JWT tokens have exactly 3 parts separated by dots (header.payload.signature).
|
||||
func IsJWT(token string) bool {
|
||||
// Remove any whitespace
|
||||
token = strings.TrimSpace(token)
|
||||
|
||||
// JWT has exactly 3 parts separated by dots
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Each part should be non-empty (base64url encoded)
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// decrypt decrypts a JWT token string using the secret associated with the given service.
|
||||
// It returns the JWT claims if decryption is successful, or an error otherwise.
|
||||
// The service parameter specifies the expected issuer of the token.
|
||||
func decrypt(service, token string) (jwt.MapClaims, error) {
|
||||
if service == "" {
|
||||
err := tracerr.Errorf(errAuthServiceNameEmpty, "Decrypt", service)
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return jwt.MapClaims{}, err
|
||||
}
|
||||
|
||||
secret, err := findSecret(service)
|
||||
if secret == nil || len(secret) == 0 || err != nil {
|
||||
err := tracerr.Errorf(errAuthMissingToken, "Decrypt", service)
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return jwt.MapClaims{}, err
|
||||
}
|
||||
|
||||
tkn, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
|
||||
if t.Method.Alg() != jwt.SigningMethodHS256.Alg() {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
|
||||
return secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
err = tracerr.Errorf(errAuthTokenNotParsable, "Decrypt", err)
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return jwt.MapClaims{}, err
|
||||
}
|
||||
|
||||
claims, ok := tkn.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
err := tracerr.Errorf(errAuthInvalidJWT, "Decrypt")
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return jwt.MapClaims{}, err
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// serviceAuth validates the service based on JWT claims and authorized services.
|
||||
// It returns the AuthorizedService if validation is successful, or an error otherwise.
|
||||
// The services parameter contains the map of authorized services to validate against.
|
||||
func serviceAuth(claims jwt.MapClaims, services map[string]app.AuthorizedService) (out *app.AuthorizedService, err error) {
|
||||
issuer, ok := claims[claimIss].(string)
|
||||
if !ok {
|
||||
err := tracerr.Errorf("issuer claim is not a string")
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
service, ok := services[issuer]
|
||||
if !ok {
|
||||
err = tracerr.Errorf("Unknown service attempted access - issuer: %s", issuer)
|
||||
|
||||
log.Warn().Str("issuer", issuer).Msg(err.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: change required permission as needed
|
||||
ok = service.HasPermissions(app.ServicePermissionFullAccess)
|
||||
if !ok {
|
||||
err = tracerr.Errorf("Service without required permissions attempted access")
|
||||
|
||||
log.Warn().Str("issuer", issuer).Msg(err.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &service, nil
|
||||
}
|
||||
|
||||
// findSecret retrieves the secret key for a given application name from environment variables.
|
||||
// It caches the secret after the first lookup for performance.
|
||||
// It returns the secret as a byte slice or an error if not found.
|
||||
func findSecret(appName string) ([]byte, error) {
|
||||
key := strings.ToUpper(appName) + postFix
|
||||
|
||||
if k, ok := secretCache.Load(key); ok {
|
||||
if b, ok := k.([]byte); ok {
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
|
||||
secret := os.Getenv(key)
|
||||
if secret == "" {
|
||||
err := tracerr.Errorf(errAuthSecretKeyNotFound, key)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secretCache.Store(key, []byte(secret))
|
||||
|
||||
return []byte(secret), nil
|
||||
}
|
||||
|
||||
// validateJWTClaims checks the presence and validity of required JWT claims.
|
||||
// It returns true if all claims are valid, or an error otherwise.
|
||||
func validateJWTClaims(claims jwt.MapClaims) (ok bool, err error) {
|
||||
for _, claim := range claimsToValidate {
|
||||
ok, err = validateClaims(claims, claim)
|
||||
if err != nil || !ok {
|
||||
err := tracerr.Errorf("error %s token at ValidateClaims: %w", claim, err)
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// validateClaims validates a specific JWT claim based on its type.
|
||||
func validateClaims(claims jwt.MapClaims, claim string) (ok bool, err error) {
|
||||
switch claim {
|
||||
case claimExp:
|
||||
return validateExpiration(claims)
|
||||
case claimToken:
|
||||
return validateToken(claims)
|
||||
case claimIss:
|
||||
return validateIssuer(claims)
|
||||
default:
|
||||
err := fmt.Errorf("unknown claim %s at validateTokenClaims", claim)
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: when needed add claimExp to claimsToValidate var to implement.
|
||||
func validateExpiration(claims jwt.MapClaims) (ok bool, err error) {
|
||||
exp, ok := claims[claimExp].(float64)
|
||||
if !ok {
|
||||
err := fmt.Errorf(errAuthExpClaim, "validateExpiration")
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if int64(exp) < time.Now().Unix() {
|
||||
err := fmt.Errorf(errAuthExpiredToken, "validateExpiration")
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// validateToken checks for the presence of the token claim in the JWT claims.
|
||||
func validateToken(claims jwt.MapClaims) (ok bool, err error) {
|
||||
_, ok = claims[claimToken].(string)
|
||||
if !ok {
|
||||
err = tracerr.Errorf(errAuthMissingTokenClaim, claimToken, "validateToken")
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// validateIssuer checks for the presence of the issuer claim in the JWT claims.
|
||||
func validateIssuer(claims jwt.MapClaims) (ok bool, err error) {
|
||||
_, ok = claims[claimIss].(string)
|
||||
if !ok {
|
||||
err = tracerr.Errorf(errAuthMissingTokenClaim, claimIss, "validateIssuer")
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
85
src/common/logger/handler.go
Normal file
85
src/common/logger/handler.go
Normal file
@ -0,0 +1,85 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"quantex.com/qfixdpl/src/domain"
|
||||
)
|
||||
|
||||
//nolint:gochecknoglobals // need to be global
|
||||
var notify domain.Notifier
|
||||
|
||||
type QuantexHandler struct {
|
||||
slog.Handler
|
||||
levels []slog.Level
|
||||
}
|
||||
|
||||
func SetNotifier(n domain.Notifier) {
|
||||
notify = n
|
||||
}
|
||||
|
||||
// NewQuantexHandler creates a log handler that send logs using a notifier.
|
||||
func NewQuantexHandler(handler slog.Handler, levels []slog.Level) *QuantexHandler {
|
||||
return &QuantexHandler{
|
||||
Handler: handler,
|
||||
levels: levels,
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether the handler handles records at the given level.
|
||||
func (s *QuantexHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return s.Handler.Enabled(ctx, level)
|
||||
}
|
||||
|
||||
// Handle intercepts and processes logger messages.
|
||||
// In our case, send a message to the notifier.
|
||||
//
|
||||
//revive:disable:cognitive-complexity we need this level of complexity
|
||||
func (s *QuantexHandler) Handle(ctx context.Context, record slog.Record) error {
|
||||
const (
|
||||
shortErrKey = "err"
|
||||
longErrKey = "error"
|
||||
)
|
||||
|
||||
if notify != nil && slices.Contains(s.levels, record.Level) {
|
||||
switch record.Level {
|
||||
case slog.LevelError:
|
||||
msg := record.Message
|
||||
record.Attrs(func(attr slog.Attr) bool {
|
||||
if attr.Key == shortErrKey || attr.Key == longErrKey {
|
||||
if err, ok := attr.Value.Any().(error); ok {
|
||||
msg += err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
notify.SendMsg(domain.MessageChannelError, msg, domain.MessageStatusStopper, nil)
|
||||
case slog.LevelWarn:
|
||||
notify.SendMsg(domain.MessageChannelError, record.Message, domain.MessageStatusWarning, nil)
|
||||
}
|
||||
}
|
||||
|
||||
err := s.Handler.Handle(ctx, record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error from QuantexHandler: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//revive:enable
|
||||
|
||||
// WithAttrs returns a new QuantexHandler whose attributes consists.
|
||||
func (s *QuantexHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return NewQuantexHandler(s.Handler.WithAttrs(attrs), s.levels)
|
||||
}
|
||||
|
||||
// WithGroup returns a new QuantexHandler whose group consists.
|
||||
func (s *QuantexHandler) WithGroup(name string) slog.Handler {
|
||||
return NewQuantexHandler(s.Handler.WithGroup(name), s.levels)
|
||||
}
|
||||
137
src/common/logger/logger.go
Normal file
137
src/common/logger/logger.go
Normal file
@ -0,0 +1,137 @@
|
||||
// Package logger provides logging functionality for the application.
|
||||
// It includes a Gin middleware for logging requests and responses.
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"quantex.com/qfixdpl/src/app/version"
|
||||
"quantex.com/qfixdpl/src/common/logger/tint"
|
||||
)
|
||||
|
||||
//nolint:gochecknoglobals // need to be global
|
||||
var loggerWoNotifier *slog.Logger
|
||||
|
||||
func ErrorWoNotifier(msg string, args ...any) {
|
||||
if loggerWoNotifier == nil || !loggerWoNotifier.Enabled(context.Background(), slog.LevelError) {
|
||||
return
|
||||
}
|
||||
var pcs [1]uintptr
|
||||
runtime.Callers(2, pcs[:]) // skip [Callers, Infof]
|
||||
r := slog.NewRecord(time.Now(), slog.LevelError, fmt.Sprintf(msg, args...), pcs[0])
|
||||
err := loggerWoNotifier.Handler().Handle(context.Background(), r)
|
||||
if err != nil {
|
||||
// We can't use slog.Error here because it will try to send the message to the notifier
|
||||
// and will create a recursive loop
|
||||
slog.Info("error handling slog record in ErrorWoNotifier: " + err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// loggerWoNotifier.Error(msg, args...)
|
||||
}
|
||||
|
||||
func NewTextHandler(level slog.Level) slog.Handler {
|
||||
replace := func(_ []string, a slog.Attr) slog.Attr {
|
||||
if a.Key == slog.SourceKey {
|
||||
source, ok := a.Value.Any().(*slog.Source)
|
||||
if !ok {
|
||||
// handle the case when the assertion fails
|
||||
slog.Warn(fmt.Sprintf("unexpected type %T for slog.Source", a.Value.Any()))
|
||||
|
||||
return a
|
||||
}
|
||||
// Just add a space before the file path to enable to open the file from the IDE
|
||||
p := strings.Split(source.File, strings.ToLower(version.AppName)+"/")
|
||||
if len(p) > 1 {
|
||||
source.File = " " + p[1]
|
||||
} else {
|
||||
source.File = " " + source.File
|
||||
}
|
||||
}
|
||||
|
||||
if a.Key == slog.TimeKey {
|
||||
a.Value = slog.StringValue(a.Value.Time().Format("15:04:05.000000"))
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
opts := &slog.HandlerOptions{
|
||||
AddSource: true,
|
||||
Level: level,
|
||||
ReplaceAttr: replace,
|
||||
}
|
||||
|
||||
handler := slog.NewTextHandler(os.Stdout, opts)
|
||||
loggerWoNotifier = slog.New(handler)
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
func NewTextHandlerTint(level slog.Level, lines tint.LinesCount) slog.Handler {
|
||||
handler := tint.NewHandler(os.Stdout, &tint.Options{
|
||||
AddSource: true,
|
||||
Level: level,
|
||||
TimeFormat: "15:04:05.000000",
|
||||
Lines: lines,
|
||||
})
|
||||
|
||||
loggerWoNotifier = slog.New(handler)
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
func NewJSONHandler(level slog.Level) slog.Handler {
|
||||
opts := &slog.HandlerOptions{
|
||||
AddSource: true,
|
||||
Level: level,
|
||||
}
|
||||
|
||||
handler := slog.NewJSONHandler(os.Stdout, opts)
|
||||
loggerWoNotifier = slog.New(handler)
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
// GinLoggerMiddleware creates a Gin middleware for logging using slog.
|
||||
func GinLoggerMiddleware(logger *slog.Logger) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
start := time.Now()
|
||||
|
||||
// Store request parameters
|
||||
params := []slog.Attr{
|
||||
slog.String("method", ctx.Request.Method),
|
||||
slog.String("path", ctx.Request.URL.Path),
|
||||
slog.Any("query", ctx.Request.URL.Query()),
|
||||
slog.Any("headers", ctx.Request.Header),
|
||||
}
|
||||
|
||||
// Store the request body if needed
|
||||
if ctx.Request.Method == http.MethodPost || ctx.Request.Method == http.MethodPut {
|
||||
if body, err := ctx.GetRawData(); err == nil {
|
||||
params = append(params, slog.String("body", string(body)))
|
||||
}
|
||||
}
|
||||
|
||||
// Add status and duration to parameters
|
||||
params = append(params,
|
||||
slog.Int("status", ctx.Writer.Status()),
|
||||
slog.String("duration", time.Since(start).String()),
|
||||
)
|
||||
|
||||
// Log request details with slog
|
||||
logger.LogAttrs(context.Background(), slog.LevelInfo, "Request Details", params...)
|
||||
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
56
src/common/logger/tint/buffer.go
Normal file
56
src/common/logger/tint/buffer.go
Normal file
@ -0,0 +1,56 @@
|
||||
package tint
|
||||
|
||||
import "sync"
|
||||
|
||||
type buffer []byte
|
||||
|
||||
//nolint:gochecknoglobals // We are ok with this global definition
|
||||
var bufPool = sync.Pool{
|
||||
New: func() any {
|
||||
b := make(buffer, 0, 1024)
|
||||
|
||||
return &b
|
||||
},
|
||||
}
|
||||
|
||||
func newBuffer() *buffer {
|
||||
return bufPool.Get().(*buffer) //nolint:forcetypeassert // bufPool will always return a buffer
|
||||
}
|
||||
|
||||
func (b *buffer) Free() {
|
||||
// To reduce peak allocation, return only smaller buffers to the pool.
|
||||
const maxBufferSize = 16 << 10
|
||||
if cap(*b) <= maxBufferSize {
|
||||
*b = (*b)[:0]
|
||||
bufPool.Put(b)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *buffer) Write(bytes []byte) (int, error) {
|
||||
*b = append(*b, bytes...)
|
||||
|
||||
return len(bytes), nil
|
||||
}
|
||||
|
||||
func (b *buffer) WriteByte(char byte) error {
|
||||
*b = append(*b, char)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *buffer) WriteString(str string) (int, error) {
|
||||
*b = append(*b, str...)
|
||||
|
||||
return len(str), nil
|
||||
}
|
||||
|
||||
// revive:disable:flag-parameter in this case we consider is ok to use a flag as a parameter
|
||||
func (b *buffer) WriteStringIf(ok bool, str string) (int, error) {
|
||||
if !ok {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return b.WriteString(str)
|
||||
}
|
||||
|
||||
// revive:enable
|
||||
522
src/common/logger/tint/handler.go
Normal file
522
src/common/logger/tint/handler.go
Normal file
@ -0,0 +1,522 @@
|
||||
//nolint:dupword // This is documentation
|
||||
/*
|
||||
Package tint implements a zero-dependency [slog.Handler] that writes tinted
|
||||
(colorized) logs. The output format is inspired by the [zerolog.ConsoleWriter]
|
||||
and [slog.TextHandler].
|
||||
|
||||
The output format can be customized using [Options], which is a drop-in
|
||||
replacement for [slog.HandlerOptions].
|
||||
|
||||
# Customize Attributes
|
||||
|
||||
Options.ReplaceAttr can be used to alter or drop attributes. If set, it is
|
||||
called on each non-group attribute before it is logged.
|
||||
See [slog.HandlerOptions] for details.
|
||||
|
||||
Create a new logger that doesn't write the time:
|
||||
|
||||
w := os.Stderr
|
||||
logger := slog.New(
|
||||
tint.NewHandler(w, &tint.Options{
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||
if a.Key == slog.TimeKey && len(groups) == 0 {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
Create a new logger that writes all errors in red:
|
||||
|
||||
w := os.Stderr
|
||||
logger := slog.New(
|
||||
tint.NewHandler(w, &tint.Options{
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||
if err, ok := a.Value.Any().(error); ok {
|
||||
aErr := tint.Err(err)
|
||||
aErr.Key = a.Key
|
||||
return aErr
|
||||
}
|
||||
return a
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
# Automatically Enable Colors
|
||||
|
||||
Colors are enabled by default and can be disabled using the Options.NoColor
|
||||
attribute. To automatically enable colors based on the terminal capabilities,
|
||||
use e.g. the [go-isatty] package.
|
||||
|
||||
w := os.Stderr
|
||||
logger := slog.New(
|
||||
tint.NewHandler(w, &tint.Options{
|
||||
NoColor: !isatty.IsTerminal(w.Fd()),
|
||||
}),
|
||||
)
|
||||
|
||||
# Windows Support
|
||||
|
||||
Color support on Windows can be added by using e.g. the [go-colorable] package.
|
||||
|
||||
w := os.Stderr
|
||||
logger := slog.New(
|
||||
tint.NewHandler(colorable.NewColorable(w), nil),
|
||||
)
|
||||
|
||||
[zerolog.ConsoleWriter]: https://pkg.go.dev/github.com/rs/zerolog#ConsoleWriter
|
||||
[go-isatty]: https://pkg.go.dev/github.com/mattn/go-isatty
|
||||
[go-colorable]: https://pkg.go.dev/github.com/mattn/go-colorable
|
||||
*/
|
||||
package tint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"quantex.com/qfixdpl/src/app/version"
|
||||
)
|
||||
|
||||
// ANSI modes
|
||||
const (
|
||||
ansiReset = "\033[0m"
|
||||
ansiFaint = "\033[2m"
|
||||
ansiResetFaint = "\033[22m"
|
||||
ansiBrightRed = "\033[91m"
|
||||
ansiBrightGreen = "\033[92m"
|
||||
ansiBrightYellow = "\033[93m"
|
||||
ansiBrightRedFaint = "\033[91;2m"
|
||||
)
|
||||
|
||||
const (
|
||||
errKey = "err"
|
||||
defaultLevel = slog.LevelInfo
|
||||
defaultTimeFormat = time.StampMilli
|
||||
OneLine = 1
|
||||
TwoLines = 2
|
||||
)
|
||||
|
||||
type LinesCount int
|
||||
|
||||
// Options for a slog.Handler that writes tinted logs. A zero Options consists
|
||||
// entirely of default values.
|
||||
//
|
||||
// Options can be used as a drop-in replacement for [slog.HandlerOptions].
|
||||
type Options struct {
|
||||
// Enable source code location (Default: false)
|
||||
AddSource bool
|
||||
|
||||
// Minimum level to log (Default: slog.LevelInfo)
|
||||
Level slog.Leveler
|
||||
|
||||
// ReplaceAttr is called to rewrite each non-group attribute before it is logged.
|
||||
// See https://pkg.go.dev/log/slog#HandlerOptions for details.
|
||||
ReplaceAttr func(groups []string, attr slog.Attr) slog.Attr
|
||||
|
||||
// Time format (Default: time.StampMilli)
|
||||
TimeFormat string
|
||||
|
||||
// Disable color (Default: false)
|
||||
NoColor bool
|
||||
|
||||
Lines LinesCount
|
||||
}
|
||||
|
||||
// NewHandler creates a [slog.Handler] that writes tinted logs to Writer w,
|
||||
// using the default options. If opts is nil, the default options are used.
|
||||
func NewHandler(w io.Writer, opts *Options) slog.Handler {
|
||||
h := &handler{
|
||||
w: w,
|
||||
level: defaultLevel,
|
||||
timeFormat: defaultTimeFormat,
|
||||
}
|
||||
if opts == nil {
|
||||
return h
|
||||
}
|
||||
|
||||
h.addSource = opts.AddSource
|
||||
if opts.Level != nil {
|
||||
h.level = opts.Level
|
||||
}
|
||||
h.replaceAttr = opts.ReplaceAttr
|
||||
if opts.TimeFormat != "" {
|
||||
h.timeFormat = opts.TimeFormat
|
||||
}
|
||||
h.noColor = opts.NoColor
|
||||
|
||||
if opts.Lines != OneLine && opts.Lines != TwoLines {
|
||||
panic("invalid lines count " + strconv.Itoa(int(opts.Lines)) + ", must be OneLine or TwoLines")
|
||||
}
|
||||
|
||||
h.lines = opts.Lines
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// handler implements a [slog.Handler].
|
||||
type handler struct {
|
||||
attrsPrefix string
|
||||
groupPrefix string
|
||||
groups []string
|
||||
|
||||
mu sync.Mutex
|
||||
w io.Writer
|
||||
|
||||
addSource bool
|
||||
level slog.Leveler
|
||||
replaceAttr func([]string, slog.Attr) slog.Attr
|
||||
timeFormat string
|
||||
noColor bool
|
||||
lines LinesCount
|
||||
}
|
||||
|
||||
func (h *handler) clone() *handler {
|
||||
return &handler{
|
||||
attrsPrefix: h.attrsPrefix,
|
||||
groupPrefix: h.groupPrefix,
|
||||
groups: h.groups,
|
||||
w: h.w,
|
||||
addSource: h.addSource,
|
||||
level: h.level,
|
||||
replaceAttr: h.replaceAttr,
|
||||
timeFormat: h.timeFormat,
|
||||
noColor: h.noColor,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) Enabled(_ context.Context, level slog.Level) bool {
|
||||
return level >= h.level.Level()
|
||||
}
|
||||
|
||||
// revive:disable:cognitive-complexity we accept this complexity here
|
||||
// revive:disable:cyclomatic we accept this complexity here
|
||||
//
|
||||
//nolint:nestif // We accept this complexity
|
||||
func (h *handler) Handle(_ context.Context, record slog.Record) error {
|
||||
// get a buffer from the sync pool
|
||||
buf := newBuffer()
|
||||
defer buf.Free()
|
||||
|
||||
rep := h.replaceAttr
|
||||
|
||||
// write time
|
||||
if !record.Time.IsZero() {
|
||||
val := record.Time.Round(0) // strip monotonic to match Attr behavior
|
||||
if rep == nil {
|
||||
h.appendTime(buf, record.Time)
|
||||
buf.WriteByte(' ')
|
||||
} else if a := rep(nil /* groups */, slog.Time(slog.TimeKey, val)); a.Key != "" {
|
||||
if a.Value.Kind() == slog.KindTime {
|
||||
h.appendTime(buf, a.Value.Time())
|
||||
} else {
|
||||
h.appendValue(buf, a.Value, false)
|
||||
}
|
||||
buf.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
|
||||
// write level
|
||||
if rep == nil {
|
||||
h.appendLevel(buf, record.Level)
|
||||
buf.WriteByte(' ')
|
||||
} else if a := rep(nil /* groups */, slog.Any(slog.LevelKey, record.Level)); a.Key != "" {
|
||||
h.appendValue(buf, a.Value, false)
|
||||
buf.WriteByte(' ')
|
||||
}
|
||||
|
||||
// write message
|
||||
if rep == nil {
|
||||
buf.WriteString(record.Message)
|
||||
buf.WriteByte(' ')
|
||||
} else if a := rep(nil /* groups */, slog.String(slog.MessageKey, record.Message)); a.Key != "" {
|
||||
h.appendValue(buf, a.Value, false)
|
||||
buf.WriteByte(' ')
|
||||
}
|
||||
|
||||
// write handler attributes
|
||||
if len(h.attrsPrefix) > 0 {
|
||||
buf.WriteString(h.attrsPrefix)
|
||||
}
|
||||
|
||||
// write attributes
|
||||
record.Attrs(func(attr slog.Attr) bool {
|
||||
h.appendAttr(buf, attr, h.groupPrefix, h.groups)
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if len(*buf) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if h.lines == TwoLines {
|
||||
(*buf)[len(*buf)-1] = '\n' // replace last space with newline
|
||||
}
|
||||
|
||||
// write source
|
||||
if h.addSource {
|
||||
fs := runtime.CallersFrames([]uintptr{record.PC})
|
||||
f, _ := fs.Next()
|
||||
if f.File != "" {
|
||||
src := &slog.Source{
|
||||
Function: f.Function,
|
||||
File: f.File,
|
||||
Line: f.Line,
|
||||
}
|
||||
|
||||
if rep == nil {
|
||||
h.appendSource(buf, src)
|
||||
buf.WriteByte(' ')
|
||||
} else if a := rep(nil /* groups */, slog.Any(slog.SourceKey, src)); a.Key != "" {
|
||||
h.appendValue(buf, a.Value, false)
|
||||
buf.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
_, err := h.w.Write(*buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write log: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// revive:enable
|
||||
|
||||
func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
if len(attrs) == 0 {
|
||||
return h
|
||||
}
|
||||
h2 := h.clone()
|
||||
|
||||
buf := newBuffer()
|
||||
defer buf.Free()
|
||||
|
||||
// write attributes to buffer
|
||||
for _, attr := range attrs {
|
||||
h.appendAttr(buf, attr, h.groupPrefix, h.groups)
|
||||
}
|
||||
h2.attrsPrefix = h.attrsPrefix + string(*buf)
|
||||
|
||||
return h2
|
||||
}
|
||||
|
||||
func (h *handler) WithGroup(name string) slog.Handler {
|
||||
if name == "" {
|
||||
return h
|
||||
}
|
||||
h2 := h.clone()
|
||||
h2.groupPrefix += name + "."
|
||||
h2.groups = append(h2.groups, name)
|
||||
|
||||
return h2
|
||||
}
|
||||
|
||||
func (h *handler) appendTime(buf *buffer, t time.Time) {
|
||||
buf.WriteStringIf(!h.noColor, ansiFaint)
|
||||
*buf = t.AppendFormat(*buf, h.timeFormat)
|
||||
buf.WriteStringIf(!h.noColor, ansiReset)
|
||||
}
|
||||
|
||||
func (h *handler) appendLevel(buf *buffer, level slog.Level) {
|
||||
switch {
|
||||
case level < slog.LevelInfo:
|
||||
buf.WriteString("DBG")
|
||||
appendLevelDelta(buf, level-slog.LevelDebug)
|
||||
case level < slog.LevelWarn:
|
||||
buf.WriteStringIf(!h.noColor, ansiBrightGreen)
|
||||
buf.WriteString("INF")
|
||||
appendLevelDelta(buf, level-slog.LevelInfo)
|
||||
buf.WriteStringIf(!h.noColor, ansiReset)
|
||||
case level < slog.LevelError:
|
||||
buf.WriteStringIf(!h.noColor, ansiBrightYellow)
|
||||
buf.WriteString("WRN")
|
||||
appendLevelDelta(buf, level-slog.LevelWarn)
|
||||
buf.WriteStringIf(!h.noColor, ansiReset)
|
||||
default:
|
||||
buf.WriteStringIf(!h.noColor, ansiBrightRed)
|
||||
buf.WriteString("ERR")
|
||||
appendLevelDelta(buf, level-slog.LevelError)
|
||||
buf.WriteStringIf(!h.noColor, ansiReset)
|
||||
}
|
||||
}
|
||||
|
||||
func appendLevelDelta(buf *buffer, delta slog.Level) {
|
||||
if delta == 0 {
|
||||
return
|
||||
} else if delta > 0 {
|
||||
buf.WriteByte('+')
|
||||
}
|
||||
*buf = strconv.AppendInt(*buf, int64(delta), 10)
|
||||
}
|
||||
|
||||
func (h *handler) appendSource(buf *buffer, src *slog.Source) {
|
||||
// dir, file := filepath.Split(src.File)
|
||||
|
||||
buf.WriteStringIf(!h.noColor, ansiFaint)
|
||||
// Just add a space before the file path to enable to open the file from the IDE
|
||||
p := strings.Split(src.File, strings.ToLower(version.AppName)+"/")
|
||||
if len(p) > 1 {
|
||||
buf.WriteString(p[1])
|
||||
} else {
|
||||
buf.WriteString(src.File)
|
||||
}
|
||||
// buf.WriteString(filepath.Join(filepath.Base(dir), file))
|
||||
buf.WriteByte(':')
|
||||
buf.WriteString(strconv.Itoa(src.Line))
|
||||
buf.WriteStringIf(!h.noColor, ansiReset)
|
||||
}
|
||||
|
||||
func (h *handler) appendAttr(buf *buffer, attr slog.Attr, groupsPrefix string, groups []string) {
|
||||
attr.Value = attr.Value.Resolve()
|
||||
if rep := h.replaceAttr; rep != nil && attr.Value.Kind() != slog.KindGroup {
|
||||
attr = rep(groups, attr)
|
||||
attr.Value = attr.Value.Resolve()
|
||||
}
|
||||
|
||||
if attr.Equal(slog.Attr{}) {
|
||||
return
|
||||
}
|
||||
|
||||
var err *tintError
|
||||
tintErr, ok := attr.Value.Any().(tintError)
|
||||
if attr.Value.Kind() == slog.KindGroup {
|
||||
if attr.Key != "" {
|
||||
groupsPrefix += attr.Key + "."
|
||||
groups = append(groups, attr.Key)
|
||||
}
|
||||
for _, groupAttr := range attr.Value.Group() {
|
||||
h.appendAttr(buf, groupAttr, groupsPrefix, groups)
|
||||
}
|
||||
} else if ok && errors.As(tintErr, err) {
|
||||
// append tintError
|
||||
h.appendTintError(buf, err, attr.Key, groupsPrefix)
|
||||
buf.WriteByte(' ')
|
||||
} else {
|
||||
h.appendKey(buf, attr.Key, groupsPrefix)
|
||||
h.appendValue(buf, attr.Value, true)
|
||||
buf.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) appendKey(buf *buffer, key, groups string) {
|
||||
buf.WriteStringIf(!h.noColor, ansiFaint)
|
||||
appendString(buf, groups+key, true)
|
||||
buf.WriteByte('=')
|
||||
buf.WriteStringIf(!h.noColor, ansiReset)
|
||||
}
|
||||
|
||||
// revive:disable:cyclomatic we accept this complexity here and don't seems to be so compex
|
||||
//
|
||||
//nolint:funlen // We accept this complexity
|
||||
func (h *handler) appendValue(buf *buffer, value slog.Value, quote bool) {
|
||||
switch value.Kind() {
|
||||
case slog.KindString:
|
||||
appendString(buf, value.String(), quote)
|
||||
case slog.KindInt64:
|
||||
*buf = strconv.AppendInt(*buf, value.Int64(), 10)
|
||||
case slog.KindUint64:
|
||||
*buf = strconv.AppendUint(*buf, value.Uint64(), 10)
|
||||
case slog.KindFloat64:
|
||||
*buf = strconv.AppendFloat(*buf, value.Float64(), 'g', -1, 64)
|
||||
case slog.KindBool:
|
||||
*buf = strconv.AppendBool(*buf, value.Bool())
|
||||
case slog.KindDuration:
|
||||
appendString(buf, value.Duration().String(), quote)
|
||||
case slog.KindTime:
|
||||
appendString(buf, value.Time().String(), quote)
|
||||
case slog.KindAny:
|
||||
switch cv := value.Any().(type) {
|
||||
case slog.Level:
|
||||
h.appendLevel(buf, cv)
|
||||
case encoding.TextMarshaler:
|
||||
data, err := cv.MarshalText()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
appendString(buf, string(data), quote)
|
||||
case *slog.Source:
|
||||
h.appendSource(buf, cv)
|
||||
default:
|
||||
appendString(buf, fmt.Sprintf("%+v", value.Any()), quote)
|
||||
}
|
||||
case slog.KindGroup:
|
||||
attrs := value.Group()
|
||||
*buf = append(*buf, '{')
|
||||
for i, attr := range attrs {
|
||||
if i > 0 {
|
||||
*buf = append(*buf, ',')
|
||||
}
|
||||
appendString(buf, attr.Key, true)
|
||||
*buf = append(*buf, '=')
|
||||
h.appendValue(buf, attr.Value, true)
|
||||
}
|
||||
*buf = append(*buf, '}')
|
||||
case slog.KindLogValuer:
|
||||
resolvedValue := value.LogValuer().LogValue()
|
||||
h.appendValue(buf, resolvedValue, quote)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) appendTintError(buf *buffer, err error, attrKey, groupsPrefix string) {
|
||||
buf.WriteStringIf(!h.noColor, ansiBrightRedFaint)
|
||||
appendString(buf, groupsPrefix+attrKey, true)
|
||||
buf.WriteByte('=')
|
||||
buf.WriteStringIf(!h.noColor, ansiResetFaint)
|
||||
appendString(buf, err.Error(), true)
|
||||
buf.WriteStringIf(!h.noColor, ansiReset)
|
||||
}
|
||||
|
||||
// revive:disable:flag-parameter in this case we consider is ok to use a flag as a parameter
|
||||
func appendString(buf *buffer, s string, quote bool) {
|
||||
if quote && needsQuoting(s) {
|
||||
*buf = strconv.AppendQuote(*buf, s)
|
||||
} else {
|
||||
buf.WriteString(s)
|
||||
}
|
||||
}
|
||||
|
||||
// revive:enable
|
||||
|
||||
func needsQuoting(s string) bool {
|
||||
if len(s) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, r := range s {
|
||||
if unicode.IsSpace(r) || r == '"' || r == '=' || !unicode.IsPrint(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type tintError struct{ error }
|
||||
|
||||
// Err returns a tinted (colorized) [slog.Attr] that will be written in red color
|
||||
// by the [tint.Handler]. When used with any other [slog.Handler], it behaves as
|
||||
//
|
||||
// slog.Any("err", err)
|
||||
func Err(err error) slog.Attr {
|
||||
if err != nil {
|
||||
err = tintError{err}
|
||||
}
|
||||
|
||||
return slog.Any(errKey, err)
|
||||
}
|
||||
144
src/common/tracerr/tracerr.go
Normal file
144
src/common/tracerr/tracerr.go
Normal file
@ -0,0 +1,144 @@
|
||||
// Package tracerr provides a custom error type that includes a stack trace
|
||||
package tracerr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StackTraceError is a custom error type that includes a stack trace
|
||||
type StackTraceError struct {
|
||||
Err error
|
||||
Stack string
|
||||
}
|
||||
|
||||
// Newf creates a new error with stack trace
|
||||
func New(msg string) *StackTraceError {
|
||||
return &StackTraceError{
|
||||
Err: fmt.Errorf(msg, nil),
|
||||
Stack: GetStackTrace(3),
|
||||
}
|
||||
}
|
||||
|
||||
// Errorf creates a new formated error with stack trace
|
||||
func Errorf(format string, a ...any) *StackTraceError {
|
||||
var e error
|
||||
if a == nil {
|
||||
e = errors.New(format)
|
||||
} else {
|
||||
e = fmt.Errorf(format, a...)
|
||||
}
|
||||
|
||||
return &StackTraceError{
|
||||
Err: e,
|
||||
Stack: GetStackTrace(3),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *StackTraceError) Error() string {
|
||||
return fmt.Sprintf("%s\nStack Trace:\n%s", e.Err, e.Stack)
|
||||
}
|
||||
|
||||
// Unwrap implements the errors.Unwrap interface
|
||||
func (e *StackTraceError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// Is implements the errors.Is interface
|
||||
func (e *StackTraceError) Is(target error) bool {
|
||||
if target == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the target is also a StackTraceError
|
||||
t, ok := target.(*StackTraceError)
|
||||
if !ok {
|
||||
return errors.Is(e.Err, target)
|
||||
}
|
||||
|
||||
return errors.Is(e.Err, t.Err)
|
||||
}
|
||||
|
||||
// As implements the errors.As interface
|
||||
func (e *StackTraceError) As(target any) bool {
|
||||
// Try to convert the target to *StackTraceError
|
||||
if target, ok := target.(*StackTraceError); ok {
|
||||
*target = *e
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// If target is not *StackTraceError, delegate to the wrapped error
|
||||
return errors.As(e.Err, target)
|
||||
}
|
||||
|
||||
// Join creates a new error that wraps the given errors.
|
||||
// If there is only one error, it returns that error.
|
||||
// If there are no errors, it returns nil.
|
||||
func Join(errs ...error) error {
|
||||
// Filter out nil errors
|
||||
nonNil := make([]error, 0, len(errs))
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
nonNil = append(nonNil, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(nonNil) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(nonNil) == 1 {
|
||||
return nonNil[0]
|
||||
}
|
||||
|
||||
// Create a new StackTraceError with joined errors
|
||||
msg := make([]string, len(nonNil))
|
||||
for i, err := range nonNil {
|
||||
msg[i] = err.Error()
|
||||
}
|
||||
|
||||
return &StackTraceError{
|
||||
Err: errors.Join(nonNil...),
|
||||
Stack: GetStackTrace(3),
|
||||
}
|
||||
}
|
||||
|
||||
// GetStackTrace returns a formatted stack trace similar to gopkg.in/src-d/go-errors.v1
|
||||
// It skips the first 'skip' number of frames from the stack trace
|
||||
func GetStackTrace(skip int) string {
|
||||
// Allocate space for 32 PCs
|
||||
pc := make([]uintptr, 32)
|
||||
|
||||
// Get the program counters for the stack trace
|
||||
n := runtime.Callers(skip, pc)
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
pc = pc[:n] // Only use PCs that were actually filled
|
||||
|
||||
// Create frames from program counters
|
||||
frames := runtime.CallersFrames(pc)
|
||||
|
||||
// Build the stack trace string
|
||||
var builder strings.Builder
|
||||
|
||||
// Process frames until we run out
|
||||
for {
|
||||
frame, more := frames.Next()
|
||||
|
||||
// Format this frame according to the specified format
|
||||
//revive:disable:unhandled-error No need to handle errors here WriteString always returns error nil
|
||||
builder.WriteString(frame.Function + "\n")
|
||||
builder.WriteString(fmt.Sprintf("\t%s:%d\n", frame.File, frame.Line))
|
||||
//revive:enable
|
||||
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
Reference in New Issue
Block a user