Skip to content

Commit

Permalink
trace: add OTEL initialization
Browse files Browse the repository at this point in the history
This is a bunch of OTEL initialization code. It's all in
`internal/` because there are re-usable parts here, but Compose
isn't the right spot. Once we've stabilized the interfaces a bit
and the need arises, we can move it to a separate module.

Currently, a single span is produced to wrap the root Compose
command.

Compose will respect the standard OTEL environment variables
as well as OTEL metadata from the Docker context. Both can be
used simultaneously. The latter is intended for local system
integration and is restricted to Unix sockets / named pipes.

None of this is enabled by default. It requires setting the
`COMPOSE_EXPERIMENTAL_OTEL=1` environment variable to gate it
during development.

Signed-off-by: Milas Bowman <milas.bowman@docker.com>
  • Loading branch information
milas committed Jun 1, 2023
1 parent d2aa15c commit 368e242
Show file tree
Hide file tree
Showing 10 changed files with 530 additions and 20 deletions.
51 changes: 51 additions & 0 deletions cmd/main.go
Expand Up @@ -17,35 +17,86 @@
package main

import (
"context"
"os"
"time"

dockercli "github.com/docker/cli/cli"
"github.com/docker/cli/cli-plugins/manager"
"github.com/docker/cli/cli-plugins/plugin"
"github.com/docker/cli/cli/command"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"

"github.com/docker/compose/v2/cmd/compatibility"
commands "github.com/docker/compose/v2/cmd/compose"
"github.com/docker/compose/v2/internal"
"github.com/docker/compose/v2/internal/tracing"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/compose"
)

