Repository: opentofu/opentofu
Pull request #2665: [OpenTelemetry] Add traces to `init` command
Author: login: Yantrio
Created: 2025-04-15T10:39:57Z | Merged: 2025-04-25T11:40:48Z

## Description

> Part of the OTEL Tracing implementation tracked here #2664

This PR attempts to solve the initial parts of adding opentelemetry traces to the opentofu codebase.

It does this by defining the standards of how we want to be adding otel traces to opentofu in a developer friendly way, and then providing tracing around the initialization phase of the project as an example that can be used for discussion.

**Please use this PR as a point of discussion for the approach and conventions, however please remember that this is experimental and can be subject to change in the future**

## Creation of tracers across the project
The creation of the tracers used to be handled by creating a single file inside the package and defining a package-wide variable to store the tracer. This became a point of friction whenever moving around files because the developer would have to ensure that the file is created for every package in which tracing can occur! This file also contained hard-coded strings for the scope name, which could cause room for human error.

To simplify this I have introduced a utility method (`tracing.Tracer()`) which will provide the developer with a tracer with a correctly set scope based on the package name. The small downside of this is that we will be using the `runtime` package to reflect and understand the package name. This adds a minor overhead and I have ensured that this overhead is only incurred if opentelemetry tracing is enabled. For the amount of tracing that this project will do I am quite certain that this overhead is very acceptable.

This now means that to begin a span in any part of the codebase, the developer only needs to call `tracing.Tracer().Start(ctx, "<spanname>")`.

## Naming convention of spans
There is currently no convention of naming spans in opentelemetry as far as I am aware, however there is conventions for naming attributes which can be found [here](https://opentelemetry.io/docs/specs/semconv/general/naming/). 

By using similar conventions of attributes such as namespacing, we can define a set of span names that help end users and consumers of the telemetry to understand what is happening.

Please provide any/all feedback and ideas you have about this area.

## Span errors
It is recommended that whenever a method returns an error, that we set that span to the error status and record the error in the span. This can be achieved by doing the following:

```go
if err != nil {
    span.SetStatus(codes.Error, "failed to do XYZ")
    span.RecordError(err)
}
```

## Example traces of installing 2 providers
<img width="1720" alt="image" src="https://github.com/user-attachments/assets/e8d96a84-86cb-4d1a-934d-c8b9db4d71e5" />


## Remaining work

This PR only covers the basics of the intialization phase (Installation of providers, etc) and is not exhaustive.

- Module Installation
- OCI provider + module tracing
- Backend Initialization

## Target Release

1.10.0 / 1.11.0

## Checklist

<!-- Please check of ALL items in this list for all PRs: -->

- [x] I have read the [contribution guide](https://github.com/opentofu/opentofu/blob/main/CONTRIBUTING.md).
- [x] I have not used an AI coding assistant to create this PR.
- [x] I have written all code in this PR myself OR I have marked all code I have not written myself (including modified code, e.g. copied from other places and then modified) with a comment indicating where it came from.
- [x] I (and other contributors to this PR) have not looked at the Terraform source code while implementing this PR.

### Go checklist

<!-- If your PR contains Go code, please make sure you check off all items on this list: --> 

- [ ] I have run golangci-lint on my change and receive no errors relevant to my code.
- [ ] I have run existing tests to ensure my code doesn't break anything.
- [x] I have added tests for all relevant use cases of my code, and those tests are passing.
- [x] I have only exported functions, variables and structs that should be used from other packages.
- [x] I have added meaningful comments to all exported functions, variables, and structs.


Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8b86adad243..314e9a7b3bb 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 a2449db07a6..a8002e648aa 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 5b1f3a66157..00000000000
--- 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 e00745e8e99..bf98afd4d7b 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 9f5a0c4e639..12100f2e55a 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 5b5d21918fb..edfdfb2c199 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 bc6874371a1..e25c9e1677a 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 6decff084f8..bb5aee56b1e 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 41ef4d16f08..19e145c1e5c 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 d810136d2bf..5a92e40af58 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 b7af46d1766..67497a34cb3 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 3d471c66916..00000000000
--- 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 0a066289e4e..65616779ec8 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 ecf424e43e2..d53e7ad0f68 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 3ec1564f501..2b05fad70a4 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 d63a551a44a..54a9a287f39 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 f963bad17bf..2364d05f1ff 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 7c1bce50330..c12906b55bc 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 &registryClient{
@@ -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 9b3931fef24..49ccec3c97a 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 00000000000..fb140f7477d
--- /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 00000000000..d84888923e3
--- /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 00000000000..5da25283748
--- /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 00000000000..8bca39d50de
--- /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)
+		}
+	}
+}


