diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5191c19..8c5056e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,11 +36,19 @@ jobs: cache: false # see actions/setup-go#368 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + - name: Lint + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: version: v2.13 skip-cache: true + - name: Lint otel + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + version: v2.13 + skip-cache: true + working-directory: otel + # # Project checks # @@ -95,6 +103,10 @@ jobs: echo "GOPATH=${{ github.workspace }}" >> $GITHUB_ENV echo "${{ github.workspace }}/bin" >> $GITHUB_PATH - - run: | - go test -v -race + - name: Test + run: go test -v -race working-directory: src/github.com/containerd/log + + - name: Test otel + run: go test -v -race + working-directory: src/github.com/containerd/log/otel diff --git a/otel/go.mod b/otel/go.mod new file mode 100644 index 0000000..c198532 --- /dev/null +++ b/otel/go.mod @@ -0,0 +1,14 @@ +module github.com/containerd/log/otel + +go 1.23 + +require ( + github.com/containerd/log v0.1.0 + go.opentelemetry.io/otel v1.35.0 + go.opentelemetry.io/otel/trace v1.35.0 +) + +require ( + github.com/sirupsen/logrus v1.10.1 // indirect + golang.org/x/sys v0.13.0 // indirect +) diff --git a/otel/go.sum b/otel/go.sum new file mode 100644 index 0000000..a2c71db --- /dev/null +++ b/otel/go.sum @@ -0,0 +1,16 @@ +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q= +github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk= +github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= +github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/otel/helpers.go b/otel/helpers.go new file mode 100644 index 0000000..4bf73c8 --- /dev/null +++ b/otel/helpers.go @@ -0,0 +1,87 @@ +/* + Copyright The containerd 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 otel + +import ( + "encoding/json" + "fmt" + + "go.opentelemetry.io/otel/attribute" +) + +func keyValue(k string, v any) attribute.KeyValue { + if v == nil { + return attribute.String(k, "") + } + + switch typed := v.(type) { + case bool: + return attribute.Bool(k, typed) + case []bool: + return attribute.BoolSlice(k, typed) + case int: + return attribute.Int(k, typed) + case []int: + return attribute.IntSlice(k, typed) + case int8: + return attribute.Int(k, int(typed)) + case []int8: + ls := make([]int, 0, len(typed)) + for _, i := range typed { + ls = append(ls, int(i)) + } + return attribute.IntSlice(k, ls) + case int16: + return attribute.Int(k, int(typed)) + case []int16: + ls := make([]int, 0, len(typed)) + for _, i := range typed { + ls = append(ls, int(i)) + } + return attribute.IntSlice(k, ls) + case int32: + return attribute.Int64(k, int64(typed)) + case []int32: + ls := make([]int64, 0, len(typed)) + for _, i := range typed { + ls = append(ls, int64(i)) + } + return attribute.Int64Slice(k, ls) + case int64: + return attribute.Int64(k, typed) + case []int64: + return attribute.Int64Slice(k, typed) + case float64: + return attribute.Float64(k, typed) + case []float64: + return attribute.Float64Slice(k, typed) + case string: + return attribute.String(k, typed) + case []string: + return attribute.StringSlice(k, typed) + case error: + return attribute.String(k, fmt.Sprint(typed)) + } + + if stringer, ok := v.(fmt.Stringer); ok { + return attribute.String(k, fmt.Sprint(stringer)) + } + if b, err := json.Marshal(v); b != nil && err == nil { + return attribute.String(k, string(b)) + } + return attribute.String(k, fmt.Sprint(v)) +} diff --git a/otel/helpers_test.go b/otel/helpers_test.go new file mode 100644 index 0000000..08265ae --- /dev/null +++ b/otel/helpers_test.go @@ -0,0 +1,88 @@ +/* + Copyright The containerd 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 otel + +import ( + "errors" + "testing" + + "go.opentelemetry.io/otel/attribute" +) + +type stringer string + +func (s stringer) String() string { return string(s) } + +type nilStringer struct{} + +func (*nilStringer) String() string { panic("should not panic") } + +type nilError struct{} + +func (*nilError) Error() string { panic("should not panic") } + +func TestKeyValue(t *testing.T) { + tests := []struct { + name string + in any + want attribute.KeyValue + }{ + { + name: "nil", + want: attribute.String("key", ""), + }, + { + name: "string", + in: "value", + want: attribute.String("key", "value"), + }, + { + name: "error", + in: errors.New("error message"), + want: attribute.String("key", "error message"), + }, + { + name: "typed nil error", + in: (*nilError)(nil), + want: attribute.String("key", ""), + }, + { + name: "stringer", + in: stringer("string value"), + want: attribute.String("key", "string value"), + }, + { + name: "typed nil stringer", + in: (*nilStringer)(nil), + want: attribute.String("key", ""), + }, + { + name: "JSON", + in: struct{ Value string }{"foo"}, + want: attribute.String("key", `{"Value":"foo"}`), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := keyValue("key", tc.in) + if got != tc.want { + t.Errorf("keyValue() = %v; want %v", got, tc.want) + } + }) + } +} diff --git a/otel/log.go b/otel/log.go new file mode 100644 index 0000000..a7fc5bd --- /dev/null +++ b/otel/log.go @@ -0,0 +1,108 @@ +/* + Copyright The containerd 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 otel provides integration between containerd/log and OpenTelemetry. +// +// In particular, it provides a hook that records log entries as events on +// active OpenTelemetry spans. +package otel + +import ( + "github.com/containerd/log" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +// allLevels is the equivalent to [logrus.AllLevels]. +// +// [logrus.AllLevels]: https://github.com/sirupsen/logrus/blob/v1.9.3/logrus.go#L80-L89 +var allLevels = []log.Level{ + log.PanicLevel, + log.FatalLevel, + log.ErrorLevel, + log.WarnLevel, + log.InfoLevel, + log.DebugLevel, + log.TraceLevel, +} + +type HookOpt func(*LogrusHook) + +// NewLogrusHook creates a new logrus hook +func NewLogrusHook(opts ...HookOpt) *LogrusHook { + hook := &LogrusHook{} + for _, opt := range opts { + opt(hook) + } + return hook +} + +func WithTraceIDField(enabled bool) HookOpt { + return func(h *LogrusHook) { + h.enableTraceIDField = enabled + } +} + +// LogrusHook is a [logrus.Hook] which adds logrus events to active spans. +// If the span is not recording or the span context is invalid, the hook +// is a no-op. +// +// [logrus.Hook]: https://github.com/sirupsen/logrus/blob/v1.9.3/hooks.go#L3-L11 +type LogrusHook struct { + enableTraceIDField bool +} + +// Levels returns the logrus levels that this hook is interested in. +func (h *LogrusHook) Levels() []log.Level { + return allLevels +} + +// Fire is called when a log event occurs. +func (h *LogrusHook) Fire(entry *log.Entry) error { + span := trace.SpanFromContext(entry.Context) + if span == nil { + return nil + } + + if !span.SpanContext().IsValid() { + return nil + } + + if h.enableTraceIDField { + entry.Data["trace_id"] = span.SpanContext().TraceID().String() + } + + if !span.IsRecording() { + return nil + } + + span.AddEvent( + entry.Message, + trace.WithAttributes(logrusDataToAttrs(entry.Data)...), + trace.WithAttributes(attribute.String("level", entry.Level.String())), + trace.WithTimestamp(entry.Time), + ) + + return nil +} + +func logrusDataToAttrs(data map[string]any) []attribute.KeyValue { + attrs := make([]attribute.KeyValue, 0, len(data)) + for k, v := range data { + attrs = append(attrs, keyValue(k, v)) + } + return attrs +} diff --git a/otel/log_test.go b/otel/log_test.go new file mode 100644 index 0000000..b5b2fd8 --- /dev/null +++ b/otel/log_test.go @@ -0,0 +1,97 @@ +/* + Copyright The containerd 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 otel_test + +import ( + "context" + "testing" + + "github.com/containerd/log" + "github.com/containerd/log/otel" + "go.opentelemetry.io/otel/trace" +) + +const expectedTraceIDStr = "0102030405060708090a0b0c0d0e0f10" + +var ( + testTraceID = trace.TraceID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + testSpanID = trace.SpanID{1, 2, 3, 4, 5, 6, 7, 8} +) + +func TestLogrusHookTraceID(t *testing.T) { + tests := []struct { + name string + enableOpt bool + withSpan bool + expectedTID string + }{ + { + name: "TraceIDInjected", + enableOpt: true, + withSpan: true, + expectedTID: expectedTraceIDStr, + }, + { + name: "TraceIDNotInjected_OptionDisabled", + enableOpt: false, + withSpan: true, + }, + { + name: "TraceIDNotInjected_NoSpan", + enableOpt: true, + withSpan: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + if tc.withSpan { + ctx = trace.ContextWithSpanContext( + ctx, + trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: testTraceID, + SpanID: testSpanID, + }), + ) + } + + hook := otel.NewLogrusHook(otel.WithTraceIDField(tc.enableOpt)) + entry := &log.Entry{ + Context: ctx, + Data: make(log.Fields), + } + + err := hook.Fire(entry) + if err != nil { + t.Fatal(err) + } + + traceID, ok := entry.Data["trace_id"] + if tc.expectedTID != "" { + if !ok { + t.Fatal(`expected "trace_id" field`) + } + if traceID != tc.expectedTID { + t.Errorf(`"trace_id" = %v; want %q`, traceID, tc.expectedTID) + } + } else if ok { + t.Errorf(`unexpected "trace_id" field: %v`, traceID) + } + }) + } +}