Skip to content

Commit abfceb5

Browse files
authored
Replace log package with fully slog-based system (#8606)
Replace our //log package with a new //blog package, based on the go stdlib's slog (structured logging) library. This makes all of our log lines uniformly json (or structured text in test envs), for ease of parsing by systems like clickhouse. The new package has a very similar API to the old one: new loggers are built by cmd/shell.go and stored on our various "impl" structs. When we want to log a line, we call a method with the desired log level. In fact, the new logger type (blog.Logger) has the same name as the old one, so most struct definitions and constructor arguments don't even need to be updated. The changes come in both how we use the logger, and what its output looks like. The most obvious change is that all logging calls now take a context as their first argument. The blog package provides utilities to store key-value pairs (slog.Attr) on a context, and the blog.Logger will extract those attributes from the context and include them in the log output. This makes it very easy to ensure that all log lines after a certain point (e.g. after the request has been parsed and we've identified the user and order in question) include relevant identify details (e.g. the regID and orderID). More subtly, we no longer ever log fmt.Sprintf-formatted strings or large "auditEvent" structs. Instead, all of those fields are either attached to the context (if relevant to multiple potential log lines) or included as slog.Attrs in the log call itself. This removes the need for our old log.Info / log.Infof / log.InfoObject variants: everything is plain messages and attributes. As before, all log messages include the log level, the current time, the program being run, the hostname the program is running on, and several other universally-relevant details. However, these are now presented as key-value pairs like any other attribute, rather than as unkeyed tags at the beginning of the message. All logs are written as either text (for tests) or json (for production code), tagged with the `[AUDIT]` tag, and checksummed. This means that our existing log routing and validation should continue to work unchanged. > [!NOTE] > Notes for reviewers: > - This is a very large PR. I suggest reviewing it one commit at a time to keep things reasonable. Each commit in the stack is logically scoped to a single subdirectory for ease of reviewing. However, don't expect code to compile or tests to pass at intermediate points in the commit stack, if you check it out locally. > - This change removes the stdout/stderr split and level-based coloration from our non-syslog output. We've decided this is explicitly desirable for prod, and at least temporarily acceptable for integration tests. > - Sometimes we log in-memory structures, like identifiers or validation records. In the JSON output, this works great: they get serialized to json. In the text output, this works less great: they get pushed through `fmt.Sprint("%v")`, which is somewhat readable but rarely optimal. I'm not aware of any good workarounds for this. > - One drawback of collecting attributes on the context is that it's not always obvious to the code reader (or writer) which attributes have already been added to the context, and therefore don't need to be added again. In writing this PR, several times I had log output like `acct=123 acct=123` because I'd added the same attribute twice in two different places. I'm not sure if this is a problem worth solving, or if it is, what the best approach might be. > [!WARNING] > **Open question**: As mentioned above, these logs include universal information like the program name and hostname. Because syslog adds similar tags of its own, this may mean that syslog-based production logs have redundant information in them. Are these attributes useful? Should we just remove them? Future work that we may want to do, but is not included in this change: - gRPC server interceptors to attach the service and method name as attributes to all incoming contexts - gRPC client/server interceptors to propagate certain well-known (e.g. acct, serial) attributes across the gRPC boundary - restore coloration (or even weirder formatting) to integration test log output, for ease of local debugging Fixes #8557
1 parent f9e80fb commit abfceb5

178 files changed

Lines changed: 2857 additions & 6083 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bdns/dns.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"errors"
77
"fmt"
88
"io"
9+
"log/slog"
910
"net"
1011
"net/http"
1112
"strconv"
@@ -17,7 +18,7 @@ import (
1718
"github.com/prometheus/client_golang/prometheus"
1819
"github.com/prometheus/client_golang/prometheus/promauto"
1920

20-
blog "github.com/letsencrypt/boulder/log"
21+
"github.com/letsencrypt/boulder/blog"
2122
"github.com/letsencrypt/boulder/metrics"
2223
)
2324

@@ -226,7 +227,12 @@ func (c *impl) exchangeOne(ctx context.Context, hostname string, qtype uint16) (
226227
}).Observe(rtt.Seconds())
227228

