From d92d4f9c110badd9eb9a11fca9c0c6d208efcc19 Mon Sep 17 00:00:00 2001 From: James Humphries Date: Fri, 25 Apr 2025 12:40:48 +0100 Subject: [PATCH] [OpenTelemetry] Add traces to `init` command (#2665) Signed-off-by: James Humphries Signed-off-by: Christian Mesh Co-authored-by: Christian Mesh --- CHANGELOG.md | 1 + cmd/tofu/main.go | 33 ++--- cmd/tofu/telemetry.go | 93 ------------- go.mod | 14 +- go.sum | 20 +-- internal/command/cliconfig/cliconfig.go | 4 +- internal/command/cliconfig/cliconfig_test.go | 4 +- internal/command/init.go | 31 +++-- internal/command/meta_config.go | 10 -- internal/command/meta_dependencies.go | 5 +- internal/command/providers_lock.go | 2 +- internal/command/telemetry.go | 17 --- internal/command/version_test.go | 5 +- internal/depsfile/locks_file.go | 14 +- internal/depsfile/locks_file_test.go | 4 +- .../package_location_http_archive.go | 11 ++ .../package_location_local_archive.go | 18 ++- internal/getproviders/registry_client.go | 62 +++++++-- internal/providercache/installer.go | 19 ++- internal/tracing/init.go | 128 ++++++++++++++++++ internal/tracing/traceattrs/traceattrs.go | 13 ++ internal/tracing/utils.go | 121 +++++++++++++++++ internal/tracing/utils_test.go | 39 ++++++ 23 files changed, 476 insertions(+), 192 deletions(-) delete mode 100644 cmd/tofu/telemetry.go delete mode 100644 internal/command/telemetry.go create mode 100644 internal/tracing/init.go create mode 100644 internal/tracing/traceattrs/traceattrs.go create mode 100644 internal/tracing/utils.go create mode 100644 internal/tracing/utils_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d33f2709a..22ee2961bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ NEW FEATURES: - Added support for S3 native locking ([#599](https://github.com/opentofu/opentofu/issues/599)) - Backend `pg` now allows the `table_name` and `index_name` to be specified. This enables a single database schema to support multiple backends via multiple tables. ([#2465](https://github.com/opentofu/opentofu/pull/2465)) - Module variables and outputs can now be marked as `deprecated` to indicate their removal in the future. ([#1005](https://github.com/opentofu/opentofu/issues/1005)) +- OpenTelemetry tracing has been added to the `init` command for provider installation. Note: This feature is experimental and subject to change in the future. ([#2665](https://github.com/opentofu/opentofu/pull/2665)) ENHANCEMENTS: diff --git a/cmd/tofu/main.go b/cmd/tofu/main.go index a2449db07a..a8002e648a 100644 --- a/cmd/tofu/main.go +++ b/cmd/tofu/main.go @@ -15,13 +15,14 @@ import ( "path/filepath" "runtime" "strings" + "time" - "github.com/apparentlymart/go-shquot/shquot" "github.com/hashicorp/go-plugin" "github.com/hashicorp/terraform-svchost/disco" "github.com/mattn/go-shellwords" "github.com/mitchellh/cli" "github.com/mitchellh/colorstring" + "github.com/opentofu/opentofu/internal/addrs" "github.com/opentofu/opentofu/internal/command/cliconfig" "github.com/opentofu/opentofu/internal/command/format" @@ -29,8 +30,8 @@ import ( "github.com/opentofu/opentofu/internal/httpclient" "github.com/opentofu/opentofu/internal/logging" "github.com/opentofu/opentofu/internal/terminal" + "github.com/opentofu/opentofu/internal/tracing" "github.com/opentofu/opentofu/version" - "go.opentelemetry.io/otel/trace" backendInit "github.com/opentofu/opentofu/internal/backend/init" ) @@ -69,25 +70,22 @@ func main() { func realMain() int { defer logging.PanicHandler() - var err error - - err = openTelemetryInit() + err := tracing.OpenTelemetryInit() if err != nil { // openTelemetryInit can only fail if OpenTofu was run with an // explicit environment variable to enable telemetry collection, // so in typical use we cannot get here. Ui.Error(fmt.Sprintf("Could not initialize telemetry: %s", err)) - Ui.Error(fmt.Sprintf("Unset environment variable %s if you don't intend to collect telemetry from OpenTofu.", openTelemetryExporterEnvVar)) + Ui.Error(fmt.Sprintf("Unset environment variable %s if you don't intend to collect telemetry from OpenTofu.", tracing.OTELExporterEnvVar)) + return 1 } - var ctx context.Context - var otelSpan trace.Span - { - // At minimum we emit a span covering the entire command execution. - _, displayArgs := shquot.POSIXShellSplit(os.Args) - ctx, otelSpan = tracer.Start(context.Background(), fmt.Sprintf("tofu %s", displayArgs)) - defer otelSpan.End() - } + defer tracing.ForceFlush(5 * time.Second) + ctx := context.Background() + + // At minimum, we emit a span covering the entire command execution. + ctx, span := tracing.Tracer().Start(ctx, "tofu") + defer span.End() tmpLogPath := os.Getenv(envTmpLogPath) if tmpLogPath != "" { @@ -102,9 +100,7 @@ func realMain() int { } } - log.Printf( - "[INFO] OpenTofu version: %s %s", - Version, VersionPrerelease) + log.Printf("[INFO] OpenTofu version: %s %s", Version, VersionPrerelease) for _, depMod := range version.InterestingDependencies() { log.Printf("[DEBUG] using %s %s", depMod.Path, depMod.Version) } @@ -117,6 +113,7 @@ func realMain() int { streams, err := terminal.Init() if err != nil { Ui.Error(fmt.Sprintf("Failed to configure the terminal: %s", err)) + return 1 } if streams.Stdout.IsTerminal() { @@ -140,7 +137,7 @@ func realMain() int { // path in the TERRAFORM_CONFIG_FILE environment variable (though probably // ill-advised) will be resolved relative to the true working directory, // not the overridden one. - config, diags := cliconfig.LoadConfig() + config, diags := cliconfig.LoadConfig(ctx) if len(diags) > 0 { // Since we haven't instantiated a command.Meta yet, we need to do diff --git a/cmd/tofu/telemetry.go b/cmd/tofu/telemetry.go deleted file mode 100644 index 5b1f3a6615..0000000000 --- a/cmd/tofu/telemetry.go +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) The OpenTofu Authors -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2023 HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package main - -import ( - "context" - "os" - - "go.opentelemetry.io/contrib/exporters/autoexport" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/propagation" - "go.opentelemetry.io/otel/sdk/resource" - sdktrace "go.opentelemetry.io/otel/sdk/trace" - semconv "go.opentelemetry.io/otel/semconv/v1.4.0" - "go.opentelemetry.io/otel/trace" - - "github.com/opentofu/opentofu/version" -) - -// If this environment variable is set to "otlp" when running OpenTofu CLI -// then we'll enable an experimental OTLP trace exporter. -// -// BEWARE! This is not a committed external interface. -// -// Everything about this is experimental and subject to change in future -// releases. Do not depend on anything about the structure of this output. -// This mechanism might be removed altogether if a different strategy seems -// better based on experience with this experiment. -const openTelemetryExporterEnvVar = "OTEL_TRACES_EXPORTER" - -// tracer is the OpenTelemetry tracer to use for traces in package main only. -var tracer trace.Tracer - -func init() { - tracer = otel.Tracer("github.com/opentofu/opentofu") -} - -// openTelemetryInit initializes the optional OpenTelemetry exporter. -// -// By default we don't export telemetry information at all, since OpenTofu is -// a CLI tool and so we don't assume we're running in an environment with -// a telemetry collector available. -// -// However, for those running OpenTofu in automation we allow setting -// the standard OpenTelemetry environment variable OTEL_TRACES_EXPORTER=otlp -// to enable an OTLP exporter, which is in turn configured by all of the -// standard OTLP exporter environment variables: -// -// https://opentelemetry.io/docs/specs/otel/protocol/exporter/#configuration-options -// -// We don't currently support any other telemetry export protocols, because -// OTLP has emerged as a de-facto standard and each other exporter we support -// means another relatively-heavy external dependency. OTLP happens to use -// protocol buffers and gRPC, which OpenTofu would depend on for other reasons -// anyway. -func openTelemetryInit() error { - // We'll check the environment variable ourselves first, because the - // "autoexport" helper we're about to use is built under the assumption - // that exporting should always be enabled and so will expect to find - // an OTLP server on localhost if no environment variables are set at all. - if os.Getenv(openTelemetryExporterEnvVar) != "otlp" { - return nil // By default we just discard all telemetry calls - } - - otelResource := resource.NewWithAttributes( - semconv.SchemaURL, - semconv.ServiceNameKey.String("OpenTofu CLI"), - semconv.ServiceVersionKey.String(version.Version), - ) - - // If the environment variable was set to explicitly enable telemetry - // then we'll enable it, using the "autoexport" library to automatically - // handle the details based on the other OpenTelemetry standard environment - // variables. - exp, err := autoexport.NewSpanExporter(context.Background()) - if err != nil { - return err - } - sp := sdktrace.NewSimpleSpanProcessor(exp) - provider := sdktrace.NewTracerProvider( - sdktrace.WithSpanProcessor(sp), - sdktrace.WithResource(otelResource), - ) - otel.SetTracerProvider(provider) - - pgtr := propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}) - otel.SetTextMapPropagator(pgtr) - - return nil -} diff --git a/go.mod b/go.mod index e00745e8e9..bf98afd4d7 100644 --- a/go.mod +++ b/go.mod @@ -34,9 +34,10 @@ require ( github.com/cli/browser v1.3.0 github.com/davecgh/go-spew v1.1.1 github.com/dylanmei/winrmtest v0.0.0-20210303004826-fbc9ae56efb6 + github.com/go-logr/stdr v1.2.2 github.com/go-test/deep v1.1.0 github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 - github.com/google/go-cmp v0.6.0 + github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/googleapis/gax-go/v2 v2.12.0 github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.62 @@ -93,9 +94,10 @@ require ( github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 github.com/zclconf/go-cty-yaml v1.1.0 go.opentelemetry.io/contrib/exporters/autoexport v0.0.0-20230703072336-9a582bd098a2 - go.opentelemetry.io/otel v1.34.0 - go.opentelemetry.io/otel/sdk v1.33.0 - go.opentelemetry.io/otel/trace v1.34.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 + go.opentelemetry.io/otel v1.35.0 + go.opentelemetry.io/otel/sdk v1.35.0 + go.opentelemetry.io/otel/trace v1.35.0 go.uber.org/mock v0.4.0 golang.org/x/crypto v0.35.0 golang.org/x/exp v0.0.0-20230905200255-921286631fa9 @@ -180,7 +182,6 @@ require ( github.com/fsnotify/fsnotify v1.5.4 // indirect github.com/go-jose/go-jose/v3 v3.0.3 // indirect github.com/go-logr/logr v1.4.2 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/errors v0.20.2 // indirect github.com/go-openapi/strfmt v0.21.3 // indirect github.com/gofrs/uuid v4.0.0+incompatible // indirect @@ -252,11 +253,10 @@ require ( go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/github.com/aws/aws-sdk-go-v2/otelaws v0.59.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 // indirect - go.opentelemetry.io/otel/metric v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/proto/otlp v1.0.0 // indirect golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a // indirect golang.org/x/sync v0.11.0 // indirect diff --git a/go.sum b/go.sum index 9f5a0c4e63..12100f2e55 100644 --- a/go.sum +++ b/go.sum @@ -567,8 +567,8 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/google/go-github/v45 v45.2.0 h1:5oRLszbrkvxDDqBCNj2hjDZMKmvexaZ1xw/FCD+K3FI= github.com/google/go-github/v45 v45.2.0/go.mod h1:FObaZJEDSTa/WGCzZ2Z3eoCDXWJKMenWWTrd8jrta28= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= @@ -1088,8 +1088,8 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.4 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1/go.mod h1:4UoMYEZOC0yN/sPGH76KPkkU7zgiEWYWL9vwmbnTJPE= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +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/exporters/otlp/otlptrace v1.21.0 h1:cl5P5/GIfFh4t6xyruOgJP5QiA1pw4fYYdv6nc6CBWw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0/go.mod h1:zgBdWWAu7oEEMC06MMKc5NLbA/1YDXV1sMpSqEeLQLg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0 h1:tIqheXEFWAZ7O8A7m+J0aPTmpJN3YQ7qetUAdkkkKpk= @@ -1098,12 +1098,12 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 h1:digkE go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0/go.mod h1:/OpE/y70qVkndM0TrxT4KBoN3RsFZP0QaofcfYrj76I= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.16.0 h1:+XWJd3jf75RXJq29mxbuXhCXFDG3S3R4vBUeSI2P7tE= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.16.0/go.mod h1:hqgzBPTf4yONMFgdZvL/bK42R/iinTyVQtiWihs3SZc= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= -go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= diff --git a/internal/command/cliconfig/cliconfig.go b/internal/command/cliconfig/cliconfig.go index 5b5d21918f..edfdfb2c19 100644 --- a/internal/command/cliconfig/cliconfig.go +++ b/internal/command/cliconfig/cliconfig.go @@ -14,6 +14,7 @@ package cliconfig import ( + "context" "errors" "fmt" "io/fs" @@ -23,7 +24,6 @@ import ( "strings" "github.com/hashicorp/hcl" - svchost "github.com/hashicorp/terraform-svchost" "github.com/opentofu/opentofu/internal/tfdiags" @@ -116,7 +116,7 @@ func DataDirs() ([]string, error) { // LoadConfig reads the CLI configuration from the various filesystem locations // and from the environment, returning a merged configuration along with any // diagnostics (errors and warnings) encountered along the way. -func LoadConfig() (*Config, tfdiags.Diagnostics) { +func LoadConfig(_ context.Context) (*Config, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics configVal := BuiltinConfig // copy config := &configVal diff --git a/internal/command/cliconfig/cliconfig_test.go b/internal/command/cliconfig/cliconfig_test.go index bc6874371a..e25c9e1677 100644 --- a/internal/command/cliconfig/cliconfig_test.go +++ b/internal/command/cliconfig/cliconfig_test.go @@ -6,6 +6,7 @@ package cliconfig import ( + "context" "os" "path/filepath" "reflect" @@ -13,6 +14,7 @@ import ( "github.com/davecgh/go-spew/spew" "github.com/google/go-cmp/cmp" + "github.com/opentofu/opentofu/internal/tfdiags" ) @@ -66,7 +68,7 @@ func TestLoadConfig_non_existing_file(t *testing.T) { t.Setenv("TF_CLI_CONFIG_FILE", cliTmpFile) - c, errs := LoadConfig() + c, errs := LoadConfig(context.Background()) if errs.HasErrors() || c.Validate().HasErrors() { t.Fatalf("err: %s", errs) } diff --git a/internal/command/init.go b/internal/command/init.go index 6decff084f..bb5aee56b1 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -17,8 +17,7 @@ import ( svchost "github.com/hashicorp/terraform-svchost" "github.com/posener/complete" "github.com/zclconf/go-cty/cty" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" + otelAttr "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "github.com/opentofu/opentofu/internal/addrs" @@ -36,6 +35,7 @@ import ( "github.com/opentofu/opentofu/internal/tfdiags" "github.com/opentofu/opentofu/internal/tofu" "github.com/opentofu/opentofu/internal/tofumigrate" + "github.com/opentofu/opentofu/internal/tracing" tfversion "github.com/opentofu/opentofu/version" ) @@ -46,6 +46,11 @@ type InitCommand struct { } func (c *InitCommand) Run(args []string) int { + ctx := c.CommandContext() + + ctx, span := tracing.Tracer().Start(ctx, "Init") + defer span.End() + var flagFromModule, flagLockfile, testsDirectory string var flagBackend, flagCloud, flagGet, flagUpgrade bool var flagPluginPath FlagStringSlice @@ -129,7 +134,7 @@ func (c *InitCommand) Run(args []string) int { } // Initialization can be aborted by interruption signals - ctx, done := c.InterruptibleContext(c.CommandContext()) + ctx, done := c.InterruptibleContext(ctx) defer done() // This will track whether we outputted anything so that we know whether @@ -159,19 +164,19 @@ func (c *InitCommand) Run(args []string) int { ShowLocalPaths: false, // since they are in a weird location for init } - ctx, span := tracer.Start(ctx, "-from-module=...", trace.WithAttributes( - attribute.String("module_source", src), + ctx, span := tracing.Tracer().Start(ctx, "From module", trace.WithAttributes( + otelAttr.String("opentofu.module_source", src), )) + defer span.End() initDirFromModuleAbort, initDirFromModuleDiags := c.initDirFromModule(ctx, path, src, hooks) diags = diags.Append(initDirFromModuleDiags) if initDirFromModuleAbort || initDirFromModuleDiags.HasErrors() { c.showDiagnostics(diags) - span.SetStatus(codes.Error, "module installation failed") + tracing.SetSpanError(span, initDirFromModuleDiags) span.End() return 1 } - span.End() c.Ui.Output("") } @@ -402,8 +407,8 @@ func (c *InitCommand) getModules(ctx context.Context, path, testsDir string, ear return false, false, nil } - ctx, span := tracer.Start(ctx, "install modules", trace.WithAttributes( - attribute.Bool("upgrade", upgrade), + ctx, span := tracing.Tracer().Start(ctx, "Get modules", trace.WithAttributes( + otelAttr.Bool("opentofu.modules.upgrade", upgrade), )) defer span.End() @@ -442,7 +447,7 @@ func (c *InitCommand) getModules(ctx context.Context, path, testsDir string, ear } func (c *InitCommand) initCloud(ctx context.Context, root *configs.Module, extraConfig rawFlags, enc encryption.Encryption) (be backend.Backend, output bool, diags tfdiags.Diagnostics) { - ctx, span := tracer.Start(ctx, "initialize cloud backend") + ctx, span := tracing.Tracer().Start(ctx, "Cloud backend init") _ = ctx // prevent staticcheck from complaining to avoid a maintenance hazard of having the wrong ctx in scope here defer span.End() @@ -470,7 +475,7 @@ func (c *InitCommand) initCloud(ctx context.Context, root *configs.Module, extra } func (c *InitCommand) initBackend(ctx context.Context, root *configs.Module, extraConfig rawFlags, enc encryption.Encryption) (be backend.Backend, output bool, diags tfdiags.Diagnostics) { - ctx, span := tracer.Start(ctx, "initialize backend") + ctx, span := tracing.Tracer().Start(ctx, "Backend init") _ = ctx // prevent staticcheck from complaining to avoid a maintenance hazard of having the wrong ctx in scope here defer span.End() @@ -555,7 +560,7 @@ the backend configuration is present and valid. // Load the complete module tree, and fetch any missing providers. // This method outputs its own Ui. func (c *InitCommand) getProviders(ctx context.Context, config *configs.Config, state *states.State, upgrade bool, pluginDirs []string, flagLockfile string) (output, abort bool, diags tfdiags.Diagnostics) { - ctx, span := tracer.Start(ctx, "install providers") + ctx, span := tracing.Tracer().Start(ctx, "Get Providers") defer span.End() // Dev overrides cause the result of "tofu init" to be irrelevant for @@ -1035,7 +1040,7 @@ in the .terraform.lock.hcl file. Review those changes and commit them to your version control system if they represent changes you intended to make.`)) } - moreDiags = c.replaceLockedDependencies(newLocks) + moreDiags = c.replaceLockedDependencies(ctx, newLocks) diags = diags.Append(moreDiags) } diff --git a/internal/command/meta_config.go b/internal/command/meta_config.go index 41ef4d16f0..19e145c1e5 100644 --- a/internal/command/meta_config.go +++ b/internal/command/meta_config.go @@ -17,8 +17,6 @@ import ( "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/convert" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/trace" "github.com/opentofu/opentofu/internal/addrs" "github.com/opentofu/opentofu/internal/configs" @@ -275,9 +273,6 @@ func (m *Meta) loadHCLFile(filename string) (hcl.Body, tfdiags.Diagnostics) { // this package has a reasonable implementation for displaying notifications // via a provided cli.Ui. func (m *Meta) installModules(ctx context.Context, rootDir, testsDir string, upgrade, installErrsOnly bool, hooks initwd.ModuleInstallHooks) (abort bool, diags tfdiags.Diagnostics) { - ctx, span := tracer.Start(ctx, "install modules") - defer span.End() - rootDir = m.normalizePath(rootDir) err := os.MkdirAll(m.modulesDir(), os.ModePerm) @@ -322,11 +317,6 @@ func (m *Meta) installModules(ctx context.Context, rootDir, testsDir string, upg // this package has a reasonable implementation for displaying notifications // via a provided cli.Ui. func (m *Meta) initDirFromModule(ctx context.Context, targetDir string, addr string, hooks initwd.ModuleInstallHooks) (abort bool, diags tfdiags.Diagnostics) { - ctx, span := tracer.Start(ctx, "initialize directory from module", trace.WithAttributes( - attribute.String("source_addr", addr), - )) - defer span.End() - loader, err := m.initConfigLoader() if err != nil { diags = diags.Append(err) diff --git a/internal/command/meta_dependencies.go b/internal/command/meta_dependencies.go index d810136d2b..5a92e40af5 100644 --- a/internal/command/meta_dependencies.go +++ b/internal/command/meta_dependencies.go @@ -6,6 +6,7 @@ package command import ( + "context" "log" "os" @@ -64,8 +65,8 @@ func (m *Meta) lockedDependencies() (*depsfile.Locks, tfdiags.Diagnostics) { // replaceLockedDependencies creates or overwrites the lock file in the // current working directory to contain the information recorded in the given // locks object. -func (m *Meta) replaceLockedDependencies(new *depsfile.Locks) tfdiags.Diagnostics { - return depsfile.SaveLocksToFile(new, dependencyLockFilename) +func (m *Meta) replaceLockedDependencies(ctx context.Context, new *depsfile.Locks) tfdiags.Diagnostics { + return depsfile.SaveLocksToFile(ctx, new, dependencyLockFilename) } // annotateDependencyLocksWithOverrides modifies the given Locks object in-place diff --git a/internal/command/providers_lock.go b/internal/command/providers_lock.go index b7af46d176..67497a34cb 100644 --- a/internal/command/providers_lock.go +++ b/internal/command/providers_lock.go @@ -331,7 +331,7 @@ func (c *ProvidersLockCommand) Run(args []string) int { newLocks.SetProvider(provider, version, constraints, hashes) } - moreDiags = c.replaceLockedDependencies(newLocks) + moreDiags = c.replaceLockedDependencies(ctx, newLocks) diags = diags.Append(moreDiags) c.showDiagnostics(diags) diff --git a/internal/command/telemetry.go b/internal/command/telemetry.go deleted file mode 100644 index 3d471c6691..0000000000 --- a/internal/command/telemetry.go +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) The OpenTofu Authors -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2023 HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package command - -import ( - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/trace" -) - -var tracer trace.Tracer - -func init() { - tracer = otel.Tracer("github.com/opentofu/opentofu/internal/command") -} diff --git a/internal/command/version_test.go b/internal/command/version_test.go index 0a066289e4..65616779ec 100644 --- a/internal/command/version_test.go +++ b/internal/command/version_test.go @@ -6,6 +6,7 @@ package command import ( + "context" "strings" "testing" @@ -51,7 +52,7 @@ func TestVersion(t *testing.T) { VersionPrerelease: "foo", Platform: getproviders.Platform{OS: "aros", Arch: "riscv64"}, } - if err := c.replaceLockedDependencies(locks); err != nil { + if err := c.replaceLockedDependencies(context.Background(), locks); err != nil { t.Fatal(err) } if code := c.Run([]string{}); code != 0 { @@ -149,7 +150,7 @@ func TestVersion_json(t *testing.T) { VersionPrerelease: "foo", Platform: getproviders.Platform{OS: "aros", Arch: "riscv64"}, } - if err := c.replaceLockedDependencies(locks); err != nil { + if err := c.replaceLockedDependencies(context.Background(), locks); err != nil { t.Fatal(err) } if code := c.Run([]string{"-json"}); code != 0 { diff --git a/internal/depsfile/locks_file.go b/internal/depsfile/locks_file.go index ecf424e43e..d53e7ad0f6 100644 --- a/internal/depsfile/locks_file.go +++ b/internal/depsfile/locks_file.go @@ -6,6 +6,7 @@ package depsfile import ( + "context" "fmt" "sort" @@ -15,11 +16,14 @@ import ( "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/hcl/v2/hclwrite" "github.com/zclconf/go-cty/cty" + semconv "go.opentelemetry.io/otel/semconv/v1.30.0" + "go.opentelemetry.io/otel/trace" "github.com/opentofu/opentofu/internal/addrs" "github.com/opentofu/opentofu/internal/getproviders" "github.com/opentofu/opentofu/internal/replacefile" "github.com/opentofu/opentofu/internal/tfdiags" + "github.com/opentofu/opentofu/internal/tracing" "github.com/opentofu/opentofu/version" ) @@ -90,12 +94,19 @@ func loadLocks(loadParse func(*hclparse.Parser) (*hcl.File, hcl.Diagnostics)) (* // the file as a signal to invalidate cached metadata. Consequently, other // temporary files may be temporarily created in the same directory as the // given filename during the operation. -func SaveLocksToFile(locks *Locks, filename string) tfdiags.Diagnostics { +func SaveLocksToFile(ctx context.Context, locks *Locks, filename string) tfdiags.Diagnostics { + _, span := tracing.Tracer().Start(ctx, "Save lockfile", trace.WithAttributes(semconv.FileName(filename))) + defer span.End() + src, diags := SaveLocksToBytes(locks) if diags.HasErrors() { + tracing.SetSpanError(span, diags) return diags } + span.AddEvent("Serialized lockfile") + span.SetAttributes(semconv.FileSize(len(src))) + err := replacefile.AtomicWriteFile(filename, src, 0644) if err != nil { diags = diags.Append(tfdiags.Sourceless( @@ -103,6 +114,7 @@ func SaveLocksToFile(locks *Locks, filename string) tfdiags.Diagnostics { "Failed to update dependency lock file", fmt.Sprintf("Error while writing new dependency lock information to %s: %s.", filename, err), )) + tracing.SetSpanError(span, diags) return diags } diff --git a/internal/depsfile/locks_file_test.go b/internal/depsfile/locks_file_test.go index 3ec1564f50..2b05fad70a 100644 --- a/internal/depsfile/locks_file_test.go +++ b/internal/depsfile/locks_file_test.go @@ -7,12 +7,14 @@ package depsfile import ( "bufio" + "context" "os" "path/filepath" "strings" "testing" "github.com/google/go-cmp/cmp" + "github.com/opentofu/opentofu/internal/addrs" "github.com/opentofu/opentofu/internal/getproviders" "github.com/opentofu/opentofu/internal/tfdiags" @@ -227,7 +229,7 @@ func TestSaveLocksToFile(t *testing.T) { dir := t.TempDir() filename := filepath.Join(dir, LockFilePath) - diags := SaveLocksToFile(locks, filename) + diags := SaveLocksToFile(context.Background(), locks, filename) if diags.HasErrors() { t.Fatalf("unexpected errors\n%s", diags.Err().Error()) } diff --git a/internal/getproviders/package_location_http_archive.go b/internal/getproviders/package_location_http_archive.go index d63a551a44..54a9a287f3 100644 --- a/internal/getproviders/package_location_http_archive.go +++ b/internal/getproviders/package_location_http_archive.go @@ -15,8 +15,13 @@ import ( "github.com/hashicorp/go-getter" "github.com/hashicorp/go-retryablehttp" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + semconv "go.opentelemetry.io/otel/semconv/v1.30.0" + "go.opentelemetry.io/otel/trace" + "github.com/opentofu/opentofu/internal/httpclient" "github.com/opentofu/opentofu/internal/logging" + "github.com/opentofu/opentofu/internal/tracing" ) // PackageHTTPURL is a provider package location accessible via HTTP. @@ -33,6 +38,11 @@ func (p PackageHTTPURL) String() string { return string(p) } func (p PackageHTTPURL) InstallProviderPackage(ctx context.Context, meta PackageMeta, targetDir string, allowedHashes []Hash) (*PackageAuthenticationResult, error) { url := meta.Location.String() + ctx, span := tracing.Tracer().Start(ctx, "Install (http)", trace.WithAttributes( + semconv.URLFull(url), + )) + defer span.End() + // When we're installing from an HTTP URL we expect the URL to refer to // a zip file. We'll fetch that into a temporary file here and then // delegate to installFromLocalArchive below to actually extract it. @@ -43,6 +53,7 @@ func (p PackageHTTPURL) InstallProviderPackage(ctx context.Context, meta Package retryableClient := retryablehttp.NewClient() retryableClient.HTTPClient = httpclient.New() + retryableClient.HTTPClient.Transport = otelhttp.NewTransport(retryableClient.HTTPClient.Transport) retryableClient.RetryMax = maxHTTPPackageRetryCount retryableClient.RequestLogHook = func(logger retryablehttp.Logger, _ *http.Request, i int) { if i > 0 { diff --git a/internal/getproviders/package_location_local_archive.go b/internal/getproviders/package_location_local_archive.go index f963bad17b..2364d05f1f 100644 --- a/internal/getproviders/package_location_local_archive.go +++ b/internal/getproviders/package_location_local_archive.go @@ -10,6 +10,9 @@ import ( "fmt" "github.com/hashicorp/go-getter" + semconv "go.opentelemetry.io/otel/semconv/v1.30.0" + + "github.com/opentofu/opentofu/internal/tracing" ) // We borrow the "unpack a zip file into a target directory" logic from @@ -31,7 +34,10 @@ var _ PackageLocation = PackageLocalArchive("") func (p PackageLocalArchive) String() string { return string(p) } -func (p PackageLocalArchive) InstallProviderPackage(_ context.Context, meta PackageMeta, targetDir string, allowedHashes []Hash) (*PackageAuthenticationResult, error) { +func (p PackageLocalArchive) InstallProviderPackage(ctx context.Context, meta PackageMeta, targetDir string, allowedHashes []Hash) (*PackageAuthenticationResult, error) { + _, span := tracing.Tracer().Start(ctx, "Decompress (local archive)") + defer span.End() + var authResult *PackageAuthenticationResult if meta.Authentication != nil { var err error @@ -42,19 +48,24 @@ func (p PackageLocalArchive) InstallProviderPackage(_ context.Context, meta Pack if len(allowedHashes) > 0 { if matches, err := meta.MatchesAnyHash(allowedHashes); err != nil { - return authResult, fmt.Errorf( + err := fmt.Errorf( "failed to calculate checksum for %s %s package at %s: %w", meta.Provider, meta.Version, meta.Location, err, ) + tracing.SetSpanError(span, err) + return authResult, err } else if !matches { - return authResult, fmt.Errorf( + err := fmt.Errorf( "the current package for %s %s doesn't match any of the checksums previously recorded in the dependency lock file; for more information: https://opentofu.org/docs/language/files/dependency-lock/#checksum-verification", meta.Provider, meta.Version, ) + tracing.SetSpanError(span, err) + return authResult, err } } filename := meta.Location.String() + span.SetAttributes(semconv.FilePath(filename)) // NOTE: We're not checking whether there's already a directory at // targetDir with some files in it. Packages are supposed to be immutable @@ -67,6 +78,7 @@ func (p PackageLocalArchive) InstallProviderPackage(_ context.Context, meta Pack //nolint:mnd // magic number predates us using this linter err := unzip.Decompress(targetDir, filename, true, 0000) if err != nil { + tracing.SetSpanError(span, err) return authResult, err } diff --git a/internal/getproviders/registry_client.go b/internal/getproviders/registry_client.go index 7c1bce5033..c12906b55b 100644 --- a/internal/getproviders/registry_client.go +++ b/internal/getproviders/registry_client.go @@ -24,10 +24,16 @@ import ( "github.com/hashicorp/go-retryablehttp" svchost "github.com/hashicorp/terraform-svchost" svcauth "github.com/hashicorp/terraform-svchost/auth" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + otelAttr "go.opentelemetry.io/otel/attribute" + semconv "go.opentelemetry.io/otel/semconv/v1.21.0" + "go.opentelemetry.io/otel/trace" "github.com/opentofu/opentofu/internal/addrs" "github.com/opentofu/opentofu/internal/httpclient" "github.com/opentofu/opentofu/internal/logging" + "github.com/opentofu/opentofu/internal/tracing" + "github.com/opentofu/opentofu/internal/tracing/traceattrs" "github.com/opentofu/opentofu/version" ) @@ -82,6 +88,8 @@ func newRegistryClient(baseURL *url.URL, creds svcauth.HostCredentials) *registr retryableClient.RequestLogHook = requestLogHook retryableClient.ErrorHandler = maxRetryErrorHandler + retryableClient.HTTPClient.Transport = otelhttp.NewTransport(retryableClient.HTTPClient.Transport) + retryableClient.Logger = log.New(logging.LogOutput(), "", log.Flags()) return ®istryClient{ @@ -99,6 +107,13 @@ func newRegistryClient(baseURL *url.URL, creds svcauth.HostCredentials) *registr // ErrUnauthorized if the registry responds with 401 or 403 status codes, or // ErrQueryFailed for any other protocol or operational problem. func (c *registryClient) ProviderVersions(ctx context.Context, addr addrs.Provider) (map[string][]string, []string, error) { + ctx, span := tracing.Tracer().Start(ctx, + "List Versions", + trace.WithAttributes( + otelAttr.String(traceattrs.ProviderAddress, addr.String()), + ), + ) + defer span.End() endpointPath, err := url.Parse(path.Join(addr.Namespace, addr.Type, "versions")) if err != nil { // Should never happen because we're constructing this from @@ -106,6 +121,7 @@ func (c *registryClient) ProviderVersions(ctx context.Context, addr addrs.Provid return nil, nil, err } endpointURL := c.baseURL.ResolveReference(endpointPath) + span.SetAttributes(semconv.URLFull(endpointURL.String())) req, err := retryablehttp.NewRequest("GET", endpointURL.String(), nil) if err != nil { return nil, nil, err @@ -115,7 +131,9 @@ func (c *registryClient) ProviderVersions(ctx context.Context, addr addrs.Provid resp, err := c.httpClient.Do(req) if err != nil { - return nil, nil, c.errQueryFailed(addr, err) + errResult := c.errQueryFailed(addr, err) + tracing.SetSpanError(span, errResult) + return nil, nil, errResult } defer resp.Body.Close() @@ -123,13 +141,19 @@ func (c *registryClient) ProviderVersions(ctx context.Context, addr addrs.Provid case http.StatusOK: // Great! case http.StatusNotFound: - return nil, nil, ErrRegistryProviderNotKnown{ + err := ErrRegistryProviderNotKnown{ Provider: addr, } + tracing.SetSpanError(span, err) + return nil, nil, err case http.StatusUnauthorized, http.StatusForbidden: - return nil, nil, c.errUnauthorized(addr.Hostname) + err := c.errUnauthorized(addr.Hostname) + tracing.SetSpanError(span, err) + return nil, nil, err default: - return nil, nil, c.errQueryFailed(addr, errors.New(resp.Status)) + err := c.errQueryFailed(addr, errors.New(resp.Status)) + tracing.SetSpanError(span, err) + return nil, nil, err } // We ignore the platforms portion of the response body, because the @@ -146,7 +170,9 @@ func (c *registryClient) ProviderVersions(ctx context.Context, addr addrs.Provid dec := json.NewDecoder(resp.Body) if err := dec.Decode(&body); err != nil { - return nil, nil, c.errQueryFailed(addr, err) + errResult := c.errQueryFailed(addr, err) + tracing.SetSpanError(span, errResult) + return nil, nil, errResult } if len(body.Versions) == 0 { @@ -181,12 +207,23 @@ func (c *registryClient) PackageMeta(ctx context.Context, provider addrs.Provide target.OS, target.Arch, )) + ctx, span := tracing.Tracer().Start(ctx, + "Fetch metadata", + trace.WithAttributes( + otelAttr.String(traceattrs.ProviderAddress, provider.String()), + otelAttr.String(traceattrs.ProviderVersion, version.String()), + )) + defer span.End() + if err != nil { // Should never happen because we're constructing this from // already-validated components. return PackageMeta{}, err } endpointURL := c.baseURL.ResolveReference(endpointPath) + span.SetAttributes( + semconv.URLFull(endpointURL.String()), + ) req, err := retryablehttp.NewRequest("GET", endpointURL.String(), nil) if err != nil { @@ -197,6 +234,7 @@ func (c *registryClient) PackageMeta(ctx context.Context, provider addrs.Provide resp, err := c.httpClient.Do(req) if err != nil { + tracing.SetSpanError(span, err) return PackageMeta{}, c.errQueryFailed(provider, err) } defer resp.Body.Close() @@ -328,7 +366,7 @@ func (c *registryClient) PackageMeta(ctx context.Context, provider addrs.Provide if shasumsURL.Scheme != "http" && shasumsURL.Scheme != "https" { return PackageMeta{}, fmt.Errorf("registry response includes invalid SHASUMS URL: must use http or https scheme") } - document, err := c.getFile(shasumsURL) + document, err := c.getFile(ctx, shasumsURL) if err != nil { return PackageMeta{}, c.errQueryFailed( provider, @@ -343,7 +381,7 @@ func (c *registryClient) PackageMeta(ctx context.Context, provider addrs.Provide if signatureURL.Scheme != "http" && signatureURL.Scheme != "https" { return PackageMeta{}, fmt.Errorf("registry response includes invalid SHASUMS signature URL: must use http or https scheme") } - signature, err := c.getFile(signatureURL) + signature, err := c.getFile(ctx, signatureURL) if err != nil { return PackageMeta{}, c.errQueryFailed( provider, @@ -434,8 +472,14 @@ func (c *registryClient) errUnauthorized(hostname svchost.Hostname) error { } } -func (c *registryClient) getFile(url *url.URL) ([]byte, error) { - resp, err := c.httpClient.Get(url.String()) +func (c *registryClient) getFile(ctx context.Context, url *url.URL) ([]byte, error) { + req, err := retryablehttp.NewRequest("GET", url.String(), nil) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + + resp, err := c.httpClient.Do(req) if err != nil { return nil, err } diff --git a/internal/providercache/installer.go b/internal/providercache/installer.go index 9b3931fef2..49ccec3c97 100644 --- a/internal/providercache/installer.go +++ b/internal/providercache/installer.go @@ -15,11 +15,15 @@ import ( "github.com/apparentlymart/go-versions/versions" "github.com/apparentlymart/go-versions/versions/constraints" + otelAttr "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "github.com/opentofu/opentofu/internal/addrs" copydir "github.com/opentofu/opentofu/internal/copy" "github.com/opentofu/opentofu/internal/depsfile" "github.com/opentofu/opentofu/internal/getproviders" + "github.com/opentofu/opentofu/internal/tracing" + "github.com/opentofu/opentofu/internal/tracing/traceattrs" ) // Installer is the main type in this package, representing a provider installer @@ -439,20 +443,31 @@ func (i *Installer) ensureProviderVersionsInstall( authResults := map[addrs.Provider]*getproviders.PackageAuthenticationResult{} // record auth results for all successfully fetched providers for provider, version := range need { - if err := ctx.Err(); err != nil { + traceCtx, span := tracing.Tracer().Start(ctx, + "Install Provider", + trace.WithAttributes( + otelAttr.String(traceattrs.ProviderAddress, provider.String()), + otelAttr.String(traceattrs.ProviderVersion, version.String()), + ), + ) + + if err := traceCtx.Err(); err != nil { // If our context has been cancelled or reached a timeout then // we'll abort early, because subsequent operations against // that context will fail immediately anyway. + tracing.SetSpanError(span, err) + span.End() return nil, err } - authResult, err := i.ensureProviderVersionInstall(ctx, locks, reqs, mode, provider, version, targetPlatform) + authResult, err := i.ensureProviderVersionInstall(traceCtx, locks, reqs, mode, provider, version, targetPlatform) if authResult != nil { authResults[provider] = authResult } if err != nil { errs[provider] = err } + span.End() } return authResults, nil } diff --git a/internal/tracing/init.go b/internal/tracing/init.go new file mode 100644 index 0000000000..fb140f7477 --- /dev/null +++ b/internal/tracing/init.go @@ -0,0 +1,128 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tracing + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/go-logr/stdr" + "go.opentelemetry.io/contrib/exporters/autoexport" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + + "github.com/opentofu/opentofu/version" +) + +/* +BEWARE! This is not a committed external interface. + +Everything about this is experimental and subject to change in future +releases. Do not depend on anything about the structure of this output. +This mechanism might be removed altogether if a different strategy seems +better based on experience with this experiment. + +*/ + +// OTELExporterEnvVar is the env var that should be used to instruct opentofu which +// exporter to use +// If this environment variable is set to "otlp" when running OpenTofu CLI +// then we'll enable an experimental OTLP trace exporter. +const OTELExporterEnvVar = "OTEL_TRACES_EXPORTER" + +// isTracingEnabled is true if OpenTelemetry is enabled. +var isTracingEnabled bool + +// OpenTelemetryInit initializes the optional OpenTelemetry exporter. +// +// By default, we don't export telemetry information at all, since OpenTofu is +// a CLI tool, and so we don't assume we're running in an environment with +// a telemetry collector available. +// +// However, for those running OpenTofu in automation we allow setting +// the standard OpenTelemetry environment variable OTEL_TRACES_EXPORTER=otlp +// to enable an OTLP exporter, which is in turn configured by all the +// standard OTLP exporter environment variables: +// +// https://opentelemetry.io/docs/specs/otel/protocol/exporter/#configuration-options +// +// We don't currently support any other telemetry export protocols, because +// OTLP has emerged as a de-facto standard and each other exporter we support +// means another relatively-heavy external dependency. OTLP happens to use +// protocol buffers and gRPC, which OpenTofu would depend on for other reasons +// anyway. +func OpenTelemetryInit() error { + isTracingEnabled = false + // We'll check the environment variable ourselves first, because the + // "autoexport" helper we're about to use is built under the assumption + // that exporting should always be enabled and so will expect to find + // an OTLP server on localhost if no environment variables are set at all. + if os.Getenv(OTELExporterEnvVar) != "otlp" { + return nil // By default, we just discard all telemetry calls + } + + isTracingEnabled = true + + log.Printf("[TRACE] OpenTelemetry: enabled") + + otelResource, err := resource.New(context.Background(), + // Use built-in detectors to simplify the collation of the racing information + resource.WithOS(), + resource.WithHost(), + resource.WithProcess(), + resource.WithSchemaURL(semconv.SchemaURL), + resource.WithAttributes(), + + // Add custom service attributes + resource.WithAttributes( + semconv.ServiceName("OpenTofu CLI"), + semconv.ServiceVersion(version.Version), + + // We add in the telemetry SDK information so that we don't end up with + // duplicate schema urls that clash + semconv.TelemetrySDKName("opentelemetry"), + semconv.TelemetrySDKLanguageGo, + semconv.TelemetrySDKVersion(sdk.Version()), + ), + ) + if err != nil { + return fmt.Errorf("failed to create resource: %w", err) + } + + exporter, err := autoexport.NewSpanExporter(context.Background()) + if err != nil { + return err + } + + // Set the global tracer provider, this allows us to use this global TracerProvider + // to create tracers around the project + provider := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter, + sdktrace.WithBlocking(), + ), + sdktrace.WithSampler(sdktrace.AlwaysSample()), + sdktrace.WithResource(otelResource), + ) + otel.SetTracerProvider(provider) + + prop := propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}) + otel.SetTextMapPropagator(prop) + + logger := stdr.New(log.New(os.Stdout, "", log.LstdFlags|log.Lshortfile)) + otel.SetLogger(logger) + + otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) { + panic(fmt.Sprintf("OpenTelemetry error: %v", err)) + })) + + return nil +} diff --git a/internal/tracing/traceattrs/traceattrs.go b/internal/tracing/traceattrs/traceattrs.go new file mode 100644 index 0000000000..d84888923e --- /dev/null +++ b/internal/tracing/traceattrs/traceattrs.go @@ -0,0 +1,13 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package traceattrs + +const ( + // Common attributes names used across the codebase + + ProviderAddress = "opentofu.provider.address" + ProviderVersion = "opentofu.provider.version" +) diff --git a/internal/tracing/utils.go b/internal/tracing/utils.go new file mode 100644 index 0000000000..5da2528374 --- /dev/null +++ b/internal/tracing/utils.go @@ -0,0 +1,121 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tracing + +import ( + "context" + "errors" + "log" + "runtime" + "strings" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + + "go.opentelemetry.io/otel/trace" + + "github.com/opentofu/opentofu/internal/tfdiags" +) + +func Tracer() trace.Tracer { + if !isTracingEnabled { + return otel.Tracer("") + } + + pc, _, _, ok := runtime.Caller(1) + if !ok || runtime.FuncForPC(pc) == nil { + return otel.Tracer("") + } + + // We use the import path of the caller function as the tracer name. + return otel.GetTracerProvider().Tracer(extractImportPath(runtime.FuncForPC(pc).Name())) +} + +// SetSpanError sets the error or diagnostic information on the span. +// It accepts an error, a string, or a diagnostics object. +// It also sets the span status to Error and records the error or message. +func SetSpanError(span trace.Span, input any) { + if span == nil || input == nil { + return + } + + switch v := input.(type) { + case error: + if v != nil { + span.SetStatus(codes.Error, v.Error()) + span.RecordError(v) + } + case string: + if v != "" { + span.SetStatus(codes.Error, v) + span.RecordError(errors.New(v)) + } + case tfdiags.Diagnostics: // Assuming Diagnostics is a custom type you have defined elsewhere + if v.HasErrors() { // Assuming IsEmpty() checks if the diagnostics object has content + span.SetStatus(codes.Error, v.Err().Error()) + span.RecordError(v.Err()) + } + default: + // Handle unsupported types gracefully + // TODO: Discuss if this should panic? + span.SetStatus(codes.Error, "ERROR: unsupported input type for SetSpanError.") + span.AddEvent("ERROR: unsupported input type for SetSpanError") + } +} + +// ForceFlush ensures that all spans are exported to the collector before +// the application terminates. This is particularly important for CLI +// applications where the process exits immediately after the operation. +// +// This should be called before the application terminates to ensure +// all spans are exported properly. +func ForceFlush(timeout time.Duration) { + if !isTracingEnabled { + return + } + + provider, ok := otel.GetTracerProvider().(*sdktrace.TracerProvider) + if !ok { + log.Printf("[DEBUG] OpenTelemetry: tracer provider is not an SDK provider, can't force flush") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + log.Printf("[DEBUG] OpenTelemetry: flushing spans") + if err := provider.ForceFlush(ctx); err != nil { + log.Printf("[WARN] OpenTelemetry: error flushing spans: %v", err) + } +} + +// extractImportPath extracts the import path from a full function name. +// the function names returned by runtime.FuncForPC(pc).Name() can be in the following formats +// +// main.(*MyType).MyMethod +// github.com/you/pkg.(*SomeType).Method-fm +// github.com/you/pkg.functionName +func extractImportPath(fullName string) string { + lastSlash := strings.LastIndex(fullName, "/") + if lastSlash == -1 { + // When there is no slash, then use everything before the first dot + if dot := strings.Index(fullName, "."); dot != -1 { + return fullName[:dot] + } + log.Printf("[WARN] unable to extract import path from function name: %q. Tracing may be incomplete. This is a bug in OpenTofu, please report it.", fullName) + return "unknown" + } + + dotAfterSlash := strings.Index(fullName[lastSlash:], ".") + if dotAfterSlash == -1 { + log.Printf("[WARN] unable to extract import path from function name: %q. Tracing may be incomplete. This is a bug in OpenTofu, please report it.", fullName) + return "unknown" + } + + return fullName[:lastSlash+dotAfterSlash] +} diff --git a/internal/tracing/utils_test.go b/internal/tracing/utils_test.go new file mode 100644 index 0000000000..8bca39d50d --- /dev/null +++ b/internal/tracing/utils_test.go @@ -0,0 +1,39 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tracing + +import "testing" + +func TestExtractImportPath(t *testing.T) { + tests := []struct { + fullName string + expected string + }{ + { + fullName: "github.com/opentofu/opentofu/internal/getproviders.(*registryClient).Get", + expected: "github.com/opentofu/opentofu/internal/getproviders", + }, + { + fullName: "github.com/opentofu/opentofu/pkg/module.Function", + expected: "github.com/opentofu/opentofu/pkg/module", + }, + { + fullName: "main.main", + expected: "main", + }, + { + fullName: "unknownFormat", + expected: "unknown", + }, + } + + for _, test := range tests { + got := extractImportPath(test.fullName) + if got != test.expected { + t.Errorf("extractImportPath(%q) = %q; want %q", test.fullName, got, test.expected) + } + } +}