Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions etcdctl/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,10 @@ go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJyS
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
Expand Down
8 changes: 7 additions & 1 deletion pkg/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ require (
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
go.etcd.io/etcd/client/pkg/v3 v3.6.0-alpha.0
go.opentelemetry.io/otel v1.38.0
go.opentelemetry.io/otel/sdk v1.38.0
go.opentelemetry.io/otel/trace v1.38.0
go.uber.org/zap v1.27.0
golang.org/x/sys v0.36.0
Expand All @@ -20,9 +22,13 @@ require (
require (
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/text v0.29.0 // indirect
Expand Down
9 changes: 5 additions & 4 deletions pkg/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
Expand Down Expand Up @@ -41,10 +42,10 @@ go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
Expand Down
96 changes: 96 additions & 0 deletions pkg/traceutil/exporter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Copyright 2025 The etcd Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package traceutil

import (
"context"
"fmt"
"slices"
"strings"

"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/sdk/trace"
"go.uber.org/zap"
)

// LogExporter writes Span to specified Logger.
type LogExporter struct {
// Log is usually zap.Logger.Info.
Log func(msg string, fields ...zap.Field)
}

var _ trace.SpanExporter = (*LogExporter)(nil)

// NewLogExporter creates a new LogExporter which will write Spans as Log messages.
func NewLogExporter(logger *zap.Logger) *LogExporter {
if logger == nil {
logger = zap.NewNop()
}
return &LogExporter{Log: logger.Info}
}

func (e *LogExporter) ExportSpans(ctx context.Context, spans []trace.ReadOnlySpan) error {
for _, span := range spans {
msg, fields := logSpan(span)
e.Log(msg, fields...)
}
return nil
}

func (e *LogExporter) Shutdown(ctx context.Context) error {
return nil
}

func logSpan(s trace.ReadOnlySpan) (string, []zap.Field) {
start := s.StartTime()
end := s.EndTime()
duration := end.Sub(start)
events := s.Events()
steps := make([]string, 0, len(events))
slices.SortFunc(events, func(a, b trace.Event) int {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does it work with nesting?

Copy link
Contributor Author

@AwesomePatrol AwesomePatrol Sep 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From PR description:

Currently, the only difference are "child traces" which are not yet instrumented in Etcd's OpenTelemetry tracing. This can be later mitigated by adding "links" or copying events between spans. I also have other ideas...

so either printing spanID so it will be easy to search in logs/Otel collector or returning the span to use for copying like in #20307

Please note that "applier span" is the only case when one trace is part of another

return a.Time.Compare(b.Time)
})
for _, event := range events {
step := fmt.Sprintf("%s %s [+%dms]",
event.Name, writeAttrs(event.Attributes), event.Time.Sub(start).Milliseconds())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we match the resolution?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to %.3fms

steps = append(steps, step)
}
msg := fmt.Sprintf("trace[%s] %s", s.SpanContext().SpanID().String(), s.Name())

return msg, []zap.Field{
zap.String("detail", writeAttrs(s.Attributes())),
zap.Duration("duration", duration),
zap.Time("start", s.StartTime()),
zap.Time("end", s.EndTime()),
zap.Strings("steps", steps),
zap.Int("step_count", len(steps)),
}
}

func writeAttrs(attrs []attribute.KeyValue) string {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why implement own json serializer?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to keep it as close to the previous implementation as possible: https://github.com/etcd-io/etcd/blob/v3.6.5/pkg/traceutil/trace.go#L44-L55

if len(attrs) == 0 {
return ""
}
Comment on lines +83 to +85
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if len(attrs) == 0 {
return ""
}
if len(attrs) == 0 {
return "{}"
}

var buf strings.Builder
buf.WriteString("{")
for _, attr := range attrs {
buf.WriteString(string(attr.Key))
buf.WriteString(":")
buf.WriteString(attr.Value.Emit())
buf.WriteString("; ")
}
buf.WriteString("}")
return buf.String()
}
86 changes: 86 additions & 0 deletions pkg/traceutil/exporter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright 2025 The etcd Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package traceutil_test

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"go.uber.org/zap"

"go.etcd.io/etcd/pkg/v3/traceutil"
)

func TestLogSpan(t *testing.T) {
duration := 123 * time.Second

startTime, _ := time.Parse("2006-01-02 15:04:05", "2025-01-01 00:00:00")
endTime := startTime.Add(duration)

tests := []struct {
span trace.ReadOnlySpan
wantMsg string
wantFields []zap.Field
}{
{
span: tracetest.SpanStub{
Name: "span_with_two_events",
StartTime: startTime,
EndTime: endTime,
Attributes: []attribute.KeyValue{
attribute.String("key1", "value1"),
attribute.String("key2", "value2"),
},
Events: []trace.Event{
{
Time: startTime.Add(1 * time.Second),
Name: "event1",
Attributes: []attribute.KeyValue{attribute.String("key3", "value3")},
},
{
Time: startTime.Add(2 * time.Second),
Name: "event2",
Attributes: []attribute.KeyValue{attribute.String("key4", "value4")},
},
},
}.Snapshot(),
wantMsg: "trace[0000000000000000] span_with_two_events",
wantFields: []zap.Field{
zap.String("detail", "{key1:value1; key2:value2; }"),
zap.Duration("duration", duration),
zap.Time("start", startTime),
zap.Time("end", endTime),
zap.Strings("steps", []string{"event1 {key3:value3; } [+1000ms]", "event2 {key4:value4; } [+2000ms]"}),
zap.Int("step_count", 2),
},
},
}

for _, tt := range tests {
t.Run(tt.span.Name(), func(t *testing.T) {
exporter := traceutil.LogExporter{
Log: func(msg string, fields ...zap.Field) {
assert.Equal(t, tt.wantMsg, msg)
assert.Equal(t, tt.wantFields, fields)
},
}
exporter.ExportSpans(t.Context(), []trace.ReadOnlySpan{tt.span})
})
}
}
65 changes: 65 additions & 0 deletions pkg/traceutil/processor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright 2025 The etcd Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package traceutil

import (
"context"
"time"

"go.opentelemetry.io/otel/sdk/trace"
)

// LongSpanProcessor is a SpanProcessor that passes to SpanExporter only Spans
// with a duration longer than Threshold and operation in Allowlist.
type LongSpanProcessor struct {
trace.SpanExporter
// Threshold is the duration under which spans are not logged.
Threshold time.Duration
// Allowlist specifies operations for which a log may be emitted.
Allowlist map[string]bool
}

var _ trace.SpanProcessor = (*LongSpanProcessor)(nil)

// NewLongSpanProcessor creates a new LongSpanProcessor which will pass to
// SpanExporter all Spans with duration longer than Threshold and operation in
// Allowlist.
func NewLongSpanProcessor(exporter trace.SpanExporter, threshold time.Duration) *LongSpanProcessor {
return &LongSpanProcessor{
SpanExporter: exporter,
Threshold: threshold,
Allowlist: map[string]bool{
"txn": true,
"range": true,
"put": true,
"delete_range": true,
"compact": true,
"lease_grant": true,
"lease_revoke": true,
},
}
}

func (f LongSpanProcessor) OnStart(parent context.Context, s trace.ReadWriteSpan) {}
func (f LongSpanProcessor) ForceFlush(ctx context.Context) error { return nil }
func (f LongSpanProcessor) OnEnd(s trace.ReadOnlySpan) {
if f.Threshold > 0 && s.EndTime().Sub(s.StartTime()) < f.Threshold {
return
}
if f.Allowlist != nil && !f.Allowlist[s.Name()] {
return
}
f.ExportSpans(context.Background(), []trace.ReadOnlySpan{s})
}
Loading