228229
if err != nil {
229-
c.log.Infof("logDNSError chosenServer=[%s] hostname=[%s] queryType=[%s] err=[%s]", chosenServer, hostname, qtypeStr, err)
230+
c.log.Info(ctx, "logDNSError",
231+
slog.String("chosenServer", chosenServer),
232+
slog.String("hostname", hostname),
233+
slog.String("qtype", qtypeStr),
234+
blog.Error(err),
235+
)
230236

231237
// Check if the error is a network timeout, rather than a local context
232238
// timeout. If it is, retry instead of giving up.

bdns/dns_test.go

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import (
2222
"github.com/miekg/dns"
2323
"github.com/prometheus/client_golang/prometheus"
2424

25-
blog "github.com/letsencrypt/boulder/log"
25+
"github.com/letsencrypt/boulder/blog"
2626
"github.com/letsencrypt/boulder/metrics"
2727
"github.com/letsencrypt/boulder/test"
2828
)
@@ -283,7 +283,7 @@ func TestDNSNoServers(t *testing.T) {
283283
staticProvider, err := NewStaticProvider([]string{})
284284
test.AssertNotError(t, err, "Got error creating StaticProvider")
285285

286-
obj := New(time.Hour, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.UseMock(), tlsConfig)
286+
obj := New(time.Hour, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.NewMock(), tlsConfig)
287287

288288
_, resolver, err := obj.LookupA(context.Background(), "letsencrypt.org")
289289
test.AssertEquals(t, resolver, "")
@@ -306,7 +306,7 @@ func TestDNSOneServer(t *testing.T) {
306306
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr})
307307
test.AssertNotError(t, err, "Got error creating StaticProvider")
308308

309-
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.UseMock(), tlsConfig)
309+
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.NewMock(), tlsConfig)
310310

311311
_, resolver, err := obj.LookupA(context.Background(), "letsencrypt.org")
312312
test.AssertNotError(t, err, "No message")
@@ -317,7 +317,7 @@ func TestDNSDuplicateServers(t *testing.T) {
317317
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr, dnsLoopbackAddr})
318318
test.AssertNotError(t, err, "Got error creating StaticProvider")
319319

320-
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.UseMock(), tlsConfig)
320+
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.NewMock(), tlsConfig)
321321

322322
_, resolver, err := obj.LookupA(context.Background(), "letsencrypt.org")
323323
test.AssertNotError(t, err, "No message")
@@ -328,7 +328,7 @@ func TestDNSServFail(t *testing.T) {
328328
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr})
329329
test.AssertNotError(t, err, "Got error creating StaticProvider")
330330

331-
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.UseMock(), tlsConfig)
331+
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.NewMock(), tlsConfig)
332332
bad := "servfail.com"
333333

334334
_, _, err = obj.LookupTXT(context.Background(), "servfail.com")
@@ -348,7 +348,7 @@ func TestDNSLookupTXT(t *testing.T) {
348348
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr})
349349
test.AssertNotError(t, err, "Got error creating StaticProvider")
350350

351-
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.UseMock(), tlsConfig)
351+
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.NewMock(), tlsConfig)
352352

353353
_, _, err = obj.LookupTXT(context.Background(), "letsencrypt.org")
354354
test.AssertNotError(t, err, "No message")
@@ -363,7 +363,7 @@ func TestDNSLookupA(t *testing.T) {
363363
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr})
364364
test.AssertNotError(t, err, "Got error creating StaticProvider")
365365

366-
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.UseMock(), tlsConfig)
366+
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.NewMock(), tlsConfig)
367367

368368
for _, tc := range []struct {
369369
name string
@@ -448,7 +448,7 @@ func TestDNSLookupAAAA(t *testing.T) {
448448
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr})
449449
test.AssertNotError(t, err, "Got error creating StaticProvider")
450450

451-
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.UseMock(), tlsConfig)
451+
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.NewMock(), tlsConfig)
452452

453453
for _, tc := range []struct {
454454
name string
@@ -533,7 +533,7 @@ func TestDNSNXDOMAIN(t *testing.T) {
533533
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr})
534534
test.AssertNotError(t, err, "Got error creating StaticProvider")
535535

536-
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.UseMock(), tlsConfig)
536+
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.NewMock(), tlsConfig)
537537
hostname := "nxdomain.letsencrypt.org"
538538

539539
_, _, err = obj.LookupA(context.Background(), hostname)
@@ -551,7 +551,7 @@ func TestDNSLookupCAA(t *testing.T) {
551551
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr})
552552
test.AssertNotError(t, err, "Got error creating StaticProvider")
553553

554-
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.UseMock(), tlsConfig)
554+
obj := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 1, "", blog.NewMock(), tlsConfig)
555555
removeIDExp := regexp.MustCompile(" id: [[:digit:]]+")
556556

