Repository: samber/oops
Pull request #52: Add HasTag method to OopsError
Author: login: zzzLobster
Created: 2025-03-04T13:35:25Z | Merged: 2025-03-05T13:40:19Z

This is to implement proposal #51 


Diff:
diff --git a/README.md b/README.md
index 5103567..727a05e 100644
--- a/README.md
+++ b/README.md
@@ -253,20 +253,21 @@ The library provides an error builder. Each method can be used standalone (eg: `
 The `oops.OopsError` builder must finish with either `.Errorf(...)`, `.Wrap(...)`, `.Wrapf(...)`, `.Join(...)`, `.Recover(...)` or `.Recoverf(...)`.
 
 | Builder method                          | Getter                                  | Description                                                                                                                                                                                |
-| --------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+|-----------------------------------------|-----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
 | `.With(string, any)`                    | `err.Context() map[string]any`          | Supply a list of attributes key+value. Values of type `func() any {}` are accepted and evaluated lazily.                                                                                   |
 | `.WithContext(context.Context, ...any)` | `err.Context() map[string]any`          | Supply a list of values declared in context. Values of type `func() any {}` are accepted and evaluated lazily.                                                                             |
-| `.Code(string)`                         | `err.Code() string`                     | Set a code or slug that describes the error. Error messages are intented to be read by humans, but such code is expected to be read by machines and be transported over different services |
+| `.Code(string)`                         | `err.Code() string`                     | Set a code or slug that describes the error. Error messages are intended to be read by humans, but such code is expected to be read by machines and be transported over different services |
 | `.Public(string)`                       | `err.Public() string`                   | Set a message that is safe to show to an end user                                                                                                                                          |
 | `.Time(time.Time)`                      | `err.Time() time.Time`                  | Set the error time (default: `time.Now()`)                                                                                                                                                 |
 | `.Since(time.Time)`                     | `err.Duration() time.Duration`          | Set the error duration                                                                                                                                                                     |
 | `.Duration(time.Duration)`              | `err.Duration() time.Duration`          | Set the error duration                                                                                                                                                                     |
 | `.In(string)`                           | `err.Domain() string`                   | Set the feature category or domain                                                                                                                                                         |
 | `.Tags(...string)`                      | `err.Tags() []string`                   | Add multiple tags, describing the feature returning an error                                                                                                                               |
+|                                         | `err.HasTag(string) bool`               | Check whether the error contains provided tag                                                                                                                                              |
 | `.Trace(string)`                        | `err.Trace() string`                    | Add a transaction id, trace id, correlation id... (default: ULID)                                                                                                                          |
 | `.Span(string)`                         | `err.Span() string`                     | Add a span representing a unit of work or operation... (default: ULID)                                                                                                                     |
 | `.Hint(string)`                         | `err.Hint() string`                     | Set a hint for faster debugging                                                                                                                                                            |
-| `.Owner(string)`                        | `err.Owner() (string)`                  | Set the name/email of the collegue/team responsible for handling this error. Useful for alerting purpose                                                                                   |
+| `.Owner(string)`                        | `err.Owner() (string)`                  | Set the name/email of the colleague/team responsible for handling this error. Useful for alerting purpose                                                                                  |
 | `.User(string, any...)`                 | `err.User() (string, map[string]any)`   | Supply user id and a chain of key/value                                                                                                                                                    |
 | `.Tenant(string, any...)`               | `err.Tenant() (string, map[string]any)` | Supply tenant id and a chain of key/value                                                                                                                                                  |
 | `.Request(*http.Request, bool)`         | `err.Request() *http.Request`           | Supply http request                                                                                                                                                                        |
diff --git a/error.go b/error.go
index 6abfc9f..31820e8 100644
--- a/error.go
+++ b/error.go
@@ -127,6 +127,23 @@ func (o OopsError) Tags() []string {
 	return lo.Uniq(tags)
 }
 
+// HasTag returns true if the tags of the error contain provided value.
+func (o OopsError) HasTag(tag string) bool {
+	if lo.Contains(o.tags, tag) {
+		return true
+	}
+
+	if o.err == nil {
+		return false
+	}
+
+	if child, ok := AsOops(o.err); ok {
+		return child.HasTag(tag)
+	}
+
+	return false
+}
+
 // Context returns a k/v context of the error.
 func (o OopsError) Context() map[string]any {
 	return dereferencePointers(
diff --git a/oops_test.go b/oops_test.go
index 045979e..1a3fc71 100644
--- a/oops_test.go
+++ b/oops_test.go
@@ -163,11 +163,33 @@ func TestOopsIn(t *testing.T) {
 func TestOopsTags(t *testing.T) {
 	is := assert.New(t)
 
-	err := new().Tags("iam", "authz", "iam").Wrap(assert.AnError)
-	is.Error(err)
-	is.Equal(assert.AnError, err.(OopsError).err)
+	err := new().Tags("iam", "authz", "iam").Join(
+		new().Tags("iam", "internal").Wrap(assert.AnError),
+		new().Tags("not-found").Wrap(assert.AnError))
+	join, ok := err.(OopsError).err.(interface{ Unwrap() []error })
+	is.True(ok)
+	is.Len(join.Unwrap(), 2)
+	is.Equal(assert.AnError, join.Unwrap()[0].(OopsError).err)
+	is.Equal(assert.AnError, join.Unwrap()[1].(OopsError).err)
 	is.Equal([]string{"iam", "authz", "iam"}, err.(OopsError).tags) // not deduplicated
-	is.Equal([]string{"iam", "authz"}, err.(OopsError).Tags())      // deduplicated
+	is.Equal([]string{"iam", "internal"}, join.Unwrap()[0].(OopsError).tags)
+	is.Equal([]string{"not-found"}, join.Unwrap()[1].(OopsError).tags)
+	is.Equal([]string{"iam", "authz", "internal"}, err.(OopsError).Tags()) // deduplicated and recursive
+}
+
+func TestOopsHasTag(t *testing.T) {
+	is := assert.New(t)
+
+	err := new().Tags("iam", "authz").Join(
+		new().Tags("internal").Wrap(assert.AnError),
+		new().Tags("not-found").Wrap(assert.AnError))
+	is.Error(err)
+	is.True(err.(OopsError).HasTag("internal"))
+	is.True(err.(OopsError).HasTag("authz"))
+	is.False(err.(OopsError).HasTag("not-found")) // Does not go over all joined errors so far
+	is.False(err.(OopsError).HasTag("1234"))
+
+	is.False(OopsError{}.HasTag("not-found"))
 }
 
 func TestOopsTx(t *testing.T) {


Reviews:
user: login: zzzLobster
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T13:38:45Z
user: login: samber
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-04T13:56:51Z
user: login: samber
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-04T14:06:43Z

Review comments:
diff_hunk: @@ -163,11 +163,31 @@ func TestOopsIn(t *testing.T) {
 func TestOopsTags(t *testing.T) {
 	is := assert.New(t)
 
-	err := new().Tags("iam", "authz", "iam").Wrap(assert.AnError)
-	is.Error(err)
-	is.Equal(assert.AnError, err.(OopsError).err)
+	err := new().Tags("iam", "authz", "iam").Join(
+		new().Tags("iam", "internal").Wrap(assert.AnError),
+		new().Tags("not-found").Wrap(assert.AnError))
+	join, ok := err.(OopsError).err.(interface{ Unwrap() []error })
+	is.True(ok)
+	is.Len(join.Unwrap(), 2)
+	is.Equal(assert.AnError, join.Unwrap()[0].(OopsError).err)
+	is.Equal(assert.AnError, join.Unwrap()[1].(OopsError).err)
 	is.Equal([]string{"iam", "authz", "iam"}, err.(OopsError).tags) // not deduplicated
-	is.Equal([]string{"iam", "authz"}, err.(OopsError).Tags())      // deduplicated
+	is.Equal([]string{"iam", "internal"}, join.Unwrap()[0].(OopsError).tags)
+	is.Equal([]string{"not-found"}, join.Unwrap()[1].(OopsError).tags)
+	is.Equal([]string{"iam", "authz", "internal"}, err.(OopsError).Tags()) // deduplicated and recursive
+}
+
+func TestOopsHasTag(t *testing.T) {
+	is := assert.New(t)
+
+	err := new().Tags("iam", "authz").Join(
+		new().Tags("internal").Wrap(assert.AnError),
+		new().Tags("not-found").Wrap(assert.AnError))
+	is.Error(err)
+	is.True(err.(OopsError).HasTag("internal"))
+	is.True(err.(OopsError).HasTag("authz"))
+	is.False(err.(OopsError).HasTag("not-found")) // Should we fix that?
path: oops_test.go
user: login: zzzLobster
body: @samber , In case of having `join` in the hierarchy of errors, current recursive implementation does not process it correctly. What do you think about it? Should it be fixed?
created_at: 2025-03-04T13:38:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -163,11 +163,31 @@ func TestOopsIn(t *testing.T) {
 func TestOopsTags(t *testing.T) {
 	is := assert.New(t)
 
-	err := new().Tags("iam", "authz", "iam").Wrap(assert.AnError)
-	is.Error(err)
-	is.Equal(assert.AnError, err.(OopsError).err)
+	err := new().Tags("iam", "authz", "iam").Join(
+		new().Tags("iam", "internal").Wrap(assert.AnError),
+		new().Tags("not-found").Wrap(assert.AnError))
+	join, ok := err.(OopsError).err.(interface{ Unwrap() []error })
+	is.True(ok)
+	is.Len(join.Unwrap(), 2)
+	is.Equal(assert.AnError, join.Unwrap()[0].(OopsError).err)
+	is.Equal(assert.AnError, join.Unwrap()[1].(OopsError).err)
 	is.Equal([]string{"iam", "authz", "iam"}, err.(OopsError).tags) // not deduplicated
-	is.Equal([]string{"iam", "authz"}, err.(OopsError).Tags())      // deduplicated
+	is.Equal([]string{"iam", "internal"}, join.Unwrap()[0].(OopsError).tags)
+	is.Equal([]string{"not-found"}, join.Unwrap()[1].(OopsError).tags)
+	is.Equal([]string{"iam", "authz", "internal"}, err.(OopsError).Tags()) // deduplicated and recursive
+}
+
+func TestOopsHasTag(t *testing.T) {
+	is := assert.New(t)
+
+	err := new().Tags("iam", "authz").Join(
+		new().Tags("internal").Wrap(assert.AnError),
+		new().Tags("not-found").Wrap(assert.AnError))
+	is.Error(err)
+	is.True(err.(OopsError).HasTag("internal"))
+	is.True(err.(OopsError).HasTag("authz"))
+	is.False(err.(OopsError).HasTag("not-found")) // Should we fix that?
path: oops_test.go
user: login: samber
body: I agree with you.

I'm going to open a new issue. We need some deep changes on this.
created_at: 2025-03-04T13:56:51Z
author_association: OWNER
diff_hunk: @@ -163,11 +163,31 @@ func TestOopsIn(t *testing.T) {
 func TestOopsTags(t *testing.T) {
 	is := assert.New(t)
 
-	err := new().Tags("iam", "authz", "iam").Wrap(assert.AnError)
-	is.Error(err)
-	is.Equal(assert.AnError, err.(OopsError).err)
+	err := new().Tags("iam", "authz", "iam").Join(
+		new().Tags("iam", "internal").Wrap(assert.AnError),
+		new().Tags("not-found").Wrap(assert.AnError))
+	join, ok := err.(OopsError).err.(interface{ Unwrap() []error })
+	is.True(ok)
+	is.Len(join.Unwrap(), 2)
+	is.Equal(assert.AnError, join.Unwrap()[0].(OopsError).err)
+	is.Equal(assert.AnError, join.Unwrap()[1].(OopsError).err)
 	is.Equal([]string{"iam", "authz", "iam"}, err.(OopsError).tags) // not deduplicated
-	is.Equal([]string{"iam", "authz"}, err.(OopsError).Tags())      // deduplicated
+	is.Equal([]string{"iam", "internal"}, join.Unwrap()[0].(OopsError).tags)
+	is.Equal([]string{"not-found"}, join.Unwrap()[1].(OopsError).tags)
+	is.Equal([]string{"iam", "authz", "internal"}, err.(OopsError).Tags()) // deduplicated and recursive
+}
+
+func TestOopsHasTag(t *testing.T) {
+	is := assert.New(t)
+
+	err := new().Tags("iam", "authz").Join(
+		new().Tags("internal").Wrap(assert.AnError),
+		new().Tags("not-found").Wrap(assert.AnError))
+	is.Error(err)
+	is.True(err.(OopsError).HasTag("internal"))
+	is.True(err.(OopsError).HasTag("authz"))
+	is.False(err.(OopsError).HasTag("not-found")) // Should we fix that?
path: oops_test.go
user: login: samber
body: -> #53
created_at: 2025-03-04T14:06:43Z
author_association: OWNER

PR comments:
user: login: samber
created_at: 2025-03-04T13:40:37Z
body: Hey @zzzLobster 

Thanks for this first contrib.

Can you add it to the readme please?

Did you evaluate to change `o.tags` from a slice to a map? I would be faster for HasTag, but maybe too slow for other helpers.
author_association: OWNER
user: login: zzzLobster
created_at: 2025-03-04T13:44:04Z
body: Hey @samber 

> Can you add it to the readme please?

Sure, I'll do that.

> Did you evaluate to change `o.tags` from a slice to a map? I would be faster for HasTag, but maybe too slow for other helpers.

I did not, to avoid unnecessary changes. However, I think that's a good idea, so I'll check this option.
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-03-05T06:34:33Z
body: ## [Codecov](https://app.codecov.io/gh/samber/oops/pull/52?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Samuel+Berthe) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 76.71%. Comparing base [(`eeb65a7`)](https://app.codecov.io/gh/samber/oops/commit/eeb65a7c1b003280a5cfd6c4b2c968bd3bfdf138?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Samuel+Berthe) to head [(`11f4dbe`)](https://app.codecov.io/gh/samber/oops/commit/11f4dbe498ca3e0694e640f1f0a61fdb7998aa2e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Samuel+Berthe).
> Report is 1 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main      #52      +/-   ##
==========================================
+ Coverage   76.17%   76.71%   +0.54%     
==========================================
  Files          13       13              
  Lines        1024     1035      +11     
==========================================
+ Hits          780      794      +14     
+ Misses        212      210       -2     
+ Partials       32       31       -1     
```

| [Flag](https://app.codecov.io/gh/samber/oops/pull/52/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Samuel+Berthe) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/samber/oops/pull/52/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Samuel+Berthe) | `76.71% <100.00%> (+0.54%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Samuel+Berthe#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/samber/oops/pull/52?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Samuel+Berthe).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Samuel+Berthe).

author_association: NONE
user: login: zzzLobster
created_at: 2025-03-05T07:20:27Z
body: > Did you evaluate to change `o.tags` from a slice to a map? I would be faster for HasTag, but maybe too slow for other helpers.

TLDR: it does not worth to replace slice with map in this case.

Implementation with map:
https://github.com/samber/oops/commit/f81c1cf9c1fd4d030fc73e3d1687bd97ceb32b41
I've tried these 3 test cases:
https://github.com/samber/oops/commit/ff082250fb397c235b6a53ac71e3f13a7fcfccc0

And run 2 variants:
1. `const errorNum = 10` - stack of 10 errors
    `const tagsPerError = 10` - 10 tags for each error
    results are pretty much the same on this scale
2. `const errorNum = 50`
    `const tagsPerError = 100`
    On this scale (insane one, IMO) `HasTags()` works faster, but `Tags()` as well as instantiation of errors with all the tags works slower, sometimes significantly 

```text
-- tags as slice, variant 1
go test -benchmem -count=3 -bench=.
goos: darwin
goarch: arm64
pkg: github.com/samber/oops
cpu: Apple M1 Pro
BenchmarkCreateErrorsWithTags-8            10017            119628 ns/op           56208 B/op        841 allocs/op
BenchmarkCreateErrorsWithTags-8            10000            116857 ns/op           56208 B/op        841 allocs/op
BenchmarkCreateErrorsWithTags-8             9493            116186 ns/op           56208 B/op        841 allocs/op
BenchmarkGetTags-8                        120414              9779 ns/op           13464 B/op         19 allocs/op
BenchmarkGetTags-8                        115333              9613 ns/op           13464 B/op         19 allocs/op
BenchmarkGetTags-8                        122136              9243 ns/op           13464 B/op         19 allocs/op
BenchmarkHasTag-8                         228723              5191 ns/op            5824 B/op         22 allocs/op
BenchmarkHasTag-8                         242548              5251 ns/op            5824 B/op         22 allocs/op
BenchmarkHasTag-8                         230253              5184 ns/op            5824 B/op         22 allocs/op
```

```text
-- tags as slice, variant 2
go test -benchmem -count=3 -bench=.
goos: darwin
goarch: arm64
pkg: github.com/samber/oops
cpu: Apple M1 Pro
BenchmarkCreateErrorsWithTags-8             1828            629100 ns/op          361492 B/op       4201 allocs/op
BenchmarkCreateErrorsWithTags-8             2005            596663 ns/op          361496 B/op       4201 allocs/op
BenchmarkCreateErrorsWithTags-8             1944            602877 ns/op          361492 B/op       4201 allocs/op
BenchmarkGetTags-8                          3998            288678 ns/op          517498 B/op         63 allocs/op
BenchmarkGetTags-8                          3855            288675 ns/op          517498 B/op         63 allocs/op
BenchmarkGetTags-8                          3997            297040 ns/op          517498 B/op         63 allocs/op
BenchmarkHasTag-8                          24432             48337 ns/op           28864 B/op        102 allocs/op
BenchmarkHasTag-8                          25134             48675 ns/op           28864 B/op        102 allocs/op
BenchmarkHasTag-8                          24434             47938 ns/op           28864 B/op        102 allocs/op
```

```text
-- tags as map, variant 1
go test -benchmem -count=3 -bench=.
goos: darwin
goarch: arm64
pkg: github.com/samber/oops
cpu: Apple M1 Pro
BenchmarkCreateErrorsWithTags-8             7268            140579 ns/op           59440 B/op        861 allocs/op
BenchmarkCreateErrorsWithTags-8             9007            128058 ns/op           59438 B/op        861 allocs/op
BenchmarkCreateErrorsWithTags-8             8281            133277 ns/op           59439 B/op        861 allocs/op
BenchmarkGetTags-8                         85934             13683 ns/op           10376 B/op         20 allocs/op
BenchmarkGetTags-8                         99601             13267 ns/op           10375 B/op         20 allocs/op
BenchmarkGetTags-8                         93613             12337 ns/op           10376 B/op         20 allocs/op
BenchmarkHasTag-8                         231360              5158 ns/op            5824 B/op         22 allocs/op
BenchmarkHasTag-8                         185398              5855 ns/op            5824 B/op         22 allocs/op
BenchmarkHasTag-8                         220131              5347 ns/op            5824 B/op         22 allocs/op
```

```text
-- tags as map, variant 2
go test -benchmem -count=3 -bench=.
goos: darwin
goarch: arm64
pkg: github.com/samber/oops
cpu: Apple M1 Pro
BenchmarkCreateErrorsWithTags-8             1171           1080981 ns/op          552351 B/op       4673 allocs/op
BenchmarkCreateErrorsWithTags-8             1309            933342 ns/op          552261 B/op       4673 allocs/op
BenchmarkCreateErrorsWithTags-8             1363            888331 ns/op          552350 B/op       4673 allocs/op
BenchmarkGetTags-8                          2350            535821 ns/op          438888 B/op        164 allocs/op
BenchmarkGetTags-8                          2335            527145 ns/op          438880 B/op        163 allocs/op
BenchmarkGetTags-8                          2397            549266 ns/op          438800 B/op        163 allocs/op
BenchmarkHasTag-8                          39244             33631 ns/op           28864 B/op        102 allocs/op
BenchmarkHasTag-8                          39927             27897 ns/op           28864 B/op        102 allocs/op
BenchmarkHasTag-8                          44702             27582 ns/op           28864 B/op        102 allocs/op
```
author_association: CONTRIBUTOR
user: login: zzzLobster
created_at: 2025-03-05T09:23:01Z
body: @samber 
I've added a line to readme, please let me know if that's better to describe it in some other place as well
author_association: CONTRIBUTOR
user: login: samber
created_at: 2025-03-05T13:40:16Z
body: thanks @zzzLobster for your benchmarks ! ✌️

let's merge ;)
author_association: OWNER