Skip to content

Commit f3a0668

Browse files
committed
trace: add OTEL initialization
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 <[email protected]>
1 parent e63ab14 commit f3a0668

10 files changed

Lines changed: 533 additions & 23 deletions

File tree

cmd/main.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,35 +17,86 @@
1717
package main
1818

1919
import (
20+
"context"
2021
"os"
22+
"time"
2123

2224
dockercli "github.com/docker/cli/cli"
2325
"github.com/docker/cli/cli-plugins/manager"
2426
"github.com/docker/cli/cli-plugins/plugin"
2527
"github.com/docker/cli/cli/command"
28+
"github.com/pkg/errors"
2629
"github.com/spf13/cobra"
30+
"go.opentelemetry.io/otel/attribute"
31+
"go.opentelemetry.io/otel/codes"
32+
"go.opentelemetry.io/otel/trace"
2733

2834
"github.com/docker/compose/v2/cmd/compatibility"
2935
commands "github.com/docker/compose/v2/cmd/compose"
3036
"github.com/docker/compose/v2/internal"
37+
"github.com/docker/compose/v2/internal/tracing"
3138
"github.com/docker/compose/v2/pkg/api"
3239
"github.com/docker/compose/v2/pkg/compose"
3340
)
3441

3542
func pluginMain() {
3643
plugin.Run(func(dockerCli command.Cli) *cobra.Command {
44+
var tracingShutdown tracing.ShutdownFunc
45+
var cmdSpan trace.Span
46+
3747
serviceProxy := api.NewServiceProxy().WithService(compose.NewComposeService(dockerCli))
3848
cmd := commands.RootCommand(dockerCli, serviceProxy)
3949
originalPreRun := cmd.PersistentPreRunE
4050
cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
4151
if err := plugin.PersistentPreRunE(cmd, args); err != nil {
4252
return err
4353
}
54+
// the call to plugin.PersistentPreRunE is what actually
55+
// initializes the command.Cli instance, so this is the earliest
56+
// that tracing can be practically initialized (in the future,
57+
// this could ideally happen in coordination with docker/cli)
58+
tracingShutdown, _ = tracing.InitTracing(dockerCli)
59+
60+
ctx := cmd.Context()
61+
ctx, cmdSpan = tracing.Tracer.Start(
62+
ctx, "cli/"+cmd.Name(),
63+
trace.WithAttributes(
64+
attribute.String("compose.version", internal.Version),
65+
attribute.String("docker.context", dockerCli.CurrentContext()),
66+
),
67+
)
68+
cmd.SetContext(ctx)
69+
4470
if originalPreRun != nil {
4571
return originalPreRun(cmd, args)
4672
}
4773
return nil
4874
}
75+
76+
// manually wrap RunE instead of using PersistentPostRunE because the
77+
// latter only runs when RunE does _not_ return an error, but the
78+
// tracing clean-up logic should always be invoked
79+
originalPersistentPostRunE := cmd.PersistentPostRunE
80+
cmd.PersistentPostRunE = func(cmd *cobra.Command, args []string) (err error) {
81+
defer func() {
82+
if cmdSpan != nil {
83+
if err != nil && !errors.Is(err, context.Canceled) {
84+
cmdSpan.SetStatus(codes.Error, "CLI command returned error")
85+
cmdSpan.RecordError(err)
86+
}
87+
cmdSpan.End()
88+
}
89+
if tracingShutdown != nil {
90+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
91+
defer cancel()
92+
_ = tracingShutdown(ctx)
93+
}
94+
}()
95+
if originalPersistentPostRunE != nil {
96+
return originalPersistentPostRunE(cmd, args)
97+
}
98+
return nil
99+
}
49100
cmd.SetFlagErrorFunc(func(c *cobra.Command, err error) error {
50101
return dockercli.StatusError{
51102
StatusCode: compose.CommandSyntaxFailure.ExitCode,

go.mod

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.20
44

55
require (
66
github.com/AlecAivazis/survey/v2 v2.3.6
7+
github.com/Microsoft/go-winio v0.5.2
78
github.com/buger/goterm v1.0.4
89
github.com/compose-spec/compose-go v1.14.0
910
github.com/containerd/console v1.0.3
@@ -16,12 +17,15 @@ require (
1617
github.com/docker/docker v24.0.2+incompatible
1718
github.com/docker/go-connections v0.4.0
1819
github.com/docker/go-units v0.5.0
20+
github.com/fsnotify/fsevents v0.1.1
1921
github.com/golang/mock v1.6.0
2022
github.com/hashicorp/go-multierror v1.1.1
2123
github.com/hashicorp/go-version v1.6.0
24+
github.com/jonboulle/clockwork v0.4.0
2225
github.com/mattn/go-shellwords v1.0.12
2326
github.com/mitchellh/mapstructure v1.5.0
2427
github.com/moby/buildkit v0.11.7-0.20230519102302-348e79dfed17
28+
github.com/moby/patternmatcher v0.5.0
2529
github.com/moby/term v0.5.0
2630
github.com/morikuni/aec v1.0.0
2731
github.com/opencontainers/go-digest v1.0.0
@@ -34,15 +38,19 @@ require (
3438
github.com/theupdateframework/notary v0.7.0
3539
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375
3640
go.opentelemetry.io/otel v1.15.1
41+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.4.1
42+
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.4.1
43+
go.opentelemetry.io/otel/sdk v1.4.1
44+
go.opentelemetry.io/otel/trace v1.15.1
3745
go.uber.org/goleak v1.2.1
3846
golang.org/x/sync v0.2.0
47+
google.golang.org/grpc v1.53.0
3948
gopkg.in/yaml.v2 v2.4.0
4049
gotest.tools/v3 v3.4.0
4150
)
4251

4352
require (
4453
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
45-
github.com/Microsoft/go-winio v0.5.2 // indirect
4654
github.com/aws/aws-sdk-go-v2 v1.16.3 // indirect
4755
github.com/aws/aws-sdk-go-v2/config v1.15.5 // indirect
4856
github.com/aws/aws-sdk-go-v2/credentials v1.12.0 // indirect
@@ -70,7 +78,6 @@ require (
7078
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
7179
github.com/docker/go-metrics v0.0.1 // indirect
7280
github.com/felixge/httpsnoop v1.0.2 // indirect
73-
github.com/fsnotify/fsevents v0.1.1
7481
github.com/fsnotify/fsnotify v1.6.0 // indirect
7582
github.com/fvbommel/sortorder v1.0.2 // indirect
7683
github.com/go-logr/logr v1.2.4 // indirect
@@ -95,7 +102,6 @@ require (
95102
github.com/imdario/mergo v0.3.15 // indirect
96103
github.com/inconshreveable/mousetrap v1.1.0 // indirect
97104
github.com/jinzhu/gorm v1.9.11 // indirect
98-
github.com/jonboulle/clockwork v0.4.0
99105
github.com/json-iterator/go v1.1.12 // indirect
100106
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
101107
github.com/klauspost/compress v1.16.5 // indirect
@@ -107,7 +113,6 @@ require (
107113
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
108114
github.com/miekg/pkcs11 v1.1.1 // indirect
109115
github.com/moby/locker v1.0.1 // indirect
110-
github.com/moby/patternmatcher v0.5.0
111116
github.com/moby/spdystream v0.2.0 // indirect
112117
github.com/moby/sys/mountinfo v0.6.2 // indirect
113118
github.com/moby/sys/sequential v0.5.0 // indirect
@@ -140,13 +145,9 @@ require (
140145
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.29.0 // indirect
141146
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.29.0 // indirect
142147
go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.4.1 // indirect
143-
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.4.1 // indirect
144-
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.4.1 // indirect
145148
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.4.1 // indirect
146149
go.opentelemetry.io/otel/internal/metric v0.27.0 // indirect
147150
go.opentelemetry.io/otel/metric v0.27.0 // indirect
148-
go.opentelemetry.io/otel/sdk v1.4.1 // indirect
149-
go.opentelemetry.io/otel/trace v1.15.1 // indirect
150151
go.opentelemetry.io/proto/otlp v0.12.0 // indirect
151152
golang.org/x/crypto v0.7.0 // indirect
152153
golang.org/x/net v0.8.0 // indirect
@@ -157,7 +158,6 @@ require (
157158
golang.org/x/time v0.1.0 // indirect
158159
google.golang.org/appengine v1.6.7 // indirect
159160
google.golang.org/genproto v0.0.0-20230320184635-7606e756e683 // indirect
160-
google.golang.org/grpc v1.53.0 // indirect
161161
google.golang.org/protobuf v1.29.1 // indirect
162162
gopkg.in/inf.v0 v0.9.1 // indirect
163163
gopkg.in/ini.v1 v1.67.0 // indirect

internal/tracing/conn_unix.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//go:build !windows
2+
3+
/*
4+
Copyright 2023 Docker Compose CLI authors
5+
6+
Licensed under the Apache License, Version 2.0 (the "License");
7+
you may not use this file except in compliance with the License.
8+
You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing, software
13+
distributed under the License is distributed on an "AS IS" BASIS,
14+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
See the License for the specific language governing permissions and
16+
limitations under the License.
17+
*/
18+
19+
package tracing
20+
21+
import (
22+
"context"
23+
"fmt"
24+
"net"
25+
"strings"
26+
"syscall"
27+
)
28+
29+
const maxUnixSocketPathSize = len(syscall.RawSockaddrUnix{}.Path)
30+
31+
func DialInMemory(ctx context.Context, addr string) (net.Conn, error) {
32+
if !strings.HasPrefix(addr, "unix://") {
33+
return nil, fmt.Errorf("not a Unix socket address: %s", addr)
34+
}
35+
addr = strings.TrimPrefix(addr, "unix://")
36+
37+
if len(addr) > maxUnixSocketPathSize {
38+
//goland:noinspection GoErrorStringFormat
39+
return nil, fmt.Errorf("Unix socket address is too long: %s", addr)
40+
}
41+
42+
var d net.Dialer
43+
return d.DialContext(ctx, "unix", addr)
44+
}

internal/tracing/conn_windows.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
Copyright 2023 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package tracing
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"net"
23+
"strings"
24+
25+
"github.com/Microsoft/go-winio"
26+
)
27+
28+
func DialInMemory(ctx context.Context, addr string) (net.Conn, error) {
29+
if !strings.HasPrefix(addr, "npipe://") {
30+
return nil, fmt.Errorf("not a named pipe address: %s", addr)
31+
}
32+
addr = strings.TrimPrefix(addr, "npipe://")
33+
34+
return winio.DialPipeContext(ctx, addr)
35+
}

internal/tracing/docker_context.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/*
2+
Copyright 2023 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package tracing
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"os"
23+
"time"
24+
25+
"github.com/docker/cli/cli/command"
26+
"github.com/docker/cli/cli/context/store"
27+
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
28+
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
29+
"google.golang.org/grpc"
30+
"google.golang.org/grpc/credentials/insecure"
31+
)
32+
33+
const otelConfigFieldName = "otel"
34+
35+
// traceClientFromDockerContext creates a gRPC OTLP client based on metadata
36+
// from the active Docker CLI context.
37+
func traceClientFromDockerContext(dockerCli command.Cli, otelEnv envMap) (otlptrace.Client, error) {
38+
// attempt to extract an OTEL config from the Docker context to enable
39+
// automatic integration with Docker Desktop;
40+
cfg, err := ConfigFromDockerContext(dockerCli.ContextStore(), dockerCli.CurrentContext())
41+
if err != nil {
42+
return nil, fmt.Errorf("loading otel config from docker context metadata: %v", err)
43+
}
44+
45+
if cfg.Endpoint == "" {
46+
return nil, nil
47+
}
48+
49+
// HACK: unfortunately _all_ public OTEL initialization functions
50+
// implicitly read from the OS env, so temporarily unset them all and
51+
// restore afterwards
52+
defer func() {
53+
for k, v := range otelEnv {
54+
if err := os.Setenv(k, v); err != nil {
55+
panic(fmt.Errorf("restoring env for %q: %v", k, err))
56+
}
57+
}
58+
}()
59+
for k := range otelEnv {
60+
if err := os.Unsetenv(k); err != nil {
61+
return nil, fmt.Errorf("stashing env for %q: %v", k, err)
62+
}
63+
}
64+
65+
dialCtx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
66+
defer cancel()
67+
conn, err := grpc.DialContext(
68+
dialCtx,
69+
cfg.Endpoint,
70+
grpc.WithContextDialer(DialInMemory),
71+
grpc.WithTransportCredentials(insecure.NewCredentials()),
72+
grpc.WithBlock(),
73+
)
74+
if err != nil {
75+
return nil, fmt.Errorf("initializing otel connection from docker context metadata: %v", err)
76+
}
77+
78+
client := otlptracegrpc.NewClient(otlptracegrpc.WithGRPCConn(conn))
79+
return client, nil
80+
}
81+
82+
// ConfigFromDockerContext inspects extra metadata included as part of the
83+
// specified Docker context to try and extract a valid OTLP client configuration.
84+
func ConfigFromDockerContext(st store.Store, name string) (OTLPConfig, error) {
85+
meta, err := st.GetMetadata(name)
86+
if err != nil {
87+
return OTLPConfig{}, err
88+
}
89+
90+
var otelCfg interface{}
91+
switch m := meta.Metadata.(type) {
92+
case command.DockerContext:
93+
otelCfg = m.AdditionalFields[otelConfigFieldName]
94+
case map[string]interface{}:
95+
otelCfg = m[otelConfigFieldName]
96+
}
97+
otelMap, ok := otelCfg.(map[string]interface{})
98+
if !ok {
99+
return OTLPConfig{}, fmt.Errorf(
100+
"unexpected type for field %q: %T (expected: %T)",
101+
otelConfigFieldName,
102+
otelCfg,
103+
otelMap,
104+
)
105+
}
106+
107+
// keys from https://opentelemetry.io/docs/concepts/sdk-configuration/otlp-exporter-configuration/
108+
cfg := OTLPConfig{
109+
Endpoint: strValue(otelMap, "OTEL_EXPORTER_OTLP_ENDPOINT"),
110+
}
111+
return cfg, nil
112+
}
113+
114+
// strValue returns the string value at the specified key in the map if present
115+
// and a string type; otherwise, it returns an empty string.
116+
func strValue(m map[string]interface{}, key string) string {
117+
if v, ok := m[key].(string); ok {
118+
return v
119+
}
120+
return ""
121+
}

0 commit comments

Comments
 (0)