557557
caas, resolver, err := obj.LookupCAA(context.Background(), "bracewel.net")
@@ -759,7 +759,7 @@ func TestRetry(t *testing.T) {
759759
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr})
760760
test.AssertNotError(t, err, "Got error creating StaticProvider")
761761

762-
testClient := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), tc.maxTries, "", blog.UseMock(), tlsConfig)
762+
testClient := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), tc.maxTries, "", blog.NewMock(), tlsConfig)
763763
dr := testClient.(*impl)
764764
dr.exchanger = tc.te
765765
_, _, err = dr.LookupTXT(context.Background(), "example.com")
@@ -796,7 +796,7 @@ func TestRetryMetrics(t *testing.T) {
796796
// context itself being cancelled. It should never see the error in the
797797
// testExchanger, because the fake exchanger (like the real http package)
798798
// checks for cancellation before doing any work.
799-
testClient := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 3, "", blog.UseMock(), tlsConfig)
799+
testClient := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 3, "", blog.NewMock(), tlsConfig)
800800
dr := testClient.(*impl)
801801
dr.exchanger = &testExchanger{errs: []error{errors.New("oops")}}
802802
ctx, cancel := context.WithCancel(t.Context())
@@ -815,7 +815,7 @@ func TestRetryMetrics(t *testing.T) {
815815

816816
// Same as above, except rather than cancelling the context ourselves, we
817817
// let the go runtime cancel it as a result of a deadline in the past.
818-
testClient = New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 3, "", blog.UseMock(), tlsConfig)
818+
testClient = New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 3, "", blog.NewMock(), tlsConfig)
819819
dr = testClient.(*impl)
820820
dr.exchanger = &testExchanger{errs: []error{errors.New("oops")}}
821821
ctx, cancel = context.WithTimeout(t.Context(), -10*time.Hour)
@@ -883,7 +883,7 @@ func TestRotateServerOnErr(t *testing.T) {
883883
test.AssertNotError(t, err, "Got error creating StaticProvider")
884884

885885
maxTries := 5
886-
client := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), maxTries, "", blog.UseMock(), tlsConfig)
886+
client := New(time.Second*10, staticProvider, metrics.NoopRegisterer, clock.NewFake(), maxTries, "", blog.NewMock(), tlsConfig)
887887

