diff --git a/build/build.go b/build/build.go index 2ae0fbd8e78..29f3c968551 100644 --- a/build/build.go +++ b/build/build.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "crypto/rand" + "crypto/sha256" _ "crypto/sha256" // ensure digests can be computed "encoding/base64" "encoding/hex" @@ -55,6 +56,8 @@ import ( specs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/trace" "golang.org/x/sync/errgroup" ) @@ -469,11 +472,11 @@ func toSolveOpt(ctx context.Context, node builder.Node, multiDriver bool, opt Op return &so, releaseF, nil } -func Build(ctx context.Context, nodes []builder.Node, opt map[string]Options, docker *dockerutil.Client, configDir string, w progress.Writer) (resp map[string]*client.SolveResponse, err error) { - return BuildWithResultHandler(ctx, nodes, opt, docker, configDir, w, nil) +func Build(ctx context.Context, nodes []builder.Node, opt map[string]Options, docker *dockerutil.Client, configDir string, w progress.Writer, mp metric.MeterProvider) (resp map[string]*client.SolveResponse, err error) { + return BuildWithResultHandler(ctx, nodes, opt, docker, configDir, w, mp, nil) } -func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opt map[string]Options, docker *dockerutil.Client, configDir string, w progress.Writer, resultHandleFunc func(driverIndex int, rCtx *ResultHandle)) (resp map[string]*client.SolveResponse, err error) { +func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opt map[string]Options, docker *dockerutil.Client, configDir string, w progress.Writer, mp metric.MeterProvider, resultHandleFunc func(driverIndex int, rCtx *ResultHandle)) (resp map[string]*client.SolveResponse, err error) { if len(nodes) == 0 { return nil, errors.Errorf("driver required for build") } @@ -686,6 +689,14 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opt map[s return err } + buildAttributes := attribute.NewSet( + driverAttribute(dp), + buildIdentityAttribute(so), + buildRefAttribute(so), + ) + pw, record := progress.Metrics(mp, pw, + metric.WithAttributeSet(buildAttributes)) + frontendInputs := make(map[string]*pb.Definition) for key, st := range so.FrontendInputs { def, err := st.Marshal(ctx) @@ -785,6 +796,7 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opt map[s } else { rr, err = c.Build(ctx, *so, "buildx", buildFunc, ch) } + if desktop.BuildBackendEnabled() && node.Driver.HistoryAPISupported(ctx) { buildRef := fmt.Sprintf("%s/%s/%s", node.Builder, node.Name, so.Ref) if err != nil { @@ -842,6 +854,9 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opt map[s } } } + + // Record the result of the build. + record(ctx) return nil }) } @@ -1523,3 +1538,35 @@ func ReadSourcePolicy() (*spb.Policy, error) { return &pol, nil } + +// driverAttribute is a utility to retrieve the backend attribute from a resolvedNode. +func driverAttribute(dp *resolvedNode) attribute.KeyValue { + driverName := dp.Node().Driver.Factory().Name() + return attribute.String("driver", driverName) +} + +// buildIdentityAttribute is a utility to retrieve the build id attribute from the solve options. +// This value should be consistent between builds. +func buildIdentityAttribute(so *client.SolveOpt) attribute.KeyValue { + vcs := so.FrontendAttrs["vcs:source"] + target := so.FrontendAttrs["target"] + context := so.FrontendAttrs["context"] + filename := so.FrontendAttrs["filename"] + + buildID := "" + if vcs != "" || target != "" || context != "" || filename != "" { + h := sha256.New() + for _, s := range []string{vcs, target, context, filename} { + _, _ = io.WriteString(h, s) + h.Write([]byte{0}) + } + buildID = hex.EncodeToString(h.Sum(nil)) + } + return attribute.String("build.identity", buildID) +} + +// buildRefAttribute is a utility to retrieve the build ref attribute from the solve options. +// This value should be unique to each build. +func buildRefAttribute(so *client.SolveOpt) attribute.KeyValue { + return attribute.String("build.ref", so.Ref) +} diff --git a/commands/bake.go b/commands/bake.go index e17a8ebc7da..1cf29f1a2e9 100644 --- a/commands/bake.go +++ b/commands/bake.go @@ -14,12 +14,12 @@ import ( "github.com/docker/buildx/build" "github.com/docker/buildx/builder" "github.com/docker/buildx/localstate" + sdkmetric "github.com/docker/buildx/otel/sdk/metric" "github.com/docker/buildx/util/buildflags" "github.com/docker/buildx/util/cobrautil/completion" "github.com/docker/buildx/util/confutil" "github.com/docker/buildx/util/desktop" "github.com/docker/buildx/util/dockerutil" - "github.com/docker/buildx/util/metrics" "github.com/docker/buildx/util/progress" "github.com/docker/buildx/util/tracing" "github.com/docker/cli/cli/command" @@ -43,13 +43,11 @@ type bakeOptions struct { } func runBake(ctx context.Context, dockerCli command.Cli, targets []string, in bakeOptions, cFlags commonFlags) (err error) { - mp, report, err := metrics.MeterProvider(dockerCli) + mp, err := sdkmetric.NewMeterProvider(ctx, dockerCli) if err != nil { return err } - defer report() - - recordVersionInfo(mp, "bake") + defer mp.Report(context.Background()) ctx, end, err := tracing.TraceCurrentCommand(ctx, "bake") if err != nil { @@ -235,7 +233,7 @@ func runBake(ctx context.Context, dockerCli command.Cli, targets []string, in ba return err } - resp, err := build.Build(ctx, nodes, bo, dockerutil.NewClient(dockerCli), confutil.ConfigDir(dockerCli), printer) + resp, err := build.Build(ctx, nodes, bo, dockerutil.NewClient(dockerCli), confutil.ConfigDir(dockerCli), printer, mp) if err != nil { return wrapBuildError(err, true) } diff --git a/commands/build.go b/commands/build.go index e142befd988..dbbbdeb45c1 100644 --- a/commands/build.go +++ b/commands/build.go @@ -24,16 +24,15 @@ import ( controllererrors "github.com/docker/buildx/controller/errdefs" controllerapi "github.com/docker/buildx/controller/pb" "github.com/docker/buildx/monitor" + sdkmetric "github.com/docker/buildx/otel/sdk/metric" "github.com/docker/buildx/store" "github.com/docker/buildx/store/storeutil" "github.com/docker/buildx/util/buildflags" "github.com/docker/buildx/util/cobrautil" "github.com/docker/buildx/util/desktop" "github.com/docker/buildx/util/ioset" - "github.com/docker/buildx/util/metrics" "github.com/docker/buildx/util/progress" "github.com/docker/buildx/util/tracing" - "github.com/docker/buildx/version" "github.com/docker/cli-docs-tool/annotation" "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" @@ -53,8 +52,6 @@ import ( "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/pflag" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "google.golang.org/grpc/codes" ) @@ -216,13 +213,11 @@ func (o *buildOptions) toDisplayMode() (progressui.DisplayMode, error) { } func runBuild(ctx context.Context, dockerCli command.Cli, options buildOptions) (err error) { - mp, report, err := metrics.MeterProvider(dockerCli) + mp, err := sdkmetric.NewMeterProvider(ctx, dockerCli) if err != nil { return err } - defer report() - - recordVersionInfo(mp, "build") + defer mp.Report(context.Background()) ctx, end, err := tracing.TraceCurrentCommand(ctx, "build") if err != nil { @@ -288,9 +283,9 @@ func runBuild(ctx context.Context, dockerCli command.Cli, options buildOptions) var resp *client.SolveResponse var retErr error if isExperimental() { - resp, retErr = runControllerBuild(ctx, dockerCli, opts, options, printer) + resp, retErr = runControllerBuild(ctx, dockerCli, opts, options, printer, mp) } else { - resp, retErr = runBasicBuild(ctx, dockerCli, opts, options, printer) + resp, retErr = runBasicBuild(ctx, dockerCli, opts, options, printer, mp) } if err := printer.Wait(); retErr == nil { @@ -332,20 +327,20 @@ func getImageID(resp map[string]string) string { return dgst } -func runBasicBuild(ctx context.Context, dockerCli command.Cli, opts *controllerapi.BuildOptions, options buildOptions, printer *progress.Printer) (*client.SolveResponse, error) { - resp, res, err := cbuild.RunBuild(ctx, dockerCli, *opts, dockerCli.In(), printer, false) +func runBasicBuild(ctx context.Context, dockerCli command.Cli, opts *controllerapi.BuildOptions, options buildOptions, printer *progress.Printer, mp metric.MeterProvider) (*client.SolveResponse, error) { + resp, res, err := cbuild.RunBuild(ctx, dockerCli, *opts, dockerCli.In(), printer, mp, false) if res != nil { res.Done() } return resp, err } -func runControllerBuild(ctx context.Context, dockerCli command.Cli, opts *controllerapi.BuildOptions, options buildOptions, printer *progress.Printer) (*client.SolveResponse, error) { +func runControllerBuild(ctx context.Context, dockerCli command.Cli, opts *controllerapi.BuildOptions, options buildOptions, printer *progress.Printer, mp metric.MeterProvider) (*client.SolveResponse, error) { if options.invokeConfig != nil && (options.dockerfileName == "-" || options.contextPath == "-") { // stdin must be usable for monitor return nil, errors.Errorf("Dockerfile or context from stdin is not supported with invoke") } - c, err := controller.NewController(ctx, options.ControlOptions, dockerCli, printer) + c, err := controller.NewController(ctx, options.ControlOptions, dockerCli, printer, mp) if err != nil { return nil, err } @@ -936,30 +931,3 @@ func maybeJSONArray(v string) []string { } return []string{v} } - -func recordVersionInfo(mp metric.MeterProvider, command string) { - // Still in the process of testing/stabilizing these counters. - if !isExperimental() { - return - } - - meter := mp.Meter("github.com/docker/buildx", - metric.WithInstrumentationVersion(version.Version), - ) - - counter, err := meter.Int64Counter("docker.cli.count", - metric.WithDescription("Number of invocations of the docker buildx command."), - ) - if err != nil { - otel.Handle(err) - } - - counter.Add(context.Background(), 1, - metric.WithAttributes( - attribute.String("command", command), - attribute.String("package", version.Package), - attribute.String("version", version.Version), - attribute.String("revision", version.Revision), - ), - ) -} diff --git a/commands/debug/root.go b/commands/debug/root.go index b16c75e4e68..87af0fa1310 100644 --- a/commands/debug/root.go +++ b/commands/debug/root.go @@ -17,6 +17,7 @@ import ( "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "go.opentelemetry.io/otel/metric/noop" ) // DebugConfig is a user-specified configuration for the debugger. @@ -50,7 +51,7 @@ func RootCmd(dockerCli command.Cli, children ...DebuggableCmd) *cobra.Command { } ctx := context.TODO() - c, err := controller.NewController(ctx, controlOptions, dockerCli, printer) + c, err := controller.NewController(ctx, controlOptions, dockerCli, printer, noop.NewMeterProvider()) if err != nil { return err } diff --git a/controller/build/build.go b/controller/build/build.go index 3629982b0b7..24ef18ff8e8 100644 --- a/controller/build/build.go +++ b/controller/build/build.go @@ -26,6 +26,7 @@ import ( "github.com/moby/buildkit/session/auth/authprovider" "github.com/moby/buildkit/util/grpcerrors" "github.com/pkg/errors" + "go.opentelemetry.io/otel/metric" "google.golang.org/grpc/codes" ) @@ -36,7 +37,7 @@ const defaultTargetName = "default" // NOTE: When an error happens during the build and this function acquires the debuggable *build.ResultHandle, // this function returns it in addition to the error (i.e. it does "return nil, res, err"). The caller can // inspect the result and debug the cause of that error. -func RunBuild(ctx context.Context, dockerCli command.Cli, in controllerapi.BuildOptions, inStream io.Reader, progress progress.Writer, generateResult bool) (*client.SolveResponse, *build.ResultHandle, error) { +func RunBuild(ctx context.Context, dockerCli command.Cli, in controllerapi.BuildOptions, inStream io.Reader, progress progress.Writer, mp metric.MeterProvider, generateResult bool) (*client.SolveResponse, *build.ResultHandle, error) { if in.NoCache && len(in.NoCacheFilter) > 0 { return nil, nil, errors.Errorf("--no-cache and --no-cache-filter cannot currently be used together") } @@ -187,7 +188,7 @@ func RunBuild(ctx context.Context, dockerCli command.Cli, in controllerapi.Build return nil, nil, err } - resp, res, err := buildTargets(ctx, dockerCli, b.NodeGroup, nodes, map[string]build.Options{defaultTargetName: opts}, progress, generateResult) + resp, res, err := buildTargets(ctx, dockerCli, b.NodeGroup, nodes, map[string]build.Options{defaultTargetName: opts}, progress, mp, generateResult) err = wrapBuildError(err, false) if err != nil { // NOTE: buildTargets can return *build.ResultHandle even on error. @@ -201,14 +202,14 @@ func RunBuild(ctx context.Context, dockerCli command.Cli, in controllerapi.Build // NOTE: When an error happens during the build and this function acquires the debuggable *build.ResultHandle, // this function returns it in addition to the error (i.e. it does "return nil, res, err"). The caller can // inspect the result and debug the cause of that error. -func buildTargets(ctx context.Context, dockerCli command.Cli, ng *store.NodeGroup, nodes []builder.Node, opts map[string]build.Options, progress progress.Writer, generateResult bool) (*client.SolveResponse, *build.ResultHandle, error) { +func buildTargets(ctx context.Context, dockerCli command.Cli, ng *store.NodeGroup, nodes []builder.Node, opts map[string]build.Options, progress progress.Writer, mp metric.MeterProvider, generateResult bool) (*client.SolveResponse, *build.ResultHandle, error) { var res *build.ResultHandle var resp map[string]*client.SolveResponse var err error if generateResult { var mu sync.Mutex var idx int - resp, err = build.BuildWithResultHandler(ctx, nodes, opts, dockerutil.NewClient(dockerCli), confutil.ConfigDir(dockerCli), progress, func(driverIndex int, gotRes *build.ResultHandle) { + resp, err = build.BuildWithResultHandler(ctx, nodes, opts, dockerutil.NewClient(dockerCli), confutil.ConfigDir(dockerCli), progress, mp, func(driverIndex int, gotRes *build.ResultHandle) { mu.Lock() defer mu.Unlock() if res == nil || driverIndex < idx { @@ -216,7 +217,7 @@ func buildTargets(ctx context.Context, dockerCli command.Cli, ng *store.NodeGrou } }) } else { - resp, err = build.Build(ctx, nodes, opts, dockerutil.NewClient(dockerCli), confutil.ConfigDir(dockerCli), progress) + resp, err = build.Build(ctx, nodes, opts, dockerutil.NewClient(dockerCli), confutil.ConfigDir(dockerCli), progress, mp) } if err != nil { return nil, res, err diff --git a/controller/controller.go b/controller/controller.go index 635a7a234bd..bca8a1994ae 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -10,9 +10,10 @@ import ( "github.com/docker/buildx/util/progress" "github.com/docker/cli/cli/command" "github.com/pkg/errors" + "go.opentelemetry.io/otel/metric" ) -func NewController(ctx context.Context, opts control.ControlOptions, dockerCli command.Cli, pw progress.Writer) (control.BuildxController, error) { +func NewController(ctx context.Context, opts control.ControlOptions, dockerCli command.Cli, pw progress.Writer, mp metric.MeterProvider) (control.BuildxController, error) { var name string if opts.Detach { name = "remote" @@ -23,9 +24,9 @@ func NewController(ctx context.Context, opts control.ControlOptions, dockerCli c var c control.BuildxController err := progress.Wrap(fmt.Sprintf("[internal] connecting to %s controller", name), pw.Write, func(l progress.SubLogger) (err error) { if opts.Detach { - c, err = remote.NewRemoteBuildxController(ctx, dockerCli, opts, l) + c, err = remote.NewRemoteBuildxController(ctx, dockerCli, opts, l, mp) } else { - c = local.NewLocalBuildxController(ctx, dockerCli, l) + c = local.NewLocalBuildxController(ctx, dockerCli, l, mp) } return err }) diff --git a/controller/local/controller.go b/controller/local/controller.go index fb1cd282d39..fd00a3bc9c9 100644 --- a/controller/local/controller.go +++ b/controller/local/controller.go @@ -16,13 +16,19 @@ import ( "github.com/docker/cli/cli/command" "github.com/moby/buildkit/client" "github.com/pkg/errors" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/metric/noop" ) -func NewLocalBuildxController(ctx context.Context, dockerCli command.Cli, logger progress.SubLogger) control.BuildxController { +func NewLocalBuildxController(ctx context.Context, dockerCli command.Cli, logger progress.SubLogger, mp metric.MeterProvider) control.BuildxController { + if mp == nil { + mp = noop.NewMeterProvider() + } return &localController{ dockerCli: dockerCli, ref: "local", processes: processes.NewManager(), + mp: mp, } } @@ -38,6 +44,7 @@ type localController struct { ref string buildConfig buildConfig processes *processes.Manager + mp metric.MeterProvider buildOnGoing atomic.Bool } @@ -48,7 +55,7 @@ func (b *localController) Build(ctx context.Context, options controllerapi.Build } defer b.buildOnGoing.Store(false) - resp, res, buildErr := cbuild.RunBuild(ctx, b.dockerCli, options, in, progress, true) + resp, res, buildErr := cbuild.RunBuild(ctx, b.dockerCli, options, in, progress, b.mp, true) // NOTE: RunBuild can return *build.ResultHandle even on error. if res != nil { b.buildConfig = buildConfig{ diff --git a/controller/remote/controller.go b/controller/remote/controller.go index 2fbe175819c..17be67fe2d5 100644 --- a/controller/remote/controller.go +++ b/controller/remote/controller.go @@ -30,6 +30,8 @@ import ( "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/metric/noop" "google.golang.org/grpc" ) @@ -54,7 +56,7 @@ type serverConfig struct { LogFile string `toml:"log_file"` } -func NewRemoteBuildxController(ctx context.Context, dockerCli command.Cli, opts control.ControlOptions, logger progress.SubLogger) (control.BuildxController, error) { +func NewRemoteBuildxController(ctx context.Context, dockerCli command.Cli, opts control.ControlOptions, logger progress.SubLogger, mp metric.MeterProvider) (control.BuildxController, error) { rootDir := opts.Root if rootDir == "" { rootDir = rootDataDir(dockerCli) @@ -149,7 +151,7 @@ func serveCmd(dockerCli command.Cli) *cobra.Command { // prepare server b := NewServer(func(ctx context.Context, options *controllerapi.BuildOptions, stdin io.Reader, progress progress.Writer) (*client.SolveResponse, *build.ResultHandle, error) { - return cbuild.RunBuild(ctx, dockerCli, *options, stdin, progress, true) + return cbuild.RunBuild(ctx, dockerCli, *options, stdin, progress, noop.NewMeterProvider(), true) }) defer b.Close() diff --git a/controller/remote/controller_nolinux.go b/controller/remote/controller_nolinux.go index 07c1c3b2473..ca53d67e6cc 100644 --- a/controller/remote/controller_nolinux.go +++ b/controller/remote/controller_nolinux.go @@ -10,9 +10,10 @@ import ( "github.com/docker/cli/cli/command" "github.com/pkg/errors" "github.com/spf13/cobra" + "go.opentelemetry.io/otel/metric" ) -func NewRemoteBuildxController(ctx context.Context, dockerCli command.Cli, opts control.ControlOptions, logger progress.SubLogger) (control.BuildxController, error) { +func NewRemoteBuildxController(ctx context.Context, dockerCli command.Cli, opts control.ControlOptions, logger progress.SubLogger, mp metric.MeterProvider) (control.BuildxController, error) { return nil, errors.New("remote buildx unsupported") } diff --git a/go.mod b/go.mod index 19049f90096..98f14fed0ce 100644 --- a/go.mod +++ b/go.mod @@ -42,6 +42,7 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.42.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.42.0 go.opentelemetry.io/otel/metric v1.19.0 + go.opentelemetry.io/otel/sdk v1.19.0 go.opentelemetry.io/otel/sdk/metric v1.19.0 go.opentelemetry.io/otel/trace v1.19.0 golang.org/x/mod v0.13.0 @@ -147,7 +148,6 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 // indirect go.opentelemetry.io/otel/exporters/prometheus v0.42.0 // indirect - go.opentelemetry.io/otel/sdk v1.19.0 // indirect go.opentelemetry.io/proto/otlp v1.0.0 // indirect golang.org/x/crypto v0.17.0 // indirect golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect diff --git a/util/metrics/metrics.go b/otel/sdk/metric/metric.go similarity index 60% rename from util/metrics/metrics.go rename to otel/sdk/metric/metric.go index 36e1cc62c2e..fb0e650d982 100644 --- a/util/metrics/metrics.go +++ b/otel/sdk/metric/metric.go @@ -1,4 +1,4 @@ -package metrics +package metric import ( "context" @@ -7,8 +7,9 @@ import ( "path" "time" + "github.com/docker/buildx/util/confutil" + "github.com/docker/buildx/version" "github.com/docker/cli/cli/command" - "github.com/moby/buildkit/util/tracing/detect" "github.com/pkg/errors" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" "go.opentelemetry.io/otel/metric" @@ -20,75 +21,93 @@ import ( const ( otelConfigFieldName = "otel" - shutdownTimeout = 2 * time.Second + reportTimeout = 2 * time.Second ) -// ReportFunc is invoked to signal the metrics should be sent to the -// desired endpoint. It should be invoked on application shutdown. -type ReportFunc func() +// MeterProvider holds a MeterProvider for metric generation and the configured +// exporters for reporting metrics from the CLI. +type MeterProvider struct { + metric.MeterProvider + reader *sdkmetric.ManualReader + exporters []sdkmetric.Exporter +} -// MeterProvider returns a MeterProvider suitable for CLI usage. -// The primary difference between this metric reader and a more typical -// usage is that metric reporting only happens once when ReportFunc -// is invoked. -func MeterProvider(cli command.Cli) (metric.MeterProvider, ReportFunc, error) { +// NewMeterProvider configures a MeterProvider from the CLI context. +func NewMeterProvider(ctx context.Context, cli command.Cli) (*MeterProvider, error) { var exps []sdkmetric.Exporter - if exp, err := dockerOtelExporter(cli); err != nil { - return nil, nil, err - } else if exp != nil { - exps = append(exps, exp) - } + // Only metric exporters if the experimental flag is set. + if confutil.IsExperimental() { + if exp, err := dockerOtelExporter(cli); err != nil { + return nil, err + } else if exp != nil { + exps = append(exps, exp) + } - if exp, err := detectOtlpExporter(context.Background()); err != nil { - return nil, nil, err - } else if exp != nil { - exps = append(exps, exp) + if exp, err := detectOtlpExporter(ctx); err != nil { + return nil, err + } else if exp != nil { + exps = append(exps, exp) + } } if len(exps) == 0 { // No exporters are configured so use a noop provider. - return noop.NewMeterProvider(), func() {}, nil + return &MeterProvider{ + MeterProvider: noop.NewMeterProvider(), + }, nil } - // Use delta temporality because, since this is a CLI program, we can never - // know the cumulative value. reader := sdkmetric.NewManualReader( sdkmetric.WithTemporalitySelector(deltaTemporality), ) mp := sdkmetric.NewMeterProvider( - sdkmetric.WithResource(detect.Resource()), + sdkmetric.WithResource(Resource()), sdkmetric.WithReader(reader), ) - return mp, reportFunc(reader, exps), nil + return &MeterProvider{ + MeterProvider: mp, + reader: reader, + exporters: exps, + }, nil } -// reportFunc returns a ReportFunc for collecting ResourceMetrics and then -// exporting them to the configured Exporter. -func reportFunc(reader sdkmetric.Reader, exps []sdkmetric.Exporter) ReportFunc { - return func() { - ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) - defer cancel() - - var rm metricdata.ResourceMetrics - if err := reader.Collect(ctx, &rm); err != nil { - // Error when collecting metrics. Do not send any. - return - } +// Report exports metrics to the configured exporter. This should be done before the CLI +// exits. +func (m *MeterProvider) Report(ctx context.Context) { + if m.reader == nil { + // Not configured. + return + } - var eg errgroup.Group - for _, exp := range exps { - exp := exp - eg.Go(func() error { - _ = exp.Export(ctx, &rm) - _ = exp.Shutdown(ctx) - return nil - }) - } + ctx, cancel := context.WithTimeout(ctx, reportTimeout) + defer cancel() - // Can't report an error because we don't allow it to. - _ = eg.Wait() + var rm metricdata.ResourceMetrics + if err := m.reader.Collect(ctx, &rm); err != nil { + // Error when collecting metrics. Do not send any. + return } + + var eg errgroup.Group + for _, exp := range m.exporters { + exp := exp + eg.Go(func() error { + _ = exp.Export(ctx, &rm) + _ = exp.Shutdown(ctx) + return nil + }) + } + + // Can't report an error because we don't allow it to. + _ = eg.Wait() +} + +// Meter returns a Meter from the MetricProvider that indicates the measurement +// comes from buildx with the appropriate version. +func Meter(mp metric.MeterProvider) metric.Meter { + return mp.Meter(version.Package, + metric.WithInstrumentationVersion(version.Version)) } // dockerOtelExporter reads the CLI metadata to determine an OTLP exporter @@ -184,6 +203,13 @@ func otelExporterOtlpEndpoint(cli command.Cli) (string, error) { } // deltaTemporality sets the Temporality of every instrument to delta. +// +// This isn't really needed since we create a unique resource on each invocation, +// but it can help with cardinality concerns for downstream processors since they can +// perform aggregation for a time interval and then discard the data once that time +// period has passed. Cumulative temporality would imply to the downstream processor +// that they might receive a successive point and they may unnecessarily keep state +// they really shouldn't. func deltaTemporality(_ sdkmetric.InstrumentKind) metricdata.Temporality { return metricdata.DeltaTemporality } diff --git a/util/metrics/otlp.go b/otel/sdk/metric/otlp.go similarity index 86% rename from util/metrics/otlp.go rename to otel/sdk/metric/otlp.go index b121ac3cd7a..09b9aa74def 100644 --- a/util/metrics/otlp.go +++ b/otel/sdk/metric/otlp.go @@ -1,4 +1,4 @@ -package metrics +package metric import ( "context" @@ -35,13 +35,9 @@ func detectOtlpExporter(ctx context.Context) (sdkmetric.Exporter, error) { switch proto { case "grpc": - return otlpmetricgrpc.New(ctx, - otlpmetricgrpc.WithTemporalitySelector(deltaTemporality), - ) + return otlpmetricgrpc.New(ctx) case "http/protobuf": - return otlpmetrichttp.New(ctx, - otlpmetrichttp.WithTemporalitySelector(deltaTemporality), - ) + return otlpmetrichttp.New(ctx) // case "http/json": // unsupported by library default: return nil, errors.Errorf("unsupported otlp protocol %v", proto) diff --git a/otel/sdk/metric/resource.go b/otel/sdk/metric/resource.go new file mode 100644 index 00000000000..06b10f2ad22 --- /dev/null +++ b/otel/sdk/metric/resource.go @@ -0,0 +1,50 @@ +package metric + +import ( + "context" + "os" + "path/filepath" + "sync" + + "github.com/google/uuid" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/sdk/resource" + semconv "go.opentelemetry.io/otel/semconv/v1.21.0" +) + +var ( + res *resource.Resource + resOnce sync.Once +) + +// Resource retrieves the OTEL resource for the buildx CLI. +func Resource() *resource.Resource { + resOnce.Do(func() { + var err error + res, err = resource.New(context.Background(), + resource.WithDetectors(serviceNameDetector{}), + resource.WithAttributes( + attribute.Stringer("service.instance.id", uuid.New()), + ), + resource.WithFromEnv(), + resource.WithTelemetrySDK(), + ) + if err != nil { + otel.Handle(err) + } + }) + return res +} + +type serviceNameDetector struct{} + +func (serviceNameDetector) Detect(ctx context.Context) (*resource.Resource, error) { + return resource.StringDetector( + semconv.SchemaURL, + semconv.ServiceNameKey, + func() (string, error) { + return filepath.Base(os.Args[0]), nil + }, + ).Detect(ctx) +} diff --git a/util/confutil/exp.go b/util/confutil/exp.go new file mode 100644 index 00000000000..03c6b97a648 --- /dev/null +++ b/util/confutil/exp.go @@ -0,0 +1,15 @@ +package confutil + +import ( + "os" + "strconv" +) + +// IsExperimental checks if the experimental flag has been configured. +func IsExperimental() bool { + if v, ok := os.LookupEnv("BUILDX_EXPERIMENTAL"); ok { + vv, _ := strconv.ParseBool(v) + return vv + } + return false +} diff --git a/util/progress/metricwriter.go b/util/progress/metricwriter.go new file mode 100644 index 00000000000..01add5b2ed3 --- /dev/null +++ b/util/progress/metricwriter.go @@ -0,0 +1,94 @@ +package progress + +import ( + "context" + "strings" + "time" + + sdkmetric "github.com/docker/buildx/otel/sdk/metric" + "github.com/moby/buildkit/client" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +type ( + buildStatus int +) + +const ( + buildStatusComplete buildStatus = iota + buildStatusCanceled + buildStatusError +) + +type RecordFunc func(ctx context.Context) + +type MetricWriter struct { + Writer + meter metric.Meter + opts []metric.MeasurementOption + status buildStatus + start time.Time +} + +func Metrics(mp metric.MeterProvider, pw Writer, opts ...metric.MeasurementOption) (Writer, RecordFunc) { + mw := &MetricWriter{ + Writer: pw, + meter: sdkmetric.Meter(mp), + opts: opts, + status: buildStatusComplete, + start: time.Now(), + } + return mw, mw.Record +} + +func (mw *MetricWriter) Write(ss *client.SolveStatus) { + mw.write(ss) + mw.Writer.Write(ss) +} + +func (mw *MetricWriter) write(ss *client.SolveStatus) { + for _, v := range ss.Vertexes { + if v.Error != "" { + newBuildStatus := buildStatusError + if strings.HasSuffix(v.Error, context.Canceled.Error()) { + newBuildStatus = buildStatusCanceled + } + + if mw.status < newBuildStatus { + mw.status = newBuildStatus + } + } + } +} + +func (mw *MetricWriter) Record(ctx context.Context) { + mw.record(ctx) +} + +func (mw *MetricWriter) record(ctx context.Context) { + buildDuration, _ := mw.meter.Int64Counter("build.duration", + metric.WithDescription("Measures the total build duration."), + metric.WithUnit("ms")) + + totalDur := time.Since(mw.start) + bopts := make([]metric.AddOption, 0, len(mw.opts)+1) + for _, opt := range mw.opts { + bopts = append(bopts, opt) + } + bopts = append(bopts, metric.WithAttributes(mw.statusAttribute())) + buildDuration.Add(ctx, int64(totalDur/time.Millisecond), bopts...) +} + +func (mw *MetricWriter) statusAttribute() attribute.KeyValue { + status := "unknown" + switch mw.status { + case buildStatusComplete: + status = "completed" + case buildStatusCanceled: + status = "canceled" + case buildStatusError: + status = "error" + } + return attribute.String("status", status) +}