Skip to content

Commit 8093603

Browse files
authored
feat: Add Structured Logging (#9)
2 parents a0f1e04 + f9bcc92 commit 8093603

14 files changed

Lines changed: 737 additions & 141 deletions

.golangci.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ linters:
2525
- gosec
2626
- govet
2727
- ineffassign
28+
- iface
2829
- loggercheck
2930
- makezero
3031
- mirror
@@ -178,6 +179,14 @@ linters:
178179
rowserrcheck:
179180
packages:
180181
- github.com/jmoiron/sqlx
182+
sloglint:
183+
static-msg: true
184+
attr-only: true
185+
args-on-sep-lines: true
186+
no-global: all
187+
context: all
188+
msg-style: capitalized
189+
key-naming-case: snake
181190
staticcheck:
182191
checks:
183192
- -ST1003

context.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (c) 2025 Bart Venter <bartventer@proton.me>
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package httpcache
16+
17+
import (
18+
"context"
19+
20+
"github.com/bartventer/httpcache/internal"
21+
)
22+
23+
// ContextWithTraceID adds a trace ID to the context, which can be used for
24+
// logging or tracing purposes. The trace ID can be retrieved later using
25+
// [TraceIDFromContext].
26+
func ContextWithTraceID(ctx context.Context, traceID string) context.Context {
27+
return context.WithValue(ctx, internal.TraceIDKey, traceID)
28+
}
29+
30+
// TraceIDFromContext retrieves the trace ID from the context, if it exists.
31+
func TraceIDFromContext(ctx context.Context) (string, bool) {
32+
return internal.TraceIDFromContext(ctx)
33+
}

docs/log.schema.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Log Schema for HTTP Cache
2+
3+
This document describes the structured log schema, in JSON format, used by the HTTP cache service. The schema is designed to provide detailed, machine-readable logs for cache operations, including hits, misses, stale responses, revalidations, bypasses, and errors.
4+
5+
| Field | Type | Description |
6+
| --------- | ------ | ----------------------------------------------------------------- |
7+
| timestamp | string | ISO8601 timestamp of the log entry |
8+
| level | string | Log level: `DEBUG`, `INFO`, `WARN`, `ERROR` |
9+
| service | string | Always `httpcache` |
10+
| event | string | Event type: `HIT`, `MISS`, etc. |
11+
| msg | string | Human-readable summary |
12+
| trace_id | string | Trace/correlation ID (if provided) |
13+
| error | string | Error message (only for error logs) |
14+
| request | object | HTTP request context (see [below](#request-object)) |
15+
| cache | object | Cache context (see [below](#cache-object)) |
16+
| misc | object | Additional context (optional, see [below](#misc-object-optional)) |
17+
18+
## `request` object
19+
| Field | Type | Description |
20+
| ------ | ------ | ---------------- |
21+
| method | string | HTTP method |
22+
| url | string | Full request URL |
23+
| host | string | Host header |
24+
25+
## `cache` object
26+
| Field | Type | Description |
27+
| ------- | ------ | ------------------- |
28+
| status | string | HIT, MISS, STALE... |
29+
| url_key | string | Cache key |
30+
31+
## `misc` object (optional)
32+
| Field | Type | Description |
33+
| ----------- | ------ | -------------------------------------------------------------- |
34+
| cc_request | object | Cache-Control request directives (if present) |
35+
| cc_response | object | Cache-Control response directives (if present) |
36+
| stored | object | Cached response details (if present) |
37+
| freshness | object | Freshness details (if present, see [below](#freshness-object)) |
38+
| ref | object | Reference details (if present) |
39+
40+
## `freshness` object (optional)
41+
| Field | Type | Description |
42+
| --------- | ------- | ----------------------------------------------------- |
43+
| is_stale | boolean | Whether the response is stale |
44+
| age | object | Age of the cached response (see [below](#age-object)) |
45+
| timestamp | string | Timestamp of the age calculation (ISO8601) |
46+
47+
## `age` object (optional)
48+
| Field | Type | Description |
49+
| --------- | ------ | ------------------------------------------ |
50+
| value | number | Age in seconds |
51+
| timestamp | string | Timestamp of the age calculation (ISO8601) |
52+
53+
54+
### Example
55+
```json
56+
{
57+
"timestamp": "2025-07-01T14:20:00.000Z",
58+
"level": "INFO",
59+
"service": "httpcache",
60+
"event": "HIT",
61+
"msg": "Cache hit; served from cache.",
62+
"trace_id": "abc123def456",
63+
"request": {
64+
"method": "GET",
65+
"url": "https://api.example.com/data",
66+
"host": "api.example.com"
67+
},
68+
"cache": {
69+
"status": "HIT",
70+
"url_key": "https://api.example.com/data"
71+
},
72+
"misc": {
73+
"cc_request": {"max-age":"60"},
74+
"cc_response": {"max-age":"120", "stale-while-revalidate":"30"},
75+
"freshness": {
76+
"is_stale":false,
77+
"age": {
78+
"value":298741541,
79+
"timestamp":"2025-07-01T14:35:19.298742743Z"
80+
},
81+
}
82+
}
83+
```

internal/entry.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"fmt"
2323
"io"
2424
"iter"
25+
"log/slog"
2526
"net/http"
2627
"net/http/httputil"
2728
"os"
@@ -36,6 +37,18 @@ type Response struct {
3637
ReceivedAt time.Time // time when the response was received, used for determining cache freshness
3738
}
3839

40+
var _ slog.LogValuer = (*Response)(nil)
41+
42+
func (r Response) LogValue() slog.Value {
43+
return slog.GroupValue(
44+
slog.String("id", r.ID),
45+
slog.Time("requested_at", r.RequestedAt),
46+
slog.Time("received_at", r.ReceivedAt),
47+
slog.String("status", r.Data.Status),
48+
slog.Int("status_code", r.Data.StatusCode),
49+
)
50+
}
51+
3952
// DateHeader returns the parsed value of the "Date" header from the response.
4053
//
4154
// NOTE: It assumes a valid "Date" header has been set by [FixDateHeader].
@@ -65,7 +78,7 @@ func (r *Response) ExpiresHeader() (t time.Time, found bool, valid bool) {
6578
}
6679
expires, err := parseHTTPDateCompat(expiresStr)
6780
if err != nil || expires.IsZero() {
68-
return time.Time{}, false, false
81+
return
6982
}
7083
return expires, true, true
7184
}
@@ -138,6 +151,17 @@ type ResponseRef struct {
138151
ReceivedAt time.Time `json:"received_at,omitzero"` // when the response was generated.
139152
}
140153

154+
var _ slog.LogValuer = (*ResponseRef)(nil)
155+
156+
func (r ResponseRef) LogValue() slog.Value {
157+
return slog.GroupValue(
158+
slog.String("response_id", r.ResponseID),
159+
slog.String("vary", r.Vary),
160+
slog.Any("vary_resolved", r.VaryResolved),
161+
slog.Time("received_at", r.ReceivedAt),
162+
)
163+
}
164+
141165
type ResponseRefs []*ResponseRef
142166

143167
func (he ResponseRefs) ResponseIDs() iter.Seq[string] {

internal/freshness.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
package internal
1616

1717
import (
18+
"cmp"
19+
"log/slog"
1820
"net/http"
1921
"strconv"
2022
"time"
@@ -25,12 +27,31 @@ type Age struct {
2527
Timestamp time.Time // Time when the age was calculated
2628
}
2729

30+
var _ slog.LogValuer = (*Age)(nil)
31+
32+
func (a Age) LogValue() slog.Value {
33+
return slog.GroupValue(
34+
slog.Duration("value", a.Value),
35+
slog.Time("timestamp", a.Timestamp),
36+
)
37+
}
38+
2839
type Freshness struct {
2940
IsStale bool // Whether the response is stale
3041
Age *Age // Current age (seconds) of the response (RFC9111 §4.2.3)
3142
UsefulLife time.Duration // Freshness lifetime (seconds) of the response (RFC9111 §4.2.1)
3243
}
3344

45+
var _ slog.LogValuer = (*Freshness)(nil)
46+
47+
func (f Freshness) LogValue() slog.Value {
48+
return slog.GroupValue(
49+
slog.Bool("is_stale", f.IsStale),
50+
slog.Any("age", cmp.Or(f.Age, &Age{Value: 0, Timestamp: time.Time{}})),
51+
slog.Duration("useful_life", f.UsefulLife),
52+
)
53+
}
54+
3455
// heuristicFreshness calculates freshness lifetime using heuristics (10% of (date - last-modified)),
3556
// per RFC9111 §4.2.2.
3657
func heuristicFreshness(h http.Header, date time.Time) time.Duration {

internal/header.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package internal
1616

1717
import (
18+
"log/slog"
1819
"net/http"
1920
)
2021

@@ -41,6 +42,15 @@ func (s CacheStatus) ApplyTo(header http.Header) {
4142
}
4243
}
4344

45+
var _ slog.LogValuer = (*CacheStatus)(nil)
46+
47+
func (s CacheStatus) LogValue() slog.Value {
48+
return slog.GroupValue(
49+
slog.String("value", s.Value),
50+
slog.Bool("from_cache", s.Legacy == FromCache),
51+
)
52+
}
53+
4454
const (
4555
FromCache = "1"
4656
NotFromCache = ""

internal/helpers.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,8 @@ func validOptionalPort(port string) bool {
153153
return true
154154
}
155155

156-
func IsUnsafeMethod(req *http.Request) bool {
157-
switch req.Method {
156+
func IsUnsafeMethod(method string) bool {
157+
switch method {
158158
case http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch:
159159
return true
160160
default:

0 commit comments

Comments
 (0)