Skip to content

Commit 7fcc286

Browse files
authored
Merge pull request #15 from StudioLambda/refactor/correlation-package
refactor(framework): extract correlation into dedicated package
2 parents bc9b23c + 055f61a commit 7fcc286

11 files changed

Lines changed: 723 additions & 17 deletions

File tree

.agents/skills/lambda-cosmos/SKILL.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,14 @@ middleware.HTTP(stdlibMiddleware) // adapt stdlib middleware
172172

173173
## Subpackages
174174

175+
### Correlation
176+
177+
```go
178+
app.Use(correlation.Middleware()) // ensures correlation ID on every request
179+
logger := slog.New(correlation.Handler(h)) // injects correlation_id into log records
180+
id := correlation.From(r) // retrieve from request context
181+
```
182+
175183
### Sessions
176184

177185
```go

.agents/skills/lambda-cosmos/references/framework.md

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ func (handler Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
7676
```
7777

7878
Key points:
79+
7980
- Uses `errors.As` (not type assertions) for wrapped error support.
8081
- `problem.Problem` implements both `HTTPStatus` and `http.Handler`,
8182
so it takes the `http.Handler` branch and renders itself.
@@ -304,17 +305,17 @@ func handler(w http.ResponseWriter, r *http.Request) error {
304305
- Sessions are loaded from the driver at the start of each request.
305306
- Changes are persisted via a BeforeWriteHeader hook (fires just
306307
before the HTTP status line).
307-
- New sessions get a UUID v7 ID and are marked as changed.
308+
- New sessions get a unique ID and are marked as changed.
308309
- `ExpirationDelta` controls automatic session extension: if the
309310
session expires within `delta` of the current time, it is
310311
automatically extended.
311312

312313
### Constants
313314

314-
| Constant | Value |
315-
|---|---|
316-
| `DefaultCookie` | `"cosmos.session"` |
317-
| `DefaultTTL` | `2 * time.Hour` |
315+
| Constant | Value |
316+
| ------------------------ | ------------------ |
317+
| `DefaultCookie` | `"cosmos.session"` |
318+
| `DefaultTTL` | `2 * time.Hour` |
318319
| `DefaultExpirationDelta` | `15 * time.Minute` |
319320

320321
---
@@ -414,6 +415,7 @@ The nonce is randomly generated for each `Encrypt` call and prepended
414415
to the output. `Decrypt` reads the nonce from the front.
415416

416417
Sentinel errors:
418+
417419
- `crypto.ErrMismatchedAESNonceSize` — ciphertext too short for AES.
418420
- `crypto.ErrMismatchedChaCha20NonceSize` — ciphertext too short for
419421
ChaCha20.
@@ -525,13 +527,13 @@ Package: `github.com/studiolambda/cosmos/framework/event`
525527

526528
### Broker Implementations
527529

528-
| Broker | Backend | Constructor |
529-
|---|---|---|
530-
| `MemoryBroker` | In-memory | `NewMemoryBroker()` |
531-
| `RedisBroker` | Redis Pub/Sub | `NewRedisBroker(options)` |
532-
| `NATSBroker` | NATS | `NewNATSBroker(url)` |
533-
| `AMQPBroker` | RabbitMQ | `NewAMQPBroker(url)` |
534-
| `MQTTBroker` | MQTT v5 | `NewMQTTBroker(url)` |
530+
| Broker | Backend | Constructor |
531+
| -------------- | ------------- | ------------------------- |
532+
| `MemoryBroker` | In-memory | `NewMemoryBroker()` |
533+
| `RedisBroker` | Redis Pub/Sub | `NewRedisBroker(options)` |
534+
| `NATSBroker` | NATS | `NewNATSBroker(url)` |
535+
| `AMQPBroker` | RabbitMQ | `NewAMQPBroker(url)` |
536+
| `MQTTBroker` | MQTT v5 | `NewMQTTBroker(url)` |
535537

536538
All implement `contract.Events`.
537539

@@ -558,11 +560,11 @@ err := broker.Publish(ctx, "user.42.created", user)
558560

559561
### Wildcard Syntax
560562

561-
| Pattern | Meaning | MemoryBroker | Redis | NATS | AMQP | MQTT |
562-
|---|---|---|---|---|---|---|
563-
| `*` | Single token | `*` | `*` | `*` | `*` | `+` (auto-converted) |
564-
| `#` | Zero or more tokens | `#` | `*` (auto-converted) | `>` (auto-converted) | `#` | `#` |
565-
| `.` | Token separator | `.` | `.` | `.` | `.` | `/` (auto-converted) |
563+
| Pattern | Meaning | MemoryBroker | Redis | NATS | AMQP | MQTT |
564+
| ------- | ------------------- | ------------ | -------------------- | -------------------- | ---- | -------------------- |
565+
| `*` | Single token | `*` | `*` | `*` | `*` | `+` (auto-converted) |
566+
| `#` | Zero or more tokens | `#` | `*` (auto-converted) | `>` (auto-converted) | `#` | `#` |
567+
| `.` | Token separator | `.` | `.` | `.` | `.` | `/` (auto-converted) |
566568

567569
Write patterns using `*` and `#` with `.` separators. The broker
568570
translates to the native wildcard syntax automatically.

AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,12 @@ Available in `framework/middleware`:
150150
- `Provide(key, value)` / `ProvideWith(fn)` — context injection
151151
- `HTTP(func(http.Handler) http.Handler)` — stdlib middleware adapter
152152

153+
Correlation ID in `framework/correlation`:
154+
155+
- `Middleware()` / `MiddlewareWith(opts)` — ensures every request has a correlation ID (W3C traceparent, header, or generated)
156+
- `Handler(next)` — slog handler decorator that injects correlation ID into log records
157+
- `From(r)` — retrieves correlation ID from request context
158+
153159
Session middleware in `framework/session`:
154160

155161
- `Middleware(driver)` / `MiddlewareWith(driver, opts)` — session lifecycle
@@ -220,6 +226,7 @@ Session middleware in `framework/session`:
220226
- Framework hooks: framework/hooks.go, framework/hooks_writer.go
221227
- Router: router/router.go
222228
- Problem: problem/problem.go
229+
- Correlation: framework/correlation/middleware.go, framework/correlation/handler.go
223230
- Middleware: framework/middleware/\*.go
224231
- Session: framework/session/\*.go
225232
- Cache: framework/cache/memory.go, framework/cache/redis.go

contract/correlation.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package contract
2+
3+
// correlationIDKey is a private type used as a context key to avoid collisions.
4+
type correlationIDKey struct{}
5+
6+
// CorrelationIDKey is the context key used to store and retrieve
7+
// the correlation ID from a context.Context.
8+
var CorrelationIDKey = correlationIDKey{}

contract/request/correlation_id.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package request
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/studiolambda/cosmos/contract"
7+
)
8+
9+
// CorrelationID retrieves the correlation ID from the request
10+
// context. Returns an empty string if no correlation ID middleware
11+
// was applied to the request.
12+
func CorrelationID(r *http.Request) string {
13+
id, _ := r.Context().Value(contract.CorrelationIDKey).(string)
14+
15+
return id
16+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package request_test
2+
3+
import (
4+
"context"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/studiolambda/cosmos/contract"
9+
"github.com/studiolambda/cosmos/contract/request"
10+
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestCorrelationIDReturnsValueFromContext(t *testing.T) {
15+
t.Parallel()
16+
17+
ctx := context.WithValue(context.Background(), contract.CorrelationIDKey, "abc123")
18+
r := httptest.NewRequest("GET", "/", nil).WithContext(ctx)
19+
20+
require.Equal(t, "abc123", request.CorrelationID(r))
21+
}
22+
23+
func TestCorrelationIDReturnsEmptyWhenMissing(t *testing.T) {
24+
t.Parallel()
25+
26+
r := httptest.NewRequest("GET", "/", nil)
27+
28+
require.Empty(t, request.CorrelationID(r))
29+
}

framework/correlation/handler.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package correlation
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
7+
"github.com/studiolambda/cosmos/contract"
8+
)
9+
10+
// Handler wraps a [slog.Handler] to automatically
11+
// include the correlation ID in every log record whose context
12+
// carries one. This enables transparent correlation ID propagation
13+
// across all log calls without requiring manual attribute injection.
14+
//
15+
// Usage: wrap your existing handler when constructing the logger:
16+
//
17+
// handler := slog.NewJSONHandler(os.Stdout, nil)
18+
// logger := slog.New(correlation.Handler(handler))
19+
//
20+
// Then use context-aware logging methods in your handlers:
21+
//
22+
// logger.InfoContext(r.Context(), "processing request")
23+
// // Output automatically includes: correlation_id=<value>
24+
//
25+
// If no correlation ID is present in the context, the log record
26+
// is passed through unmodified.
27+
func Handler(next slog.Handler) slog.Handler {
28+
return handler{next: next}
29+
}
30+
31+
// handler is a [slog.Handler] decorator that enriches
32+
// log records with the correlation ID from context.
33+
type handler struct {
34+
next slog.Handler
35+
}
36+
37+
// Enabled delegates to the wrapped handler.
38+
func (handler handler) Enabled(ctx context.Context, level slog.Level) bool {
39+
return handler.next.Enabled(ctx, level)
40+
}
41+
42+
// Handle adds the correlation ID attribute to the record if present
43+
// in the context, then delegates to the wrapped handler.
44+
func (handler handler) Handle(ctx context.Context, record slog.Record) error {
45+
if id, ok := ctx.Value(contract.CorrelationIDKey).(string); ok && id != "" {
46+
record.AddAttrs(slog.String("correlation_id", id))
47+
}
48+
49+
return handler.next.Handle(ctx, record)
50+
}
51+
52+
// WithAttrs returns a new handler with the given attributes.
53+
func (handler handler) WithAttrs(attrs []slog.Attr) slog.Handler {
54+
return Handler(handler.next.WithAttrs(attrs))
55+
}
56+
57+
// WithGroup returns a new handler with the given group name.
58+
func (handler handler) WithGroup(name string) slog.Handler {
59+
return Handler(handler.next.WithGroup(name))
60+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package correlation_test
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"log/slog"
7+
"testing"
8+
9+
"github.com/studiolambda/cosmos/contract"
10+
"github.com/studiolambda/cosmos/framework/correlation"
11+
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestHandlerAddsIDFromContext(t *testing.T) {
16+
t.Parallel()
17+
18+
var buf bytes.Buffer
19+
handler := correlation.Handler(slog.NewTextHandler(&buf, nil))
20+
logger := slog.New(handler)
21+
22+
ctx := context.WithValue(context.Background(), contract.CorrelationIDKey, "trace-abc-123")
23+
logger.InfoContext(ctx, "test message")
24+
25+
output := buf.String()
26+
require.Contains(t, output, "correlation_id")
27+
require.Contains(t, output, "trace-abc-123")
28+
}
29+
30+
func TestHandlerOmitsWhenMissing(t *testing.T) {
31+
t.Parallel()
32+
33+
var buf bytes.Buffer
34+
handler := correlation.Handler(slog.NewTextHandler(&buf, nil))
35+
logger := slog.New(handler)
36+
37+
logger.InfoContext(context.Background(), "test message")
38+
39+
output := buf.String()
40+
require.NotContains(t, output, "correlation_id")
41+
}
42+
43+
func TestHandlerOmitsWhenEmpty(t *testing.T) {
44+
t.Parallel()
45+
46+
var buf bytes.Buffer
47+
handler := correlation.Handler(slog.NewTextHandler(&buf, nil))
48+
logger := slog.New(handler)
49+
50+
ctx := context.WithValue(context.Background(), contract.CorrelationIDKey, "")
51+
logger.InfoContext(ctx, "test message")
52+
53+
output := buf.String()
54+
require.NotContains(t, output, "correlation_id")
55+
}
56+
57+
func TestHandlerPreservesExistingAttrs(t *testing.T) {
58+
t.Parallel()
59+
60+
var buf bytes.Buffer
61+
handler := correlation.Handler(slog.NewTextHandler(&buf, nil))
62+
logger := slog.New(handler)
63+
64+
ctx := context.WithValue(context.Background(), contract.CorrelationIDKey, "id-456")
65+
logger.InfoContext(ctx, "test", "extra", "value")
66+
67+
output := buf.String()
68+
require.Contains(t, output, "correlation_id")
69+
require.Contains(t, output, "id-456")
70+
require.Contains(t, output, "extra")
71+
require.Contains(t, output, "value")
72+
}
73+
74+
func TestHandlerWithAttrsPreservesWrapping(t *testing.T) {
75+
t.Parallel()
76+
77+
var buf bytes.Buffer
78+
handler := correlation.Handler(slog.NewTextHandler(&buf, nil))
79+
logger := slog.New(handler).With("service", "api")
80+
81+
ctx := context.WithValue(context.Background(), contract.CorrelationIDKey, "id-789")
82+
logger.InfoContext(ctx, "test")
83+
84+
output := buf.String()
85+
require.Contains(t, output, "service")
86+
require.Contains(t, output, "api")
87+
require.Contains(t, output, "correlation_id")
88+
require.Contains(t, output, "id-789")
89+
}
90+
91+
func TestHandlerWithGroupPreservesWrapping(t *testing.T) {
92+
t.Parallel()
93+
94+
var buf bytes.Buffer
95+
handler := correlation.Handler(slog.NewTextHandler(&buf, nil))
96+
logger := slog.New(handler).WithGroup("request")
97+
98+
ctx := context.WithValue(context.Background(), contract.CorrelationIDKey, "id-group")
99+
logger.InfoContext(ctx, "test")
100+
101+
output := buf.String()
102+
require.Contains(t, output, "id-group")
103+
}
104+
105+
func TestHandlerRespectsLevel(t *testing.T) {
106+
t.Parallel()
107+
108+
var buf bytes.Buffer
109+
handler := correlation.Handler(
110+
slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelError}),
111+
)
112+
logger := slog.New(handler)
113+
114+
ctx := context.WithValue(context.Background(), contract.CorrelationIDKey, "id-level")
115+
logger.InfoContext(ctx, "should not appear")
116+
117+
require.Empty(t, buf.String())
118+
}

0 commit comments

Comments
 (0)