diff --git a/.env.example b/.env.example index 95a58b0..dc23cbe 100644 --- a/.env.example +++ b/.env.example @@ -54,13 +54,6 @@ STACKS_CREATE_MAX=1 LOG_DIR=logs LOG_FILE_PREFIX=app LOG_MAX_BODY_BYTES=1048576 -LOG_WEBHOOK_QUEUE_SIZE=1000 -LOG_WEBHOOK_TIMEOUT=5s -LOG_WEBHOOK_BATCH_SIZE=20 -LOG_WEBHOOK_BATCH_WAIT=2s -LOG_WEBHOOK_MAX_CHARS=1800 -LOG_DISCORD_WEBHOOK_URL= -LOG_SLACK_WEBHOOK_URL= # S3 Challenge Files S3_ENABLED=false diff --git a/README.md b/README.md index b2e05fc..8440135 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,7 @@ See [SMCTF Docs](https://ctf.null4u.cloud/smctf/) for more details. This README - Flag submission with rate limiting and HMAC verification - Scoreboard and Timeline (Redis caching support) - User profile with statistics (Some implementations are still WIP) -- Logging middleware with file logging and webhook support (e.g., Discord, Slack, etc.) - - Supports queuing and batching for webhooks to prevent rate limiting issues, and splitting long messages. +- Logging middleware with file logging support - Ref Issue: [#9](https://github.com/nullforu/smctf/issues/9), PR: [#10](https://github.com/nullforu/smctf/pull/10) - User and Team management (WIP) - Ref Issue: [#11](https://github.com/nullforu/smctf/issues/11), [#22](https://github.com/nullforu/smctf/issues/22), PR: [#12](https://github.com/nullforu/smctf/pull/12), [#15](https://github.com/nullforu/smctf/pull/15), [#23](https://github.com/nullforu/smctf/pull/23) @@ -178,13 +177,6 @@ STACKS_CREATE_MAX=1 LOG_DIR=logs LOG_FILE_PREFIX=app LOG_MAX_BODY_BYTES=1048576 -LOG_WEBHOOK_QUEUE_SIZE=1000 -LOG_WEBHOOK_TIMEOUT=5s -LOG_WEBHOOK_BATCH_SIZE=20 -LOG_WEBHOOK_BATCH_WAIT=2s -LOG_WEBHOOK_MAX_CHARS=1800 -LOG_DISCORD_WEBHOOK_URL= -LOG_SLACK_WEBHOOK_URL= # S3 Challenge Files S3_ENABLED=false @@ -218,6 +210,52 @@ go build -o smctf ./cmd/server > [!NOTE] > > Running in Docker environment will be supported in the future. + +**Logging Schema** + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SMCTF Log Event", + "type": "object", + "additionalProperties": true, + "required": ["ts", "level", "msg", "app"], + "properties": { + "ts": { + "type": "string", + "format": "date-time", + "description": "RFC3339 timestamp with timezone" + }, + "level": { + "type": "string", + "enum": ["debug", "info", "warn", "error"] + }, + "msg": { "type": "string" }, + "app": { "type": "string" }, + "legacy": { "type": "boolean" }, + "error": {}, + "stack": { "type": "string" }, + "http": { + "type": "object", + "additionalProperties": true, + "properties": { + "method": { "type": "string" }, + "path": { "type": "string" }, + "status": { "type": "integer" }, + "latency": { "type": "string" }, + "ip": { "type": "string" }, + "query": { "type": "string" }, + "user_agent": { "type": "string" }, + "content_type": { "type": "string" }, + "content_length": { "type": "integer" }, + "user_id": { "type": "integer" }, + "body": { "type": "string" } + } + } + } +} +``` + > Currently, please use local installation for development and testing. Requires Go and NodeJS, NPM installation. ## Testing @@ -288,7 +326,7 @@ Defaults live in `./scripts/generate_dummy_sql/defaults/` and can be overridden It provides sample challenges, 30 users (including admin), and random submissions data from the last ~48 hours. > [!WARNING] -> +> > **This will TRUNCATE all tables in the database! Use only in development/test environments.** ## FAQ, Troubleshooting diff --git a/cmd/server/main.go b/cmd/server/main.go index e8636ee..7015b7c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,8 +2,7 @@ package main import ( "context" - "io" - "log" + "log/slog" nethttp "net/http" "os" "os/signal" @@ -24,41 +23,54 @@ import ( func main() { cfg, err := config.Load() if err != nil { - log.Fatalf("config error: %v", err) + boot := slog.New(slog.NewJSONHandler(os.Stderr, nil)) + boot.Error("config error", slog.Any("error", err)) + os.Exit(1) } - logger, err := logging.New(cfg.Logging) + logger, err := logging.New(cfg.Logging, logging.Options{ + Service: "smctf", + Env: cfg.AppEnv, + AddSource: false, + }) if err != nil { - log.Fatalf("logging init error: %v", err) + boot := slog.New(slog.NewJSONHandler(os.Stderr, nil)) + boot.Error("logging init error", slog.Any("error", err)) + os.Exit(1) } + slog.SetDefault(logger.Logger) + defer func() { if err := logger.Close(); err != nil { - log.Printf("log close error: %v", err) + logger.Error("log close error", slog.Any("error", err)) } }() - log.SetOutput(io.MultiWriter(os.Stdout, logger)) - log.Printf("config loaded:\n%s", config.FormatForLog(cfg)) + logger.Info("config loaded", slog.Any("config", config.FormatForLog(cfg))) ctx := context.Background() database, err := db.New(cfg.DB, cfg.AppEnv) if err != nil { - log.Fatalf("db init error: %v", err) + logger.Error("db init error", slog.Any("error", err)) + os.Exit(1) } if err := database.PingContext(ctx); err != nil { - log.Fatalf("db ping error: %v", err) + logger.Error("db ping error", slog.Any("error", err)) + os.Exit(1) } redisClient := cache.New(cfg.Redis) if err := redisClient.Ping(ctx).Err(); err != nil { - log.Fatalf("redis ping error: %v", err) + logger.Error("redis ping error", slog.Any("error", err)) + os.Exit(1) } if cfg.AutoMigrate { if err := db.AutoMigrate(ctx, database); err != nil { - log.Fatalf("auto migrate error: %v", err) + logger.Error("auto migrate error", slog.Any("error", err)) + os.Exit(1) } } @@ -75,7 +87,8 @@ func main() { if cfg.S3.Enabled { store, err := storage.NewS3ChallengeFileStore(ctx, cfg.S3) if err != nil { - log.Fatalf("s3 init error: %v", err) + logger.Error("s3 init error", slog.Any("error", err)) + os.Exit(1) } fileStore = store } @@ -90,9 +103,9 @@ func main() { stackSvc := service.NewStackService(cfg.Stack, stackRepo, challengeRepo, submissionRepo, stackClient, redisClient) if cfg, _, _, err := appConfigSvc.Get(ctx); err != nil { - log.Printf("app config load warning: %v", err) + logger.Warn("app config load warning", slog.Any("error", err)) } else if cfg.CTFStartAt == "" && cfg.CTFEndAt == "" { - log.Printf("warning: ctf_start_at and ctf_end_at not configured; competition will always be active at all times") + logger.Warn("ctf window not configured; competition always active") } router := httpserver.NewRouter(cfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, teamSvc, stackSvc, redisClient, logger) @@ -109,9 +122,10 @@ func main() { defer stop() go func() { - log.Printf("server listening on %s", cfg.HTTPAddr) + logger.Info("server listening", slog.String("addr", cfg.HTTPAddr)) if err := srv.ListenAndServe(); err != nil && err != nethttp.ErrServerClosed { - log.Fatalf("server error: %v", err) + logger.Error("server error", slog.Any("error", err)) + os.Exit(1) } }() @@ -120,14 +134,14 @@ func main() { defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { - log.Printf("server shutdown error: %v", err) + logger.Error("server shutdown error", slog.Any("error", err)) } if err := redisClient.Close(); err != nil { - log.Printf("redis close error: %v", err) + logger.Error("redis close error", slog.Any("error", err)) } if err := database.Close(); err != nil { - log.Printf("db close error: %v", err) + logger.Error("db close error", slog.Any("error", err)) } } diff --git a/go.mod b/go.mod index fd47b60..9982a20 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect github.com/aws/smithy-go v1.23.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -87,12 +88,17 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/morikuni/aec v1.0.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.0 // indirect @@ -116,6 +122,7 @@ require ( go.opentelemetry.io/otel/sdk v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.40.0 // indirect diff --git a/go.sum b/go.sum index 846b08b..b872114 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -171,6 +173,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +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/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -183,6 +187,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= @@ -257,6 +269,8 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= diff --git a/internal/config/config.go b/internal/config/config.go index d6eec23..c589e72 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -73,16 +73,9 @@ type CORSConfig struct { } type LoggingConfig struct { - Dir string - FilePrefix string - DiscordWebhookURL string - SlackWebhookURL string - MaxBodyBytes int - WebhookQueueSize int - WebhookTimeout time.Duration - WebhookBatchSize int - WebhookBatchWait time.Duration - WebhookMaxChars int + Dir string + FilePrefix string + MaxBodyBytes int } type S3Config struct { @@ -209,31 +202,6 @@ func Load() (Config, error) { errs = append(errs, err) } - logWebhookQueueSize, err := getEnvInt("LOG_WEBHOOK_QUEUE_SIZE", 1000) - if err != nil { - errs = append(errs, err) - } - - logWebhookTimeout, err := getDuration("LOG_WEBHOOK_TIMEOUT", 5*time.Second) - if err != nil { - errs = append(errs, err) - } - - logWebhookBatchSize, err := getEnvInt("LOG_WEBHOOK_BATCH_SIZE", 20) - if err != nil { - errs = append(errs, err) - } - - logWebhookBatchWait, err := getDuration("LOG_WEBHOOK_BATCH_WAIT", 2*time.Second) - if err != nil { - errs = append(errs, err) - } - - logWebhookMaxChars, err := getEnvInt("LOG_WEBHOOK_MAX_CHARS", 1800) - if err != nil { - errs = append(errs, err) - } - s3Enabled, err := getEnvBool("S3_ENABLED", false) if err != nil { errs = append(errs, err) @@ -317,16 +285,9 @@ func Load() (Config, error) { AllowedOrigins: corsAllowedOrigins, }, Logging: LoggingConfig{ - Dir: logDir, - FilePrefix: logPrefix, - DiscordWebhookURL: getEnv("LOG_DISCORD_WEBHOOK_URL", ""), - SlackWebhookURL: getEnv("LOG_SLACK_WEBHOOK_URL", ""), - MaxBodyBytes: logMaxBodyBytes, - WebhookQueueSize: logWebhookQueueSize, - WebhookTimeout: logWebhookTimeout, - WebhookBatchSize: logWebhookBatchSize, - WebhookBatchWait: logWebhookBatchWait, - WebhookMaxChars: logWebhookMaxChars, + Dir: logDir, + FilePrefix: logPrefix, + MaxBodyBytes: logMaxBodyBytes, }, S3: S3Config{ Enabled: s3Enabled, @@ -484,26 +445,6 @@ func validateConfig(cfg Config) error { errs = append(errs, errors.New("LOG_MAX_BODY_BYTES must be positive")) } - if cfg.Logging.WebhookQueueSize <= 0 { - errs = append(errs, errors.New("LOG_WEBHOOK_QUEUE_SIZE must be positive")) - } - - if cfg.Logging.WebhookTimeout <= 0 { - errs = append(errs, errors.New("LOG_WEBHOOK_TIMEOUT must be positive")) - } - - if cfg.Logging.WebhookBatchSize <= 0 { - errs = append(errs, errors.New("LOG_WEBHOOK_BATCH_SIZE must be positive")) - } - - if cfg.Logging.WebhookBatchWait <= 0 { - errs = append(errs, errors.New("LOG_WEBHOOK_BATCH_WAIT must be positive")) - } - - if cfg.Logging.WebhookMaxChars <= 0 { - errs = append(errs, errors.New("LOG_WEBHOOK_MAX_CHARS must be positive")) - } - if cfg.S3.Enabled { if cfg.S3.Region == "" { errs = append(errs, errors.New("S3_REGION must not be empty")) @@ -552,8 +493,6 @@ func Redact(cfg Config) Config { cfg.Redis.Password = redact(cfg.Redis.Password) cfg.JWT.Secret = redact(cfg.JWT.Secret) cfg.Security.FlagHMACSecret = redact(cfg.Security.FlagHMACSecret) - cfg.Logging.DiscordWebhookURL = redact(cfg.Logging.DiscordWebhookURL) - cfg.Logging.SlackWebhookURL = redact(cfg.Logging.SlackWebhookURL) cfg.S3.AccessKeyID = redact(cfg.S3.AccessKeyID) cfg.S3.SecretAccessKey = redact(cfg.S3.SecretAccessKey) cfg.Stack.ProvisionerAPIKey = redact(cfg.Stack.ProvisionerAPIKey) @@ -564,6 +503,7 @@ func redact(value string) string { if value == "" { return "" } + const ( visiblePrefix = 2 visibleSuffix = 2 @@ -571,6 +511,7 @@ func redact(value string) string { if len(value) <= visiblePrefix+visibleSuffix { return "***" } + return value[:visiblePrefix] + "***" + value[len(value)-visibleSuffix:] } @@ -590,70 +531,77 @@ func parseCSV(value string) []string { return out } -func FormatForLog(cfg Config) string { +func FormatForLog(cfg Config) map[string]any { cfg = Redact(cfg) - var b strings.Builder - fmt.Fprintf(&b, "AppEnv=%s\n", cfg.AppEnv) - fmt.Fprintf(&b, "HTTPAddr=%s\n", cfg.HTTPAddr) - fmt.Fprintf(&b, "ShutdownTimeout=%s\n", cfg.ShutdownTimeout) - fmt.Fprintf(&b, "AutoMigrate=%t\n", cfg.AutoMigrate) - fmt.Fprintf(&b, "PasswordBcryptCost=%d\n", cfg.PasswordBcryptCost) - fmt.Fprintln(&b, "DB:") - fmt.Fprintf(&b, " Host=%s\n", cfg.DB.Host) - fmt.Fprintf(&b, " Port=%d\n", cfg.DB.Port) - fmt.Fprintf(&b, " User=%s\n", cfg.DB.User) - fmt.Fprintf(&b, " Password=%s\n", cfg.DB.Password) - fmt.Fprintf(&b, " Name=%s\n", cfg.DB.Name) - fmt.Fprintf(&b, " SSLMode=%s\n", cfg.DB.SSLMode) - fmt.Fprintf(&b, " MaxOpenConns=%d\n", cfg.DB.MaxOpenConns) - fmt.Fprintf(&b, " MaxIdleConns=%d\n", cfg.DB.MaxIdleConns) - fmt.Fprintf(&b, " ConnMaxLifetime=%s\n", cfg.DB.ConnMaxLifetime) - fmt.Fprintln(&b, "Redis:") - fmt.Fprintf(&b, " Addr=%s\n", cfg.Redis.Addr) - fmt.Fprintf(&b, " Password=%s\n", cfg.Redis.Password) - fmt.Fprintf(&b, " DB=%d\n", cfg.Redis.DB) - fmt.Fprintf(&b, " PoolSize=%d\n", cfg.Redis.PoolSize) - fmt.Fprintln(&b, "JWT:") - fmt.Fprintf(&b, " Secret=%s\n", cfg.JWT.Secret) - fmt.Fprintf(&b, " Issuer=%s\n", cfg.JWT.Issuer) - fmt.Fprintf(&b, " AccessTTL=%s\n", cfg.JWT.AccessTTL) - fmt.Fprintf(&b, " RefreshTTL=%s\n", cfg.JWT.RefreshTTL) - fmt.Fprintln(&b, "Security:") - fmt.Fprintf(&b, " FlagHMACSecret=%s\n", cfg.Security.FlagHMACSecret) - fmt.Fprintf(&b, " SubmissionWindow=%s\n", cfg.Security.SubmissionWindow) - fmt.Fprintf(&b, " SubmissionMax=%d\n", cfg.Security.SubmissionMax) - fmt.Fprintln(&b, "Cache:") - fmt.Fprintf(&b, " TimelineTTL=%s\n", cfg.Cache.TimelineTTL) - fmt.Fprintf(&b, " LeaderboardTTL=%s\n", cfg.Cache.LeaderboardTTL) - fmt.Fprintln(&b, "CORS:") - fmt.Fprintf(&b, " AllowedOrigins=%s\n", strings.Join(cfg.CORS.AllowedOrigins, ",")) - fmt.Fprintln(&b, "Logging:") - fmt.Fprintf(&b, " Dir=%s\n", cfg.Logging.Dir) - fmt.Fprintf(&b, " FilePrefix=%s\n", cfg.Logging.FilePrefix) - fmt.Fprintf(&b, " DiscordWebhookURL=%s\n", cfg.Logging.DiscordWebhookURL) - fmt.Fprintf(&b, " SlackWebhookURL=%s\n", cfg.Logging.SlackWebhookURL) - fmt.Fprintf(&b, " MaxBodyBytes=%d\n", cfg.Logging.MaxBodyBytes) - fmt.Fprintf(&b, " WebhookQueueSize=%d\n", cfg.Logging.WebhookQueueSize) - fmt.Fprintf(&b, " WebhookTimeout=%s\n", cfg.Logging.WebhookTimeout) - fmt.Fprintf(&b, " WebhookBatchSize=%d\n", cfg.Logging.WebhookBatchSize) - fmt.Fprintf(&b, " WebhookBatchWait=%s\n", cfg.Logging.WebhookBatchWait) - fmt.Fprintf(&b, " WebhookMaxChars=%d\n", cfg.Logging.WebhookMaxChars) - fmt.Fprintln(&b, "S3:") - fmt.Fprintf(&b, " Enabled=%t\n", cfg.S3.Enabled) - fmt.Fprintf(&b, " Region=%s\n", cfg.S3.Region) - fmt.Fprintf(&b, " Bucket=%s\n", cfg.S3.Bucket) - fmt.Fprintf(&b, " AccessKeyID=%s\n", cfg.S3.AccessKeyID) - fmt.Fprintf(&b, " SecretAccessKey=%s\n", cfg.S3.SecretAccessKey) - fmt.Fprintf(&b, " Endpoint=%s\n", cfg.S3.Endpoint) - fmt.Fprintf(&b, " ForcePathStyle=%t\n", cfg.S3.ForcePathStyle) - fmt.Fprintf(&b, " PresignTTL=%s\n", cfg.S3.PresignTTL) - fmt.Fprintln(&b, "Stack:") - fmt.Fprintf(&b, " Enabled=%t\n", cfg.Stack.Enabled) - fmt.Fprintf(&b, " MaxPerUser=%d\n", cfg.Stack.MaxPerUser) - fmt.Fprintf(&b, " ProvisionerBaseURL=%s\n", cfg.Stack.ProvisionerBaseURL) - fmt.Fprintf(&b, " ProvisionerAPIKey=%s\n", cfg.Stack.ProvisionerAPIKey) - fmt.Fprintf(&b, " ProvisionerTimeout=%s\n", cfg.Stack.ProvisionerTimeout) - fmt.Fprintf(&b, " CreateWindow=%s\n", cfg.Stack.CreateWindow) - fmt.Fprintf(&b, " CreateMax=%d\n", cfg.Stack.CreateMax) - return b.String() + return map[string]any{ + "app_env": cfg.AppEnv, + "http_addr": cfg.HTTPAddr, + "shutdown_timeout": seconds(cfg.ShutdownTimeout), + "auto_migrate": cfg.AutoMigrate, + "password_bcrypt_cost": cfg.PasswordBcryptCost, + "db": map[string]any{ + "host": cfg.DB.Host, + "port": cfg.DB.Port, + "user": cfg.DB.User, + "password": cfg.DB.Password, + "name": cfg.DB.Name, + "ssl_mode": cfg.DB.SSLMode, + "max_open_conns": cfg.DB.MaxOpenConns, + "max_idle_conns": cfg.DB.MaxIdleConns, + "conn_max_lifetime": seconds(cfg.DB.ConnMaxLifetime), + }, + "redis": map[string]any{ + "addr": cfg.Redis.Addr, + "password": cfg.Redis.Password, + "db": cfg.Redis.DB, + "pool_size": cfg.Redis.PoolSize, + }, + "jwt": map[string]any{ + "secret": cfg.JWT.Secret, + "issuer": cfg.JWT.Issuer, + "access_ttl": seconds(cfg.JWT.AccessTTL), + "refresh_ttl": seconds(cfg.JWT.RefreshTTL), + }, + "security": map[string]any{ + "flag_hmac_secret": cfg.Security.FlagHMACSecret, + "submission_window": seconds(cfg.Security.SubmissionWindow), + "submission_max": cfg.Security.SubmissionMax, + }, + "cache": map[string]any{ + "timeline_ttl": seconds(cfg.Cache.TimelineTTL), + "leaderboard_ttl": seconds(cfg.Cache.LeaderboardTTL), + "app_config_ttl": seconds(cfg.Cache.AppConfigTTL), + }, + "cors": map[string]any{ + "allowed_origins": cfg.CORS.AllowedOrigins, + }, + "logging": map[string]any{ + "dir": cfg.Logging.Dir, + "file_prefix": cfg.Logging.FilePrefix, + "max_body_bytes": cfg.Logging.MaxBodyBytes, + }, + "s3": map[string]any{ + "enabled": cfg.S3.Enabled, + "region": cfg.S3.Region, + "bucket": cfg.S3.Bucket, + "access_key_id": cfg.S3.AccessKeyID, + "secret_access_key": cfg.S3.SecretAccessKey, + "endpoint": cfg.S3.Endpoint, + "force_path_style": cfg.S3.ForcePathStyle, + "presign_ttl": seconds(cfg.S3.PresignTTL), + }, + "stack": map[string]any{ + "enabled": cfg.Stack.Enabled, + "max_per_user": cfg.Stack.MaxPerUser, + "provisioner_base_url": cfg.Stack.ProvisionerBaseURL, + "provisioner_api_key": cfg.Stack.ProvisionerAPIKey, + "provisioner_timeout": seconds(cfg.Stack.ProvisionerTimeout), + "create_window": seconds(cfg.Stack.CreateWindow), + "create_max": cfg.Stack.CreateMax, + }, + } +} + +func seconds(d time.Duration) int64 { + return int64(d.Seconds()) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9a4ea59..fd62f43 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -89,14 +89,7 @@ func TestLoadConfigCustomValues(t *testing.T) { os.Setenv("SUBMIT_MAX", "5") os.Setenv("LOG_DIR", "logs-test") os.Setenv("LOG_FILE_PREFIX", "app-test") - os.Setenv("LOG_DISCORD_WEBHOOK_URL", "https://discord.example/hook") - os.Setenv("LOG_SLACK_WEBHOOK_URL", "https://slack.example/hook") os.Setenv("LOG_MAX_BODY_BYTES", "2048") - os.Setenv("LOG_WEBHOOK_QUEUE_SIZE", "250") - os.Setenv("LOG_WEBHOOK_TIMEOUT", "3s") - os.Setenv("LOG_WEBHOOK_BATCH_SIZE", "5") - os.Setenv("LOG_WEBHOOK_BATCH_WAIT", "1s") - os.Setenv("LOG_WEBHOOK_MAX_CHARS", "1900") os.Setenv("S3_ENABLED", "true") os.Setenv("S3_REGION", "ap-northeast-2") os.Setenv("S3_BUCKET", "smctf-test") @@ -165,12 +158,6 @@ func TestLoadConfigCustomValues(t *testing.T) { if cfg.Logging.FilePrefix != "app-test" { t.Errorf("expected Logging.FilePrefix app-test, got %s", cfg.Logging.FilePrefix) } - if cfg.Logging.DiscordWebhookURL != "https://discord.example/hook" { - t.Errorf("expected Logging.DiscordWebhookURL, got %s", cfg.Logging.DiscordWebhookURL) - } - if cfg.Logging.SlackWebhookURL != "https://slack.example/hook" { - t.Errorf("expected Logging.SlackWebhookURL, got %s", cfg.Logging.SlackWebhookURL) - } if !cfg.S3.Enabled { t.Errorf("expected S3.Enabled true") @@ -199,21 +186,6 @@ func TestLoadConfigCustomValues(t *testing.T) { if cfg.Logging.MaxBodyBytes != 2048 { t.Errorf("expected Logging.MaxBodyBytes 2048, got %d", cfg.Logging.MaxBodyBytes) } - if cfg.Logging.WebhookQueueSize != 250 { - t.Errorf("expected Logging.WebhookQueueSize 250, got %d", cfg.Logging.WebhookQueueSize) - } - if cfg.Logging.WebhookTimeout != 3*time.Second { - t.Errorf("expected Logging.WebhookTimeout 3s, got %s", cfg.Logging.WebhookTimeout) - } - if cfg.Logging.WebhookBatchSize != 5 { - t.Errorf("expected Logging.WebhookBatchSize 5, got %d", cfg.Logging.WebhookBatchSize) - } - if cfg.Logging.WebhookBatchWait != time.Second { - t.Errorf("expected Logging.WebhookBatchWait 1s, got %s", cfg.Logging.WebhookBatchWait) - } - if cfg.Logging.WebhookMaxChars != 1900 { - t.Errorf("expected Logging.WebhookMaxChars 1900, got %d", cfg.Logging.WebhookMaxChars) - } if cfg.Stack.CreateWindow != 2*time.Minute { t.Errorf("expected Stack.CreateWindow 2m, got %v", cfg.Stack.CreateWindow) } @@ -236,11 +208,6 @@ func TestLoadConfigInvalidValues(t *testing.T) { {"negative db port", "DB_PORT", "-1"}, {"zero db port", "DB_PORT", "0"}, {"invalid log max body", "LOG_MAX_BODY_BYTES", "nope"}, - {"invalid log queue size", "LOG_WEBHOOK_QUEUE_SIZE", "bad"}, - {"invalid log timeout", "LOG_WEBHOOK_TIMEOUT", "bad"}, - {"invalid log batch size", "LOG_WEBHOOK_BATCH_SIZE", "bad"}, - {"invalid log batch wait", "LOG_WEBHOOK_BATCH_WAIT", "bad"}, - {"invalid log max chars", "LOG_WEBHOOK_MAX_CHARS", "bad"}, {"invalid s3 enabled", "S3_ENABLED", "not-a-bool"}, {"invalid s3 presign ttl", "S3_PRESIGN_TTL", "bad-duration"}, {"invalid s3 force path", "S3_FORCE_PATH_STYLE", "bad-bool"}, @@ -339,14 +306,9 @@ func TestValidateConfigInvalidS3(t *testing.T) { SubmissionMax: 10, }, Logging: LoggingConfig{ - Dir: "logs", - FilePrefix: "app", - MaxBodyBytes: 1024, - WebhookQueueSize: 10, - WebhookTimeout: time.Second, - WebhookBatchSize: 5, - WebhookBatchWait: time.Second, - WebhookMaxChars: 100, + Dir: "logs", + FilePrefix: "app", + MaxBodyBytes: 1024, }, S3: S3Config{ Enabled: true, @@ -417,14 +379,9 @@ func TestValidateConfigInvalidLogging(t *testing.T) { SubmissionMax: 10, }, Logging: LoggingConfig{ - Dir: "", - FilePrefix: "", - MaxBodyBytes: 0, - WebhookQueueSize: 0, - WebhookTimeout: 0, - WebhookBatchSize: 0, - WebhookBatchWait: 0, - WebhookMaxChars: 0, + Dir: "", + FilePrefix: "", + MaxBodyBytes: 0, }, } @@ -576,14 +533,9 @@ func TestValidateConfigEmptyValues(t *testing.T) { SubmissionMax: 10, }, Logging: LoggingConfig{ - Dir: "logs", - FilePrefix: "app", - MaxBodyBytes: 1024, - WebhookQueueSize: 10, - WebhookTimeout: time.Second, - WebhookBatchSize: 5, - WebhookBatchWait: time.Second, - WebhookMaxChars: 100, + Dir: "logs", + FilePrefix: "app", + MaxBodyBytes: 1024, }, } @@ -622,14 +574,9 @@ func TestValidateConfigInvalidDBConfig(t *testing.T) { SubmissionMax: 10, }, Logging: LoggingConfig{ - Dir: "logs", - FilePrefix: "app", - MaxBodyBytes: 1024, - WebhookQueueSize: 10, - WebhookTimeout: time.Second, - WebhookBatchSize: 5, - WebhookBatchWait: time.Second, - WebhookMaxChars: 100, + Dir: "logs", + FilePrefix: "app", + MaxBodyBytes: 1024, }, } @@ -677,14 +624,9 @@ func TestValidateConfigInvalidStackConfig(t *testing.T) { AllowedOrigins: []string{"http://localhost:3000", "https://smctf.example.com"}, }, Logging: LoggingConfig{ - Dir: "logs", - FilePrefix: "app", - MaxBodyBytes: 1024, - WebhookQueueSize: 10, - WebhookTimeout: time.Second, - WebhookBatchSize: 5, - WebhookBatchWait: time.Second, - WebhookMaxChars: 100, + Dir: "logs", + FilePrefix: "app", + MaxBodyBytes: 1024, }, Stack: StackConfig{ Enabled: true, @@ -742,13 +684,9 @@ func TestValidateConfigAdditionalValidation(t *testing.T) { AppConfigTTL: 2 * time.Minute, }, Logging: LoggingConfig{ - Dir: "logs", - FilePrefix: "app", - MaxBodyBytes: 1024, - WebhookQueueSize: 10, - WebhookTimeout: time.Second, - WebhookBatchSize: 5, - WebhookBatchWait: time.Second, + Dir: "logs", + FilePrefix: "app", + MaxBodyBytes: 1024, }, } @@ -770,8 +708,9 @@ func TestRedact(t *testing.T) { FlagHMACSecret: "flagsecret", }, Logging: LoggingConfig{ - DiscordWebhookURL: "https://discord.example/hook", - SlackWebhookURL: "https://slack.example/hook", + Dir: "logs", + FilePrefix: "app", + MaxBodyBytes: 1024, }, S3: S3Config{ AccessKeyID: "access-key", @@ -799,14 +738,6 @@ func TestRedact(t *testing.T) { t.Fatalf("expected flag secret redacted") } - if redacted.Logging.DiscordWebhookURL == cfg.Logging.DiscordWebhookURL { - t.Fatalf("expected discord webhook redacted") - } - - if redacted.Logging.SlackWebhookURL == cfg.Logging.SlackWebhookURL { - t.Fatalf("expected slack webhook redacted") - } - if redacted.S3.AccessKeyID == cfg.S3.AccessKeyID { t.Fatalf("expected s3 access key redacted") } @@ -879,16 +810,9 @@ func TestFormatForLog(t *testing.T) { AppConfigTTL: 2 * time.Minute, }, Logging: LoggingConfig{ - Dir: "logs", - FilePrefix: "app", - DiscordWebhookURL: "https://discord.example/hook", - SlackWebhookURL: "https://slack.example/hook", - MaxBodyBytes: 1024, - WebhookQueueSize: 10, - WebhookTimeout: time.Second, - WebhookBatchSize: 5, - WebhookBatchWait: time.Second, - WebhookMaxChars: 100, + Dir: "logs", + FilePrefix: "app", + MaxBodyBytes: 1024, }, S3: S3Config{ Enabled: true, @@ -910,15 +834,26 @@ func TestFormatForLog(t *testing.T) { } out := FormatForLog(cfg) - if out == "" { + if len(out) == 0 { t.Fatalf("expected output") } - if strings.Contains(out, "dbpass") || strings.Contains(out, "redispass") || strings.Contains(out, "jwtsecret") || strings.Contains(out, "flagsecret") || strings.Contains(out, "stack-key") { + db := out["db"].(map[string]any) + redis := out["redis"].(map[string]any) + jwt := out["jwt"].(map[string]any) + security := out["security"].(map[string]any) + stack := out["stack"].(map[string]any) + + if db["password"].(string) == "dbpass" || redis["password"].(string) == "redispass" || jwt["secret"].(string) == "jwtsecret" || security["flag_hmac_secret"].(string) == "flagsecret" || stack["provisioner_api_key"].(string) == "stack-key" { t.Fatalf("expected secrets redacted") } - if !strings.Contains(out, "AppEnv=local") || !strings.Contains(out, "DB:") || !strings.Contains(out, "Cache:") { - t.Fatalf("expected formatted config fields") + if out["app_env"] != "local" || out["http_addr"] != ":8080" { + t.Fatalf("expected top-level config fields") + } + + cache := out["cache"].(map[string]any) + if cache["timeline_ttl"] == nil || cache["leaderboard_ttl"] == nil { + t.Fatalf("expected cache fields") } } diff --git a/internal/http/handlers/errors.go b/internal/http/handlers/errors.go index 84e40af..ae02416 100644 --- a/internal/http/handlers/errors.go +++ b/internal/http/handlers/errors.go @@ -4,10 +4,12 @@ import ( "encoding/json" "errors" "io" + "log/slog" "net/http" "strconv" "strings" + "smctf/internal/http/middleware" "smctf/internal/service" "github.com/gin-gonic/gin" @@ -25,6 +27,33 @@ func writeError(ctx *gin.Context, err error) { for key, value := range headers { ctx.Header(key, value) } + + if err != nil && status >= http.StatusInternalServerError { + attrs := make([]slog.Attr, 0, 4) + if ctx != nil && ctx.Request != nil { + attrs = append(attrs, + slog.String("method", ctx.Request.Method), + slog.String("path", ctx.Request.URL.Path), + ) + } + + if ctx != nil { + if userID := middleware.UserID(ctx); userID > 0 { + attrs = append(attrs, slog.Int64("user_id", userID)) + } + } + + anyAttrs := make([]any, 0, len(attrs)) + for _, attr := range attrs { + anyAttrs = append(anyAttrs, attr) + } + + slog.Default().Error("http handler error", + slog.Any("error", err), + slog.Group("http", anyAttrs...), + ) + } + ctx.JSON(status, resp) } diff --git a/internal/http/integration/testenv_test.go b/internal/http/integration/testenv_test.go index 45c48bf..2d0cfa1 100644 --- a/internal/http/integration/testenv_test.go +++ b/internal/http/integration/testenv_test.go @@ -158,14 +158,9 @@ func TestMain(m *testing.M) { AppConfigTTL: 2 * time.Minute, }, Logging: config.LoggingConfig{ - Dir: "", - FilePrefix: "test", - MaxBodyBytes: 1024 * 1024, - WebhookQueueSize: 100, - WebhookTimeout: time.Second, - WebhookBatchSize: 10, - WebhookBatchWait: time.Second, - WebhookMaxChars: 1000, + Dir: "", + FilePrefix: "test", + MaxBodyBytes: 1024 * 1024, }, } @@ -176,7 +171,7 @@ func TestMain(m *testing.M) { testCfg.Logging.Dir = logDir - testLogger, err = logging.New(testCfg.Logging) + testLogger, err = logging.New(testCfg.Logging, logging.Options{Service: "smctf", Env: "test"}) if err != nil { panic(err) } diff --git a/internal/http/middleware/recovery_logger.go b/internal/http/middleware/recovery_logger.go new file mode 100644 index 0000000..8e953f2 --- /dev/null +++ b/internal/http/middleware/recovery_logger.go @@ -0,0 +1,37 @@ +package middleware + +import ( + "log/slog" + "net/http" + "runtime/debug" + + "smctf/internal/logging" + + "github.com/gin-gonic/gin" +) + +func RecoveryLogger(logger *logging.Logger) gin.HandlerFunc { + return func(ctx *gin.Context) { + var log *slog.Logger + if logger != nil { + log = logger.Logger + } + + defer func() { + if recovered := recover(); recovered != nil { + if log != nil { + log.Error("panic recovered", + slog.Any("error", recovered), + slog.String("path", ctx.Request.URL.Path), + slog.String("method", ctx.Request.Method), + slog.String("stack", string(debug.Stack())), + ) + } + + ctx.AbortWithStatus(http.StatusInternalServerError) + } + }() + + ctx.Next() + } +} diff --git a/internal/http/middleware/request_logger.go b/internal/http/middleware/request_logger.go index 6b32d56..2ede7ba 100644 --- a/internal/http/middleware/request_logger.go +++ b/internal/http/middleware/request_logger.go @@ -2,13 +2,13 @@ package middleware import ( "bytes" - "fmt" "io" "net/http" - "strconv" "strings" "time" + "log/slog" + "smctf/internal/config" "smctf/internal/logging" @@ -30,6 +30,11 @@ var bodyLogSkipPaths = map[string]struct{}{ func RequestLogger(cfg config.LoggingConfig, logger *logging.Logger) gin.HandlerFunc { return func(ctx *gin.Context) { + var log *slog.Logger + if logger != nil { + log = logger.Logger + } + start := time.Now().UTC() _, bodyStr := readRequestBody(ctx, cfg.MaxBodyBytes) @@ -46,45 +51,47 @@ func RequestLogger(cfg config.LoggingConfig, logger *logging.Logger) gin.Handler contentType := ctx.GetHeader("Content-Type") contentLength := ctx.Request.ContentLength - var b strings.Builder - b.Grow(256 + len(bodyStr)) - fmt.Fprintf(&b, "ts=%s level=INFO msg=\"http request\" method=%s path=%s status=%d latency=%s ip=%s", - start.UTC().Format(time.RFC3339Nano), - method, - path, - status, - latency, - clientIP, + attrs := make([]slog.Attr, 0, 12) + attrs = append(attrs, + slog.String("method", method), + slog.String("path", path), + slog.Int("status", status), + slog.Duration("latency", latency), + slog.String("ip", clientIP), ) if rawQuery != "" { - fmt.Fprintf(&b, " query=%s", strconv.Quote(rawQuery)) + attrs = append(attrs, slog.String("query", rawQuery)) } if userAgent != "" { - fmt.Fprintf(&b, " ua=%s", strconv.Quote(userAgent)) + attrs = append(attrs, slog.String("user_agent", userAgent)) } if contentType != "" { - fmt.Fprintf(&b, " content_type=%s", strconv.Quote(contentType)) + attrs = append(attrs, slog.String("content_type", contentType)) } if contentLength >= 0 { - fmt.Fprintf(&b, " content_length=%d", contentLength) + attrs = append(attrs, slog.Int64("content_length", contentLength)) } if userID := UserID(ctx); userID > 0 { - fmt.Fprintf(&b, " user_id=%d", userID) + attrs = append(attrs, slog.Int64("user_id", userID)) } if bodyStr != "" { - fmt.Fprintf(&b, " body=%s", strconv.Quote(bodyStr)) + attrs = append(attrs, slog.String("body", bodyStr)) } - if logger != nil { - _, _ = logger.Write([]byte(b.String() + "\n")) - } + if log != nil { + anyAttrs := make([]any, 0, len(attrs)) + for _, attr := range attrs { + anyAttrs = append(anyAttrs, attr) + } + log.Info("http request", slog.Group("http", anyAttrs...)) + } } } diff --git a/internal/http/middleware/request_logger_test.go b/internal/http/middleware/request_logger_test.go index 0335012..8d14e5e 100644 --- a/internal/http/middleware/request_logger_test.go +++ b/internal/http/middleware/request_logger_test.go @@ -1,13 +1,13 @@ package middleware import ( + "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" - "time" "smctf/internal/config" "smctf/internal/logging" @@ -20,15 +20,10 @@ func TestRequestLoggerIncludesUserIDAndBody(t *testing.T) { dir := t.TempDir() logger, err := logging.New(config.LoggingConfig{ - Dir: dir, - FilePrefix: "req", - MaxBodyBytes: 1024, - WebhookQueueSize: 10, - WebhookTimeout: time.Second, - WebhookBatchSize: 1, - WebhookBatchWait: time.Millisecond, - WebhookMaxChars: 1000, - }) + Dir: dir, + FilePrefix: "req", + MaxBodyBytes: 1024, + }, logging.Options{Service: "smctf", Env: "test"}) if err != nil { t.Fatalf("logger init: %v", err) } @@ -54,12 +49,17 @@ func TestRequestLoggerIncludesUserIDAndBody(t *testing.T) { } line := readLogLine(t, dir, "req") - if !strings.Contains(line, "method=POST") || !strings.Contains(line, "user_id=123") { - t.Fatalf("expected method/user_id in log: %s", line) + httpFields := extractGroup(t, line, "http") + if httpFields["method"] != "POST" { + t.Fatalf("expected method in log: %v", httpFields) + } + + if httpFields["user_id"] != float64(123) { + t.Fatalf("expected user_id in log: %v", httpFields) } - if !strings.Contains(line, "body=") || !strings.Contains(line, "foo") { - t.Fatalf("expected body in log: %s", line) + if body, ok := httpFields["body"].(string); !ok || !strings.Contains(body, "foo") { + t.Fatalf("expected body in log: %v", httpFields) } } @@ -68,15 +68,10 @@ func TestRequestLoggerSkipsBodyForGET(t *testing.T) { dir := t.TempDir() logger, err := logging.New(config.LoggingConfig{ - Dir: dir, - FilePrefix: "req", - MaxBodyBytes: 1024, - WebhookQueueSize: 10, - WebhookTimeout: time.Second, - WebhookBatchSize: 1, - WebhookBatchWait: time.Millisecond, - WebhookMaxChars: 1000, - }) + Dir: dir, + FilePrefix: "req", + MaxBodyBytes: 1024, + }, logging.Options{Service: "smctf", Env: "test"}) if err != nil { t.Fatalf("logger init: %v", err) } @@ -101,8 +96,9 @@ func TestRequestLoggerSkipsBodyForGET(t *testing.T) { } line := readLogLine(t, dir, "req") - if strings.Contains(line, "body=") { - t.Fatalf("expected no body in log: %s", line) + httpFields := extractGroup(t, line, "http") + if _, ok := httpFields["body"]; ok { + t.Fatalf("expected no body in log: %v", httpFields) } } @@ -111,15 +107,10 @@ func TestRequestLoggerSkipsBodyForSensitivePaths(t *testing.T) { dir := t.TempDir() logger, err := logging.New(config.LoggingConfig{ - Dir: dir, - FilePrefix: "req", - MaxBodyBytes: 1024, - WebhookQueueSize: 10, - WebhookTimeout: time.Second, - WebhookBatchSize: 1, - WebhookBatchWait: time.Millisecond, - WebhookMaxChars: 1000, - }) + Dir: dir, + FilePrefix: "req", + MaxBodyBytes: 1024, + }, logging.Options{Service: "smctf", Env: "test"}) if err != nil { t.Fatalf("logger init: %v", err) } @@ -156,8 +147,9 @@ func TestRequestLoggerSkipsBodyForSensitivePaths(t *testing.T) { } line := readLogLine(t, dir, "req") - if strings.Contains(line, "body=") { - t.Fatalf("expected no body in log: %s", line) + httpFields := extractGroup(t, line, "http") + if _, ok := httpFields["body"]; ok { + t.Fatalf("expected no body in log: %v", httpFields) } req = httptest.NewRequest(http.MethodPost, "/api/challenges/123/submit", strings.NewReader(`{"flag":"FLAG{1}"}`)) @@ -170,8 +162,9 @@ func TestRequestLoggerSkipsBodyForSensitivePaths(t *testing.T) { } line = readLogLine(t, dir, "req") - if strings.Contains(line, "body=") { - t.Fatalf("expected no body in log: %s", line) + httpFields = extractGroup(t, line, "http") + if _, ok := httpFields["body"]; ok { + t.Fatalf("expected no body in log: %v", httpFields) } req = httptest.NewRequest(http.MethodPost, "/api/admin/challenges", strings.NewReader(`{"title":"Secret"}`)) @@ -184,8 +177,9 @@ func TestRequestLoggerSkipsBodyForSensitivePaths(t *testing.T) { } line = readLogLine(t, dir, "req") - if strings.Contains(line, "body=") { - t.Fatalf("expected no body in log: %s", line) + httpFields = extractGroup(t, line, "http") + if _, ok := httpFields["body"]; ok { + t.Fatalf("expected no body in log: %v", httpFields) } req = httptest.NewRequest(http.MethodPut, "/api/admin/challenges/123", strings.NewReader(`{"title":"Secret 2"}`)) @@ -198,12 +192,13 @@ func TestRequestLoggerSkipsBodyForSensitivePaths(t *testing.T) { } line = readLogLine(t, dir, "req") - if strings.Contains(line, "body=") { - t.Fatalf("expected no body in log: %s", line) + httpFields = extractGroup(t, line, "http") + if _, ok := httpFields["body"]; ok { + t.Fatalf("expected no body in log: %v", httpFields) } } -func readLogLine(t *testing.T, dir, prefix string) string { +func readLogLine(t *testing.T, dir, prefix string) map[string]any { t.Helper() matches, err := filepath.Glob(filepath.Join(dir, prefix+"-*.log")) @@ -221,5 +216,26 @@ func readLogLine(t *testing.T, dir, prefix string) string { t.Fatalf("no log lines found") } - return lines[len(lines)-1] + var payload map[string]any + if err := json.Unmarshal([]byte(lines[len(lines)-1]), &payload); err != nil { + t.Fatalf("invalid json log: %v", err) + } + + return payload +} + +func extractGroup(t *testing.T, payload map[string]any, key string) map[string]any { + t.Helper() + + value, ok := payload[key] + if !ok { + t.Fatalf("missing group %s in log: %v", key, payload) + } + + group, ok := value.(map[string]any) + if !ok { + t.Fatalf("invalid group %s in log: %T", key, value) + } + + return group } diff --git a/internal/http/router.go b/internal/http/router.go index 5bee24c..0465cec 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -1,9 +1,7 @@ package http import ( - "io" nethttp "net/http" - "os" "smctf/internal/config" "smctf/internal/http/handlers" @@ -12,6 +10,7 @@ import ( "smctf/internal/service" "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/redis/go-redis/v9" ) @@ -20,14 +19,8 @@ func NewRouter(cfg config.Config, authSvc *service.AuthService, ctfSvc *service. gin.SetMode(gin.ReleaseMode) } - if logger != nil { - gin.DefaultWriter = io.MultiWriter(os.Stdout, logger) - gin.DefaultErrorWriter = io.MultiWriter(os.Stderr, logger) - } - r := gin.New() - r.Use(gin.Logger()) - r.Use(gin.Recovery()) + r.Use(middleware.RecoveryLogger(logger)) r.Use(middleware.RequestLogger(cfg.Logging, logger)) r.Use(middleware.CORS(cfg.AppEnv != "production", cfg.CORS.AllowedOrigins)) @@ -36,6 +29,7 @@ func NewRouter(cfg config.Config, authSvc *service.AuthService, ctfSvc *service. r.GET("/healthz", func(ctx *gin.Context) { ctx.JSON(nethttp.StatusOK, gin.H{"status": "ok"}) }) + r.GET("/metrics", gin.WrapH(promhttp.Handler())) api := r.Group("/api") { diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 0af29aa..efdafb0 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -1,14 +1,13 @@ package logging import ( - "bytes" "context" - "encoding/json" "fmt" "io" - "net/http" + "log/slog" "os" "path/filepath" + "runtime/debug" "strings" "sync" "time" @@ -16,46 +15,223 @@ import ( "smctf/internal/config" ) +type Options struct { + Service string + Env string + AddSource bool +} + type Logger struct { - writer *rotatingFileWriter - sender *webhookSender + *slog.Logger + writer io.Writer + closer io.Closer } -func New(cfg config.LoggingConfig) (*Logger, error) { - writer, err := newRotatingFileWriter(cfg.Dir, cfg.FilePrefix) - if err != nil { - return nil, err +func New(cfg config.LoggingConfig, opts Options) (*Logger, error) { + var fileWriter *rotatingFileWriter + var err error + if cfg.Dir != "" { + fileWriter, err = newRotatingFileWriter(cfg.Dir, cfg.FilePrefix) + if err != nil { + return nil, err + } + } + + handlerOptions := &slog.HandlerOptions{ + AddSource: opts.AddSource, + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + switch a.Key { + case slog.TimeKey: + a.Key = "ts" + case slog.LevelKey: + a.Value = slog.StringValue(strings.ToLower(a.Value.String())) + } + return a + }, + } + + handlers := make([]slog.Handler, 0, 3) + + stdoutHandler := slog.NewJSONHandler(os.Stdout, handlerOptions) + handlers = append(handlers, levelRangeHandler{ + min: slog.LevelDebug, + max: slog.LevelWarn, + hasMax: true, + handler: stdoutHandler, + }) + + stderrHandler := slog.NewJSONHandler(os.Stderr, handlerOptions) + handlers = append(handlers, levelRangeHandler{ + min: slog.LevelError, + handler: stderrHandler, + }) + + if fileWriter != nil { + var w io.Writer + w = fileWriter + + fileHandler := slog.NewJSONHandler(w, handlerOptions) + handlers = append(handlers, levelRangeHandler{ + min: slog.LevelDebug, + handler: fileHandler, + }) + } + + logger := slog.New(teeHandler{handlers: handlers}) + + baseAttrs := make([]slog.Attr, 0, 1) + if app := appName(opts.Service, opts.Env); app != "" { + baseAttrs = append(baseAttrs, slog.String("app", app)) + } + + if len(baseAttrs) > 0 { + anyAttrs := make([]any, 0, len(baseAttrs)) + for _, attr := range baseAttrs { + anyAttrs = append(anyAttrs, attr) + } + + logger = logger.With(anyAttrs...) + } + + var closer io.Closer + if fileWriter != nil { + closer = fileWriter } return &Logger{ - writer: writer, - sender: newWebhookSender(cfg), + Logger: logger, + writer: &logWriter{logger: logger}, + closer: closer, }, nil } +func (l *Logger) Close() error { + if l == nil { + return nil + } + + if l.closer != nil { + return l.closer.Close() + } + + return nil +} + func (l *Logger) Write(p []byte) (int, error) { if l == nil { return len(p), nil } - n, err := l.writer.Write(p) - if l.sender != nil { - _ = l.sender.Enqueue(context.Background(), string(p)) + return l.writer.Write(p) +} + +func PanicLogger(logger *Logger, ctx context.Context, recovered any) { + if logger == nil { + return } - return n, err + log := logger.Logger + + err := fmt.Errorf("panic: %v", recovered) + log.Error("panic recovered", + slog.Any("error", err), + slog.String("stack", string(debug.Stack())), + ) } -func (l *Logger) Close() error { - if l == nil || l.writer == nil { - return nil +type logWriter struct { + logger *slog.Logger +} + +func (w *logWriter) Write(p []byte) (int, error) { + if w == nil || w.logger == nil { + return len(p), nil + } + + msg := strings.TrimRight(string(p), "\n") + if msg == "" { + return len(p), nil + } + + w.logger.Info(msg, slog.Bool("legacy", true)) + return len(p), nil +} + +type levelRangeHandler struct { + min slog.Level + max slog.Level + hasMax bool + handler slog.Handler +} + +func (h levelRangeHandler) Enabled(ctx context.Context, level slog.Level) bool { + if level < h.min { + return false + } + if h.hasMax && level > h.max { + return false + } + return h.handler.Enabled(ctx, level) +} + +func (h levelRangeHandler) Handle(ctx context.Context, record slog.Record) error { + return h.handler.Handle(ctx, record) +} + +func (h levelRangeHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return levelRangeHandler{ + min: h.min, + max: h.max, + hasMax: h.hasMax, + handler: h.handler.WithAttrs(attrs), + } +} + +func (h levelRangeHandler) WithGroup(name string) slog.Handler { + return levelRangeHandler{ + min: h.min, + max: h.max, + hasMax: h.hasMax, + handler: h.handler.WithGroup(name), + } +} + +type teeHandler struct { + handlers []slog.Handler +} + +func (t teeHandler) Enabled(ctx context.Context, level slog.Level) bool { + for _, h := range t.handlers { + if h.Enabled(ctx, level) { + return true + } } + return false +} - if l.sender != nil { - _ = l.sender.Close() +func (t teeHandler) Handle(ctx context.Context, record slog.Record) error { + for _, h := range t.handlers { + if h.Enabled(ctx, record.Level) { + _ = h.Handle(ctx, record) + } } + return nil +} - return l.writer.Close() +func (t teeHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + next := make([]slog.Handler, 0, len(t.handlers)) + for _, h := range t.handlers { + next = append(next, h.WithAttrs(attrs)) + } + return teeHandler{handlers: next} +} + +func (t teeHandler) WithGroup(name string) slog.Handler { + next := make([]slog.Handler, 0, len(t.handlers)) + for _, h := range t.handlers { + next = append(next, h.WithGroup(name)) + } + return teeHandler{handlers: next} } type rotatingFileWriter struct { @@ -139,7 +315,7 @@ func (w *rotatingFileWriter) rotate(now time.Time) error { } path := w.pathForTime(now) - file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) // 0111b = execute for all, 0o644 = rw-r--r-- + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return err } @@ -163,213 +339,15 @@ func sameHour(a, b time.Time) bool { return !b.IsZero() && truncateToHour(a).Equal(truncateToHour(b)) } -type webhookSender struct { - discordURL string - slackURL string - client *http.Client - queue chan string - wg sync.WaitGroup - closeOnce sync.Once - timeout time.Duration - batchSize int - batchWait time.Duration - maxChars int -} - -func newWebhookSender(cfg config.LoggingConfig) *webhookSender { - if cfg.DiscordWebhookURL == "" && cfg.SlackWebhookURL == "" { - return nil +func appName(service, env string) string { + parts := make([]string, 0, 2) + if service != "" { + parts = append(parts, service) } - s := &webhookSender{ - discordURL: cfg.DiscordWebhookURL, - slackURL: cfg.SlackWebhookURL, - client: &http.Client{ - Timeout: cfg.WebhookTimeout, - }, - queue: make(chan string, cfg.WebhookQueueSize), - timeout: cfg.WebhookTimeout, - batchSize: cfg.WebhookBatchSize, - batchWait: cfg.WebhookBatchWait, - maxChars: cfg.WebhookMaxChars, + if env != "" { + parts = append(parts, env) } - s.wg.Add(1) - go s.worker() - - return s -} - -func (s *webhookSender) Enqueue(ctx context.Context, msg string) error { - if s == nil { - return nil - } - - select { - case s.queue <- msg: - return nil - default: - return fmt.Errorf("webhook queue full") - } -} - -func (s *webhookSender) Send(ctx context.Context, msg string) error { - if s == nil { - return nil - } - - for _, chunk := range splitWebhookMessage(strings.TrimRight(msg, "\n"), s.maxChars) { - payload := "```\n" + chunk + "\n```" - - if s.discordURL != "" { - if err := s.post(ctx, s.discordURL, map[string]string{"content": payload}); err != nil { - return err - } - } - - if s.slackURL != "" { - if err := s.post(ctx, s.slackURL, map[string]string{"text": payload}); err != nil { - return err - } - } - } - - return nil -} - -func (s *webhookSender) worker() { - defer s.wg.Done() - ticker := time.NewTicker(s.batchWait) - defer ticker.Stop() - - batch := make([]string, 0, s.batchSize) - - flush := func() { - if len(batch) == 0 { - return - } - - payload := strings.Join(batch, "\n") - _ = s.Send(context.Background(), payload) - batch = batch[:0] - } - - for { - select { - case msg, ok := <-s.queue: - if !ok { - flush() - return - } - - batch = append(batch, msg) - if len(batch) >= s.batchSize { - flush() - } - case <-ticker.C: - flush() - } - } -} - -func (s *webhookSender) Close() error { - if s == nil { - return nil - } - - s.closeOnce.Do(func() { - close(s.queue) - }) - - s.wg.Wait() - return nil -} - -func (s *webhookSender) post(ctx context.Context, url string, payload map[string]string) error { - body, err := json.Marshal(payload) - if err != nil { - return err - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return err - } - - req.Header.Set("Content-Type", "application/json") - resp, err := s.client.Do(req) - if err != nil { - return err - } - - defer resp.Body.Close() - - _, _ = io.Copy(io.Discard, resp.Body) - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("webhook status %d", resp.StatusCode) - } - - return nil -} - -func splitWebhookMessage(msg string, maxChars int) []string { - if maxChars <= 0 { - return []string{msg} - } - - const wrapperLen = 8 - if maxChars <= wrapperLen { - return []string{""} - } - - limit := maxChars - wrapperLen - if len(msg) <= limit { - return []string{msg} - } - - lines := strings.Split(msg, "\n") - chunks := make([]string, 0, (len(msg)/limit)+1) - var b strings.Builder - - flush := func() { - if b.Len() == 0 { - return - } - - chunks = append(chunks, b.String()) - b.Reset() - } - - for _, line := range lines { - if len(line) > limit { - flush() - - for len(line) > 0 { - n := min(len(line), limit) - chunks = append(chunks, line[:n]) - line = line[n:] - } - - continue - } - - if b.Len() == 0 { - b.WriteString(line) - - continue - } - - if b.Len()+1+len(line) > limit { - flush() - b.WriteString(line) - - continue - } - - b.WriteByte('\n') - b.WriteString(line) - } - - flush() - return chunks + return strings.Join(parts, ":") } diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go index 8d001b1..682ec4e 100644 --- a/internal/logging/logging_test.go +++ b/internal/logging/logging_test.go @@ -2,11 +2,6 @@ package logging import ( "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" "os" "path/filepath" "strconv" @@ -119,234 +114,10 @@ func TestLoggerConcurrentWrites(t *testing.T) { } } -func TestWebhookSender(t *testing.T) { - var discordPayload map[string]string - discordSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - body, _ := io.ReadAll(r.Body) - _ = json.Unmarshal(body, &discordPayload) - w.WriteHeader(http.StatusOK) - })) - - defer discordSrv.Close() - - var slackPayload map[string]string - slackSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - body, _ := io.ReadAll(r.Body) - _ = json.Unmarshal(body, &slackPayload) - w.WriteHeader(http.StatusOK) - })) - - defer slackSrv.Close() - - sender := newWebhookSender(config.LoggingConfig{ - DiscordWebhookURL: discordSrv.URL, - SlackWebhookURL: slackSrv.URL, - WebhookQueueSize: 10, - WebhookTimeout: time.Second, - WebhookBatchSize: 1, - WebhookBatchWait: time.Millisecond, - WebhookMaxChars: 1000, - }) - if sender == nil { - t.Fatalf("expected sender") - } - - if err := sender.Send(context.Background(), "hello"); err != nil { - t.Fatalf("send: %v", err) - } - - if discordPayload["content"] != "```\nhello\n```" { - t.Fatalf("unexpected discord payload: %+v", discordPayload) - } - - if slackPayload["text"] != "```\nhello\n```" { - t.Fatalf("unexpected slack payload: %+v", slackPayload) - } -} - -func TestWebhookSenderNon2xx(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - })) - - defer srv.Close() - - sender := newWebhookSender(config.LoggingConfig{ - DiscordWebhookURL: srv.URL, - WebhookQueueSize: 10, - WebhookTimeout: time.Second, - WebhookBatchSize: 1, - WebhookBatchWait: time.Millisecond, - WebhookMaxChars: 1000, - }) - if sender == nil { - t.Fatalf("expected sender") - } - - if err := sender.Send(context.Background(), "hello"); err == nil { - t.Fatalf("expected error on non-2xx") - } -} - -func TestWebhookSenderQueueFull(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - sender := newWebhookSender(config.LoggingConfig{ - DiscordWebhookURL: srv.URL, - WebhookQueueSize: 1, - WebhookTimeout: time.Second, - WebhookBatchSize: 1, - WebhookBatchWait: time.Second, - WebhookMaxChars: 1000, - }) - if sender == nil { - t.Fatalf("expected sender") - } - - defer func() { - _ = sender.Close() - }() - - if err := sender.Enqueue(context.Background(), "one"); err != nil { - t.Fatalf("enqueue first: %v", err) - } - - if err := sender.Enqueue(context.Background(), "two"); err == nil { - t.Fatalf("expected queue full error") - } -} - func newTestLogger(dir string) (*Logger, error) { return New(config.LoggingConfig{ - Dir: dir, - FilePrefix: "app", - MaxBodyBytes: 1024, - WebhookQueueSize: 100, - WebhookTimeout: time.Second, - WebhookBatchSize: 5, - WebhookBatchWait: time.Second, - WebhookMaxChars: 1000, - }) -} - -func TestWebhookBatchingBySize(t *testing.T) { - var got []string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - - body, _ := io.ReadAll(r.Body) - var payload map[string]string - _ = json.Unmarshal(body, &payload) - - got = append(got, payload["content"]) - w.WriteHeader(http.StatusOK) - })) - - defer srv.Close() - - sender := newWebhookSender(config.LoggingConfig{ - DiscordWebhookURL: srv.URL, - WebhookQueueSize: 10, - WebhookTimeout: time.Second, - WebhookBatchSize: 2, - WebhookBatchWait: time.Second, - WebhookMaxChars: 1000, - }) - if sender == nil { - t.Fatalf("expected sender") - } - - _ = sender.Enqueue(context.Background(), "one") - _ = sender.Enqueue(context.Background(), "two") - _ = sender.Close() - - if len(got) != 1 { - t.Fatalf("expected 1 batch, got %d", len(got)) - } - - if got[0] != "```\none\ntwo\n```" { - t.Fatalf("unexpected batch content: %s", got[0]) - } -} - -func TestWebhookBatchingByTime(t *testing.T) { - var got []string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - - body, _ := io.ReadAll(r.Body) - var payload map[string]string - _ = json.Unmarshal(body, &payload) - - got = append(got, payload["content"]) - w.WriteHeader(http.StatusOK) - })) - - defer srv.Close() - - sender := newWebhookSender(config.LoggingConfig{ - DiscordWebhookURL: srv.URL, - WebhookQueueSize: 10, - WebhookTimeout: time.Second, - WebhookBatchSize: 10, - WebhookBatchWait: 50 * time.Millisecond, - WebhookMaxChars: 1000, - }) - if sender == nil { - t.Fatalf("expected sender") - } - - _ = sender.Enqueue(context.Background(), "one") - time.Sleep(80 * time.Millisecond) - _ = sender.Close() - - if len(got) != 1 { - t.Fatalf("expected 1 batch, got %d", len(got)) - } - - if got[0] != "```\none\n```" { - t.Fatalf("unexpected batch content: %s", got[0]) - } -} - -func TestWebhookMessageSplit(t *testing.T) { - var got []string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - - body, _ := io.ReadAll(r.Body) - var payload map[string]string - _ = json.Unmarshal(body, &payload) - - got = append(got, payload["content"]) - w.WriteHeader(http.StatusOK) - })) - - defer srv.Close() - - sender := newWebhookSender(config.LoggingConfig{ - DiscordWebhookURL: srv.URL, - WebhookQueueSize: 10, - WebhookTimeout: time.Second, - WebhookBatchSize: 1, - WebhookBatchWait: time.Millisecond, - WebhookMaxChars: 20, - }) - if sender == nil { - t.Fatalf("expected sender") - } - - long := strings.Repeat("a", 30) - if err := sender.Send(context.Background(), long); err != nil { - t.Fatalf("send: %v", err) - } - - if len(got) < 2 { - t.Fatalf("expected multiple chunks, got %d", len(got)) - } + Dir: dir, + FilePrefix: "app", + MaxBodyBytes: 1024, + }, Options{Service: "smctf", Env: "test"}) }