Skip to content

Commit fc80ec0

Browse files
committed
feat: implement correlation ID middleware, enhance request logging, and prevent cache stampedes using singleflight
1 parent 4aa563e commit fc80ec0

6 files changed

Lines changed: 186 additions & 99 deletions

File tree

internal/handlers/errors.go

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,12 @@ const (
3535

3636
// ErrorEnvelope represents a standardized error response
3737
type ErrorEnvelope struct {
38-
Code string `json:"code"`
39-
Message string `json:"message"`
40-
TraceID string `json:"trace_id"`
41-
Details map[string]interface{} `json:"details,omitempty"`
38+
Code string `json:"code"`
39+
Message string `json:"message"`
40+
TraceID string `json:"trace_id"`
41+
RequestID string `json:"request_id"`
42+
CorrelationID string `json:"correlation_id,omitempty"`
43+
Details map[string]interface{} `json:"details,omitempty"`
4244
}
4345

4446
// RespondWithError sends a standardized error response
@@ -56,17 +58,22 @@ func RespondWithErrorDetails(c *gin.Context, statusCode int, code ErrorCode, mes
5658
traceID = generateTraceID()
5759
}
5860

61+
requestID := c.GetString("request_id")
62+
correlationID := c.GetString("correlation_id")
63+
5964
// Redact message and details to prevent PII leakage
6065
redactedMessage := security.MaskPII(message)
6166
if details != nil {
6267
details = security.RedactMap(details)
6368
}
6469

6570
envelope := ErrorEnvelope{
66-
Code: string(code),
67-
Message: redactedMessage,
68-
TraceID: traceID,
69-
Details: details,
71+
Code: string(code),
72+
Message: redactedMessage,
73+
TraceID: traceID,
74+
RequestID: requestID,
75+
CorrelationID: correlationID,
76+
Details: details,
7077
}
7178

7279
c.JSON(statusCode, envelope)

internal/logger/logger.go

Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,48 @@
1-
package logger
2-
3-
import (
4-
"os"
5-
6-
"github.com/sirupsen/logrus"
7-
"go.opentelemetry.io/contrib/bridges/otellogrus"
8-
)
9-
10-
var Log = logrus.New()
11-
12-
func Init() {
13-
Log.SetFormatter(&logrus.JSONFormatter{})
14-
Log.SetOutput(os.Stdout)
15-
Log.AddHook(otellogrus.NewHook("stellarbill-backend"))
16-
17-
level := os.Getenv("LOG_LEVEL")
18-
switch level {
19-
case "debug":
20-
Log.SetLevel(logrus.DebugLevel)
21-
case "warn":
22-
Log.SetLevel(logrus.WarnLevel)
23-
case "error":
24-
Log.SetLevel(logrus.ErrorLevel)
25-
default:
26-
Log.SetLevel(logrus.InfoLevel)
27-
}
28-
}
29-
30-
func SafePrintf(format string, args ...interface{}) {
31-
Log.Printf(format, args...)
1+
package logger
2+
3+
import (
4+
"os"
5+
6+
"github.com/gin-gonic/gin"
7+
"github.com/sirupsen/logrus"
8+
"go.opentelemetry.io/contrib/bridges/otellogrus"
9+
)
10+
11+
var Log = logrus.New()
12+
13+
func Init() {
14+
Log.SetFormatter(&logrus.JSONFormatter{})
15+
Log.SetOutput(os.Stdout)
16+
Log.AddHook(otellogrus.NewHook("stellabill-backend"))
17+
18+
level := os.Getenv("LOG_LEVEL")
19+
switch level {
20+
case "debug":
21+
Log.SetLevel(logrus.DebugLevel)
22+
case "warn":
23+
Log.SetLevel(logrus.WarnLevel)
24+
case "error":
25+
Log.SetLevel(logrus.ErrorLevel)
26+
default:
27+
Log.SetLevel(logrus.InfoLevel)
28+
}
29+
}
30+
31+
// WithContextFields enriches log entries with request, correlation, and trace IDs from Gin context.
32+
func WithContextFields(c *gin.Context) *logrus.Entry {
33+
fields := logrus.Fields{}
34+
if reqID := c.GetString("request_id"); reqID != "" {
35+
fields["request_id"] = reqID
36+
}
37+
if corrID := c.GetString("correlation_id"); corrID != "" {
38+
fields["correlation_id"] = corrID
39+
}
40+
if traceID := c.GetString("traceID"); traceID != "" {
41+
fields["trace_id"] = traceID
42+
}
43+
return Log.WithFields(fields)
44+
}
45+
46+
func SafePrintf(format string, args ...interface{}) {
47+
Log.Printf(format, args...)
3248
}

internal/middleware/correlation.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package middleware
2+
3+
import (
4+
"github.com/gin-gonic/gin"
5+
"stellarbill-backend/internal/correlation"
6+
)
7+
8+
// CorrelationIDMiddleware injects a correlation (job) ID into the request context and Gin context.
9+
func CorrelationIDMiddleware() gin.HandlerFunc {
10+
return func(c *gin.Context) {
11+
// Retrieve existing job ID from context if present.
12+
ctx := c.Request.Context()
13+
jobID := correlation.JobIDFromContext(ctx)
14+
if jobID == "" {
15+
// Generate a new job ID and store it in the context.
16+
jobID = correlation.NewID()
17+
ctx = correlation.WithJobID(ctx, jobID)
18+
}
19+
// Update the request with the new context containing the job ID.
20+
c.Request = c.Request.WithContext(ctx)
21+
// Also store it in Gin context for easy access in handlers/logger.
22+
c.Set("correlation_id", jobID)
23+
c.Next()
24+
}
25+
}

internal/middleware/logger.go

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,57 @@
11
package middleware
22

33
import (
4-
"time"
4+
"time"
55

6-
"stellarbill-backend/internal/logger"
7-
8-
"github.com/gin-gonic/gin"
9-
"github.com/google/uuid"
6+
"github.com/gin-gonic/gin"
7+
"github.com/google/uuid"
8+
"stellarbill-backend/internal/logger"
9+
"stellarbill-backend/internal/correlation"
1010
)
1111

12+
// RequestLogger enriches each request with IDs and logs request details.
1213
func RequestLogger() gin.HandlerFunc {
13-
return func(c *gin.Context) {
14-
15-
start := time.Now()
16-
17-
requestID := uuid.New().String()
18-
c.Set("request_id", requestID)
19-
20-
c.Writer.Header().Set("X-Request-ID", requestID)
21-
22-
c.Next()
23-
24-
latency := time.Since(start)
25-
26-
logger.Log.WithFields(map[string]interface{}{
27-
"level": "info",
28-
"request_id": requestID,
29-
"method": c.Request.Method,
30-
"path": c.Request.URL.Path,
31-
"status": c.Writer.Status(),
32-
"latency_ms": latency.Milliseconds(),
33-
"client_ip": c.ClientIP(),
34-
}).Info("request completed")
35-
}
14+
return func(c *gin.Context) {
15+
start := time.Now()
16+
17+
// Ensure request ID exists (fallback)
18+
requestID := c.GetString("request_id")
19+
if requestID == "" {
20+
requestID = uuid.New().String()
21+
c.Set("request_id", requestID)
22+
c.Writer.Header().Set("X-Request-ID", requestID)
23+
}
24+
25+
// Ensure correlation ID exists (fallback)
26+
correlationID := c.GetString("correlation_id")
27+
if correlationID == "" {
28+
correlationID = correlation.NewID()
29+
c.Set("correlation_id", correlationID)
30+
c.Writer.Header().Set("X-Correlation-ID", correlationID)
31+
}
32+
33+
// Ensure trace ID exists (fallback)
34+
traceID := c.GetString("traceID")
35+
if traceID == "" {
36+
// TraceIDMiddleware should set this, but fallback to new UUID
37+
traceID = uuid.New().String()
38+
c.Set("traceID", traceID)
39+
c.Writer.Header().Set("X-Trace-ID", traceID)
40+
}
41+
42+
c.Next()
43+
44+
latency := time.Since(start)
45+
logger.Log.WithFields(map[string]interface{}{
46+
"level": "info",
47+
"request_id": requestID,
48+
"correlation_id": correlationID,
49+
"trace_id": traceID,
50+
"method": c.Request.Method,
51+
"path": c.Request.URL.Path,
52+
"status": c.Writer.Status(),
53+
"latency_ms": latency.Milliseconds(),
54+
"client_ip": c.ClientIP(),
55+
}).Info("request completed")
56+
}
3657
}

internal/repository/cached_plan_repo.go

Lines changed: 46 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ package repository
33
import (
44
"context"
55
"encoding/json"
6+
"fmt"
7+
"stellarbill-backend/internal/cache"
68
"sync"
79
"sync/atomic"
810
"time"
911

10-
"stellarbill-backend/internal/cache"
12+
"golang.org/x/sync/singleflight"
1113
)
1214

1315
type cacheEnvelope struct {
@@ -25,6 +27,7 @@ type CachedPlanRepo struct {
2527
misses uint64
2628
stales uint64
2729
invalidatedAt sync.Map
30+
sf singleflight.Group
2831
}
2932

3033
// NewCachedPlanRepo constructs a CachedPlanRepo.
@@ -44,10 +47,12 @@ func (cpr *CachedPlanRepo) cacheKey(id string) string {
4447
// and updates cache on a successful backend read.
4548
func (cpr *CachedPlanRepo) FindByID(ctx context.Context, id string) (*PlanRow, error) {
4649
key := cpr.cacheKey(id)
50+
// Attempt cache fetch
4751
if cpr.cache != nil {
4852
if val, err := cpr.cache.Get(ctx, key); err == nil && val != nil {
4953
var env cacheEnvelope
5054
if err := json.Unmarshal(val, &env); err == nil {
55+
// Check for staleness due to invalidation
5156
stale := false
5257
if invTimeVal, ok := cpr.invalidatedAt.Load(key); ok {
5358
if invTime, ok := invTimeVal.(time.Time); ok && env.StoredAt.Before(invTime) {
@@ -67,29 +72,34 @@ func (cpr *CachedPlanRepo) FindByID(ctx context.Context, id string) (*PlanRow, e
6772
}
6873
}
6974
}
75+
// Cache miss, use singleflight to avoid stampede
7076
atomic.AddUint64(&cpr.misses, 1)
71-
pr, err := cpr.backend.FindByID(ctx, id)
72-
if err != nil {
73-
return nil, err
74-
}
75-
if cpr.cache != nil {
76-
prBytes, err := json.Marshal(pr)
77-
if err == nil {
78-
env := cacheEnvelope{
79-
Data: prBytes,
80-
StoredAt: time.Now(),
81-
}
82-
if envBytes, err := json.Marshal(env); err == nil {
83-
_ = cpr.cache.Set(ctx, key, envBytes, cpr.ttl)
77+
v, err, _ := cpr.sf.Do(key, func() (interface{}, error) {
78+
pr, err := cpr.backend.FindByID(ctx, id)
79+
if err != nil {
80+
return nil, err
81+
}
82+
if cpr.cache != nil {
83+
prBytes, err := json.Marshal(pr)
84+
if err == nil {
85+
env := cacheEnvelope{Data: prBytes, StoredAt: time.Now()}
86+
if envBytes, err := json.Marshal(env); err == nil {
87+
_ = cpr.cache.Set(ctx, key, envBytes, cpr.ttl)
88+
}
8489
}
8590
}
91+
return pr, nil
92+
})
93+
if err != nil {
94+
return nil, err
8695
}
87-
return pr, nil
96+
return v.(*PlanRow), nil
8897
}
8998

9099
// List returns all plans. It caches the full list under a single key.
91100
func (cpr *CachedPlanRepo) List(ctx context.Context) ([]*PlanRow, error) {
92101
key := "plan:list:all"
102+
// Attempt cache fetch for list
93103
if cpr.cache != nil {
94104
if val, err := cpr.cache.Get(ctx, key); err == nil && val != nil {
95105
var env cacheEnvelope
@@ -110,27 +120,34 @@ func (cpr *CachedPlanRepo) List(ctx context.Context) ([]*PlanRow, error) {
110120
return out, nil
111121
}
112122
}
123+
} else {
124+
// Corrupted envelope JSON
125+
return nil, fmt.Errorf("corrupted cache envelope: %w", err)
113126
}
114127
}
115128
}
129+
// Cache miss, use singleflight for list
116130
atomic.AddUint64(&cpr.misses, 1)
117-
out, err := cpr.backend.List(ctx)
118-
if err != nil {
119-
return nil, err
120-
}
121-
if cpr.cache != nil {
122-
outBytes, err := json.Marshal(out)
123-
if err == nil {
124-
env := cacheEnvelope{
125-
Data: outBytes,
126-
StoredAt: time.Now(),
127-
}
128-
if envBytes, err := json.Marshal(env); err == nil {
129-
_ = cpr.cache.Set(ctx, key, envBytes, cpr.ttl)
131+
v, err, _ := cpr.sf.Do(key, func() (interface{}, error) {
132+
out, err := cpr.backend.List(ctx)
133+
if err != nil {
134+
return nil, err
135+
}
136+
if cpr.cache != nil {
137+
outBytes, err := json.Marshal(out)
138+
if err == nil {
139+
env := cacheEnvelope{Data: outBytes, StoredAt: time.Now()}
140+
if envBytes, err := json.Marshal(env); err == nil {
141+
_ = cpr.cache.Set(ctx, key, envBytes, cpr.ttl)
142+
}
130143
}
131144
}
145+
return out, nil
146+
})
147+
if err != nil {
148+
return nil, err
132149
}
133-
return out, nil
150+
return v.([]*PlanRow), nil
134151
}
135152

136153
// Delete invalidates a cached plan entry and records the invalidation time.

0 commit comments

Comments
 (0)