Reviews:
user: login: apparentlymart
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T14:52:31Z
user: login: apparentlymart
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T15:01:49Z
user: login: diofeher
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T20:55:41Z
user: login: diofeher
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T21:00:54Z
user: login: diofeher
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T21:03:02Z
user: login: Yantrio
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T07:51:00Z
user: login: Yantrio
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T07:51:37Z
user: login: Yantrio
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T07:52:15Z
user: login: yottta
body: Nice! Well done! This is clean and tidy. Like it.
Just a few comments.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T08:22:50Z
user: login: Yantrio
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T08:21:53Z
user: login: Yantrio
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T09:33:28Z
user: login: yottta
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T10:55:39Z
user: login: Yantrio
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T12:55:40Z
user: login: Yantrio
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T12:56:27Z
user: login: cam72cam
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T12:56:47Z
user: login: cam72cam
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T13:09:40Z
user: login: cam72cam
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T13:11:43Z
user: login: cam72cam
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T13:12:15Z
user: login: cam72cam
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T13:13:53Z
user: login: Yantrio
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T13:48:14Z
user: login: Yantrio
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T13:49:08Z
user: login: cam72cam
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T14:20:32Z
user: login: Yantrio
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T08:34:27Z
user: login: Yantrio
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T08:41:42Z
user: login: Gogotchuri
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-24T11:45:51Z
user: login: cam72cam
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-24T15:14:29Z
user: login: cam72cam
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T15:16:30Z
user: login: apparentlymart
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T15:33:18Z
user: login: apparentlymart
body: This looks great!

I do have some general concerns that this might be a little too granular for our intended audience (users of OpenTofu, rather than developers of OpenTofu) but as long as we're not committing to preserving any specific tracing hierarchy as a machine-readable interface I'm happy to just run with this and see how it goes.

state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T15:40:53Z
user: login: diofeher
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T17:30:41Z
user: login: diofeher
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T17:34:54Z
user: login: Yantrio
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T08:37:49Z
user: login: cam72cam
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-25T10:47:04Z

