quic: basic qlog support
Add the structure for generating and writing qlog events.
Events are generated as slog events using the structure of
the qlog events (draft-ietf-quic-qlog-quic-events-03).
The qlog package contains a slog Handler implementation
that converts the quic package events to qlog JSON.
This CL generates events for connection creation and closure.
Future CLs will add additional events.
Events follow draft-ietf-quic-qlog-quic-events-03, which
is the most recent draft supported by the qvis visualization
tool.
https://www.ietf.org/archive/id/draft-ietf-quic-qlog-main-schema-04.html
https://www.ietf.org/archive/id/draft-ietf-quic-qlog-quic-events-03.html
For golang/go#58547
Change-Id: I5fb1b7653d0257cb86726bd5bc9e8775da74686a
Reviewed-on: https://go-review.googlesource.com/c/net/+/537936
Auto-Submit: Damien Neil <dneil@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
diff --git a/internal/quic/cmd/interop/main.go b/internal/quic/cmd/interop/main.go
index cc5292e..2ca5d65 100644
--- a/internal/quic/cmd/interop/main.go
+++ b/internal/quic/cmd/interop/main.go
@@ -18,6 +18,7 @@
"fmt"
"io"
"log"
+ "log/slog"
"net"
"net/url"
"os"
@@ -25,14 +26,16 @@
"sync"
"golang.org/x/net/internal/quic"
+ "golang.org/x/net/internal/quic/qlog"
)
var (
- listen = flag.String("listen", "", "listen address")
- cert = flag.String("cert", "", "certificate")
- pkey = flag.String("key", "", "private key")
- root = flag.String("root", "", "serve files from this root")
- output = flag.String("output", "", "directory to write files to")
+ listen = flag.String("listen", "", "listen address")
+ cert = flag.String("cert", "", "certificate")
+ pkey = flag.String("key", "", "private key")
+ root = flag.String("root", "", "serve files from this root")
+ output = flag.String("output", "", "directory to write files to")
+ qlogdir = flag.String("qlog", "", "directory to write qlog output to")
)
func main() {
@@ -48,6 +51,10 @@
},
MaxBidiRemoteStreams: -1,
MaxUniRemoteStreams: -1,
+ QLogLogger: slog.New(qlog.NewJSONHandler(qlog.HandlerOptions{
+ Level: quic.QLogLevelFrame,
+ Dir: *qlogdir,
+ })),
}
if *cert != "" {
c, err := tls.LoadX509KeyPair(*cert, *pkey)
diff --git a/internal/quic/cmd/interop/run_endpoint.sh b/internal/quic/cmd/interop/run_endpoint.sh
index d72335d..442039b 100644
--- a/internal/quic/cmd/interop/run_endpoint.sh
+++ b/internal/quic/cmd/interop/run_endpoint.sh
@@ -11,7 +11,7 @@
if [ "$ROLE" == "client" ]; then
# Wait for the simulator to start up.
/wait-for-it.sh sim:57832 -s -t 30
- ./interop -output=/downloads $CLIENT_PARAMS $REQUESTS
+ ./interop -output=/downloads -qlog=$QLOGDIR $CLIENT_PARAMS $REQUESTS
elif [ "$ROLE" == "server" ]; then
- ./interop -cert=/certs/cert.pem -key=/certs/priv.key -listen=:443 -root=/www "$@" $SERVER_PARAMS
+ ./interop -cert=/certs/cert.pem -key=/certs/priv.key -qlog=$QLOGDIR -listen=:443 -root=/www "$@" $SERVER_PARAMS
fi
diff --git a/internal/quic/config.go b/internal/quic/config.go
index 6278bf8..b10ecc7 100644
--- a/internal/quic/config.go
+++ b/internal/quic/config.go
@@ -8,6 +8,7 @@
import (
"crypto/tls"
+ "log/slog"
)
// A Config structure configures a QUIC endpoint.
@@ -72,6 +73,16 @@
//
// If this field is left as zero, stateless reset is disabled.
StatelessResetKey [32]byte
+
+ // QLogLogger receives qlog events.
+ //
+ // Events currently correspond to the definitions in draft-ietf-qlog-quic-events-03.
+ // This is not the latest version of the draft, but is the latest version supported
+ // by common event log viewers as of the time this paragraph was written.
+ //
+ // The qlog package contains a slog.Handler which serializes qlog events
+ // to a standard JSON representation.
+ QLogLogger *slog.Logger
}
func configDefault(v, def, limit int64) int64 {
diff --git a/internal/quic/conn.go b/internal/quic/conn.go
index 1292f2b..cca1116 100644
--- a/internal/quic/conn.go
+++ b/internal/quic/conn.go
@@ -11,6 +11,7 @@
"crypto/tls"
"errors"
"fmt"
+ "log/slog"
"net/netip"
"time"
)
@@ -60,6 +61,8 @@
// Tests only: Send a PING in a specific number space.
testSendPingSpace numberSpace
testSendPing sentVal
+
+ log *slog.Logger
}
// connTestHooks override conn behavior in tests.
@@ -94,7 +97,7 @@
retrySrcConnID []byte // source from server's Retry
}
-func newConn(now time.Time, side connSide, cids newServerConnIDs, peerAddr netip.AddrPort, config *Config, l *Listener) (*Conn, error) {
+func newConn(now time.Time, side connSide, cids newServerConnIDs, peerAddr netip.AddrPort, config *Config, l *Listener) (conn *Conn, _ error) {
c := &Conn{
side: side,
listener: l,
@@ -106,6 +109,14 @@
idleTimeout: now.Add(defaultMaxIdleTimeout),
peerAckDelayExponent: -1,
}
+ defer func() {
+ // If we hit an error in newConn, close donec so tests don't get stuck waiting for it.
+ // This is only relevant if we've got a bug, but it makes tracking that bug down
+ // much easier.
+ if conn == nil {
+ close(c.donec)
+ }
+ }()
// A one-element buffer allows us to wake a Conn's event loop as a
// non-blocking operation.
@@ -135,6 +146,7 @@
// The smallest allowed maximum QUIC datagram size is 1200 bytes.
// TODO: PMTU discovery.
const maxDatagramSize = 1200
+ c.logConnectionStarted(cids.originalDstConnID, peerAddr)
c.keysAppData.init()
c.loss.init(c.side, maxDatagramSize, now)
c.streamsInit()
@@ -259,6 +271,7 @@
defer close(c.donec)
defer c.tls.Close()
defer c.listener.connDrained(c)
+ defer c.logConnectionClosed()
// The connection timer sends a message to the connection loop on expiry.
// We need to give it an expiry when creating it, so set the initial timeout to
diff --git a/internal/quic/conn_close_test.go b/internal/quic/conn_close_test.go
index d583ae9..0dd46dd 100644
--- a/internal/quic/conn_close_test.go
+++ b/internal/quic/conn_close_test.go
@@ -70,7 +70,8 @@
}
func TestConnCloseWithPeerResponse(t *testing.T) {
- tc := newTestConn(t, clientSide)
+ qr := &qlogRecord{}
+ tc := newTestConn(t, clientSide, qr.config)
tc.handshake()
tc.conn.Abort(nil)
@@ -99,10 +100,19 @@
if err := tc.conn.Wait(canceledContext()); !errors.Is(err, wantErr) {
t.Errorf("non-blocking conn.Wait() = %v, want %v", err, wantErr)
}
+
+ tc.advance(1 * time.Second) // long enough to exit the draining state
+ qr.wantEvents(t, jsonEvent{
+ "name": "connectivity:connection_closed",
+ "data": map[string]any{
+ "trigger": "application",
+ },
+ })
}
func TestConnClosePeerCloses(t *testing.T) {
- tc := newTestConn(t, clientSide)
+ qr := &qlogRecord{}
+ tc := newTestConn(t, clientSide, qr.config)
tc.handshake()
wantErr := &ApplicationError{
@@ -128,6 +138,14 @@
code: 9,
reason: "because",
})
+
+ tc.advance(1 * time.Second) // long enough to exit the draining state
+ qr.wantEvents(t, jsonEvent{
+ "name": "connectivity:connection_closed",
+ "data": map[string]any{
+ "trigger": "application",
+ },
+ })
}
func TestConnCloseReceiveInInitial(t *testing.T) {
diff --git a/internal/quic/conn_test.go b/internal/quic/conn_test.go
index c70c58e..514a877 100644
--- a/internal/quic/conn_test.go
+++ b/internal/quic/conn_test.go
@@ -198,6 +198,7 @@
// The initial connection ID for the server is chosen by the client.
cids.srcConnID = testPeerConnID(0)
cids.dstConnID = testPeerConnID(-1)
+ cids.originalDstConnID = cids.dstConnID
}
var configTransportParams []func(*transportParameters)
var configTestConn []func(*testConn)
diff --git a/internal/quic/qlog.go b/internal/quic/qlog.go
new file mode 100644
index 0000000..2987569
--- /dev/null
+++ b/internal/quic/qlog.go
@@ -0,0 +1,141 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build go1.21
+
+package quic
+
+import (
+ "context"
+ "encoding/hex"
+ "log/slog"
+ "net/netip"
+)
+
+// Log levels for qlog events.
+const (
+ // QLogLevelFrame includes per-frame information.
+ // When this level is enabled, packet_sent and packet_received events will
+ // contain information on individual frames sent/received.
+ QLogLevelFrame = slog.Level(-6)
+
+ // QLogLevelPacket events occur at most once per packet sent or received.
+ //
+ // For example: packet_sent, packet_received.
+ QLogLevelPacket = slog.Level(-4)
+
+ // QLogLevelConn events occur multiple times over a connection's lifetime,
+ // but less often than the frequency of individual packets.
+ //
+ // For example: connection_state_updated.
+ QLogLevelConn = slog.Level(-2)
+
+ // QLogLevelEndpoint events occur at most once per connection.
+ //
+ // For example: connection_started, connection_closed.
+ QLogLevelEndpoint = slog.Level(0)
+)
+
+func (c *Conn) logEnabled(level slog.Level) bool {
+ return c.log != nil && c.log.Enabled(context.Background(), level)
+}
+
+// slogHexstring returns a slog.Attr for a value of the hexstring type.
+//
+// https://www.ietf.org/archive/id/draft-ietf-quic-qlog-main-schema-04.html#section-1.1.1
+func slogHexstring(key string, value []byte) slog.Attr {
+ return slog.String(key, hex.EncodeToString(value))
+}
+
+func slogAddr(key string, value netip.Addr) slog.Attr {
+ return slog.String(key, value.String())
+}
+
+func (c *Conn) logConnectionStarted(originalDstConnID []byte, peerAddr netip.AddrPort) {
+ if c.config.QLogLogger == nil ||
+ !c.config.QLogLogger.Enabled(context.Background(), QLogLevelEndpoint) {
+ return
+ }
+ var vantage string
+ if c.side == clientSide {
+ vantage = "client"
+ originalDstConnID = c.connIDState.originalDstConnID
+ } else {
+ vantage = "server"
+ }
+ // A qlog Trace container includes some metadata (title, description, vantage_point)
+ // and a list of Events. The Trace also includes a common_fields field setting field
+ // values common to all events in the trace.
+ //
+ // Trace = {
+ // ? title: text
+ // ? description: text
+ // ? configuration: Configuration
+ // ? common_fields: CommonFields
+ // ? vantage_point: VantagePoint
+ // events: [* Event]
+ // }
+ //
+ // To map this into slog's data model, we start each per-connection trace with a With
+ // call that includes both the trace metadata and the common fields.
+ //
+ // This means that in slog's model, each trace event will also include
+ // the Trace metadata fields (vantage_point), which is a divergence from the qlog model.
+ c.log = c.config.QLogLogger.With(
+ // The group_id permits associating traces taken from different vantage points
+ // for the same connection.
+ //
+ // We use the original destination connection ID as the group ID.
+ //
+ // https://www.ietf.org/archive/id/draft-ietf-quic-qlog-main-schema-04.html#section-3.4.6
+ slogHexstring("group_id", originalDstConnID),
+ slog.Group("vantage_point",
+ slog.String("name", "go quic"),
+ slog.String("type", vantage),
+ ),
+ )
+ localAddr := c.listener.LocalAddr()
+ // https://www.ietf.org/archive/id/draft-ietf-quic-qlog-quic-events-03.html#section-4.2
+ c.log.LogAttrs(context.Background(), QLogLevelEndpoint,
+ "connectivity:connection_started",
+ slogAddr("src_ip", localAddr.Addr()),
+ slog.Int("src_port", int(localAddr.Port())),
+ slogHexstring("src_cid", c.connIDState.local[0].cid),
+ slogAddr("dst_ip", peerAddr.Addr()),
+ slog.Int("dst_port", int(peerAddr.Port())),
+ slogHexstring("dst_cid", c.connIDState.remote[0].cid),
+ )
+}
+
+func (c *Conn) logConnectionClosed() {
+ if !c.logEnabled(QLogLevelEndpoint) {
+ return
+ }
+ err := c.lifetime.finalErr
+ trigger := "error"
+ switch e := err.(type) {
+ case *ApplicationError:
+ // TODO: Distinguish between peer and locally-initiated close.
+ trigger = "application"
+ case localTransportError:
+ if e.code == errNo {
+ trigger = "clean"
+ }
+ case peerTransportError:
+ if e.code == errNo {
+ trigger = "clean"
+ }
+ default:
+ switch err {
+ case errStatelessReset:
+ trigger = "stateless_reset"
+ }
+ // TODO: idle_timeout, handshake_timeout
+ }
+ // https://www.ietf.org/archive/id/draft-ietf-quic-qlog-quic-events-03.html#section-4.3
+ c.log.LogAttrs(context.Background(), QLogLevelEndpoint,
+ "connectivity:connection_closed",
+ slog.String("trigger", trigger),
+ )
+}
diff --git a/internal/quic/qlog/handler.go b/internal/quic/qlog/handler.go
new file mode 100644
index 0000000..35a66cf
--- /dev/null
+++ b/internal/quic/qlog/handler.go
@@ -0,0 +1,76 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build go1.21
+
+package qlog
+
+import (
+ "context"
+ "log/slog"
+)
+
+type withAttrsHandler struct {
+ attrs []slog.Attr
+ h slog.Handler
+}
+
+func withAttrs(h slog.Handler, attrs []slog.Attr) slog.Handler {
+ if len(attrs) == 0 {
+ return h
+ }
+ return &withAttrsHandler{attrs: attrs, h: h}
+}
+
+func (h *withAttrsHandler) Enabled(ctx context.Context, level slog.Level) bool {
+ return h.h.Enabled(ctx, level)
+}
+
+func (h *withAttrsHandler) Handle(ctx context.Context, r slog.Record) error {
+ r.AddAttrs(h.attrs...)
+ return h.h.Handle(ctx, r)
+}
+
+func (h *withAttrsHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
+ return withAttrs(h, attrs)
+}
+
+func (h *withAttrsHandler) WithGroup(name string) slog.Handler {
+ return withGroup(h, name)
+}
+
+type withGroupHandler struct {
+ name string
+ h slog.Handler
+}
+
+func withGroup(h slog.Handler, name string) slog.Handler {
+ if name == "" {
+ return h
+ }
+ return &withGroupHandler{name: name, h: h}
+}
+
+func (h *withGroupHandler) Enabled(ctx context.Context, level slog.Level) bool {
+ return h.h.Enabled(ctx, level)
+}
+
+func (h *withGroupHandler) Handle(ctx context.Context, r slog.Record) error {
+ var attrs []slog.Attr
+ r.Attrs(func(a slog.Attr) bool {
+ attrs = append(attrs, a)
+ return true
+ })
+ nr := slog.NewRecord(r.Time, r.Level, r.Message, r.PC)
+ nr.Add(slog.Any(h.name, slog.GroupValue(attrs...)))
+ return h.h.Handle(ctx, nr)
+}
+
+func (h *withGroupHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
+ return withAttrs(h, attrs)
+}
+
+func (h *withGroupHandler) WithGroup(name string) slog.Handler {
+ return withGroup(h, name)
+}
diff --git a/internal/quic/qlog/json_writer.go b/internal/quic/qlog/json_writer.go
new file mode 100644
index 0000000..50cf33b
--- /dev/null
+++ b/internal/quic/qlog/json_writer.go
@@ -0,0 +1,194 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build go1.21
+
+package qlog
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "log/slog"
+ "strconv"
+ "sync"
+ "time"
+)
+
+// A jsonWriter writes JSON-SEQ (RFC 7464).
+//
+// A JSON-SEQ file consists of a series of JSON text records,
+// each beginning with an RS (0x1e) character and ending with LF (0x0a).
+type jsonWriter struct {
+ mu sync.Mutex
+ w io.WriteCloser
+ buf bytes.Buffer
+}
+
+// writeRecordStart writes the start of a JSON-SEQ record.
+func (w *jsonWriter) writeRecordStart() {
+ w.mu.Lock()
+ w.buf.WriteByte(0x1e)
+ w.buf.WriteByte('{')
+}
+
+// writeRecordEnd finishes writing a JSON-SEQ record.
+func (w *jsonWriter) writeRecordEnd() {
+ w.buf.WriteByte('}')
+ w.buf.WriteByte('\n')
+ w.w.Write(w.buf.Bytes())
+ w.buf.Reset()
+ w.mu.Unlock()
+}
+
+// writeAttrsField writes a []slog.Attr as an object field.
+func (w *jsonWriter) writeAttrsField(name string, attrs []slog.Attr) {
+ w.writeName(name)
+ w.buf.WriteByte('{')
+ for _, a := range attrs {
+ w.writeAttr(a)
+ }
+ w.buf.WriteByte('}')
+}
+
+// writeAttr writes a slog.Attr as an object field.
+func (w *jsonWriter) writeAttr(a slog.Attr) {
+ v := a.Value.Resolve()
+ switch v.Kind() {
+ case slog.KindAny:
+ w.writeStringField(a.Key, fmt.Sprint(v.Any()))
+ case slog.KindBool:
+ w.writeBoolField(a.Key, v.Bool())
+ case slog.KindDuration:
+ w.writeDurationField(a.Key, v.Duration())
+ case slog.KindFloat64:
+ w.writeFloat64Field(a.Key, v.Float64())
+ case slog.KindInt64:
+ w.writeInt64Field(a.Key, v.Int64())
+ case slog.KindString:
+ w.writeStringField(a.Key, v.String())
+ case slog.KindTime:
+ w.writeTimeField(a.Key, v.Time())
+ case slog.KindUint64:
+ w.writeUint64Field(a.Key, v.Uint64())
+ case slog.KindGroup:
+ w.writeAttrsField(a.Key, v.Group())
+ default:
+ w.writeString("unhandled kind")
+ }
+}
+
+// writeName writes an object field name followed by a colon.
+func (w *jsonWriter) writeName(name string) {
+ if b := w.buf.Bytes(); len(b) > 0 && b[len(b)-1] != '{' {
+ // Add the comma separating this from the previous field.
+ w.buf.WriteByte(',')
+ }
+ w.writeString(name)
+ w.buf.WriteByte(':')
+}
+
+// writeObject writes an object-valued object field.
+// The function f is called to write the contents.
+func (w *jsonWriter) writeObjectField(name string, f func()) {
+ w.writeName(name)
+ w.buf.WriteByte('{')
+ f()
+ w.buf.WriteByte('}')
+}
+
+// writeRawField writes an field with a raw JSON value.
+func (w *jsonWriter) writeRawField(name, v string) {
+ w.writeName(name)
+ w.buf.WriteString(v)
+}
+
+// writeBoolField writes a bool-valued object field.
+func (w *jsonWriter) writeBoolField(name string, v bool) {
+ w.writeName(name)
+ if v {
+ w.buf.WriteString("true")
+ } else {
+ w.buf.WriteString("false")
+ }
+}
+
+// writeDurationField writes a millisecond duration-valued object field.
+func (w *jsonWriter) writeDurationField(name string, v time.Duration) {
+ w.writeName(name)
+ fmt.Fprintf(&w.buf, "%d.%06d", v.Milliseconds(), v%time.Millisecond)
+}
+
+// writeFloat64Field writes an float64-valued object field.
+func (w *jsonWriter) writeFloat64Field(name string, v float64) {
+ w.writeName(name)
+ w.buf.Write(strconv.AppendFloat(w.buf.AvailableBuffer(), v, 'f', -1, 64))
+}
+
+// writeInt64Field writes an int64-valued object field.
+func (w *jsonWriter) writeInt64Field(name string, v int64) {
+ w.writeName(name)
+ w.buf.Write(strconv.AppendInt(w.buf.AvailableBuffer(), v, 10))
+}
+
+// writeUint64Field writes a uint64-valued object field.
+func (w *jsonWriter) writeUint64Field(name string, v uint64) {
+ w.writeName(name)
+ w.buf.Write(strconv.AppendUint(w.buf.AvailableBuffer(), v, 10))
+}
+
+// writeStringField writes a string-valued object field.
+func (w *jsonWriter) writeStringField(name, v string) {
+ w.writeName(name)
+ w.writeString(v)
+}
+
+// writeTimeField writes a time-valued object field.
+func (w *jsonWriter) writeTimeField(name string, v time.Time) {
+ w.writeName(name)
+ fmt.Fprintf(&w.buf, "%d.%06d", v.UnixMilli(), v.Nanosecond()%int(time.Millisecond))
+}
+
+func jsonSafeSet(c byte) bool {
+ // mask is a 128-bit bitmap with 1s for allowed bytes,
+ // so that the byte c can be tested with a shift and an and.
+ // If c > 128, then 1<<c and 1<<(c-64) will both be zero,
+ // and this function will return false.
+ const mask = 0 |
+ (1<<(0x22-0x20)-1)<<0x20 |
+ (1<<(0x5c-0x23)-1)<<0x23 |
+ (1<<(0x7f-0x5d)-1)<<0x5d
+ return ((uint64(1)<<c)&(mask&(1<<64-1)) |
+ (uint64(1)<<(c-64))&(mask>>64)) != 0
+}
+
+func jsonNeedsEscape(s string) bool {
+ for i := range s {
+ if !jsonSafeSet(s[i]) {
+ return true
+ }
+ }
+ return false
+}
+
+// writeString writes an ASCII string.
+//
+// qlog fields should never contain anything that isn't ASCII,
+// so we do the bare minimum to avoid producing invalid output if we
+// do write something unexpected.
+func (w *jsonWriter) writeString(v string) {
+ w.buf.WriteByte('"')
+ if !jsonNeedsEscape(v) {
+ w.buf.WriteString(v)
+ } else {
+ for i := range v {
+ if jsonSafeSet(v[i]) {
+ w.buf.WriteByte(v[i])
+ } else {
+ fmt.Fprintf(&w.buf, `\u%04x`, v[i])
+ }
+ }
+ }
+ w.buf.WriteByte('"')
+}
diff --git a/internal/quic/qlog/json_writer_test.go b/internal/quic/qlog/json_writer_test.go
new file mode 100644
index 0000000..7ba5e17
--- /dev/null
+++ b/internal/quic/qlog/json_writer_test.go
@@ -0,0 +1,186 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build go1.21
+
+package qlog
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "log/slog"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+type testJSONOut struct {
+ bytes.Buffer
+}
+
+func (o *testJSONOut) Close() error { return nil }
+
+func newTestJSONWriter() *jsonWriter {
+ return &jsonWriter{w: &testJSONOut{}}
+}
+
+func wantJSONRecord(t *testing.T, w *jsonWriter, want string) {
+ t.Helper()
+ want = "\x1e" + want + "\n"
+ got := w.w.(*testJSONOut).String()
+ if got != want {
+ t.Errorf("jsonWriter contains unexpected output\ngot: %q\nwant: %q", got, want)
+ }
+}
+
+func TestJSONWriterWriteConcurrentRecords(t *testing.T) {
+ w := newTestJSONWriter()
+ var wg sync.WaitGroup
+ for i := 0; i < 3; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ w.writeRecordStart()
+ w.writeInt64Field("field", 0)
+ w.writeRecordEnd()
+ }()
+ }
+ wg.Wait()
+ wantJSONRecord(t, w, strings.Join([]string{
+ `{"field":0}`,
+ `{"field":0}`,
+ `{"field":0}`,
+ }, "\n\x1e"))
+}
+
+func TestJSONWriterAttrs(t *testing.T) {
+ w := newTestJSONWriter()
+ w.writeRecordStart()
+ w.writeAttrsField("field", []slog.Attr{
+ slog.Any("any", errors.New("value")),
+ slog.Bool("bool", true),
+ slog.Duration("duration", 1*time.Second),
+ slog.Float64("float64", 1),
+ slog.Int64("int64", 1),
+ slog.String("string", "value"),
+ slog.Time("time", time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)),
+ slog.Uint64("uint64", 1),
+ slog.Group("group", "a", 1),
+ })
+ w.writeRecordEnd()
+ wantJSONRecord(t, w,
+ `{"field":{`+
+ `"any":"value",`+
+ `"bool":true,`+
+ `"duration":1000.000000,`+
+ `"float64":1,`+
+ `"int64":1,`+
+ `"string":"value",`+
+ `"time":946684800000.000000,`+
+ `"uint64":1,`+
+ `"group":{"a":1}`+
+ `}}`)
+}
+
+func TestJSONWriterObjectEmpty(t *testing.T) {
+ w := newTestJSONWriter()
+ w.writeRecordStart()
+ w.writeObjectField("field", func() {})
+ w.writeRecordEnd()
+ wantJSONRecord(t, w, `{"field":{}}`)
+}
+
+func TestJSONWriterObjectFields(t *testing.T) {
+ w := newTestJSONWriter()
+ w.writeRecordStart()
+ w.writeObjectField("field", func() {
+ w.writeStringField("a", "value")
+ w.writeInt64Field("b", 10)
+ })
+ w.writeRecordEnd()
+ wantJSONRecord(t, w, `{"field":{"a":"value","b":10}}`)
+}
+
+func TestJSONWriterRawField(t *testing.T) {
+ w := newTestJSONWriter()
+ w.writeRecordStart()
+ w.writeRawField("field", `[1]`)
+ w.writeRecordEnd()
+ wantJSONRecord(t, w, `{"field":[1]}`)
+}
+
+func TestJSONWriterBoolField(t *testing.T) {
+ w := newTestJSONWriter()
+ w.writeRecordStart()
+ w.writeBoolField("true", true)
+ w.writeBoolField("false", false)
+ w.writeRecordEnd()
+ wantJSONRecord(t, w, `{"true":true,"false":false}`)
+}
+
+func TestJSONWriterDurationField(t *testing.T) {
+ w := newTestJSONWriter()
+ w.writeRecordStart()
+ w.writeDurationField("field", (10*time.Millisecond)+(2*time.Nanosecond))
+ w.writeRecordEnd()
+ wantJSONRecord(t, w, `{"field":10.000002}`)
+}
+
+func TestJSONWriterFloat64Field(t *testing.T) {
+ w := newTestJSONWriter()
+ w.writeRecordStart()
+ w.writeFloat64Field("field", 1.1)
+ w.writeRecordEnd()
+ wantJSONRecord(t, w, `{"field":1.1}`)
+}
+
+func TestJSONWriterInt64Field(t *testing.T) {
+ w := newTestJSONWriter()
+ w.writeRecordStart()
+ w.writeInt64Field("field", 1234)
+ w.writeRecordEnd()
+ wantJSONRecord(t, w, `{"field":1234}`)
+}
+
+func TestJSONWriterUint64Field(t *testing.T) {
+ w := newTestJSONWriter()
+ w.writeRecordStart()
+ w.writeUint64Field("field", 1234)
+ w.writeRecordEnd()
+ wantJSONRecord(t, w, `{"field":1234}`)
+}
+
+func TestJSONWriterStringField(t *testing.T) {
+ w := newTestJSONWriter()
+ w.writeRecordStart()
+ w.writeStringField("field", "value")
+ w.writeRecordEnd()
+ wantJSONRecord(t, w, `{"field":"value"}`)
+}
+
+func TestJSONWriterStringFieldEscaped(t *testing.T) {
+ w := newTestJSONWriter()
+ w.writeRecordStart()
+ w.writeStringField("field", "va\x00ue")
+ w.writeRecordEnd()
+ wantJSONRecord(t, w, `{"field":"va\u0000ue"}`)
+}
+
+func TestJSONWriterStringEscaping(t *testing.T) {
+ for c := 0; c <= 0xff; c++ {
+ w := newTestJSONWriter()
+ w.writeRecordStart()
+ w.writeStringField("field", string([]byte{byte(c)}))
+ w.writeRecordEnd()
+ var want string
+ if (c >= 0x20 && c <= 0x21) || (c >= 0x23 && c <= 0x5b) || (c >= 0x5d && c <= 0x7e) {
+ want = fmt.Sprintf(`%c`, c)
+ } else {
+ want = fmt.Sprintf(`\u%04x`, c)
+ }
+ wantJSONRecord(t, w, `{"field":"`+want+`"}`)
+ }
+}
diff --git a/internal/quic/qlog/qlog.go b/internal/quic/qlog/qlog.go
new file mode 100644
index 0000000..0e71d71
--- /dev/null
+++ b/internal/quic/qlog/qlog.go
@@ -0,0 +1,267 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build go1.21
+
+// Package qlog serializes qlog events.
+package qlog
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+)
+
+// Vantage is the vantage point of a trace.
+type Vantage string
+
+const (
+ // VantageEndpoint traces contain events not specific to a single connection.
+ VantageEndpoint = Vantage("endpoint")
+
+ // VantageClient traces follow a connection from the client's perspective.
+ VantageClient = Vantage("client")
+
+ // VantageClient traces follow a connection from the server's perspective.
+ VantageServer = Vantage("server")
+)
+
+// TraceInfo contains information about a trace.
+type TraceInfo struct {
+ // Vantage is the vantage point of the trace.
+ Vantage Vantage
+
+ // GroupID identifies the logical group the trace belongs to.
+ // For a connection trace, the group will be the same for
+ // both the client and server vantage points.
+ GroupID string
+}
+
+// HandlerOptions are options for a JSONHandler.
+type HandlerOptions struct {
+ // Level reports the minimum record level that will be logged.
+ // If Level is nil, the handler assumes QLogLevelEndpoint.
+ Level slog.Leveler
+
+ // Dir is the directory in which to create trace files.
+ // The handler will create one file per connection.
+ // If NewTrace is non-nil or Dir is "", the handler will not create files.
+ Dir string
+
+ // NewTrace is called to create a new trace.
+ // If NewTrace is nil and Dir is set,
+ // the handler will create a new file in Dir for each trace.
+ NewTrace func(TraceInfo) (io.WriteCloser, error)
+}
+
+type endpointHandler struct {
+ opts HandlerOptions
+
+ traceOnce sync.Once
+ trace *jsonTraceHandler
+}
+
+// NewJSONHandler returns a handler which serializes qlog events to JSON.
+//
+// The handler will write an endpoint-wide trace,
+// and a separate trace for each connection.
+// The HandlerOptions control the location traces are written.
+//
+// It uses the streamable JSON Text Sequences mapping (JSON-SEQ)
+// defined in draft-ietf-quic-qlog-main-schema-04, Section 6.2.
+//
+// A JSONHandler may be used as the handler for a quic.Config.QLogLogger.
+// It is not a general-purpose slog handler,
+// and may not properly handle events from other sources.
+func NewJSONHandler(opts HandlerOptions) slog.Handler {
+ if opts.Dir == "" && opts.NewTrace == nil {
+ return slogDiscard{}
+ }
+ return &endpointHandler{
+ opts: opts,
+ }
+}
+
+func (h *endpointHandler) Enabled(ctx context.Context, level slog.Level) bool {
+ return enabled(h.opts.Level, level)
+}
+
+func (h *endpointHandler) Handle(ctx context.Context, r slog.Record) error {
+ h.traceOnce.Do(func() {
+ h.trace, _ = newJSONTraceHandler(h.opts, nil)
+ })
+ if h.trace != nil {
+ h.trace.Handle(ctx, r)
+ }
+ return nil
+}
+
+func (h *endpointHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
+ // Create a new trace output file for each top-level WithAttrs.
+ tr, err := newJSONTraceHandler(h.opts, attrs)
+ if err != nil {
+ return withAttrs(h, attrs)
+ }
+ return tr
+}
+
+func (h *endpointHandler) WithGroup(name string) slog.Handler {
+ return withGroup(h, name)
+}
+
+type jsonTraceHandler struct {
+ level slog.Leveler
+ w jsonWriter
+ start time.Time
+ buf bytes.Buffer
+}
+
+func newJSONTraceHandler(opts HandlerOptions, attrs []slog.Attr) (*jsonTraceHandler, error) {
+ w, err := newTraceWriter(opts, traceInfoFromAttrs(attrs))
+ if err != nil {
+ return nil, err
+ }
+
+ // For testing, it might be nice to set the start time used for relative timestamps
+ // to the time of the first event.
+ //
+ // At the expense of some additional complexity here, we could defer writing
+ // the reference_time header field until the first event is processed.
+ //
+ // Just use the current time for now.
+ start := time.Now()
+
+ h := &jsonTraceHandler{
+ w: jsonWriter{w: w},
+ level: opts.Level,
+ start: start,
+ }
+ h.writeHeader(attrs)
+ return h, nil
+}
+
+func traceInfoFromAttrs(attrs []slog.Attr) TraceInfo {
+ info := TraceInfo{
+ Vantage: VantageEndpoint, // default if not specified
+ }
+ for _, a := range attrs {
+ if a.Key == "group_id" && a.Value.Kind() == slog.KindString {
+ info.GroupID = a.Value.String()
+ }
+ if a.Key == "vantage_point" && a.Value.Kind() == slog.KindGroup {
+ for _, aa := range a.Value.Group() {
+ if aa.Key == "type" && aa.Value.Kind() == slog.KindString {
+ info.Vantage = Vantage(aa.Value.String())
+ }
+ }
+ }
+ }
+ return info
+}
+
+func newTraceWriter(opts HandlerOptions, info TraceInfo) (io.WriteCloser, error) {
+ var w io.WriteCloser
+ var err error
+ if opts.NewTrace != nil {
+ w, err = opts.NewTrace(info)
+ } else if opts.Dir != "" {
+ var filename string
+ if info.GroupID != "" {
+ filename = info.GroupID + "_"
+ }
+ filename += string(info.Vantage) + ".sqlog"
+ if !filepath.IsLocal(filename) {
+ return nil, errors.New("invalid trace filename")
+ }
+ w, err = os.Create(filepath.Join(opts.Dir, filename))
+ } else {
+ err = errors.New("no log destination")
+ }
+ return w, err
+}
+
+func (h *jsonTraceHandler) writeHeader(attrs []slog.Attr) {
+ h.w.writeRecordStart()
+ defer h.w.writeRecordEnd()
+
+ // At the time of writing this comment the most recent version is 0.4,
+ // but qvis only supports up to 0.3.
+ h.w.writeStringField("qlog_version", "0.3")
+ h.w.writeStringField("qlog_format", "JSON-SEQ")
+
+ // The attrs flatten both common trace event fields and Trace fields.
+ // This identifies the fields that belong to the Trace.
+ isTraceSeqField := func(s string) bool {
+ switch s {
+ case "title", "description", "configuration", "vantage_point":
+ return true
+ }
+ return false
+ }
+
+ h.w.writeObjectField("trace", func() {
+ h.w.writeObjectField("common_fields", func() {
+ h.w.writeRawField("protocol_type", `["QUIC"]`)
+ h.w.writeStringField("time_format", "relative")
+ h.w.writeTimeField("reference_time", h.start)
+ for _, a := range attrs {
+ if !isTraceSeqField(a.Key) {
+ h.w.writeAttr(a)
+ }
+ }
+ })
+ for _, a := range attrs {
+ if isTraceSeqField(a.Key) {
+ h.w.writeAttr(a)
+ }
+ }
+ })
+}
+
+func (h *jsonTraceHandler) Enabled(ctx context.Context, level slog.Level) bool {
+ return enabled(h.level, level)
+}
+
+func (h *jsonTraceHandler) Handle(ctx context.Context, r slog.Record) error {
+ h.w.writeRecordStart()
+ defer h.w.writeRecordEnd()
+ h.w.writeDurationField("time", r.Time.Sub(h.start))
+ h.w.writeStringField("name", r.Message)
+ h.w.writeObjectField("data", func() {
+ r.Attrs(func(a slog.Attr) bool {
+ h.w.writeAttr(a)
+ return true
+ })
+ })
+ return nil
+}
+
+func (h *jsonTraceHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
+ return withAttrs(h, attrs)
+}
+
+func (h *jsonTraceHandler) WithGroup(name string) slog.Handler {
+ return withGroup(h, name)
+}
+
+func enabled(leveler slog.Leveler, level slog.Level) bool {
+ var minLevel slog.Level
+ if leveler != nil {
+ minLevel = leveler.Level()
+ }
+ return level >= minLevel
+}
+
+type slogDiscard struct{}
+
+func (slogDiscard) Enabled(context.Context, slog.Level) bool { return false }
+func (slogDiscard) Handle(ctx context.Context, r slog.Record) error { return nil }
+func (slogDiscard) WithAttrs(attrs []slog.Attr) slog.Handler { return slogDiscard{} }
+func (slogDiscard) WithGroup(name string) slog.Handler { return slogDiscard{} }
diff --git a/internal/quic/qlog/qlog_test.go b/internal/quic/qlog/qlog_test.go
new file mode 100644
index 0000000..7575cd8
--- /dev/null
+++ b/internal/quic/qlog/qlog_test.go
@@ -0,0 +1,151 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build go1.21
+
+package qlog
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "reflect"
+ "testing"
+ "time"
+)
+
+// QLog tests are mostly in the quic package, where we can test event generation
+// and serialization together.
+
+func TestQLogHandlerEvents(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ f func(*slog.Logger)
+ want []map[string]any // events, not counting the trace header
+ }{{
+ name: "various types",
+ f: func(log *slog.Logger) {
+ log.Info("message",
+ "bool", true,
+ "duration", time.Duration(1*time.Second),
+ "float", 0.0,
+ "int", 0,
+ "string", "value",
+ "uint", uint64(0),
+ slog.Group("group",
+ "a", 0,
+ ),
+ )
+ },
+ want: []map[string]any{{
+ "name": "message",
+ "data": map[string]any{
+ "bool": true,
+ "duration": float64(1000),
+ "float": float64(0.0),
+ "int": float64(0),
+ "string": "value",
+ "uint": float64(0),
+ "group": map[string]any{
+ "a": float64(0),
+ },
+ },
+ }},
+ }, {
+ name: "WithAttrs",
+ f: func(log *slog.Logger) {
+ log = log.With(
+ "with_a", "a",
+ "with_b", "b",
+ )
+ log.Info("m1", "field", "1")
+ log.Info("m2", "field", "2")
+ },
+ want: []map[string]any{{
+ "name": "m1",
+ "data": map[string]any{
+ "with_a": "a",
+ "with_b": "b",
+ "field": "1",
+ },
+ }, {
+ "name": "m2",
+ "data": map[string]any{
+ "with_a": "a",
+ "with_b": "b",
+ "field": "2",
+ },
+ }},
+ }, {
+ name: "WithGroup",
+ f: func(log *slog.Logger) {
+ log = log.With(
+ "with_a", "a",
+ "with_b", "b",
+ )
+ log.Info("m1", "field", "1")
+ log.Info("m2", "field", "2")
+ },
+ want: []map[string]any{{
+ "name": "m1",
+ "data": map[string]any{
+ "with_a": "a",
+ "with_b": "b",
+ "field": "1",
+ },
+ }, {
+ "name": "m2",
+ "data": map[string]any{
+ "with_a": "a",
+ "with_b": "b",
+ "field": "2",
+ },
+ }},
+ }} {
+ var out bytes.Buffer
+ opts := HandlerOptions{
+ Level: slog.LevelDebug,
+ NewTrace: func(TraceInfo) (io.WriteCloser, error) {
+ return nopCloseWriter{&out}, nil
+ },
+ }
+ h, err := newJSONTraceHandler(opts, []slog.Attr{
+ slog.String("group_id", "group"),
+ slog.Group("vantage_point",
+ slog.String("type", "client"),
+ ),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ log := slog.New(h)
+ test.f(log)
+ got := []map[string]any{}
+ for i, e := range bytes.Split(out.Bytes(), []byte{0x1e}) {
+ // i==0: empty string before the initial record separator
+ // i==1: trace header; not part of this test
+ if i < 2 {
+ continue
+ }
+ var val map[string]any
+ if err := json.Unmarshal(e, &val); err != nil {
+ panic(fmt.Errorf("log unmarshal failure: %v\n%q", err, string(e)))
+ }
+ delete(val, "time")
+ got = append(got, val)
+ }
+ if !reflect.DeepEqual(got, test.want) {
+ t.Errorf("event mismatch\ngot: %v\nwant: %v", got, test.want)
+ }
+ }
+
+}
+
+type nopCloseWriter struct {
+ io.Writer
+}
+
+func (nopCloseWriter) Close() error { return nil }
diff --git a/internal/quic/qlog_test.go b/internal/quic/qlog_test.go
new file mode 100644
index 0000000..5a2858b
--- /dev/null
+++ b/internal/quic/qlog_test.go
@@ -0,0 +1,132 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build go1.21
+
+package quic
+
+import (
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "reflect"
+ "testing"
+
+ "golang.org/x/net/internal/quic/qlog"
+)
+
+func TestQLogHandshake(t *testing.T) {
+ testSides(t, "", func(t *testing.T, side connSide) {
+ qr := &qlogRecord{}
+ tc := newTestConn(t, side, qr.config)
+ tc.handshake()
+ tc.conn.Abort(nil)
+ tc.wantFrame("aborting connection generates CONN_CLOSE",
+ packetType1RTT, debugFrameConnectionCloseTransport{
+ code: errNo,
+ })
+ tc.writeFrames(packetType1RTT, debugFrameConnectionCloseTransport{})
+ tc.advanceToTimer() // let the conn finish draining
+
+ var src, dst []byte
+ if side == clientSide {
+ src = testLocalConnID(0)
+ dst = testLocalConnID(-1)
+ } else {
+ src = testPeerConnID(-1)
+ dst = testPeerConnID(0)
+ }
+ qr.wantEvents(t, jsonEvent{
+ "name": "connectivity:connection_started",
+ "data": map[string]any{
+ "src_cid": hex.EncodeToString(src),
+ "dst_cid": hex.EncodeToString(dst),
+ },
+ }, jsonEvent{
+ "name": "connectivity:connection_closed",
+ "data": map[string]any{
+ "trigger": "clean",
+ },
+ })
+ })
+}
+
+type nopCloseWriter struct {
+ io.Writer
+}
+
+func (nopCloseWriter) Close() error { return nil }
+
+type jsonEvent map[string]any
+
+func (j jsonEvent) String() string {
+ b, _ := json.MarshalIndent(j, "", " ")
+ return string(b)
+}
+
+// eventPartialEqual verifies that every field set in want matches the corresponding field in got.
+// It ignores additional fields in got.
+func eventPartialEqual(got, want jsonEvent) bool {
+ for k := range want {
+ ge, gok := got[k].(map[string]any)
+ we, wok := want[k].(map[string]any)
+ if gok && wok {
+ if !eventPartialEqual(ge, we) {
+ return false
+ }
+ } else {
+ if !reflect.DeepEqual(got[k], want[k]) {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+// A qlogRecord records events.
+type qlogRecord struct {
+ ev []jsonEvent
+}
+
+func (q *qlogRecord) Write(b []byte) (int, error) {
+ // This relies on the property that the Handler always makes one Write call per event.
+ if len(b) < 1 || b[0] != 0x1e {
+ panic(fmt.Errorf("trace Write should start with record separator, got %q", string(b)))
+ }
+ var val map[string]any
+ if err := json.Unmarshal(b[1:], &val); err != nil {
+ panic(fmt.Errorf("log unmarshal failure: %v\n%v", err, string(b)))
+ }
+ q.ev = append(q.ev, val)
+ return len(b), nil
+}
+
+func (q *qlogRecord) Close() error { return nil }
+
+// config may be passed to newTestConn to configure the conn to use this logger.
+func (q *qlogRecord) config(c *Config) {
+ c.QLogLogger = slog.New(qlog.NewJSONHandler(qlog.HandlerOptions{
+ NewTrace: func(info qlog.TraceInfo) (io.WriteCloser, error) {
+ return q, nil
+ },
+ }))
+}
+
+// wantEvents checks that every event in want occurs in the order specified.
+func (q *qlogRecord) wantEvents(t *testing.T, want ...jsonEvent) {
+ t.Helper()
+ got := q.ev
+ unseen := want
+ for _, g := range got {
+ if eventPartialEqual(g, unseen[0]) {
+ unseen = unseen[1:]
+ if len(unseen) == 0 {
+ return
+ }
+ }
+ }
+ t.Fatalf("got events:\n%v\n\nwant events:\n%v", got, want)
+}
diff --git a/internal/quic/stateless_reset_test.go b/internal/quic/stateless_reset_test.go
index 8a16597..c01375f 100644
--- a/internal/quic/stateless_reset_test.go
+++ b/internal/quic/stateless_reset_test.go
@@ -130,7 +130,8 @@
func TestStatelessResetSuccessfulNewConnectionID(t *testing.T) {
// "[...] Stateless Reset Token field values from [...] NEW_CONNECTION_ID frames [...]"
// https://www.rfc-editor.org/rfc/rfc9000#section-10.3.1-1
- tc := newTestConn(t, clientSide)
+ qr := &qlogRecord{}
+ tc := newTestConn(t, clientSide, qr.config)
tc.handshake()
tc.ignoreFrame(frameTypeAck)
@@ -158,6 +159,13 @@
tc.wantIdle("closed connection is idle in draining")
tc.advance(1 * time.Second) // long enough to exit the draining state
tc.wantIdle("closed connection is idle after draining")
+
+ qr.wantEvents(t, jsonEvent{
+ "name": "connectivity:connection_closed",
+ "data": map[string]any{
+ "trigger": "stateless_reset",
+ },
+ })
}
func TestStatelessResetSuccessfulTransportParameter(t *testing.T) {