888888
// Configure a mock exchanger that will always return a retryable error for
889889
// servers A and B. This will force server "[2606:4700:4700::1111]:53" to do
@@ -948,7 +948,7 @@ func TestDOHMetric(t *testing.T) {
948948
staticProvider, err := NewStaticProvider([]string{dnsLoopbackAddr})
949949
test.AssertNotError(t, err, "Got error creating StaticProvider")
950950

951-
testClient := New(time.Second*11, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 0, "", blog.UseMock(), tlsConfig)
951+
testClient := New(time.Second*11, staticProvider, metrics.NoopRegisterer, clock.NewFake(), 0, "", blog.NewMock(), tlsConfig)
952952
resolver := testClient.(*impl)
953953
resolver.exchanger = &dohAlwaysRetryExchanger{err: &url.Error{Op: "read", Err: testTimeoutError(true)}}
954954

blog/attr.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package blog
2+
3+
// This file contains helper functions that can be used throughout the boulder
4+
// code base to ensure that certain commonly-logged values always have the same
5+
// key name and value type. This prevents situations like sometimes calling the
6+
// requesting account "requester" or "acct" or "regID"; or sometimes logging the
7+
// authz ID as an integer and sometimes as a string.
8+
//
9+
// Any time we find ourselves logging the same slog.Attr from 3+ files we
10+
// should consider adding a helper here instead.
11+
//
12+
// Note that several other attr keys are reserved and should not be used:
13+
// - "time": used by the slog package
14+
// - "level": used by the slog package
15+
// - "msg": used by the slog package
16+
// - "source": used by the slog package
17+
// - "error": used by our blog.Error and blog.AuditError helpers
18+
// - "audit": used by our blog.AuditError and blog.AuditInfo helpers
19+
20+
import (
21+
"log/slog"
22+
23+
"github.com/letsencrypt/boulder/identifier"
24+
)
25+
26+
// Acct returns a slog.Attr whose key is "acct" and whose value is the unique
27+
// numeric ID of the account.
28+
func Acct(acctID int64) slog.Attr {
29+
return slog.Int64("acct", acctID)
30+
}
31+
32+
// Order returns a slog.Attr whose key is "order" and whose value is the unique
33+
// numeric ID of the order.
34+
func Order(orderID int64) slog.Attr {
35+
return slog.Int64("order", orderID)
36+
}
37+
38+
// Authz returns a slog.Attr whose key is "authz" and whose value is the unique
39+
// numeric ID of the authz.
40+
func Authz(authzID int64) slog.Attr {
41+
return slog.Int64("authz", authzID)
42+
}
43+
44+
// Serial returns a slog.Attr whose key is "serial" and whose value is the
45+
// given string. The argument should be hex-encoded.
46+
func Serial(serial string) slog.Attr {
47+
return slog.String("serial", serial)
48+
}
49+
50+
// Idents returns a slog.Attr whose key is "idents" and whose value is a list
51+
// of the given identifiers.
52+
func Idents(idents ...identifier.ACMEIdentifier) slog.Attr {
53+
return slog.Any("idents", idents)
54+
}
55+
56+
// Error returns a slog.Attr whose key is "error" and whose value is the value
57+
// from err.Error(). This attribute is used automatically by methods that log
58+
// at the error level, like blog.Logger.AuditError().
59+
func Error(err error) slog.Attr {
60+
return slog.String("error", err.Error())
61+
}

blog/attr_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package blog
2+
3+
import (
4+
"errors"
5+
"log/slog"
6+
"net/netip"
7+
"testing"
8+
9+
"github.com/letsencrypt/boulder/identifier"
10+
)
11+
12+
func TestAttrHelpers(t *testing.T) {
13+
t.Parallel()
14+
15+
testCases := []struct {
16+
name string
17+
got slog.Attr
18+
wantKey string
19+
wantVal slog.Value
20+
}{
21+
{
22+
name: "Acct",
23+
got: Acct(42),
24+
wantKey: "acct",
25+
wantVal: slog.Int64Value(42),
26+
},
27+
{
28+
name: "Order",
29+
got: Order(17),
30+
wantKey: "order",
31+
wantVal: slog.Int64Value(17),
32+
},
33+
{
34+
name: "Authz",
35+
got: Authz(99),
36+
wantKey: "authz",
37+
wantVal: slog.Int64Value(99),
38+
},
39+
{
40+
name: "Serial",
41+
got: Serial("deadbeef"),
42+
wantKey: "serial",
43+
wantVal: slog.StringValue("deadbeef"),
44+
},
45+
{
46+
name: "Error",
47+
got: Error(errors.New("boom")),
48+
wantKey: "error",
49+
wantVal: slog.StringValue("boom"),
50+
},
51+
}
52+
53+
for _, tc := range testCases {
54+
t.Run(tc.name, func(t *testing.T) {
55+
t.Parallel()
56+
if tc.got.Key != tc.wantKey {
57+
t.Errorf("attr key = %q, want %q", tc.got.Key, tc.wantKey)
58+
}
59+
if !tc.got.Value.Equal(tc.wantVal) {
60+
t.Errorf("attr value = %v, want %v", tc.got.Value, tc.wantVal)
61+
}
62+
})
63+
}
64+
}
65+
66+
func TestIdentsAttr(t *testing.T) {
67+
t.Parallel()
68+
69+
// This test is separate from the above because the Idents helper accepts
70+
// a variadic number of arguments.
71+
attr := Idents(identifier.NewDNS("example.com"), identifier.NewIP(netip.MustParseAddr("12.34.56.78")))
72+
if attr.Key != "idents" {
73+
t.Errorf("attr key = %q, want %q", attr.Key, "idents")
74+
}
75+
76+
idents, ok := attr.Value.Any().([]identifier.ACMEIdentifier)
77+
if !ok {
78+
t.Fatalf("idents attr value should be a slice of ACMEIdentifier, got %T", attr.Value.Any())
79+
}
80+
if len(idents) != 2 {
81+
t.Fatalf("got %d idents, want 2", len(idents))
82+
}
83+
if idents[0].Value != "example.com" {
84+
t.Errorf("idents[0].Value = %q, want %q", idents[0].Value, "example.com")
85+
}
86+
if idents[1].Value != "12.34.56.78" {
87+
t.Errorf("idents[1].Value = %q, want %q", idents[1].Value, "12.34.56.78")
88+
}
89+
}

0 commit comments

Comments
 (0)