Review comments:
diff_hunk: @@ -116,7 +117,10 @@ 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(ctx context.Context) (*Config, tfdiags.Diagnostics) {
+	_, span := tracing.Tracer().Start(ctx, "opentofu.config.load")
path: internal/command/cliconfig/cliconfig.go
user: login: apparentlymart
body: We should probably watch ourselves here because it seems like you are intending "config" to mean "CLI Configuration" here, but normally when we use that word unqualified we mean the tree of modules built from `.tf` files that describes the desired state.

Perhaps should use "cli-config" as our standard way of referring to _this_ concept in tracing.

created_at: 2025-04-15T14:52:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -159,9 +164,10 @@ 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, "opentofu.init.from_module", trace.WithAttributes(
path: internal/command/init.go
user: login: apparentlymart
body: I feel a little torn on using heirarchical-shaped trace names here, since traces are already naturally assembled into a hierarchy anyway and so this is redundant and potentially confusing.

_At the very least_ I don't think we need to re-state "opentofu." on the front of all of these names, since everything we emit is annotated as belonging to "OpenTofu CLI" already anyway.

I would also consider going further and dropping the `init.` part here, and other similar intermediate steps in other examples, so that the span names are concise and assume nothing about what they might be nested under.

---

I think the code you modified here was from some of my earlier work on plumbing OpenTelemetry, at which point I'd been trying to name the spans in ways that directly echo other parts of the user experience that the traces relate to. In this case, I named it `-from-module=...` to make it very clear that this span is dealing with the effects of that specific command line option.

Of course this is a tradeoff: there won't always be a good existing user-facing name for a process we're following. But I feel a little nervous about using names that are _entirely divorced_ from what users can see elsewhere in our UX, since it seems like we'd therefore need to produce documentation that laboriously lists every single trace name we emit and describes what it does, whereas if we choose names that better echo how users experience these features then it's more likely that they can make those associations more directly.

(Details aside, it seems like there's a specific decision to be made here about whether we are intending these trace names to be a machine-readable compatibility constraint or just another kind of UI text that's subject to change at any time. If it _is_ intended as a compatibility constraint then these structured names are probably better, but I'd personally lean towards treating this as output primarily for human consumption and so focus on making it directly understandable by English-speaking humans, using similar terminology than we use elsewhere.)
created_at: 2025-04-15T15:01:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -71,23 +72,21 @@ func realMain() int {
 
 	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()
-	}
+	ctx := context.Background()
+	var span trace.Span
+
+	// At minimum, we emit a span covering the entire command execution.
+	ctx, span = tracing.Tracer().Start(ctx, "opentofu")
path: cmd/tofu/main.go
user: login: diofeher
body: ```suggestion
	// At minimum, we emit a span covering the entire command execution.
	ctx, span := tracing.Tracer().Start(ctx, "opentofu")
```

Then we can remove the import of "go.opentelemetry.io/otel/trace" .



created_at: 2025-04-15T20:55:41Z
author_association: MEMBER
diff_hunk: @@ -67,6 +81,8 @@ 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 {
+		span.SetStatus(codes.Error, err.Error())
path: internal/getproviders/package_location_local_archive.go
user: login: diofeher
body: I was thinking if it would be a good idea to include a function on the internal/tracing library for calling these error recordings, like:

tracing.SetError(err)

```
SetError(span trace.Span, err error) {
  span.SetStatus(codes.Error, err.Error());
  span.RecordError(err)
}
```

Just so we could remove some duplication of the code. Let me know what you think about it.
created_at: 2025-04-15T21:00:54Z
author_association: MEMBER
diff_hunk: @@ -460,6 +473,7 @@ func (i *Installer) ensureProviderVersionsInstall(
 					if cb := evts.ProviderAlreadyInstalled; cb != nil {
 						cb(provider, version)
 					}
+					span.End()
path: internal/providercache/installer.go
user: login: diofeher
body: Quick question: why in this case are you using `span.End()` in different code paths and not using `defer span.End()` after the assignment?
created_at: 2025-04-15T21:03:02Z
author_association: MEMBER
diff_hunk: @@ -67,6 +81,8 @@ 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 {
+		span.SetStatus(codes.Error, err.Error())
path: internal/getproviders/package_location_local_archive.go
user: login: Yantrio
body: I was thinking about this too, but then I also realised that there are some situations where we want to report the error from a diag or something else, so i decided to just go with this method. but I'm 50/50 on this. 
created_at: 2025-04-16T07:51:00Z
author_association: MEMBER
diff_hunk: @@ -460,6 +473,7 @@ func (i *Installer) ensureProviderVersionsInstall(
 					if cb := evts.ProviderAlreadyInstalled; cb != nil {
 						cb(provider, version)
 					}
+					span.End()
path: internal/providercache/installer.go
user: login: Yantrio
body: Because this is inside the loop of `for provider, version := range need {`
created_at: 2025-04-16T07:51:37Z
author_association: MEMBER
diff_hunk: @@ -116,7 +117,10 @@ 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(ctx context.Context) (*Config, tfdiags.Diagnostics) {
+	_, span := tracing.Tracer().Start(ctx, "opentofu.config.load")
path: internal/command/cliconfig/cliconfig.go
user: login: Yantrio
body: I agree! But also after some consideration, i dont think this is worth tracing right now. It's a minor code path that doesn't take too long.
created_at: 2025-04-16T07:52:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,47 @@
+package tracing
+
+import (
+	"runtime"
+	"strings"
+
+	"go.opentelemetry.io/otel"
+	"go.opentelemetry.io/otel/trace"
+)
+
+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()))
+}
+
+// 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]
+		}
+		return "unknown"
path: internal/tracing/utils.go
user: login: yottta
body: `suggestion`
For being able to adjust this parsing in the future, we could have a trace log here writing the `fullName`. This way, in case somebody is encountering "unknown" traces, we could ask for the logs and adjust this parsing accordingly.
created_at: 2025-04-16T07:55:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -132,6 +134,9 @@ func (c *InitCommand) Run(args []string) int {
 	ctx, done := c.InterruptibleContext(c.CommandContext())
 	defer done()
 
+	ctx, span := tracing.Tracer().Start(ctx, "opentofu.init")
path: internal/command/init.go
user: login: yottta
body: Similar with the comment left by @apparentlymart, I don't think that we should `opentofu` prefix everywhere.
Here, I would suggest something like `command.init` instead.
Also, the addition of that empty line at the beginning of the method hints that this line here was there initially. If so, why moved it from there? 🤔 
I generally like to have the traces at the beginning of the functions to be able to trace back from where the flow got interrupted. Of course, this is not a hard rule, but something that I generally do.
created_at: 2025-04-16T08:04:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -159,9 +164,10 @@ 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, "opentofu.init.from_module", trace.WithAttributes(
+			attribute.String("opentofu.module_source", src),
 		))
+		defer span.End()
path: internal/command/init.go
user: login: yottta
body: This call here should be removed. Check that `span.End()` is called a little bit down the road.
created_at: 2025-04-16T08:06:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -181,6 +196,8 @@ func (c *registryClient) PackageMeta(ctx context.Context, provider addrs.Provide
 		target.OS,
 		target.Arch,
 	))
+	ctx, span := tracing.Tracer().Start(ctx, "opentofu.provider.meta")
path: internal/getproviders/registry_client.go
user: login: yottta
body: `suggestion`
Maybe moving this after the definition of `endpointURL` and add that as attribute? 🤔 
created_at: 2025-04-16T08:12:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -460,6 +473,7 @@ func (i *Installer) ensureProviderVersionsInstall(
 					if cb := evts.ProviderAlreadyInstalled; cb != nil {
 						cb(provider, version)
 					}
+					span.End()
path: internal/providercache/installer.go
user: login: yottta
body: Agree, but really error prone this part. Isn't there a way to extract the content of the `for` loop into a new function?
If somebody will ever come and add a new conditional `continue` or worse, a `break` or a `return`, that span will never be closed and will create a havoc in the traces.
created_at: 2025-04-16T08:16:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -67,6 +81,8 @@ 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 {
+		span.SetStatus(codes.Error, err.Error())
path: internal/getproviders/package_location_local_archive.go
user: login: yottta
body: I agree with Dio here. If we want to do the diags thing, we could go with a similar implementation of the `Diagnostics.Append` where it checks the type and handles that internally.
created_at: 2025-04-16T08:19:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -159,9 +164,10 @@ 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, "opentofu.init.from_module", trace.WithAttributes(
path: internal/command/init.go
user: login: Yantrio
body: So, after some thought I strongly agree with not using the heirarchical-shaped trace names here. I feel as if they add very little value when exploring the traces. 

What I am currently unsure about is the disparity between our UX for end-users and the ability to trace it back to the codebase. If we are following the intention of the RFC that was accepted for opentelemetry then I believe that these traces are not to be consumed fully by opentofu developers, but by end-users who may not be familiar with the codebase.

With your comment in mind I propose we do the following:

- Name traces in a human readable way
- Traces should not be hierarchical (The hierrachy is already created by the tracing it'sself)
- Trace names should be comparible to the UX, and not to the codebase.

With these things in mind, I believe the name of this trace quickly becomes `From module`. WDYT?
created_at: 2025-04-23T08:21:53Z
author_association: MEMBER
diff_hunk: @@ -460,6 +473,7 @@ func (i *Installer) ensureProviderVersionsInstall(
 					if cb := evts.ProviderAlreadyInstalled; cb != nil {
 						cb(provider, version)
 					}
+					span.End()
path: internal/providercache/installer.go
user: login: Yantrio
body: I didn't want to do large refactorings of existing code in this codebase, plus i feel like this sets a good example of how we should be handling spans in loops anyway.
created_at: 2025-04-23T09:33:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,50 @@
+package tracing
+
+import (
+	"log"
+	"runtime"
+	"strings"
+
+	"go.opentelemetry.io/otel"
+	"go.opentelemetry.io/otel/trace"
+)
+
+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()))
+}
+
+// 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)
path: internal/tracing/utils.go
user: login: yottta
body: ```suggestion
		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)
```

Typo?
created_at: 2025-04-23T10:49:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,50 @@
+package tracing
+
+import (
+	"log"
+	"runtime"
+	"strings"
+
+	"go.opentelemetry.io/otel"
+	"go.opentelemetry.io/otel/trace"
+)
+
+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()))
+}
+
+// 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)
path: internal/tracing/utils.go
user: login: yottta
body: Same for the other log statement.
created_at: 2025-04-23T10:49:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -67,6 +81,8 @@ 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 {
+		span.SetStatus(codes.Error, err.Error())
path: internal/getproviders/package_location_local_archive.go
user: login: Yantrio
body: I've added this method in the utils file here: https://github.com/opentofu/opentofu/pull/2665/files#diff-338d0067ba5fd1e76a34f61066eecaed4b9688f08e3869682fa04c0c05305c3cR40-R70

Let me know what you guys think @yottta / @diofeher 
created_at: 2025-04-23T12:55:40Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,50 @@
+package tracing
+
+import (
+	"log"
+	"runtime"
+	"strings"
+
+	"go.opentelemetry.io/otel"
+	"go.opentelemetry.io/otel/trace"
+)
+
+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()))
+}
+
+// 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)
path: internal/tracing/utils.go
user: login: Yantrio
body: fixed.
created_at: 2025-04-23T12:56:27Z
author_association: MEMBER
diff_hunk: @@ -117,6 +114,7 @@ func realMain() int {
 	streams, err := terminal.Init()
 	if err != nil {
 		Ui.Error(fmt.Sprintf("Failed to configure the terminal: %s", err))
+		tracing.ForceFlush(5 * time.Second)
path: cmd/tofu/main.go
user: login: cam72cam
body: What's so special about the ForceFlush and why don't we do it in a defer call as it's part of all the error paths?
created_at: 2025-04-23T12:56:46Z
author_association: MEMBER
diff_hunk: @@ -440,7 +444,16 @@ 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(
+				attribute.String(tracing.ProviderAddressAttributeName, provider.String()),
+				attribute.String(tracing.ProviderVersionAttributeName, version.String()),
+			),
+		)
+		if err := traceCtx.Err(); err != nil {
+			span.SetStatus(codes.Error, "context cancelled")
+			span.RecordError(err)
path: internal/providercache/installer.go
user: login: cam72cam
body: Do you still need to span.End() here?
created_at: 2025-04-23T13:09:39Z
author_association: MEMBER
diff_hunk: @@ -460,6 +473,7 @@ func (i *Installer) ensureProviderVersionsInstall(
 					if cb := evts.ProviderAlreadyInstalled; cb != nil {
 						cb(provider, version)
 					}
+					span.End()
path: internal/providercache/installer.go
user: login: cam72cam
body: Another approach that could be considered is something like https://github.com/opentofu/opentofu/pull/1878/files#diff-385ff28969dc6410989718822b4c24e033e3a10d679bdd792fd2d55148c49471R456

It's a similar situation where I didn't want to heavily refactor the loop.  It mixes defer and iterative "actions" in a way that's comprehensive
created_at: 2025-04-23T13:11:43Z
author_association: MEMBER
diff_hunk: @@ -460,6 +473,7 @@ func (i *Installer) ensureProviderVersionsInstall(
 					if cb := evts.ProviderAlreadyInstalled; cb != nil {
 						cb(provider, version)
 					}
+					span.End()
path: internal/providercache/installer.go
user: login: cam72cam
body: Alternatively, this is now the second time we are running into this and should probably take the time to refactor it :D
created_at: 2025-04-23T13:12:15Z
author_association: MEMBER
diff_hunk: @@ -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.
path: internal/tracing/init.go
user: login: cam72cam
body: Why not make it live in internal then?
created_at: 2025-04-23T13:13:53Z
author_association: MEMBER
diff_hunk: @@ -117,6 +114,7 @@ func realMain() int {
 	streams, err := terminal.Init()
 	if err != nil {
 		Ui.Error(fmt.Sprintf("Failed to configure the terminal: %s", err))
+		tracing.ForceFlush(5 * time.Second)
path: cmd/tofu/main.go
user: login: Yantrio
body: Because I wrote this early in the day and forgot that defer is a thing 🤦 please forgive me! I'll re-work this.
created_at: 2025-04-23T13:48:14Z
author_association: MEMBER
diff_hunk: @@ -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.
path: internal/tracing/init.go
user: login: Yantrio
body: This comment is from the original code. I think the intention behind it is to say that the trace names/attributes etc can change. and that we're not committing to this being a concrete tool that people can consume in a programatic way until it's stable
created_at: 2025-04-23T13:49:08Z
author_association: MEMBER
diff_hunk: @@ -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.
path: internal/tracing/init.go
user: login: cam72cam
body: In that case, why not move it into the internal package for now?
created_at: 2025-04-23T14:20:32Z
author_association: MEMBER
diff_hunk: @@ -460,6 +473,7 @@ func (i *Installer) ensureProviderVersionsInstall(
 					if cb := evts.ProviderAlreadyInstalled; cb != nil {
 						cb(provider, version)
 					}
+					span.End()
path: internal/providercache/installer.go
user: login: Yantrio
body: I'll rebase ontop of this PR once it's in: https://github.com/opentofu/opentofu/pull/2695
created_at: 2025-04-24T08:34:27Z
author_association: MEMBER
diff_hunk: @@ -440,7 +444,16 @@ 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(
+				attribute.String(tracing.ProviderAddressAttributeName, provider.String()),
+				attribute.String(tracing.ProviderVersionAttributeName, version.String()),
+			),
+		)
+		if err := traceCtx.Err(); err != nil {
+			span.SetStatus(codes.Error, "context cancelled")
+			span.RecordError(err)
path: internal/providercache/installer.go
user: login: Yantrio
body: Yup! I missed this one. thanks.
created_at: 2025-04-24T08:41:42Z
author_association: MEMBER
diff_hunk: @@ -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.
path: internal/tracing/init.go
user: login: cam72cam
body: I'm blind, nevermind!
created_at: 2025-04-24T15:16:30Z
author_association: MEMBER
diff_hunk: @@ -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.
path: internal/tracing/init.go
user: login: apparentlymart
body: This comment was originally from https://github.com/opentofu/opentofu/commit/503896af54d6eacd685873488494e6f14067a2de, in which I was intending it to mean that anything new that begins happening when you run with the `OTEL_TRACES_EXPORTER` environment variable is not protected by compatibility promises.

This new placement of the comment does perhaps leave it a little ambiguous which "this" it is talking about, and so perhaps we could clarify that by replacing "this" with something like "OpenTofu's tracing output". :man_shrugging: 

created_at: 2025-04-24T15:33:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -439,20 +442,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(
+				attribute.String(tracing.ProviderAddressAttributeName, provider.String()),
+				attribute.String(tracing.ProviderVersionAttributeName, version.String()),
path: internal/providercache/installer.go
user: login: apparentlymart
body: I guess I reviewed these in the wrong order :facepalm: so I should really have left https://github.com/opentofu/opentofu/pull/2694#discussion_r2056333802 here instead.
created_at: 2025-04-24T15:38:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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.")
path: internal/tracing/utils.go
user: login: diofeher
body: Do you think it could be helpful to add the `input.(type)` in the error message to help debugging?
created_at: 2025-04-24T17:30:41Z
author_association: MEMBER
diff_hunk: @@ -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)
path: internal/tracing/utils.go
user: login: diofeher
body: `question`

Why in these functions are you choosing to not returns the errors to the caller and just log them in here? 
created_at: 2025-04-24T17:34:54Z
author_association: MEMBER
diff_hunk: @@ -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)
path: internal/tracing/utils.go
user: login: Yantrio
body: This method is to add the error to the span, it shouldn't do anything else.
created_at: 2025-04-25T08:37:49Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-15T10:40:10Z
body: Reminder for the PR assignee: If this is a user-visible change, please update the changelog as part of the PR.
author_association: NONE
user: login: diofeher
created_at: 2025-04-15T21:07:09Z
body: Really good to see this work moving forward! I've left a few comments, thanks @Yantrio !
author_association: MEMBER