Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
18 changes: 15 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down Expand Up @@ -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
14 changes: 14 additions & 0 deletions otel/go.mod
Original file line number Diff line number Diff line change
@@ -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
)
16 changes: 16 additions & 0 deletions otel/go.sum
Original file line number Diff line number Diff line change
@@ -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=
87 changes: 87 additions & 0 deletions otel/helpers.go
Original file line number Diff line number Diff line change
@@ -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, "<nil>")
}

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))
}
88 changes: 88 additions & 0 deletions otel/helpers_test.go
Original file line number Diff line number Diff line change
@@ -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", "<nil>"),
},
{
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", "<nil>"),
},
{
name: "stringer",
in: stringer("string value"),
want: attribute.String("key", "string value"),
},
{
name: "typed nil stringer",
in: (*nilStringer)(nil),
want: attribute.String("key", "<nil>"),
},
{
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)
}
})
}
}
142 changes: 142 additions & 0 deletions otel/log.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
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/codes"
"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
}
}

// WithLevel configures the minimum log level handled by the hook.
// Entries below this level are ignored.
func WithLevel(level log.Level) HookOpt {
return func(h *LogrusHook) {
for i, l := range allLevels {
if l == level {
h.levels = allLevels[:i+1]
return
}
}
}
}

// WithErrorStatusLevel configures the minimum log level that marks the
// active span with an error status.
func WithErrorStatusLevel(level log.Level) HookOpt {
return func(h *LogrusHook) {
h.errorStatusLevel = &level
}
}

// 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
errorStatusLevel *log.Level
levels []log.Level
}

// Levels returns the logrus levels that this hook is interested in.
func (h *LogrusHook) Levels() []log.Level {
if h.levels == nil {
return allLevels
}
return h.levels
}

// 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),
)

// Set the span status based on the log level, rather than the presence of
// an error field. Error values may be attached to lower-severity log entries
// without indicating that the operation represented by the span failed.
if h.errorStatusLevel != nil && entry.Level <= *h.errorStatusLevel {
span.SetStatus(codes.Error, entry.Message)
}

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
}
Loading
Loading