Repository: open-telemetry/opentelemetry-go (2026-04-03T12:43:28Z)

Issue:
error.type for wrapped errors
> Was there any preceding discussion about detecting when the reported type is `*fmt.wrapError`, and calling `Unwrap` on it to dig down to instead report the type of the outermost error that is not a wrapping error?
> 
> (I ask this because my "error.type" attribute values are almost always winding up as the unhelpful "*fmt.wrapError".) 

 _Originally posted by @seh in [#6904](https://github.com/open-telemetry/opentelemetry-go/issues/6904#issuecomment-3969141128)_


Discussion:
Hi @pellared — I’d like to work on this issue. I have contributed earlier and would be glad to pick this up if it is available.
- How far do we unwrap?
- What is the behavior if there are multiple errors wrapped, and possibly at different levels? (i.e `wrapped(wrapped(err0, err1), err2)`)
- What if a user wants the type to be the wrapped type to show it is a higher level error?
I think we should publish two separate attributes for the case when [the `errors.Unwrap` function](https://pkg.go.dev/errors#Unwrap) returns a different value from its argument. You as the caller don't decide how far to unwrap; that function's implementation decides to stop when there is no next error in hand that implements an `Unwrap` method (or, rather, that's how the protocol is supposed to work, falling to individual implementations of `Unwrap` to play along).
Hi @pellared and team,

Apologies for the late reply. I saw that #7994 was merged and already handles the ErrorType() interface detection across the error chain. I am picking up the remaining part about improving the reflection fallback so that wrappers like *fmt.wrapError dont end up being reported as the error type.
Before opening a PR, I wanted to clarify a couple of points:

-  Multi-errors (`errors.Join`) – Since `errors.Unwrap(err)` returns `nil` for multi-errors (they implement `Unwrap() []error`), the loop would stop there and report the multi-error type itself. Is that acceptable or handle explicitly?
- Separate attributes – @seh previously mentioned possibly publishing separate attributes (wrapper + root). Should this change only adjust the existing attribute to report the concrete underlying error type?

```go
func errorType(err error) string {
	 // ... existing code ...

	if s == "" {
		curr := err
		for {
			if u := errors.Unwrap(curr); u != nil {
				curr = u
			} else {
				break
			}
		}

		t := reflect.TypeOf(curr)
		pkg, name := t.PkgPath(), t.Name()
		if pkg != "" && name != "" {
			s = pkg + "." + name
		} else {
			s = t.String()
		}
	}
	return s
}
```

If this direction looks correct, I will go ahead.
@Balaji01-4D, please also look at https://github.com/open-telemetry/semantic-conventions/issues/3503. I think this is not only a problem in Go.
just one note here - what is the intention of getting that "error type"? Is is used to deduce something?   Most of the time in Go you wrap errors to establish something like to stacktrace and sometimes join errors just to combine different "problems" to more generic result (like join multiple request payload validation errors).

So when you start to unwrap errors then the bottom/deepest error could be the same "type" that the other error which has absolutely different "path". Ala file io related errors -  `http.Request.MultipartForm.File` related method could return `fs.ErrNotExist`  or the same `fs.ErrNotExist` would be returned from your handler specific code (serving jpg from disk).  These may be the same unwrapped type but could mean completely different things to observer.
Those are fine counterexamples, Martti, but clearly `*fmt.wrapError` is _not_ useful information to receive when trying to categorize or find a particular failure. Not all `error`-implementing types implement an `Unwrap` method. Some wrapping types are meant to be peeled away because they don't represent anything material, such as those that exist just to manipulate callers' behavior, such as `backoff.PermanentError`, per the recent cenkalti/backoff#178.

I have an error type that communicates a recommended exit code for the containing process. There are analogous ones to convey a recommended HTTP status code. Those are the sorts of types that I want to peel away. They implement `Unwrap` as a way of indicating that they don't mean to stay in the way.

Before we canonized `errors.Unwrap`, there had been [`Causer` interface types](https://pkg.go.dev/search?q=Causer), and some of the modules that still bear them distinguish them from `Unwrap`, and some do not, the latter [treating `Cause` as a deprecated alias for `Unwrap`](https://github.com/zeebo/errs/blob/13450ab50383c5857164d564544fa0bef3ff689d/errs.go#L261-L270).
thanks for the feedback.

I agree with @aldas that wrapper errors can carry useful domain context about where a failure happened. [This](https://github.com/open-telemetry/opentelemetry-go/pull/8018) change only affects the reflection fallback when no `ErrorType()` implementation is found. In that case it walks the chain using `errors.Unwrap()` and reports the final unwrapped error type instead of the wrapper. 

`errors.Join` behavior is unchanged since `errors.Unwrap()` returns `nil` for those.

Feel free to correct me if I am wrong.

@Balaji01-4D, @seh, I discussed the issue with the Logs SIG. Long story short it make sense to have a list of known types that we want to unwrap before reporting the `error.type`. For sure this would be `*fmt.wrapError`. I am not sure if at this point we should have other types as we do not know what are their semantics. OTel Java instrumentation already do it. Here is an issue that has been created during the meeting and would probably be accepted by the Semantic Conventions SIG: https://github.com/open-telemetry/semantic-conventions/issues/3588. This should also be "good-enough" for addressing the issue reported by @seh 
Thank you for the update, Robert. I agree that unwrapping at least that first type is an improvement, though it makes me uncomfortable that we'll predicate the unwrapping on recognizing an _unexported_ type from the standard library. Clearly the authors didn't want to commit to us noticing and taking action on that type, or they would have exported it.

What do you think about an in-between approach of calling `errors.Unwrap` exactly once on each `error` value? That would cover `*fmt.wrapError` without committing to knowing its type, and cover other `error`-satisfying types that expected to be unwrapped as well.
>  though it makes me uncomfortable that we'll predicate the unwrapping on recognizing an unexported type from the standard library

Just to clarify, I didn’t mean unwrapping by checking for `*fmt.wrapError` via string matching. Rather, the idea was to compare against the concrete type produced by something like:
```go
var wrappedError = fmt.Errorf("wrapped: %w", errors.New("err"))
```

I’ll put together a PR to make this more explicit, as I think it will be clearer and easier to evaluate in code than in discussion.

> What do you think about an in-between approach of calling errors.Unwrap exactly once on each error value?  (...) cover other error-satisfying types that expected to be unwrapped as well.

I don’t think we should make assumptions about how many times an error should be unwrapped. For example, someone might define a domain-specific error type that intentionally wraps lower-level errors, where even a single unwrap would be undesirable. On the other hand, errors wrapped multiple times via `fmt.Errorf` wouldn’t benefit from unwrapping just once.

Because of that variability, enforcing a fixed number of unwrap steps feels arbitrary and could lead to incorrect behavior in both directions.

This also seems to be aligned with your initial https://github.com/open-telemetry/opentelemetry-go/issues/6904#issuecomment-3969141128 and it seems to be the best heuristic.
> This also seems to be aligned with your initial [#6904 (comment)](https://github.com/open-telemetry/opentelemetry-go/issues/6904#issuecomment-3969141128) and it seems to be the best heuristic.

Thank you for recovering that comment. As you can tell, I am both both disappointed with the current behavior and wary of some of the alternatives. I look forward to seeing your concrete proposal.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 34e6c1030f8..9d5f5a43230 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,11 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
 
 - Add `ByteSlice` and `ByteSliceValue` functions for new `BYTESLICE` attribute type in `go.opentelemetry.io/otel/attribute`. (#7948)
 
+### Changed
+
+- `ErrorType` in `go.opentelemetry.io/otel/semconv` now unwraps errors created with `fmt.Errorf` when deriving the `error.type` attribute. (#8133)
+- `go.opentelemetry.io/otel/sdk/log` now unwraps error chains created with `fmt.Errorf` when deriving the `error.type` attribute from errors on log records. (#8133)
+
 <!-- Released section -->
 <!-- Don't change this section unless doing release -->
 
diff --git a/internal/tools/semconvkit/templates/error_type.go.tmpl b/internal/tools/semconvkit/templates/error_type.go.tmpl
index 193c43ae093..080c246af99 100644
--- a/internal/tools/semconvkit/templates/error_type.go.tmpl
+++ b/internal/tools/semconvkit/templates/error_type.go.tmpl
@@ -5,6 +5,7 @@ package semconv // import "go.opentelemetry.io/otel/semconv/{{.TagVer}}"
 
 import (
 	"errors"
+	"fmt"
 	"reflect"
 
 	"go.opentelemetry.io/otel/attribute"
@@ -22,7 +23,8 @@ import (
 // the returned attribute has that method's return value. If multiple errors in
 // the chain implement this method, the value from the first match found by
 // [errors.As] is used. Otherwise, the returned attribute has a value derived
-// from the concrete type of err.
+// from the concrete type of err after unwrapping any wrappers created with
+// [fmt.Errorf].
 //
 // The key of the returned attribute is [ErrorTypeKey].
 func ErrorType(err error) attribute.KeyValue {
@@ -50,7 +52,7 @@ func errorType(err error) string {
 		// Fallback to reflection if the ErrorType method is not supported or
 		// returns an empty value.
 
-		t := reflect.TypeOf(err)
+		t := reflect.TypeOf(unwrapFmtWrapped(err))
 		pkg, name := t.PkgPath(), t.Name()
 		if pkg != "" && name != "" {
 			s = pkg + "." + name
@@ -64,3 +66,16 @@ func errorType(err error) string {
 	}
 	return s
 }
+
+var fmtWrapErrorType = reflect.TypeOf(fmt.Errorf("wrapped: %w", errors.New("err")))
+
+func unwrapFmtWrapped(err error) error {
+	for reflect.TypeOf(err) == fmtWrapErrorType {
+		u := errors.Unwrap(err)
+		if u == nil {
+			return err // When the wrapped error is nil, use the concrete type of the wrapper.
+		}
+		err = u
+	}
+	return err
+}
diff --git a/sdk/log/logger.go b/sdk/log/logger.go
index 7efb96240d3..5cb2b791900 100644
--- a/sdk/log/logger.go
+++ b/sdk/log/logger.go
@@ -5,6 +5,8 @@ package log // import "go.opentelemetry.io/otel/sdk/log"
 
 import (
 	"context"
+	"errors"
+	"fmt"
 	"reflect"
 	"time"
 
@@ -164,7 +166,7 @@ func errorType(err error) string {
 		}
 	}
 
-	t := reflect.TypeOf(err)
+	t := reflect.TypeOf(unwrapFmtWrapped(err))
 	if t == nil {
 		return ""
 	}
@@ -183,3 +185,16 @@ func errorType(err error) string {
 	// This is not guaranteed to be unique, but is a best effort.
 	return t.String()
 }
+
+var fmtWrapErrorType = reflect.TypeOf(fmt.Errorf("wrapped: %w", errors.New("err")))
+
+func unwrapFmtWrapped(err error) error {
+	for reflect.TypeOf(err) == fmtWrapErrorType {
+		u := errors.Unwrap(err)
+		if u == nil {
+			return err // When the wrapped error is nil, use the concrete type of the wrapper.
+		}
+		err = u
+	}
+	return err
+}
diff --git a/semconv/v1.40.0/error_type.go b/semconv/v1.40.0/error_type.go
index 6d26e52821b..1cb89f79d6f 100644
--- a/semconv/v1.40.0/error_type.go
+++ b/semconv/v1.40.0/error_type.go
@@ -5,6 +5,7 @@ package semconv // import "go.opentelemetry.io/otel/semconv/v1.40.0"
 
 import (
 	"errors"
+	"fmt"
 	"reflect"
 
 	"go.opentelemetry.io/otel/attribute"
@@ -22,7 +23,8 @@ import (
 // the returned attribute has that method's return value. If multiple errors in
 // the chain implement this method, the value from the first match found by
 // [errors.As] is used. Otherwise, the returned attribute has a value derived
-// from the concrete type of err.
+// from the concrete type of err after unwrapping any wrappers created with
+// [fmt.Errorf].
 //
 // The key of the returned attribute is [ErrorTypeKey].
 func ErrorType(err error) attribute.KeyValue {
@@ -50,7 +52,7 @@ func errorType(err error) string {
 		// Fallback to reflection if the ErrorType method is not supported or
 		// returns an empty value.
 
-		t := reflect.TypeOf(err)
+		t := reflect.TypeOf(unwrapFmtWrapped(err))
 		pkg, name := t.PkgPath(), t.Name()
 		if pkg != "" && name != "" {
 			s = pkg + "." + name
@@ -64,3 +66,16 @@ func errorType(err error) string {
 	}
 	return s
 }
+
+var fmtWrapErrorType = reflect.TypeOf(fmt.Errorf("wrapped: %w", errors.New("err")))
+
+func unwrapFmtWrapped(err error) error {
+	for reflect.TypeOf(err) == fmtWrapErrorType {
+		u := errors.Unwrap(err)
+		if u == nil {
+			return err // Should never happen, but avoid returning nil if unwrapping fails.
+		}
+		err = u
+	}
+	return err
+}


Test patch:
diff --git a/internal/tools/semconvkit/templates/error_type_test.go.tmpl b/internal/tools/semconvkit/templates/error_type_test.go.tmpl
index 1d8a82f5f13..b2b94762ce7 100644
--- a/internal/tools/semconvkit/templates/error_type_test.go.tmpl
+++ b/internal/tools/semconvkit/templates/error_type_test.go.tmpl
@@ -5,6 +5,7 @@ package semconv // import "go.opentelemetry.io/otel/semconv/{{.TagVer}}"
 
 import (
 	"errors"
+	"fmt"
 	"testing"
 )
 
@@ -18,6 +19,10 @@ func TestErrorType(t *testing.T) {
 	check(t, custom(""), pkg+".ErrCustomType")                     // empty ErrorType, use concrete type.
 	check(t, wrapped(custom("wrapped-aborted")), "wrapped-aborted")
 	check(t, wrapped(custom("")), pkg+".wrappedErr") // empty ErrorType in chain, use concrete top-level type.
+	check(t, fmtWrapped(custom("")), pkg+".ErrCustomType")
+	check(t, fmtWrapped(wrapped(custom(""))), pkg+".wrappedErr")
+	check(t, fmtWrapped(fmtWrapped(custom(""))), pkg+".ErrCustomType")
+	check(t, fmtWrapped(nil), fmtWrapErrorType.String()) // fmt.Errorf with nil error, use concrete type of the wrapper.
 }
 
 func check(t *testing.T, err error, want string) {
@@ -39,6 +44,10 @@ func wrapped(err error) error {
 	return wrappedErr{err: err}
 }
 
+func fmtWrapped(err error) error {
+	return fmt.Errorf("wrapped: %w", err)
+}
+
 type ErrCustomType struct {
 	Type string
 }
diff --git a/sdk/log/logger_test.go b/sdk/log/logger_test.go
index 99eb3815527..347a28823fd 100644
--- a/sdk/log/logger_test.go
+++ b/sdk/log/logger_test.go
@@ -6,6 +6,7 @@ package log // import "go.opentelemetry.io/otel/sdk/log"
 import (
 	"context"
 	"errors"
+	"fmt"
 	"strconv"
 	"testing"
 	"time"
@@ -404,6 +405,21 @@ func TestErrorType(t *testing.T) {
 		var err error = struct{ baseErr }{}
 		assert.Contains(t, errorType(err), "struct")
 	})
+
+	t.Run("FmtWrappedFallsBackToWrappedType", func(t *testing.T) {
+		err := fmt.Errorf("wrapped: %w", errWithType{msg: "boom", typ: ""})
+		assert.Equal(t, "go.opentelemetry.io/otel/sdk/log.errWithType", errorType(err))
+	})
+
+	t.Run("CustomWrapperStaysTopLevel", func(t *testing.T) {
+		err := wrappedErr{err: errWithType{msg: "boom", typ: ""}}
+		assert.Equal(t, "go.opentelemetry.io/otel/sdk/log.wrappedErr", errorType(err))
+	})
+
+	t.Run("FmtWrappedNilError", func(t *testing.T) {
+		err := fmt.Errorf("wrapped: %w", nil)
+		assert.Equal(t, fmtWrapErrorType.String(), errorType(err))
+	})
 }
 
 type errWithType struct {
@@ -419,6 +435,14 @@ type baseErr struct{}
 
 func (baseErr) Error() string { return "boom" }
 
+type wrappedErr struct {
+	err error
+}
+
+func (e wrappedErr) Error() string { return "wrapped: " + e.err.Error() }
+
+func (e wrappedErr) Unwrap() error { return e.err }
+
 func TestNewRecordSkipsExceptionWhenPresent(t *testing.T) {
 	l := newLogger(NewLoggerProvider(), instrumentation.Scope{})
 
diff --git a/semconv/v1.40.0/error_type_test.go b/semconv/v1.40.0/error_type_test.go
index b01ea796487..ccfd5514118 100644
--- a/semconv/v1.40.0/error_type_test.go
+++ b/semconv/v1.40.0/error_type_test.go
@@ -5,6 +5,7 @@ package semconv // import "go.opentelemetry.io/otel/semconv/v1.40.0"
 
 import (
 	"errors"
+	"fmt"
 	"testing"
 )
 
@@ -18,6 +19,10 @@ func TestErrorType(t *testing.T) {
 	check(t, custom(""), pkg+".ErrCustomType")                     // empty ErrorType, use concrete type.
 	check(t, wrapped(custom("wrapped-aborted")), "wrapped-aborted")
 	check(t, wrapped(custom("")), pkg+".wrappedErr") // empty ErrorType in chain, use concrete top-level type.
+	check(t, fmtWrapped(custom("")), pkg+".ErrCustomType")
+	check(t, fmtWrapped(wrapped(custom(""))), pkg+".wrappedErr")
+	check(t, fmtWrapped(fmtWrapped(custom(""))), pkg+".ErrCustomType")
+	check(t, fmtWrapped(nil), fmtWrapErrorType.String()) // fmt.Errorf with nil error, use concrete type of the wrapper.
 }
 
 func check(t *testing.T, err error, want string) {
@@ -39,6 +44,10 @@ func wrapped(err error) error {
 	return wrappedErr{err: err}
 }
 
+func fmtWrapped(err error) error {
+	return fmt.Errorf("wrapped: %w", err)
+}
+
 type ErrCustomType struct {
 	Type string
 }