diff --git a/README.md b/README.md index 157a61f..8bfe024 100644 --- a/README.md +++ b/README.md @@ -201,9 +201,9 @@ The `oops.OopsError` builder must finish with either `.Errorf(...)`, `.Wrap(...) | `.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 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 | +| `.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 with optional attributes (string key/value pairs, `map[string]any`, and `slog.Attr`) | +| `.Tenant(string, any...)` | `err.Tenant() (string, map[string]any)` | Supply tenant id with optional attributes (string key/value pairs, `map[string]any`, and `slog.Attr`) | | `.Request(*http.Request, bool)` | `err.Request() *http.Request` | Supply http request | | `.Response(*http.Response, bool)` | `err.Response() *http.Response` | Supply http response | | `.FromContext(context.Context)` | | Reuse an existing OopsErrorBuilder transported in a Go context | @@ -257,10 +257,23 @@ err7 := oops. User(userID, "firstname", "Samuel"). Errorf("could not fetch user") +// with map and slog.Attr user data +err7b := oops. + User(userID, + map[string]any{"plan": "pro"}, + slog.String("email", "samuel@example.com"), + "name", + "Samuel"). + Errorf("could not fetch user") + // with optional user and tenant err8 := oops. User(userID, "firstname", "Samuel"). - Tenant(workspaceID, "name", "my little project"). + Tenant(workspaceID, + map[string]any{"name": "my little project"}, + slog.String("country", "fr"), + "locale", + "fr-FR"). Errorf("could not fetch user") // with optional http request and response diff --git a/builder.go b/builder.go index 222ce86..a7b433d 100644 --- a/builder.go +++ b/builder.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "net/http" "time" @@ -514,15 +515,8 @@ func (o OopsErrorBuilder) Owner(owner string) OopsErrorBuilder { func (o OopsErrorBuilder) User(userID string, userData ...any) OopsErrorBuilder { o2 := o.copy() o2.userID = userID - - // Process user data key-value pairs - for i := 0; i < len(userData); i += 2 { - if i+1 < len(userData) { - key, ok := userData[i].(string) - if ok { - o2.userData[key] = userData[i+1] - } - } + for key, value := range identityArgsToMap(userData) { + o2.userData[key] = value } return o2 @@ -537,18 +531,86 @@ func (o OopsErrorBuilder) User(userID string, userData ...any) OopsErrorBuilder func (o OopsErrorBuilder) Tenant(tenantID string, tenantData ...any) OopsErrorBuilder { o2 := o.copy() o2.tenantID = tenantID + for key, value := range identityArgsToMap(tenantData) { + o2.tenantData[key] = value + } - // Process tenant data key-value pairs - for i := 0; i < len(tenantData); i += 2 { - if i+1 < len(tenantData) { - key, ok := tenantData[i].(string) - if ok { - o2.tenantData[key] = tenantData[i+1] + return o2 +} + +const badKey = "!BADKEY" + +// identityArgsToMap converts variadic user/tenant arguments into a flat map. +// +// Supported inputs: +// - slog.Attr values +// - map[string]any values +// - alternating string key/value pairs +// +// Malformed values are stored under "!BADKEY", mirroring log/slog argsToAttr. +func identityArgsToMap(args []any) map[string]any { + payload := map[string]any{} + + for len(args) > 0 { + switch value := args[0].(type) { + case slog.Attr: + payload[value.Key] = slogValueToAny(value.Value) + args = args[1:] + case map[string]any: + for key, mapValue := range value { + payload[key] = mapValue + } + args = args[1:] + case string: + if len(args) == 1 { + payload[badKey] = value + return payload } + + payload[value] = args[1] + args = args[2:] + default: + payload[badKey] = value + args = args[1:] } } - return o2 + return payload +} + +// slogValueToAny converts a slog.Value into its resolved Go representation. +// +// Group values are converted recursively into map[string]any so they can be +// stored in user/tenant payload maps. +func slogValueToAny(value slog.Value) any { + value = value.Resolve() + + switch value.Kind() { + case slog.KindString: + return value.String() + case slog.KindInt64: + return value.Int64() + case slog.KindUint64: + return value.Uint64() + case slog.KindFloat64: + return value.Float64() + case slog.KindBool: + return value.Bool() + case slog.KindDuration: + return value.Duration() + case slog.KindTime: + return value.Time() + case slog.KindGroup: + group := map[string]any{} + for _, attr := range value.Group() { + group[attr.Key] = slogValueToAny(attr.Value) + } + return group + case slog.KindLogValuer: + return slogValueToAny(value.LogValuer().LogValue()) + default: + return value.Any() + } } // Request adds HTTP request information to the error context. diff --git a/oops.go b/oops.go index d65e84f..81e7b3b 100644 --- a/oops.go +++ b/oops.go @@ -139,14 +139,18 @@ func Owner(owner string) OopsErrorBuilder { return newBuilder().Owner(owner) } -// User supplies user id and a chain of key/value. -func User(userID string, data map[string]any) OopsErrorBuilder { - return newBuilder().User(userID, data) -} - -// Tenant supplies tenant id and a chain of key/value. -func Tenant(tenantID string, data map[string]any) OopsErrorBuilder { - return newBuilder().Tenant(tenantID, data) +// User supplies a user id with optional attributes. +// Attributes can be provided as alternating string key/value pairs, +// map[string]any values, and slog.Attr values. +func User(userID string, data ...any) OopsErrorBuilder { + return newBuilder().User(userID, data...) +} + +// Tenant supplies a tenant id with optional attributes. +// Attributes can be provided as alternating string key/value pairs, +// map[string]any values, and slog.Attr values. +func Tenant(tenantID string, data ...any) OopsErrorBuilder { + return newBuilder().Tenant(tenantID, data...) } // Request supplies a http.Request. diff --git a/oops_test.go b/oops_test.go index 3ed9b82..e4ce9c1 100644 --- a/oops_test.go +++ b/oops_test.go @@ -334,12 +334,43 @@ func TestOopsUser(t *testing.T) { is.Error(err) is.Equal(assert.AnError, err.(OopsError).err) is.Equal("user-123", err.(OopsError).userID) - is.Equal(map[string]any{"firstname": "john"}, err.(OopsError).userData) + is.Equal(map[string]any{"firstname": "john", badKey: "lastname"}, err.(OopsError).userData) err = newBuilder().User("user-123", "firstname", "john", "lastname", "doe").Wrap(assert.AnError) is.Error(err) is.Equal(assert.AnError, err.(OopsError).err) is.Equal("user-123", err.(OopsError).userID) is.Equal(map[string]any{"firstname": "john", "lastname": "doe"}, err.(OopsError).userData) + err = newBuilder().User("user-123", 42).Wrap(assert.AnError) + is.Error(err) + is.Equal(assert.AnError, err.(OopsError).err) + is.Equal("user-123", err.(OopsError).userID) + is.Equal(map[string]any{badKey: 42}, err.(OopsError).userData) + err = newBuilder().User( + "user-123", + slog.String("firstname", "john"), + slog.Group("profile", "lastname", "doe", "age", 42), + ).Wrap(assert.AnError) + is.Error(err) + is.Equal(assert.AnError, err.(OopsError).err) + is.Equal("user-123", err.(OopsError).userID) + is.Equal( + map[string]any{ + "firstname": "john", + "profile": map[string]any{"lastname": "doe", "age": int64(42)}, + }, + err.(OopsError).userData, + ) + err = newBuilder().User( + "user-123", + map[string]any{"firstname": "john"}, + "lastname", + "doe", + map[string]any{"country": "fr"}, + ).Wrap(assert.AnError) + is.Error(err) + is.Equal(assert.AnError, err.(OopsError).err) + is.Equal("user-123", err.(OopsError).userID) + is.Equal(map[string]any{"firstname": "john", "lastname": "doe", "country": "fr"}, err.(OopsError).userData) } func TestOopsTenant(t *testing.T) { @@ -360,12 +391,45 @@ func TestOopsTenant(t *testing.T) { is.Error(err) is.Equal(assert.AnError, err.(OopsError).err) is.Equal("workspace-123", err.(OopsError).tenantID) - is.Equal(map[string]any{"name": "My 'hello world' project"}, err.(OopsError).tenantData) + is.Equal(map[string]any{"name": "My 'hello world' project", badKey: "date"}, err.(OopsError).tenantData) err = newBuilder().Tenant("workspace-123", "name", "My 'hello world' project", "date", "2023-01-01").Wrap(assert.AnError) is.Error(err) is.Equal(assert.AnError, err.(OopsError).err) is.Equal("workspace-123", err.(OopsError).tenantID) is.Equal(map[string]any{"name": "My 'hello world' project", "date": "2023-01-01"}, err.(OopsError).tenantData) + err = newBuilder().Tenant( + "workspace-123", + slog.String("country", "fr"), + slog.Group("billing", "plan", "pro", "seats", 42), + ).Wrap(assert.AnError) + is.Error(err) + is.Equal(assert.AnError, err.(OopsError).err) + is.Equal("workspace-123", err.(OopsError).tenantID) + is.Equal( + map[string]any{ + "country": "fr", + "billing": map[string]any{"plan": "pro", "seats": int64(42)}, + }, + err.(OopsError).tenantData, + ) + err = newBuilder().Tenant( + "workspace-123", + map[string]any{"name": "My 'hello world' project"}, + "date", + "2023-01-01", + map[string]any{"country": "fr"}, + ).Wrap(assert.AnError) + is.Error(err) + is.Equal(assert.AnError, err.(OopsError).err) + is.Equal("workspace-123", err.(OopsError).tenantID) + is.Equal( + map[string]any{ + "name": "My 'hello world' project", + "date": "2023-01-01", + "country": "fr", + }, + err.(OopsError).tenantData, + ) } func TestOopsRequest(t *testing.T) {