func pluginMain() {
plugin.Run(func(dockerCli command.Cli) *cobra.Command {
var tracingShutdown tracing.ShutdownFunc
var cmdSpan trace.Span

serviceProxy := api.NewServiceProxy().WithService(compose.NewComposeService(dockerCli))
cmd := commands.RootCommand(dockerCli, serviceProxy)
originalPreRun := cmd.PersistentPreRunE
cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
if err := plugin.PersistentPreRunE(cmd, args); err != nil {
return err
}
// the call to plugin.PersistentPreRunE is what actually
// initializes the command.Cli instance, so this is the earliest
// that tracing can be practically initialized (in the future,
// this could ideally happen in coordination with docker/cli)
tracingShutdown, _ = tracing.InitTracing(dockerCli)

ctx := cmd.Context()
ctx, cmdSpan = tracing.Tracer.Start(
ctx, cmd.Name(),
trace.WithAttributes(
attribute.String("compose.version", internal.Version),
attribute.String("docker.context", dockerCli.CurrentContext()),
),
)
cmd.SetContext(ctx)

if originalPreRun != nil {
return originalPreRun(cmd, args)
}
return nil
}

// manually wrap RunE instead of using PersistentPostRunE because the
// latter only runs when RunE does _not_ return an error, but the
// tracing clean-up logic should always be invoked
originalPersistentPostRunE := cmd.PersistentPostRunE
cmd.PersistentPostRunE = func(cmd *cobra.Command, args []string) (err error) {
defer func() {
if cmdSpan != nil {
if err != nil && !errors.Is(err, context.Canceled) {
cmdSpan.SetStatus(codes.Error, "CLI command returned error")
cmdSpan.RecordError(err)
}

Check warning on line 86 in cmd/main.go

View check run for this annotation

Codecov / codecov/patch

cmd/main.go#L84-L86

Added lines #L84 - L86 were not covered by tests
cmdSpan.End()
}
if tracingShutdown != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = tracingShutdown(ctx)
}

Check warning on line 93 in cmd/main.go

View check run for this annotation

Codecov / codecov/patch

cmd/main.go#L90-L93

Added lines #L90 - L93 were not covered by tests
}()
if originalPersistentPostRunE != nil {
return originalPersistentPostRunE(cmd, args)
}

Check warning on line 97 in cmd/main.go

View check run for this annotation

Codecov / codecov/patch

cmd/main.go#L96-L97

Added lines #L96 - L97 were not covered by tests
return nil
}
cmd.SetFlagErrorFunc(func(c *cobra.Command, err error) error {
return dockercli.StatusError{
StatusCode: compose.CommandSyntaxFailure.ExitCode,
Expand Down
12 changes: 6 additions & 6 deletions go.mod
Expand Up @@ -4,6 +4,7 @@ go 1.20

require (
github.com/AlecAivazis/survey/v2 v2.3.6
github.com/Microsoft/go-winio v0.5.2
github.com/buger/goterm v1.0.4
github.com/compose-spec/compose-go v1.14.0
github.com/containerd/console v1.0.3
Expand Down Expand Up @@ -34,6 +35,10 @@ require (
github.com/theupdateframework/notary v0.7.0
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375
go.opentelemetry.io/otel v1.15.1
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.4.1
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.4.1
go.opentelemetry.io/otel/sdk v1.4.1
go.opentelemetry.io/otel/trace v1.15.1
go.uber.org/goleak v1.2.1
golang.org/x/sync v0.2.0
gopkg.in/yaml.v2 v2.4.0
Expand All @@ -42,7 +47,6 @@ require (

require (
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Microsoft/go-winio v0.5.2 // indirect
github.com/aws/aws-sdk-go-v2 v1.16.3 // indirect
github.com/aws/aws-sdk-go-v2/config v1.15.5 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.12.0 // indirect
Expand Down Expand Up @@ -140,13 +144,9 @@ require (
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.29.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.29.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.4.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.4.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.4.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.4.1 // indirect
go.opentelemetry.io/otel/internal/metric v0.27.0 // indirect
go.opentelemetry.io/otel/metric v0.27.0 // indirect
go.opentelemetry.io/otel/sdk v1.4.1 // indirect
go.opentelemetry.io/otel/trace v1.15.1 // indirect
go.opentelemetry.io/proto/otlp v0.12.0 // indirect
golang.org/x/crypto v0.3.0 // indirect
golang.org/x/net v0.7.0 // indirect
Expand All @@ -157,7 +157,7 @@ require (
golang.org/x/time v0.1.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20221024183307-1bc688fe9f3e // indirect
google.golang.org/grpc v1.50.1 // indirect
google.golang.org/grpc v1.50.1
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
Expand Down
44 changes: 44 additions & 0 deletions internal/tracing/conn_unix.go
@@ -0,0 +1,44 @@
//go:build !windows

/*
Copyright 2023 Docker Compose CLI 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 tracing

import (
"context"
"fmt"
"net"
"strings"
"syscall"
)

const maxUnixSocketPathSize = len(syscall.RawSockaddrUnix{}.Path)

func DialInMemory(ctx context.Context, addr string) (net.Conn, error) {
if !strings.HasPrefix(addr, "unix://") {
return nil, fmt.Errorf("not a Unix socket address: %s", addr)
}
addr = strings.TrimPrefix(addr, "unix://")

if len(addr) > maxUnixSocketPathSize {
//goland:noinspection GoErrorStringFormat
return nil, fmt.Errorf("Unix socket address is too long: %s", addr)
}

Check warning on line 40 in internal/tracing/conn_unix.go

View check run for this annotation

Codecov / codecov/patch

internal/tracing/conn_unix.go#L31-L40

Added lines #L31 - L40 were not covered by tests

var d net.Dialer
return d.DialContext(ctx, "unix", addr)

Check warning on line 43 in internal/tracing/conn_unix.go

View check run for this annotation

Codecov / codecov/patch

internal/tracing/conn_unix.go#L42-L43

Added lines #L42 - L43 were not covered by tests
}
35 changes: 35 additions & 0 deletions internal/tracing/conn_windows.go
@@ -0,0 +1,35 @@
/*
Copyright 2023 Docker Compose CLI 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 tracing

import (
"context"
"fmt"
"net"
"strings"

"github.com/Microsoft/go-winio"
)

func DialInMemory(ctx context.Context, addr string) (net.Conn, error) {
if !strings.HasPrefix(addr, "npipe://") {
return nil, fmt.Errorf("not a named pipe address: %s", addr)
}
addr = strings.TrimPrefix(addr, "npipe://")

return winio.DialPipeContext(ctx, addr)
}
121 changes: 121 additions & 0 deletions internal/tracing/docker_context.go
@@ -0,0 +1,121 @@
/*
Copyright 2023 Docker Compose CLI 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 tracing

import (
"context"
"fmt"
"os"
"time"

"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/context/store"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)

const otelConfigFieldName = "otel"

// traceClientFromDockerContext creates a gRPC OTLP client based on metadata
// from the active Docker CLI context.
func traceClientFromDockerContext(dockerCli command.Cli, otelEnv envMap) (otlptrace.Client, error) {
// attempt to extract an OTEL config from the Docker context to enable
// automatic integration with Docker Desktop;
cfg, err := ConfigFromDockerContext(dockerCli.ContextStore(), dockerCli.CurrentContext())
if err != nil {
return nil, fmt.Errorf("loading otel config from docker context metadata: %v", err)
}

Check warning on line 43 in internal/tracing/docker_context.go

View check run for this annotation

Codecov / codecov/patch

internal/tracing/docker_context.go#L37-L43

Added lines #L37 - L43 were not covered by tests

if cfg.Endpoint == "" {
return nil, nil
}

Check warning on line 47 in internal/tracing/docker_context.go

View check run for this annotation

Codecov / codecov/patch

internal/tracing/docker_context.go#L45-L47

Added lines #L45 - L47 were not covered by tests

// HACK: unfortunately _all_ public OTEL initialization functions
// implicitly read from the OS env, so temporarily unset them all and
// restore afterwards
defer func() {
for k, v := range otelEnv {
if err := os.Setenv(k, v); err != nil {
panic(fmt.Errorf("restoring env for %q: %v", k, err))

Check warning on line 55 in internal/tracing/docker_context.go

View check run for this annotation

Codecov / codecov/patch

internal/tracing/docker_context.go#L52-L55

Added lines #L52 - L55 were not covered by tests
}
}
}()
for k := range otelEnv {
if err := os.Unsetenv(k); err != nil {
return nil, fmt.Errorf("stashing env for %q: %v", k, err)
}

Check warning on line 62 in internal/tracing/docker_context.go

View check run for this annotation

Codecov / codecov/patch

internal/tracing/docker_context.go#L59-L62

Added lines #L59 - L62 were not covered by tests
}

dialCtx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
conn, err := grpc.DialContext(
dialCtx,
cfg.Endpoint,
grpc.WithContextDialer(DialInMemory),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
if err != nil {
return nil, fmt.Errorf("initializing otel connection from docker context metadata: %v", err)
}

Check warning on line 76 in internal/tracing/docker_context.go

View check run for this annotation

Codecov / codecov/patch

internal/tracing/docker_context.go#L65-L76

Added lines #L65 - L76 were not covered by tests

client := otlptracegrpc.NewClient(otlptracegrpc.WithGRPCConn(conn))
return client, nil

Check warning on line 79 in internal/tracing/docker_context.go

View check run for this annotation

Codecov / codecov/patch

internal/tracing/docker_context.go#L78-L79

Added lines #L78 - L79 were not covered by tests
}

// ConfigFromDockerContext inspects extra metadata included as part of the
// specified Docker context to try and extract a valid OTLP client configuration.
func ConfigFromDockerContext(st store.Store, name string) (OTLPConfig, error) {
meta, err := st.GetMetadata(name)
if err != nil {
return OTLPConfig{}, err
}

Check warning on line 88 in internal/tracing/docker_context.go

View check run for this annotation

Codecov / codecov/patch

internal/tracing/docker_context.go#L87-L88

Added lines #L87 - L88 were not covered by tests

var otelCfg interface{}
switch m := meta.Metadata.(type) {
case command.DockerContext:
otelCfg = m.AdditionalFields[otelConfigFieldName]

Check warning on line 93 in internal/tracing/docker_context.go

View check run for this annotation

Codecov / codecov/patch

internal/tracing/docker_context.go#L92-L93

Added lines #L92 - L93 were not covered by tests
case map[string]interface{}:
otelCfg = m[otelConfigFieldName]
}
otelMap, ok := otelCfg.(map[string]interface{})
if !ok {
return OTLPConfig{}, fmt.Errorf(
"unexpected type for field %q: %T (expected: %T)",
otelConfigFieldName,
otelCfg,
otelMap,
)
}

Check warning on line 105 in internal/tracing/docker_context.go

View check run for this annotation

Codecov / codecov/patch

internal/tracing/docker_context.go#L99-L105

Added lines #L99 - L105 were not covered by tests

// keys from https://opentelemetry.io/docs/concepts/sdk-configuration/otlp-exporter-configuration/
cfg := OTLPConfig{
Endpoint: strValue(otelMap, "OTEL_EXPORTER_OTLP_ENDPOINT"),
}
return cfg, nil
}

// strValue returns the string value at the specified key in the map if present
// and a string type; otherwise, it returns an empty string.
func strValue(m map[string]interface{}, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""

Check warning on line 120 in internal/tracing/docker_context.go

View check run for this annotation

Codecov / codecov/patch

internal/tracing/docker_context.go#L120

Added line #L120 was not covered by tests
}
20 changes: 7 additions & 13 deletions cmd/compose/tracing.go → internal/tracing/errors.go
@@ -1,5 +1,5 @@
/*
Copyright 2020 Docker Compose CLI authors
Copyright 2023 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand All @@ -14,22 +14,16 @@
limitations under the License.
*/

package compose
package tracing

import (
"github.com/moby/buildkit/util/tracing/detect"
"go.opentelemetry.io/otel"

_ "github.com/moby/buildkit/util/tracing/detect/delegated" //nolint:blank-imports
_ "github.com/moby/buildkit/util/tracing/env" //nolint:blank-imports
)

func init() {
detect.ServiceName = "compose"
// do not log tracing errors to stdio
otel.SetErrorHandler(skipErrors{})
}

// skipErrors is a no-op otel.ErrorHandler.
type skipErrors struct{}

func (skipErrors) Handle(err error) {}
// Handle does nothing, ignoring any errors passed to it.
func (skipErrors) Handle(_ error) {}

Check warning on line 27 in internal/tracing/errors.go

View check run for this annotation

Codecov / codecov/patch

internal/tracing/errors.go#L27

Added line #L27 was not covered by tests

var _ otel.ErrorHandler = skipErrors{}

0 comments on commit 368e242

Please sign in to comment.