Skip to content
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)
}
})
}
}
108 changes: 108 additions & 0 deletions otel/log.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading