diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ab5930..4063892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Unified the two rate limiter store implementations (`rateLimiterStore` / `tokenRateLimiterStore`) behind a single generic `rateLimiterStore[K comparable]`; `RateLimitMiddleware` and `TokenRateLimitMiddleware` are now thin adapters over a shared factory. No behavior change. - Folded the `Algorithm` return value into `TransitKey.Algorithm` so `GetTransitKey` and `Get` return `(*TransitKey, error)` instead of a three-value tuple; replaced `crypto/domain.Algorithm` with `keyring.Algorithm` across transit and tokenization layers. No behavior change. - Collapsed `internal/crypto/{domain,repository,service,usecase}` into `internal/keyring`; all types, stores, cipher primitives, KMS adapter, and KEK use case now live in a single package. No behavior change. +- Replaced seven shallow metrics decorator structs with a single `BusinessMetricsMiddleware` applied per-route in the HTTP server and a `metrics.Track` helper for CLI commands; added `NopBusinessMetrics` so the DI container always returns a valid recorder instead of branching on `MetricsEnabled`. No behavior change. ## [0.28.0] - 2026-03-23 diff --git a/cmd/app/auth_commands.go b/cmd/app/auth_commands.go index 192511b..c6f84e0 100644 --- a/cmd/app/auth_commands.go +++ b/cmd/app/auth_commands.go @@ -44,10 +44,15 @@ func getAuthCommands() []*cli.Command { if err != nil { return err } + bm, err := container.BusinessMetrics(ctx) + if err != nil { + return err + } return commands.RunPurgeAuthTokens( ctx, tokenUseCase, + bm, container.Logger(), commands.DefaultIO().Writer, int(cmd.Int("days")), @@ -89,10 +94,15 @@ func getAuthCommands() []*cli.Command { if err != nil { return err } + bm, err := container.BusinessMetrics(ctx) + if err != nil { + return err + } return commands.RunCleanExpiredTokens( ctx, tokenizationUseCase, + bm, container.Logger(), commands.DefaultIO().Writer, int(cmd.Int("days")), diff --git a/cmd/app/commands/clean_expired_tokens.go b/cmd/app/commands/clean_expired_tokens.go index 6eecd23..f9c40a5 100644 --- a/cmd/app/commands/clean_expired_tokens.go +++ b/cmd/app/commands/clean_expired_tokens.go @@ -7,6 +7,7 @@ import ( "io" "log/slog" + "github.com/allisson/secrets/internal/metrics" tokenizationUseCase "github.com/allisson/secrets/internal/tokenization/usecase" ) @@ -44,6 +45,7 @@ func (r *CleanExpiredTokensResult) ToJSON() string { func RunCleanExpiredTokens( ctx context.Context, tokenizationUseCase tokenizationUseCase.TokenizationUseCase, + bm metrics.BusinessMetrics, logger *slog.Logger, writer io.Writer, days int, @@ -61,8 +63,12 @@ func RunCleanExpiredTokens( ) // Execute deletion or count operation - count, err := tokenizationUseCase.CleanupExpired(ctx, days, dryRun) - if err != nil { + var count int64 + if err := metrics.Track(ctx, bm, "tokenization", "tokenize_cleanup_expired", func() error { + var e error + count, e = tokenizationUseCase.CleanupExpired(ctx, days, dryRun) + return e + }); err != nil { return fmt.Errorf("failed to cleanup expired tokens: %w", err) } diff --git a/cmd/app/commands/clean_expired_tokens_test.go b/cmd/app/commands/clean_expired_tokens_test.go index 7e95cd6..58d5a82 100644 --- a/cmd/app/commands/clean_expired_tokens_test.go +++ b/cmd/app/commands/clean_expired_tokens_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/allisson/secrets/internal/metrics" tokenizationMocks "github.com/allisson/secrets/internal/tokenization/usecase/mocks" ) @@ -21,7 +22,16 @@ func TestRunCleanExpiredTokens(t *testing.T) { mockUseCase.On("CleanupExpired", ctx, days, false).Return(int64(100), nil) var out bytes.Buffer - err := RunCleanExpiredTokens(ctx, mockUseCase, logger, &out, days, false, "text") + err := RunCleanExpiredTokens( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + false, + "text", + ) require.NoError(t, err) require.Contains(t, out.String(), "Successfully deleted 100 expired token(s)") @@ -33,7 +43,16 @@ func TestRunCleanExpiredTokens(t *testing.T) { mockUseCase.On("CleanupExpired", ctx, days, true).Return(int64(50), nil) var out bytes.Buffer - err := RunCleanExpiredTokens(ctx, mockUseCase, logger, &out, days, true, "json") + err := RunCleanExpiredTokens( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + true, + "json", + ) require.NoError(t, err) require.Contains(t, out.String(), `"count": 50`) @@ -43,7 +62,16 @@ func TestRunCleanExpiredTokens(t *testing.T) { t.Run("invalid-days", func(t *testing.T) { mockUseCase := &tokenizationMocks.MockTokenizationUseCase{} - err := RunCleanExpiredTokens(ctx, mockUseCase, logger, &bytes.Buffer{}, -1, false, "text") + err := RunCleanExpiredTokens( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &bytes.Buffer{}, + -1, + false, + "text", + ) require.Error(t, err) require.Contains(t, err.Error(), "days must be a positive number") diff --git a/cmd/app/commands/purge_auth_tokens.go b/cmd/app/commands/purge_auth_tokens.go index 959bce6..d8ebc99 100644 --- a/cmd/app/commands/purge_auth_tokens.go +++ b/cmd/app/commands/purge_auth_tokens.go @@ -8,6 +8,7 @@ import ( "log/slog" "github.com/allisson/secrets/internal/auth/usecase" + "github.com/allisson/secrets/internal/metrics" ) // PurgeAuthTokensResult holds the result of the authentication token purge operation. @@ -44,6 +45,7 @@ func (r *PurgeAuthTokensResult) ToJSON() string { func RunPurgeAuthTokens( ctx context.Context, tokenUseCase usecase.TokenUseCase, + bm metrics.BusinessMetrics, logger *slog.Logger, writer io.Writer, days int, @@ -77,8 +79,12 @@ func RunPurgeAuthTokens( } // Execute purge - count, err := tokenUseCase.PurgeExpiredAndRevoked(ctx, days) - if err != nil { + var count int64 + if err := metrics.Track(ctx, bm, "auth", "token_purge", func() error { + var e error + count, e = tokenUseCase.PurgeExpiredAndRevoked(ctx, days) + return e + }); err != nil { return fmt.Errorf("failed to purge authentication tokens: %w", err) } diff --git a/cmd/app/commands/purge_auth_tokens_test.go b/cmd/app/commands/purge_auth_tokens_test.go index a640424..ecad0ce 100644 --- a/cmd/app/commands/purge_auth_tokens_test.go +++ b/cmd/app/commands/purge_auth_tokens_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" usecaseMocks "github.com/allisson/secrets/internal/auth/usecase/mocks" + "github.com/allisson/secrets/internal/metrics" ) func TestRunPurgeAuthTokens(t *testing.T) { @@ -21,7 +22,16 @@ func TestRunPurgeAuthTokens(t *testing.T) { mockUseCase.EXPECT().PurgeExpiredAndRevoked(ctx, days).Return(int64(100), nil).Once() var out bytes.Buffer - err := RunPurgeAuthTokens(ctx, mockUseCase, logger, &out, days, false, "text") + err := RunPurgeAuthTokens( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + false, + "text", + ) require.NoError(t, err) require.Contains(t, out.String(), "Successfully purged 100 expired/revoked authentication token(s)") @@ -32,7 +42,16 @@ func TestRunPurgeAuthTokens(t *testing.T) { mockUseCase.EXPECT().PurgeExpiredAndRevoked(ctx, days).Return(int64(50), nil).Once() var out bytes.Buffer - err := RunPurgeAuthTokens(ctx, mockUseCase, logger, &out, days, false, "json") + err := RunPurgeAuthTokens( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + false, + "json", + ) require.NoError(t, err) require.Contains(t, out.String(), `"count": 50`) @@ -41,7 +60,16 @@ func TestRunPurgeAuthTokens(t *testing.T) { t.Run("invalid-days", func(t *testing.T) { mockUseCase := usecaseMocks.NewMockTokenUseCase(t) - err := RunPurgeAuthTokens(ctx, mockUseCase, logger, &bytes.Buffer{}, -1, false, "text") + err := RunPurgeAuthTokens( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &bytes.Buffer{}, + -1, + false, + "text", + ) require.Error(t, err) require.Contains(t, err.Error(), "days must be a non-negative number") diff --git a/cmd/app/commands/purge_secrets.go b/cmd/app/commands/purge_secrets.go index e1047a7..c23ad4d 100644 --- a/cmd/app/commands/purge_secrets.go +++ b/cmd/app/commands/purge_secrets.go @@ -7,6 +7,7 @@ import ( "io" "log/slog" + "github.com/allisson/secrets/internal/metrics" secretsUseCase "github.com/allisson/secrets/internal/secrets/usecase" ) @@ -40,6 +41,7 @@ func (r *PurgeSecretsResult) ToJSON() string { func RunPurgeSecrets( ctx context.Context, secretUseCase secretsUseCase.SecretUseCase, + bm metrics.BusinessMetrics, logger *slog.Logger, writer io.Writer, days int, @@ -57,8 +59,12 @@ func RunPurgeSecrets( ) // Execute purge operation - count, err := secretUseCase.PurgeDeleted(ctx, days, dryRun) - if err != nil { + var count int64 + if err := metrics.Track(ctx, bm, "secrets", "secret_purge_deleted", func() error { + var e error + count, e = secretUseCase.PurgeDeleted(ctx, days, dryRun) + return e + }); err != nil { return fmt.Errorf("failed to purge secrets: %w", err) } diff --git a/cmd/app/commands/purge_secrets_test.go b/cmd/app/commands/purge_secrets_test.go index 0d7e6e4..0ba5781 100644 --- a/cmd/app/commands/purge_secrets_test.go +++ b/cmd/app/commands/purge_secrets_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/allisson/secrets/internal/metrics" secretsMocks "github.com/allisson/secrets/internal/secrets/usecase/mocks" ) @@ -21,7 +22,16 @@ func TestRunPurgeSecrets(t *testing.T) { mockUseCase.On("PurgeDeleted", ctx, days, false).Return(int64(100), nil) var out bytes.Buffer - err := RunPurgeSecrets(ctx, mockUseCase, logger, &out, days, false, "text") + err := RunPurgeSecrets( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + false, + "text", + ) require.NoError(t, err) require.Contains(t, out.String(), "Successfully deleted 100 secret(s) older than 30 day(s)") @@ -33,7 +43,16 @@ func TestRunPurgeSecrets(t *testing.T) { mockUseCase.On("PurgeDeleted", ctx, days, true).Return(int64(75), nil) var out bytes.Buffer - err := RunPurgeSecrets(ctx, mockUseCase, logger, &out, days, true, "text") + err := RunPurgeSecrets( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + true, + "text", + ) require.NoError(t, err) require.Contains(t, out.String(), "Dry-run mode: Would delete 75 secret(s) older than 30 day(s)") @@ -45,7 +64,16 @@ func TestRunPurgeSecrets(t *testing.T) { mockUseCase.On("PurgeDeleted", ctx, days, true).Return(int64(50), nil) var out bytes.Buffer - err := RunPurgeSecrets(ctx, mockUseCase, logger, &out, days, true, "json") + err := RunPurgeSecrets( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + true, + "json", + ) require.NoError(t, err) require.Contains(t, out.String(), `"count": 50`) @@ -59,7 +87,16 @@ func TestRunPurgeSecrets(t *testing.T) { mockUseCase.On("PurgeDeleted", ctx, days, false).Return(int64(25), nil) var out bytes.Buffer - err := RunPurgeSecrets(ctx, mockUseCase, logger, &out, days, false, "json") + err := RunPurgeSecrets( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + false, + "json", + ) require.NoError(t, err) require.Contains(t, out.String(), `"count": 25`) @@ -70,7 +107,16 @@ func TestRunPurgeSecrets(t *testing.T) { t.Run("invalid-days-negative", func(t *testing.T) { mockUseCase := &secretsMocks.MockSecretUseCase{} - err := RunPurgeSecrets(ctx, mockUseCase, logger, &bytes.Buffer{}, -1, false, "text") + err := RunPurgeSecrets( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &bytes.Buffer{}, + -1, + false, + "text", + ) require.Error(t, err) require.Contains(t, err.Error(), "days must be a positive number") @@ -81,7 +127,16 @@ func TestRunPurgeSecrets(t *testing.T) { mockUseCase.On("PurgeDeleted", ctx, 0, false).Return(int64(10), nil) var out bytes.Buffer - err := RunPurgeSecrets(ctx, mockUseCase, logger, &out, 0, false, "text") + err := RunPurgeSecrets( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + 0, + false, + "text", + ) require.NoError(t, err) require.Contains(t, out.String(), "Successfully deleted 10 secret(s) older than 0 day(s)") @@ -93,7 +148,16 @@ func TestRunPurgeSecrets(t *testing.T) { mockUseCase.On("PurgeDeleted", ctx, days, false).Return(int64(0), nil) var out bytes.Buffer - err := RunPurgeSecrets(ctx, mockUseCase, logger, &out, days, false, "text") + err := RunPurgeSecrets( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + false, + "text", + ) require.NoError(t, err) require.Contains(t, out.String(), "Successfully deleted 0 secret(s)") diff --git a/cmd/app/commands/purge_tokenization_keys.go b/cmd/app/commands/purge_tokenization_keys.go index 9eec7c8..8f8532f 100644 --- a/cmd/app/commands/purge_tokenization_keys.go +++ b/cmd/app/commands/purge_tokenization_keys.go @@ -7,6 +7,7 @@ import ( "io" "log/slog" + "github.com/allisson/secrets/internal/metrics" tokenizationUseCase "github.com/allisson/secrets/internal/tokenization/usecase" ) @@ -44,6 +45,7 @@ func (r *PurgeTokenizationKeysResult) ToJSON() string { func RunPurgeTokenizationKeys( ctx context.Context, tokenizationUseCase tokenizationUseCase.TokenizationKeyUseCase, + bm metrics.BusinessMetrics, logger *slog.Logger, writer io.Writer, days int, @@ -61,8 +63,12 @@ func RunPurgeTokenizationKeys( ) // Execute purge operation - count, err := tokenizationUseCase.PurgeDeleted(ctx, days, dryRun) - if err != nil { + var count int64 + if err := metrics.Track(ctx, bm, "tokenization", "tokenization_key_purge_deleted", func() error { + var e error + count, e = tokenizationUseCase.PurgeDeleted(ctx, days, dryRun) + return e + }); err != nil { return fmt.Errorf("failed to purge tokenization keys: %w", err) } diff --git a/cmd/app/commands/purge_tokenization_keys_test.go b/cmd/app/commands/purge_tokenization_keys_test.go index e18ee6b..93f74cb 100644 --- a/cmd/app/commands/purge_tokenization_keys_test.go +++ b/cmd/app/commands/purge_tokenization_keys_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/allisson/secrets/internal/metrics" tokenizationMocks "github.com/allisson/secrets/internal/tokenization/usecase/mocks" ) @@ -21,7 +22,16 @@ func TestRunPurgeTokenizationKeys(t *testing.T) { mockUseCase.On("PurgeDeleted", ctx, days, false).Return(int64(100), nil) var out bytes.Buffer - err := RunPurgeTokenizationKeys(ctx, mockUseCase, logger, &out, days, false, "text") + err := RunPurgeTokenizationKeys( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + false, + "text", + ) require.NoError(t, err) require.Contains( @@ -37,7 +47,16 @@ func TestRunPurgeTokenizationKeys(t *testing.T) { mockUseCase.On("PurgeDeleted", ctx, days, true).Return(int64(75), nil) var out bytes.Buffer - err := RunPurgeTokenizationKeys(ctx, mockUseCase, logger, &out, days, true, "text") + err := RunPurgeTokenizationKeys( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + true, + "text", + ) require.NoError(t, err) require.Contains( @@ -53,7 +72,16 @@ func TestRunPurgeTokenizationKeys(t *testing.T) { mockUseCase.On("PurgeDeleted", ctx, days, true).Return(int64(50), nil) var out bytes.Buffer - err := RunPurgeTokenizationKeys(ctx, mockUseCase, logger, &out, days, true, "json") + err := RunPurgeTokenizationKeys( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + true, + "json", + ) require.NoError(t, err) require.Contains(t, out.String(), `"count": 50`) @@ -67,7 +95,16 @@ func TestRunPurgeTokenizationKeys(t *testing.T) { mockUseCase.On("PurgeDeleted", ctx, days, false).Return(int64(25), nil) var out bytes.Buffer - err := RunPurgeTokenizationKeys(ctx, mockUseCase, logger, &out, days, false, "json") + err := RunPurgeTokenizationKeys( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + false, + "json", + ) require.NoError(t, err) require.Contains(t, out.String(), `"count": 25`) @@ -78,7 +115,16 @@ func TestRunPurgeTokenizationKeys(t *testing.T) { t.Run("invalid-days-negative", func(t *testing.T) { mockUseCase := &tokenizationMocks.MockTokenizationKeyUseCase{} - err := RunPurgeTokenizationKeys(ctx, mockUseCase, logger, &bytes.Buffer{}, -1, false, "text") + err := RunPurgeTokenizationKeys( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &bytes.Buffer{}, + -1, + false, + "text", + ) require.Error(t, err) require.Contains(t, err.Error(), "days must be a positive number") @@ -89,7 +135,16 @@ func TestRunPurgeTokenizationKeys(t *testing.T) { mockUseCase.On("PurgeDeleted", ctx, 0, false).Return(int64(10), nil) var out bytes.Buffer - err := RunPurgeTokenizationKeys(ctx, mockUseCase, logger, &out, 0, false, "text") + err := RunPurgeTokenizationKeys( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + 0, + false, + "text", + ) require.NoError(t, err) require.Contains( @@ -105,7 +160,16 @@ func TestRunPurgeTokenizationKeys(t *testing.T) { mockUseCase.On("PurgeDeleted", ctx, days, false).Return(int64(0), nil) var out bytes.Buffer - err := RunPurgeTokenizationKeys(ctx, mockUseCase, logger, &out, days, false, "text") + err := RunPurgeTokenizationKeys( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + false, + "text", + ) require.NoError(t, err) require.Contains(t, out.String(), "Successfully deleted 0 tokenization key(s)") diff --git a/cmd/app/commands/purge_transit_keys.go b/cmd/app/commands/purge_transit_keys.go index 8c8116d..637df03 100644 --- a/cmd/app/commands/purge_transit_keys.go +++ b/cmd/app/commands/purge_transit_keys.go @@ -7,6 +7,7 @@ import ( "io" "log/slog" + "github.com/allisson/secrets/internal/metrics" transitUseCase "github.com/allisson/secrets/internal/transit/usecase" ) @@ -40,6 +41,7 @@ func (r *PurgeTransitKeysResult) ToJSON() string { func RunPurgeTransitKeys( ctx context.Context, transitUseCase transitUseCase.TransitKeyUseCase, + bm metrics.BusinessMetrics, logger *slog.Logger, writer io.Writer, days int, @@ -57,8 +59,12 @@ func RunPurgeTransitKeys( ) // Execute purge operation - count, err := transitUseCase.PurgeDeleted(ctx, days, dryRun) - if err != nil { + var count int64 + if err := metrics.Track(ctx, bm, "transit", "transit_key_purge_deleted", func() error { + var e error + count, e = transitUseCase.PurgeDeleted(ctx, days, dryRun) + return e + }); err != nil { return fmt.Errorf("failed to purge transit keys: %w", err) } diff --git a/cmd/app/commands/purge_transit_keys_test.go b/cmd/app/commands/purge_transit_keys_test.go index ea631bd..e6b2945 100644 --- a/cmd/app/commands/purge_transit_keys_test.go +++ b/cmd/app/commands/purge_transit_keys_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/allisson/secrets/internal/metrics" transitMocks "github.com/allisson/secrets/internal/transit/usecase/mocks" ) @@ -21,7 +22,16 @@ func TestRunPurgeTransitKeys(t *testing.T) { mockUseCase.On("PurgeDeleted", ctx, days, false).Return(int64(100), nil) var out bytes.Buffer - err := RunPurgeTransitKeys(ctx, mockUseCase, logger, &out, days, false, "text") + err := RunPurgeTransitKeys( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + false, + "text", + ) require.NoError(t, err) require.Contains(t, out.String(), "Successfully deleted 100 transit key(s) older than 30 day(s)") @@ -33,7 +43,16 @@ func TestRunPurgeTransitKeys(t *testing.T) { mockUseCase.On("PurgeDeleted", ctx, days, true).Return(int64(75), nil) var out bytes.Buffer - err := RunPurgeTransitKeys(ctx, mockUseCase, logger, &out, days, true, "text") + err := RunPurgeTransitKeys( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + true, + "text", + ) require.NoError(t, err) require.Contains(t, out.String(), "Dry-run mode: Would delete 75 transit key(s) older than 30 day(s)") @@ -45,7 +64,16 @@ func TestRunPurgeTransitKeys(t *testing.T) { mockUseCase.On("PurgeDeleted", ctx, days, true).Return(int64(50), nil) var out bytes.Buffer - err := RunPurgeTransitKeys(ctx, mockUseCase, logger, &out, days, true, "json") + err := RunPurgeTransitKeys( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + true, + "json", + ) require.NoError(t, err) require.Contains(t, out.String(), `"count": 50`) @@ -59,7 +87,16 @@ func TestRunPurgeTransitKeys(t *testing.T) { mockUseCase.On("PurgeDeleted", ctx, days, false).Return(int64(25), nil) var out bytes.Buffer - err := RunPurgeTransitKeys(ctx, mockUseCase, logger, &out, days, false, "json") + err := RunPurgeTransitKeys( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + false, + "json", + ) require.NoError(t, err) require.Contains(t, out.String(), `"count": 25`) @@ -70,7 +107,16 @@ func TestRunPurgeTransitKeys(t *testing.T) { t.Run("invalid-days-negative", func(t *testing.T) { mockUseCase := &transitMocks.MockTransitKeyUseCase{} - err := RunPurgeTransitKeys(ctx, mockUseCase, logger, &bytes.Buffer{}, -1, false, "text") + err := RunPurgeTransitKeys( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &bytes.Buffer{}, + -1, + false, + "text", + ) require.Error(t, err) require.Contains(t, err.Error(), "days must be a positive number") @@ -81,7 +127,16 @@ func TestRunPurgeTransitKeys(t *testing.T) { mockUseCase.On("PurgeDeleted", ctx, 0, false).Return(int64(10), nil) var out bytes.Buffer - err := RunPurgeTransitKeys(ctx, mockUseCase, logger, &out, 0, false, "text") + err := RunPurgeTransitKeys( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + 0, + false, + "text", + ) require.NoError(t, err) require.Contains(t, out.String(), "Successfully deleted 10 transit key(s) older than 0 day(s)") @@ -93,7 +148,16 @@ func TestRunPurgeTransitKeys(t *testing.T) { mockUseCase.On("PurgeDeleted", ctx, days, false).Return(int64(0), nil) var out bytes.Buffer - err := RunPurgeTransitKeys(ctx, mockUseCase, logger, &out, days, false, "text") + err := RunPurgeTransitKeys( + ctx, + mockUseCase, + metrics.NewNopBusinessMetrics(), + logger, + &out, + days, + false, + "text", + ) require.NoError(t, err) require.Contains(t, out.String(), "Successfully deleted 0 transit key(s)") diff --git a/cmd/app/system_commands.go b/cmd/app/system_commands.go index b26d605..b57def7 100644 --- a/cmd/app/system_commands.go +++ b/cmd/app/system_commands.go @@ -137,10 +137,15 @@ func getSystemCommands(version string) []*cli.Command { if err != nil { return err } + bm, err := container.BusinessMetrics(ctx) + if err != nil { + return err + } return commands.RunPurgeSecrets( ctx, secretUseCase, + bm, container.Logger(), commands.DefaultIO().Writer, int(cmd.Int("days")), @@ -182,10 +187,15 @@ func getSystemCommands(version string) []*cli.Command { if err != nil { return err } + bm, err := container.BusinessMetrics(ctx) + if err != nil { + return err + } return commands.RunPurgeTransitKeys( ctx, transitUseCase, + bm, container.Logger(), commands.DefaultIO().Writer, int(cmd.Int("days")), @@ -227,10 +237,15 @@ func getSystemCommands(version string) []*cli.Command { if err != nil { return err } + bm, err := container.BusinessMetrics(ctx) + if err != nil { + return err + } return commands.RunPurgeTokenizationKeys( ctx, tokenizationUseCase, + bm, container.Logger(), commands.DefaultIO().Writer, int(cmd.Int("days")), diff --git a/internal/app/di.go b/internal/app/di.go index c71fa8c..84a33b0 100644 --- a/internal/app/di.go +++ b/internal/app/di.go @@ -247,20 +247,22 @@ func (c *Container) initMetricsProvider(ctx context.Context) (*metrics.Provider, } // initBusinessMetrics creates the business metrics recorder. +// Returns a no-op implementation when metrics are disabled so callers never receive nil. func (c *Container) initBusinessMetrics(ctx context.Context) (metrics.BusinessMetrics, error) { + if !c.config.MetricsEnabled { + return metrics.NewNopBusinessMetrics(), nil + } + provider, err := c.MetricsProvider(ctx) if err != nil { return nil, fmt.Errorf("failed to get metrics provider: %w", err) } - if provider == nil { - return nil, fmt.Errorf("metrics provider is nil despite MetricsEnabled=true") - } - businessMetrics, err := metrics.NewBusinessMetrics(provider.MeterProvider(), c.config.MetricsNamespace) + bm, err := metrics.NewBusinessMetrics(provider.MeterProvider(), c.config.MetricsNamespace) if err != nil { return nil, fmt.Errorf("failed to create business metrics: %w", err) } - return businessMetrics, nil + return bm, nil } // initHTTPServer creates the HTTP server with all its dependencies. @@ -336,6 +338,11 @@ func (c *Container) initHTTPServer(ctx context.Context) (*http.Server, error) { return nil, fmt.Errorf("failed to get metrics provider: %w", err) } + businessMetrics, err := c.BusinessMetrics(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get business metrics: %w", err) + } + server.SetupRouter(ctx, c.config, http.RouterDeps{ ClientHandler: clientHandler, TokenHandler: tokenHandler, @@ -347,6 +354,7 @@ func (c *Container) initHTTPServer(ctx context.Context) (*http.Server, error) { TokenizationHandler: tokenizationHandler, TokenUseCase: tokenUseCase, AuditLogUseCase: auditLogUseCase, + BusinessMetrics: businessMetrics, MetricsProvider: metricsProvider, MetricsNamespace: c.config.MetricsNamespace, }) diff --git a/internal/app/di_auth.go b/internal/app/di_auth.go index 6077c77..f3a91bb 100644 --- a/internal/app/di_auth.go +++ b/internal/app/di_auth.go @@ -128,21 +128,13 @@ func (c *Container) initClientUseCase(ctx context.Context) (authUseCase.ClientUs secretService := c.SecretService() - inner := authUseCase.NewClientUseCase( + return authUseCase.NewClientUseCase( txManager, clientRepository, tokenRepository, auditLogUseCase, secretService, - ) - if !c.config.MetricsEnabled { - return inner, nil - } - bm, err := c.BusinessMetrics(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get business metrics for client use case: %w", err) - } - return authUseCase.NewMetricsClientUseCase(inner, bm, "auth"), nil + ), nil } // initTokenService creates the token service for authentication. @@ -190,22 +182,14 @@ func (c *Container) initTokenUseCase(ctx context.Context) (authUseCase.TokenUseC secretService := c.SecretService() tokenService := c.TokenService() - inner := authUseCase.NewTokenUseCase( + return authUseCase.NewTokenUseCase( c.config, clientRepository, tokenRepository, auditLogUseCase, secretService, tokenService, - ) - if !c.config.MetricsEnabled { - return inner, nil - } - bm, err := c.BusinessMetrics(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get business metrics for token use case: %w", err) - } - return authUseCase.NewMetricsTokenUseCase(inner, bm, "auth"), nil + ), nil } // initAuditLogUseCase creates the audit log use case with all its dependencies. @@ -220,15 +204,7 @@ func (c *Container) initAuditLogUseCase(ctx context.Context) (authUseCase.AuditL return nil, fmt.Errorf("failed to get key signer for audit log use case: %w", err) } - inner := authUseCase.NewAuditLogUseCase(auditLogRepository, keySigner) - if !c.config.MetricsEnabled { - return inner, nil - } - bm, err := c.BusinessMetrics(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get business metrics for audit log use case: %w", err) - } - return authUseCase.NewMetricsAuditLogUseCase(inner, bm, "auth"), nil + return authUseCase.NewAuditLogUseCase(auditLogRepository, keySigner), nil } // initClientHandler creates the client HTTP handler with all its dependencies. diff --git a/internal/app/di_secrets.go b/internal/app/di_secrets.go index ffeaef0..1418299 100644 --- a/internal/app/di_secrets.go +++ b/internal/app/di_secrets.go @@ -55,20 +55,12 @@ func (c *Container) initSecretUseCase(ctx context.Context) (secretsUseCase.Secre return nil, fmt.Errorf("failed to get secret repository for secret use case: %w", err) } - inner := secretsUseCase.NewSecretUseCase( + return secretsUseCase.NewSecretUseCase( txManager, kr, secretRepository, c.config.SecretValueSizeLimitBytes, - ) - if !c.config.MetricsEnabled { - return inner, nil - } - bm, err := c.BusinessMetrics(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get business metrics for secret use case: %w", err) - } - return secretsUseCase.NewMetricsSecretUseCase(inner, bm, "secrets"), nil + ), nil } func (c *Container) initSecretHandler(ctx context.Context) (*secretsHTTP.SecretHandler, error) { diff --git a/internal/app/di_tokenization.go b/internal/app/di_tokenization.go index 6584bc2..e1a7ee7 100644 --- a/internal/app/di_tokenization.go +++ b/internal/app/di_tokenization.go @@ -98,15 +98,7 @@ func (c *Container) initTokenizationKeyUseCase( return nil, fmt.Errorf("failed to get keyring for tokenization key use case: %w", err) } - inner := tokenizationUseCase.NewTokenizationKeyUseCase(txManager, tokenizationKeyRepository, kr) - if !c.config.MetricsEnabled { - return inner, nil - } - bm, err := c.BusinessMetrics(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get business metrics for tokenization key use case: %w", err) - } - return tokenizationUseCase.NewMetricsTokenizationKeyUseCase(inner, bm, "tokenization"), nil + return tokenizationUseCase.NewTokenizationKeyUseCase(txManager, tokenizationKeyRepository, kr), nil } func (c *Container) initTokenizationUseCase( @@ -137,21 +129,13 @@ func (c *Container) initTokenizationUseCase( hashService := tokenizationUseCase.NewSHA256HashService() - inner := tokenizationUseCase.NewTokenizationUseCase( + return tokenizationUseCase.NewTokenizationUseCase( txManager, tokenizationKeyRepository, tokenRepository, hashService, kr, - ) - if !c.config.MetricsEnabled { - return inner, nil - } - bm, err := c.BusinessMetrics(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get business metrics for tokenization use case: %w", err) - } - return tokenizationUseCase.NewMetricsTokenizationUseCase(inner, bm, "tokenization"), nil + ), nil } func (c *Container) initTokenizationKeyHandler( diff --git a/internal/app/di_transit.go b/internal/app/di_transit.go index 2f2a573..080b2ef 100644 --- a/internal/app/di_transit.go +++ b/internal/app/di_transit.go @@ -60,15 +60,7 @@ func (c *Container) initTransitKeyUseCase(ctx context.Context) (transitUseCase.T return nil, fmt.Errorf("failed to get keyring for transit key use case: %w", err) } - inner := transitUseCase.NewTransitKeyUseCase(txManager, transitKeyRepository, kr) - if !c.config.MetricsEnabled { - return inner, nil - } - bm, err := c.BusinessMetrics(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get business metrics for transit key use case: %w", err) - } - return transitUseCase.NewMetricsTransitKeyUseCase(inner, bm, "transit"), nil + return transitUseCase.NewTransitKeyUseCase(txManager, transitKeyRepository, kr), nil } func (c *Container) initTransitKeyHandler(ctx context.Context) (*transitHTTP.TransitKeyHandler, error) { diff --git a/internal/auth/usecase/metrics_audit_log_usecase.go b/internal/auth/usecase/metrics_audit_log_usecase.go deleted file mode 100644 index 2c34978..0000000 --- a/internal/auth/usecase/metrics_audit_log_usecase.go +++ /dev/null @@ -1,83 +0,0 @@ -package usecase - -import ( - "context" - "time" - - "github.com/google/uuid" - - authDomain "github.com/allisson/secrets/internal/auth/domain" - "github.com/allisson/secrets/internal/metrics" -) - -type metricsAuditLogUseCase struct { - inner AuditLogUseCase - bm metrics.BusinessMetrics - domain string -} - -// NewMetricsAuditLogUseCase wraps inner with per-method timing and operation recording. -func NewMetricsAuditLogUseCase( - inner AuditLogUseCase, - bm metrics.BusinessMetrics, - domain string, -) AuditLogUseCase { - return &metricsAuditLogUseCase{inner: inner, bm: bm, domain: domain} -} - -func (m *metricsAuditLogUseCase) Create( - ctx context.Context, - requestID uuid.UUID, - clientID uuid.UUID, - capability authDomain.Capability, - path string, - metadata map[string]any, -) (err error) { - start := time.Now() - err = m.inner.Create(ctx, requestID, clientID, capability, path, metadata) - metrics.Record(ctx, m.bm, m.domain, "audit_log_create", start, err) - return -} - -func (m *metricsAuditLogUseCase) ListCursor( - ctx context.Context, - afterID *uuid.UUID, - limit int, - createdAtFrom, createdAtTo *time.Time, - clientID *uuid.UUID, -) (result []*authDomain.AuditLog, err error) { - start := time.Now() - result, err = m.inner.ListCursor(ctx, afterID, limit, createdAtFrom, createdAtTo, clientID) - metrics.Record(ctx, m.bm, m.domain, "audit_log_list", start, err) - return -} - -func (m *metricsAuditLogUseCase) DeleteOlderThan( - ctx context.Context, - days int, - dryRun bool, -) (count int64, err error) { - start := time.Now() - count, err = m.inner.DeleteOlderThan(ctx, days, dryRun) - metrics.Record(ctx, m.bm, m.domain, "audit_log_delete", start, err) - return -} - -func (m *metricsAuditLogUseCase) VerifyIntegrity(ctx context.Context, id uuid.UUID) (err error) { - start := time.Now() - err = m.inner.VerifyIntegrity(ctx, id) - metrics.Record(ctx, m.bm, m.domain, "audit_log_verify", start, err) - return -} - -func (m *metricsAuditLogUseCase) VerifyBatch( - ctx context.Context, - startTime, endTime time.Time, -) (result *VerificationReport, err error) { - start := time.Now() - result, err = m.inner.VerifyBatch(ctx, startTime, endTime) - metrics.Record(ctx, m.bm, m.domain, "audit_log_verify_batch", start, err) - return -} - -var _ AuditLogUseCase = (*metricsAuditLogUseCase)(nil) diff --git a/internal/auth/usecase/metrics_client_usecase.go b/internal/auth/usecase/metrics_client_usecase.go deleted file mode 100644 index 04d1fc5..0000000 --- a/internal/auth/usecase/metrics_client_usecase.go +++ /dev/null @@ -1,97 +0,0 @@ -package usecase - -import ( - "context" - "time" - - "github.com/google/uuid" - - authDomain "github.com/allisson/secrets/internal/auth/domain" - "github.com/allisson/secrets/internal/metrics" -) - -type metricsClientUseCase struct { - inner ClientUseCase - bm metrics.BusinessMetrics - domain string -} - -// NewMetricsClientUseCase wraps inner with per-method timing and operation recording. -func NewMetricsClientUseCase(inner ClientUseCase, bm metrics.BusinessMetrics, domain string) ClientUseCase { - return &metricsClientUseCase{inner: inner, bm: bm, domain: domain} -} - -func (m *metricsClientUseCase) Create( - ctx context.Context, - input *authDomain.CreateClientInput, -) (result *authDomain.CreateClientOutput, err error) { - start := time.Now() - result, err = m.inner.Create(ctx, input) - metrics.Record(ctx, m.bm, m.domain, "client_create", start, err) - return -} - -func (m *metricsClientUseCase) Update( - ctx context.Context, - clientID uuid.UUID, - input *authDomain.UpdateClientInput, -) (err error) { - start := time.Now() - err = m.inner.Update(ctx, clientID, input) - metrics.Record(ctx, m.bm, m.domain, "client_update", start, err) - return -} - -func (m *metricsClientUseCase) Get( - ctx context.Context, - clientID uuid.UUID, -) (result *authDomain.Client, err error) { - start := time.Now() - result, err = m.inner.Get(ctx, clientID) - metrics.Record(ctx, m.bm, m.domain, "client_get", start, err) - return -} - -func (m *metricsClientUseCase) Delete(ctx context.Context, clientID uuid.UUID) (err error) { - start := time.Now() - err = m.inner.Delete(ctx, clientID) - metrics.Record(ctx, m.bm, m.domain, "client_delete", start, err) - return -} - -func (m *metricsClientUseCase) ListCursor( - ctx context.Context, - afterID *uuid.UUID, - limit int, -) (result []*authDomain.Client, err error) { - start := time.Now() - result, err = m.inner.ListCursor(ctx, afterID, limit) - metrics.Record(ctx, m.bm, m.domain, "client_list", start, err) - return -} - -func (m *metricsClientUseCase) Unlock(ctx context.Context, clientID uuid.UUID) (err error) { - start := time.Now() - err = m.inner.Unlock(ctx, clientID) - metrics.Record(ctx, m.bm, m.domain, "client_unlock", start, err) - return -} - -func (m *metricsClientUseCase) RevokeTokens(ctx context.Context, clientID uuid.UUID) (err error) { - start := time.Now() - err = m.inner.RevokeTokens(ctx, clientID) - metrics.Record(ctx, m.bm, m.domain, "client_revoke_tokens", start, err) - return -} - -func (m *metricsClientUseCase) RotateSecret( - ctx context.Context, - clientID uuid.UUID, -) (result *authDomain.CreateClientOutput, err error) { - start := time.Now() - result, err = m.inner.RotateSecret(ctx, clientID) - metrics.Record(ctx, m.bm, m.domain, "client_rotate_secret", start, err) - return -} - -var _ ClientUseCase = (*metricsClientUseCase)(nil) diff --git a/internal/auth/usecase/metrics_token_usecase.go b/internal/auth/usecase/metrics_token_usecase.go deleted file mode 100644 index dee6154..0000000 --- a/internal/auth/usecase/metrics_token_usecase.go +++ /dev/null @@ -1,56 +0,0 @@ -package usecase - -import ( - "context" - "time" - - authDomain "github.com/allisson/secrets/internal/auth/domain" - "github.com/allisson/secrets/internal/metrics" -) - -type metricsTokenUseCase struct { - inner TokenUseCase - bm metrics.BusinessMetrics - domain string -} - -// NewMetricsTokenUseCase wraps inner with per-method timing and operation recording. -func NewMetricsTokenUseCase(inner TokenUseCase, bm metrics.BusinessMetrics, domain string) TokenUseCase { - return &metricsTokenUseCase{inner: inner, bm: bm, domain: domain} -} - -func (m *metricsTokenUseCase) Issue( - ctx context.Context, - input *authDomain.IssueTokenInput, -) (result *authDomain.IssueTokenOutput, err error) { - start := time.Now() - result, err = m.inner.Issue(ctx, input) - metrics.Record(ctx, m.bm, m.domain, "token_issue", start, err) - return -} - -func (m *metricsTokenUseCase) Authenticate( - ctx context.Context, - rawToken string, -) (result *authDomain.Client, err error) { - start := time.Now() - result, err = m.inner.Authenticate(ctx, rawToken) - metrics.Record(ctx, m.bm, m.domain, "token_authenticate", start, err) - return -} - -func (m *metricsTokenUseCase) Revoke(ctx context.Context, rawToken string) (err error) { - start := time.Now() - err = m.inner.Revoke(ctx, rawToken) - metrics.Record(ctx, m.bm, m.domain, "token_revoke", start, err) - return -} - -func (m *metricsTokenUseCase) PurgeExpiredAndRevoked(ctx context.Context, days int) (count int64, err error) { - start := time.Now() - count, err = m.inner.PurgeExpiredAndRevoked(ctx, days) - metrics.Record(ctx, m.bm, m.domain, "token_purge", start, err) - return -} - -var _ TokenUseCase = (*metricsTokenUseCase)(nil) diff --git a/internal/http/metrics_middleware.go b/internal/http/metrics_middleware.go new file mode 100644 index 0000000..76004c4 --- /dev/null +++ b/internal/http/metrics_middleware.go @@ -0,0 +1,26 @@ +package http + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/allisson/secrets/internal/metrics" +) + +// BusinessMetricsMiddleware records operation count and duration for a named route. +// Must be placed before the handler in the middleware chain. +// Uses HTTP response status to determine success (< 400) vs error (>= 400). +func BusinessMetricsMiddleware(bm metrics.BusinessMetrics, domain, operation string) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + status := "success" + if c.Writer.Status() >= http.StatusBadRequest { + status = "error" + } + bm.RecordOperation(c.Request.Context(), domain, operation, status) + bm.RecordDuration(c.Request.Context(), domain, operation, time.Since(start), status) + } +} diff --git a/internal/http/server.go b/internal/http/server.go index 0286985..fabf62b 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -76,6 +76,7 @@ type RouterDeps struct { TokenizationHandler *tokenizationHTTP.TokenizationHandler TokenUseCase authUseCase.TokenUseCase AuditLogUseCase authUseCase.AuditLogUseCase + BusinessMetrics metrics.BusinessMetrics MetricsProvider *metrics.Provider MetricsNamespace string } @@ -147,8 +148,16 @@ func (s *Server) SetupRouter(ctx context.Context, cfg *config.Config, deps Route authMiddleware, rateLimitMiddleware, authz, + deps.BusinessMetrics, + ) + s.registerSecretRoutes( + v1, + deps.SecretHandler, + authMiddleware, + rateLimitMiddleware, + authz, + deps.BusinessMetrics, ) - s.registerSecretRoutes(v1, deps.SecretHandler, authMiddleware, rateLimitMiddleware, authz) s.registerTransitRoutes( v1, deps.TransitKeyHandler, @@ -156,6 +165,7 @@ func (s *Server) SetupRouter(ctx context.Context, cfg *config.Config, deps Route authMiddleware, rateLimitMiddleware, authz, + deps.BusinessMetrics, ) s.registerTokenizationRoutes( v1, @@ -164,6 +174,7 @@ func (s *Server) SetupRouter(ctx context.Context, cfg *config.Config, deps Route authMiddleware, rateLimitMiddleware, authz, + deps.BusinessMetrics, ) } @@ -181,6 +192,7 @@ func (s *Server) registerAuthRoutes( authMiddleware gin.HandlerFunc, rateLimitMiddleware gin.HandlerFunc, authz *authHTTP.Authorizer, + bm metrics.BusinessMetrics, ) { // Create token rate limit middleware (IP-based, for unauthenticated token endpoint) var tokenRateLimitMiddleware gin.HandlerFunc @@ -195,13 +207,27 @@ func (s *Server) registerAuthRoutes( // Token issuance endpoint (no authentication required, IP-based rate limiting) if tokenRateLimitMiddleware != nil { - v1.POST("/token", tokenRateLimitMiddleware, tokenHandler.IssueTokenHandler) + v1.POST( + "/token", + tokenRateLimitMiddleware, + BusinessMetricsMiddleware(bm, "auth", "token_issue"), + tokenHandler.IssueTokenHandler, + ) } else { - v1.POST("/token", tokenHandler.IssueTokenHandler) + v1.POST( + "/token", + BusinessMetricsMiddleware(bm, "auth", "token_issue"), + tokenHandler.IssueTokenHandler, + ) } // Token revocation endpoint (requires authentication) - v1.DELETE("/token", authMiddleware, tokenHandler.RevokeTokenHandler) + v1.DELETE( + "/token", + authMiddleware, + BusinessMetricsMiddleware(bm, "auth", "token_revoke"), + tokenHandler.RevokeTokenHandler, + ) // Client management endpoints clients := v1.Group("/clients") @@ -212,34 +238,42 @@ func (s *Server) registerAuthRoutes( { clients.POST("", authz.Require(authDomain.WriteCapability), + BusinessMetricsMiddleware(bm, "auth", "client_create"), clientHandler.CreateHandler, ) clients.GET("", authz.Require(authDomain.ReadCapability), + BusinessMetricsMiddleware(bm, "auth", "client_list"), clientHandler.ListHandler, ) clients.GET("/:id", authz.Require(authDomain.ReadCapability), + BusinessMetricsMiddleware(bm, "auth", "client_get"), clientHandler.GetHandler, ) clients.PUT("/:id", authz.Require(authDomain.WriteCapability), + BusinessMetricsMiddleware(bm, "auth", "client_update"), clientHandler.UpdateHandler, ) clients.DELETE("/:id", authz.Require(authDomain.DeleteCapability), + BusinessMetricsMiddleware(bm, "auth", "client_delete"), clientHandler.DeleteHandler, ) clients.POST("/:id/unlock", authz.Require(authDomain.WriteCapability), + BusinessMetricsMiddleware(bm, "auth", "client_unlock"), clientHandler.UnlockHandler, ) clients.POST("/:id/rotate-secret", authz.Require(authDomain.RotateCapability), + BusinessMetricsMiddleware(bm, "auth", "client_rotate_secret"), clientHandler.RotateSecretHandler, ) clients.DELETE("/:id/tokens", authz.Require(authDomain.DeleteCapability), + BusinessMetricsMiddleware(bm, "auth", "client_revoke_tokens"), clientHandler.RevokeTokensHandler, ) } @@ -253,6 +287,7 @@ func (s *Server) registerAuthRoutes( { auditLogs.GET("", authz.Require(authDomain.ReadCapability), + BusinessMetricsMiddleware(bm, "auth", "audit_log_list"), auditLogHandler.ListHandler, ) } @@ -265,6 +300,7 @@ func (s *Server) registerSecretRoutes( authMiddleware gin.HandlerFunc, rateLimitMiddleware gin.HandlerFunc, authz *authHTTP.Authorizer, + bm metrics.BusinessMetrics, ) { // Secret management endpoints secrets := v1.Group("/secrets") @@ -275,18 +311,22 @@ func (s *Server) registerSecretRoutes( { secrets.GET("", authz.Require(authDomain.ReadCapability), + BusinessMetricsMiddleware(bm, "secrets", "secret_list"), secretHandler.ListHandler, ) secrets.POST("/*path", authz.Require(authDomain.EncryptCapability), + BusinessMetricsMiddleware(bm, "secrets", "secret_create_or_update"), secretHandler.CreateOrUpdateHandler, ) secrets.GET("/*path", authz.Require(authDomain.DecryptCapability), + BusinessMetricsMiddleware(bm, "secrets", "secret_get"), secretHandler.GetHandler, ) secrets.DELETE("/*path", authz.Require(authDomain.DeleteCapability), + BusinessMetricsMiddleware(bm, "secrets", "secret_delete"), secretHandler.DeleteHandler, ) } @@ -300,6 +340,7 @@ func (s *Server) registerTransitRoutes( authMiddleware gin.HandlerFunc, rateLimitMiddleware gin.HandlerFunc, authz *authHTTP.Authorizer, + bm metrics.BusinessMetrics, ) { // Transit encryption endpoints transit := v1.Group("/transit") @@ -313,42 +354,49 @@ func (s *Server) registerTransitRoutes( // List transit keys keys.GET("", authz.Require(authDomain.ReadCapability), + BusinessMetricsMiddleware(bm, "transit", "transit_key_list"), transitKeyHandler.ListHandler, ) // Get individual transit key keys.GET("/:name", authz.Require(authDomain.ReadCapability), + BusinessMetricsMiddleware(bm, "transit", "transit_key_get"), transitKeyHandler.GetHandler, ) // Create new transit key keys.POST("", authz.Require(authDomain.WriteCapability), + BusinessMetricsMiddleware(bm, "transit", "transit_key_create"), transitKeyHandler.CreateHandler, ) // Rotate transit key to new version keys.POST("/:name/rotate", authz.Require(authDomain.RotateCapability), + BusinessMetricsMiddleware(bm, "transit", "transit_key_rotate"), transitKeyHandler.RotateHandler, ) // Delete transit key keys.DELETE("/:name", authz.Require(authDomain.DeleteCapability), + BusinessMetricsMiddleware(bm, "transit", "transit_key_delete"), transitKeyHandler.DeleteHandler, ) // Encrypt plaintext with transit key keys.POST("/:name/encrypt", authz.Require(authDomain.EncryptCapability), + BusinessMetricsMiddleware(bm, "transit", "transit_encrypt"), cryptoHandler.EncryptHandler, ) // Decrypt ciphertext with transit key keys.POST("/:name/decrypt", authz.Require(authDomain.DecryptCapability), + BusinessMetricsMiddleware(bm, "transit", "transit_decrypt"), cryptoHandler.DecryptHandler, ) } @@ -363,6 +411,7 @@ func (s *Server) registerTokenizationRoutes( authMiddleware gin.HandlerFunc, rateLimitMiddleware gin.HandlerFunc, authz *authHTTP.Authorizer, + bm metrics.BusinessMetrics, ) { // Tokenization endpoints tokenization := v1.Group("/tokenization") @@ -376,42 +425,49 @@ func (s *Server) registerTokenizationRoutes( // List tokenization keys keys.GET("", authz.Require(authDomain.ReadCapability), + BusinessMetricsMiddleware(bm, "tokenization", "tokenization_key_list"), tokenizationKeyHandler.ListHandler, ) // Get individual tokenization key keys.GET("/:name", authz.Require(authDomain.ReadCapability), + BusinessMetricsMiddleware(bm, "tokenization", "tokenization_key_get"), tokenizationKeyHandler.GetByNameHandler, ) // Create new tokenization key keys.POST("", authz.Require(authDomain.WriteCapability), + BusinessMetricsMiddleware(bm, "tokenization", "tokenization_key_create"), tokenizationKeyHandler.CreateHandler, ) // Rotate tokenization key to new version keys.POST("/:name/rotate", authz.Require(authDomain.RotateCapability), + BusinessMetricsMiddleware(bm, "tokenization", "tokenization_key_rotate"), tokenizationKeyHandler.RotateHandler, ) // Delete tokenization key keys.DELETE("/:name", authz.Require(authDomain.DeleteCapability), + BusinessMetricsMiddleware(bm, "tokenization", "tokenization_key_delete"), tokenizationKeyHandler.DeleteHandler, ) // Tokenize plaintext with tokenization key keys.POST("/:name/tokenize", authz.Require(authDomain.EncryptCapability), + BusinessMetricsMiddleware(bm, "tokenization", "tokenize"), tokenizationHandler.TokenizeHandler, ) // Tokenize batch of plaintexts with tokenization key keys.POST("/:name/tokenize-batch", authz.Require(authDomain.EncryptCapability), + BusinessMetricsMiddleware(bm, "tokenization", "tokenize_batch"), tokenizationHandler.TokenizeBatchHandler, ) } @@ -419,24 +475,28 @@ func (s *Server) registerTokenizationRoutes( // Detokenize token to retrieve plaintext tokenization.POST("/detokenize", authz.Require(authDomain.DecryptCapability), + BusinessMetricsMiddleware(bm, "tokenization", "detokenize"), tokenizationHandler.DetokenizeHandler, ) // Detokenize batch of tokens to retrieve plaintexts tokenization.POST("/detokenize-batch", authz.Require(authDomain.DecryptCapability), + BusinessMetricsMiddleware(bm, "tokenization", "detokenize_batch"), tokenizationHandler.DetokenizeBatchHandler, ) // Validate token existence and validity tokenization.POST("/validate", authz.Require(authDomain.ReadCapability), + BusinessMetricsMiddleware(bm, "tokenization", "tokenize_validate"), tokenizationHandler.ValidateHandler, ) // Revoke token to prevent further detokenization tokenization.POST("/revoke", authz.Require(authDomain.DeleteCapability), + BusinessMetricsMiddleware(bm, "tokenization", "tokenize_revoke"), tokenizationHandler.RevokeHandler, ) } diff --git a/internal/metrics/business.go b/internal/metrics/business.go index c1b9291..2974a6d 100644 --- a/internal/metrics/business.go +++ b/internal/metrics/business.go @@ -100,3 +100,12 @@ func Record(ctx context.Context, m BusinessMetrics, domain, op string, start tim m.RecordOperation(ctx, domain, op, status) m.RecordDuration(ctx, domain, op, time.Since(start), status) } + +// Track records metrics for fn — a synchronous operation returning only an error. +// Return values beyond error must be captured via closure by the caller. +func Track(ctx context.Context, m BusinessMetrics, domain, op string, fn func() error) error { + start := time.Now() + err := fn() + Record(ctx, m, domain, op, start, err) + return err +} diff --git a/internal/metrics/nop.go b/internal/metrics/nop.go new file mode 100644 index 0000000..cf40594 --- /dev/null +++ b/internal/metrics/nop.go @@ -0,0 +1,15 @@ +package metrics + +import ( + "context" + "time" +) + +type nopBusinessMetrics struct{} + +func (nopBusinessMetrics) RecordOperation(_ context.Context, _, _, _ string) {} +func (nopBusinessMetrics) RecordDuration(_ context.Context, _, _ string, _ time.Duration, _ string) { +} + +// NewNopBusinessMetrics returns a BusinessMetrics that discards all recordings. +func NewNopBusinessMetrics() BusinessMetrics { return nopBusinessMetrics{} } diff --git a/internal/secrets/usecase/metrics_secret_usecase.go b/internal/secrets/usecase/metrics_secret_usecase.go deleted file mode 100644 index ae065e9..0000000 --- a/internal/secrets/usecase/metrics_secret_usecase.go +++ /dev/null @@ -1,83 +0,0 @@ -package usecase - -import ( - "context" - "time" - - "github.com/allisson/secrets/internal/metrics" - secretsDomain "github.com/allisson/secrets/internal/secrets/domain" -) - -type metricsSecretUseCase struct { - inner SecretUseCase - bm metrics.BusinessMetrics - domain string -} - -// NewMetricsSecretUseCase wraps inner with per-method timing and operation recording. -func NewMetricsSecretUseCase(inner SecretUseCase, bm metrics.BusinessMetrics, domain string) SecretUseCase { - return &metricsSecretUseCase{inner: inner, bm: bm, domain: domain} -} - -func (m *metricsSecretUseCase) CreateOrUpdate( - ctx context.Context, - path string, - value []byte, -) (result *secretsDomain.Secret, err error) { - start := time.Now() - result, err = m.inner.CreateOrUpdate(ctx, path, value) - metrics.Record(ctx, m.bm, m.domain, "secret_create_or_update", start, err) - return -} - -func (m *metricsSecretUseCase) Get( - ctx context.Context, - path string, -) (result *secretsDomain.Secret, err error) { - start := time.Now() - result, err = m.inner.Get(ctx, path) - metrics.Record(ctx, m.bm, m.domain, "secret_get", start, err) - return -} - -func (m *metricsSecretUseCase) GetByVersion( - ctx context.Context, - path string, - version uint, -) (result *secretsDomain.Secret, err error) { - start := time.Now() - result, err = m.inner.GetByVersion(ctx, path, version) - metrics.Record(ctx, m.bm, m.domain, "secret_get_by_version", start, err) - return -} - -func (m *metricsSecretUseCase) Delete(ctx context.Context, path string) (err error) { - start := time.Now() - err = m.inner.Delete(ctx, path) - metrics.Record(ctx, m.bm, m.domain, "secret_delete", start, err) - return -} - -func (m *metricsSecretUseCase) ListCursor( - ctx context.Context, - afterPath *string, - limit int, -) (result []*secretsDomain.Secret, err error) { - start := time.Now() - result, err = m.inner.ListCursor(ctx, afterPath, limit) - metrics.Record(ctx, m.bm, m.domain, "secret_list", start, err) - return -} - -func (m *metricsSecretUseCase) PurgeDeleted( - ctx context.Context, - olderThanDays int, - dryRun bool, -) (count int64, err error) { - start := time.Now() - count, err = m.inner.PurgeDeleted(ctx, olderThanDays, dryRun) - metrics.Record(ctx, m.bm, m.domain, "secret_purge_deleted", start, err) - return -} - -var _ SecretUseCase = (*metricsSecretUseCase)(nil) diff --git a/internal/tokenization/usecase/metrics_tokenization_key_usecase.go b/internal/tokenization/usecase/metrics_tokenization_key_usecase.go deleted file mode 100644 index 2179221..0000000 --- a/internal/tokenization/usecase/metrics_tokenization_key_usecase.go +++ /dev/null @@ -1,92 +0,0 @@ -package usecase - -import ( - "context" - "time" - - "github.com/allisson/secrets/internal/keyring" - "github.com/allisson/secrets/internal/metrics" - tokenizationDomain "github.com/allisson/secrets/internal/tokenization/domain" -) - -type metricsTokenizationKeyUseCase struct { - inner TokenizationKeyUseCase - bm metrics.BusinessMetrics - domain string -} - -// NewMetricsTokenizationKeyUseCase wraps inner with per-method timing and operation recording. -func NewMetricsTokenizationKeyUseCase( - inner TokenizationKeyUseCase, - bm metrics.BusinessMetrics, - domain string, -) TokenizationKeyUseCase { - return &metricsTokenizationKeyUseCase{inner: inner, bm: bm, domain: domain} -} - -func (m *metricsTokenizationKeyUseCase) Create( - ctx context.Context, - name string, - formatType tokenizationDomain.FormatType, - isDeterministic bool, - alg keyring.Algorithm, -) (result *tokenizationDomain.TokenizationKey, err error) { - start := time.Now() - result, err = m.inner.Create(ctx, name, formatType, isDeterministic, alg) - metrics.Record(ctx, m.bm, m.domain, "tokenization_key_create", start, err) - return -} - -func (m *metricsTokenizationKeyUseCase) Rotate( - ctx context.Context, - name string, - formatType tokenizationDomain.FormatType, - isDeterministic bool, - alg keyring.Algorithm, -) (result *tokenizationDomain.TokenizationKey, err error) { - start := time.Now() - result, err = m.inner.Rotate(ctx, name, formatType, isDeterministic, alg) - metrics.Record(ctx, m.bm, m.domain, "tokenization_key_rotate", start, err) - return -} - -func (m *metricsTokenizationKeyUseCase) Delete(ctx context.Context, name string) (err error) { - start := time.Now() - err = m.inner.Delete(ctx, name) - metrics.Record(ctx, m.bm, m.domain, "tokenization_key_delete", start, err) - return -} - -func (m *metricsTokenizationKeyUseCase) GetByName( - ctx context.Context, - name string, -) (result *tokenizationDomain.TokenizationKey, err error) { - start := time.Now() - result, err = m.inner.GetByName(ctx, name) - metrics.Record(ctx, m.bm, m.domain, "tokenization_key_get", start, err) - return -} - -func (m *metricsTokenizationKeyUseCase) ListCursor( - ctx context.Context, - afterName *string, - limit int, -) (result []*tokenizationDomain.TokenizationKey, err error) { - start := time.Now() - result, err = m.inner.ListCursor(ctx, afterName, limit) - metrics.Record(ctx, m.bm, m.domain, "tokenization_key_list", start, err) - return -} - -func (m *metricsTokenizationKeyUseCase) PurgeDeleted( - ctx context.Context, - olderThanDays int, - dryRun bool, -) (count int64, err error) { - start := time.Now() - count, err = m.inner.PurgeDeleted(ctx, olderThanDays, dryRun) - metrics.Record(ctx, m.bm, m.domain, "tokenization_key_purge_deleted", start, err) - return -} - -var _ TokenizationKeyUseCase = (*metricsTokenizationKeyUseCase)(nil) diff --git a/internal/tokenization/usecase/metrics_tokenization_usecase.go b/internal/tokenization/usecase/metrics_tokenization_usecase.go deleted file mode 100644 index 9b58593..0000000 --- a/internal/tokenization/usecase/metrics_tokenization_usecase.go +++ /dev/null @@ -1,100 +0,0 @@ -package usecase - -import ( - "context" - "time" - - "github.com/allisson/secrets/internal/metrics" - tokenizationDomain "github.com/allisson/secrets/internal/tokenization/domain" -) - -type metricsTokenizationUseCase struct { - inner TokenizationUseCase - bm metrics.BusinessMetrics - domain string -} - -// NewMetricsTokenizationUseCase wraps inner with per-method timing and operation recording. -func NewMetricsTokenizationUseCase( - inner TokenizationUseCase, - bm metrics.BusinessMetrics, - domain string, -) TokenizationUseCase { - return &metricsTokenizationUseCase{inner: inner, bm: bm, domain: domain} -} - -func (m *metricsTokenizationUseCase) Tokenize( - ctx context.Context, - keyName string, - plaintext []byte, - metadata map[string]any, - expiresAt *time.Time, -) (result *tokenizationDomain.Token, err error) { - start := time.Now() - result, err = m.inner.Tokenize(ctx, keyName, plaintext, metadata, expiresAt) - metrics.Record(ctx, m.bm, m.domain, "tokenize", start, err) - return -} - -func (m *metricsTokenizationUseCase) TokenizeBatch( - ctx context.Context, - keyName string, - plaintexts [][]byte, - metadatas []map[string]any, - expiresAt *time.Time, -) (result []*tokenizationDomain.Token, err error) { - start := time.Now() - result, err = m.inner.TokenizeBatch(ctx, keyName, plaintexts, metadatas, expiresAt) - metrics.Record(ctx, m.bm, m.domain, "tokenize_batch", start, err) - return -} - -func (m *metricsTokenizationUseCase) Detokenize( - ctx context.Context, - token string, -) (plaintext []byte, metadata map[string]any, err error) { - start := time.Now() - plaintext, metadata, err = m.inner.Detokenize(ctx, token) - metrics.Record(ctx, m.bm, m.domain, "detokenize", start, err) - return -} - -func (m *metricsTokenizationUseCase) DetokenizeBatch( - ctx context.Context, - tokens []string, -) (plaintexts [][]byte, metadatas []map[string]any, err error) { - start := time.Now() - plaintexts, metadatas, err = m.inner.DetokenizeBatch(ctx, tokens) - metrics.Record(ctx, m.bm, m.domain, "detokenize_batch", start, err) - return -} - -func (m *metricsTokenizationUseCase) Validate( - ctx context.Context, - token string, -) (valid bool, err error) { - start := time.Now() - valid, err = m.inner.Validate(ctx, token) - metrics.Record(ctx, m.bm, m.domain, "tokenize_validate", start, err) - return -} - -func (m *metricsTokenizationUseCase) Revoke(ctx context.Context, token string) (err error) { - start := time.Now() - err = m.inner.Revoke(ctx, token) - metrics.Record(ctx, m.bm, m.domain, "tokenize_revoke", start, err) - return -} - -func (m *metricsTokenizationUseCase) CleanupExpired( - ctx context.Context, - days int, - dryRun bool, -) (count int64, err error) { - start := time.Now() - count, err = m.inner.CleanupExpired(ctx, days, dryRun) - metrics.Record(ctx, m.bm, m.domain, "tokenize_cleanup_expired", start, err) - return -} - -var _ TokenizationUseCase = (*metricsTokenizationUseCase)(nil) diff --git a/internal/transit/usecase/metrics_transit_key_usecase.go b/internal/transit/usecase/metrics_transit_key_usecase.go deleted file mode 100644 index efb32af..0000000 --- a/internal/transit/usecase/metrics_transit_key_usecase.go +++ /dev/null @@ -1,112 +0,0 @@ -package usecase - -import ( - "context" - "time" - - "github.com/allisson/secrets/internal/keyring" - "github.com/allisson/secrets/internal/metrics" - transitDomain "github.com/allisson/secrets/internal/transit/domain" -) - -type metricsTransitKeyUseCase struct { - inner TransitKeyUseCase - bm metrics.BusinessMetrics - domain string -} - -// NewMetricsTransitKeyUseCase wraps inner with per-method timing and operation recording. -func NewMetricsTransitKeyUseCase( - inner TransitKeyUseCase, - bm metrics.BusinessMetrics, - domain string, -) TransitKeyUseCase { - return &metricsTransitKeyUseCase{inner: inner, bm: bm, domain: domain} -} - -func (m *metricsTransitKeyUseCase) Create( - ctx context.Context, - name string, - alg keyring.Algorithm, -) (result *transitDomain.TransitKey, err error) { - start := time.Now() - result, err = m.inner.Create(ctx, name, alg) - metrics.Record(ctx, m.bm, m.domain, "transit_key_create", start, err) - return -} - -func (m *metricsTransitKeyUseCase) Rotate( - ctx context.Context, - name string, - alg keyring.Algorithm, -) (result *transitDomain.TransitKey, err error) { - start := time.Now() - result, err = m.inner.Rotate(ctx, name, alg) - metrics.Record(ctx, m.bm, m.domain, "transit_key_rotate", start, err) - return -} - -func (m *metricsTransitKeyUseCase) Get( - ctx context.Context, - name string, - version uint, -) (key *transitDomain.TransitKey, err error) { - start := time.Now() - key, err = m.inner.Get(ctx, name, version) - metrics.Record(ctx, m.bm, m.domain, "transit_key_get", start, err) - return -} - -func (m *metricsTransitKeyUseCase) Delete(ctx context.Context, name string) (err error) { - start := time.Now() - err = m.inner.Delete(ctx, name) - metrics.Record(ctx, m.bm, m.domain, "transit_key_delete", start, err) - return -} - -func (m *metricsTransitKeyUseCase) Encrypt( - ctx context.Context, - name string, - plaintext, aad []byte, -) (result *transitDomain.EncryptedBlob, err error) { - start := time.Now() - result, err = m.inner.Encrypt(ctx, name, plaintext, aad) - metrics.Record(ctx, m.bm, m.domain, "transit_encrypt", start, err) - return -} - -func (m *metricsTransitKeyUseCase) Decrypt( - ctx context.Context, - name string, - ciphertext string, - aad []byte, -) (result *transitDomain.EncryptedBlob, err error) { - start := time.Now() - result, err = m.inner.Decrypt(ctx, name, ciphertext, aad) - metrics.Record(ctx, m.bm, m.domain, "transit_decrypt", start, err) - return -} - -func (m *metricsTransitKeyUseCase) ListCursor( - ctx context.Context, - afterName *string, - limit int, -) (result []*transitDomain.TransitKey, err error) { - start := time.Now() - result, err = m.inner.ListCursor(ctx, afterName, limit) - metrics.Record(ctx, m.bm, m.domain, "transit_key_list", start, err) - return -} - -func (m *metricsTransitKeyUseCase) PurgeDeleted( - ctx context.Context, - olderThanDays int, - dryRun bool, -) (count int64, err error) { - start := time.Now() - count, err = m.inner.PurgeDeleted(ctx, olderThanDays, dryRun) - metrics.Record(ctx, m.bm, m.domain, "transit_key_purge_deleted", start, err) - return -} - -var _ TransitKeyUseCase = (*metricsTransitKeyUseCase)(nil)