mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-16 21:40:34 +01:00
Compare commits
2 Commits
settings-e
...
tvats-foll
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51e7b89e41 | ||
|
|
b5922f8818 |
@@ -3565,6 +3565,7 @@ components:
|
||||
errors:
|
||||
items:
|
||||
$ref: '#/components/schemas/ErrorsResponseerroradditional'
|
||||
nullable: true
|
||||
type: array
|
||||
message:
|
||||
type: string
|
||||
@@ -3573,14 +3574,21 @@ components:
|
||||
suggestions:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
type:
|
||||
type: string
|
||||
url:
|
||||
nullable: true
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- code
|
||||
- message
|
||||
- url
|
||||
- errors
|
||||
- retry
|
||||
- suggestions
|
||||
type: object
|
||||
ErrorsResponseerroradditional:
|
||||
properties:
|
||||
@@ -3589,12 +3597,19 @@ components:
|
||||
suggestions:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- message
|
||||
- suggestions
|
||||
type: object
|
||||
ErrorsResponseretryjson:
|
||||
nullable: true
|
||||
properties:
|
||||
delay:
|
||||
$ref: '#/components/schemas/TimeDuration'
|
||||
required:
|
||||
- delay
|
||||
type: object
|
||||
FactoryResponse:
|
||||
properties:
|
||||
|
||||
@@ -36,6 +36,55 @@ var (
|
||||
|
||||
> 💡 **Note**: Error codes must match the regex `^[a-z_]+$` otherwise the code will panic.
|
||||
|
||||
### Message
|
||||
The primary, human-readable summary of what went wrong, set when the error is created via `errors.New` / `errors.Newf`. Note there are two distinct `message` fields in the response: this top-level one states the overall failure, while each entry under [Additional](#additional) carries its own [message](#message-1) explaining one specific facet of it.
|
||||
|
||||
### Url
|
||||
An optional link to documentation that explains the error in more depth, set with `WithUrl`. It is left empty when the error has no associated doc.
|
||||
|
||||
```go
|
||||
return errors.New(errors.TypeInvalidInput, CodeBadThing, "bad thing").
|
||||
WithUrl("https://signoz.io/docs/...")
|
||||
```
|
||||
|
||||
### Additional
|
||||
`errors` is a list of supplementary details that explain the top-level `message`. Each entry has its own `message` and `suggestions`, so a single error can surface several distinct problems individually. Attach details with `WithAdditional` (message only) or `WithSuggestiveAdditional` (message plus the suggestions that belong to it):
|
||||
|
||||
#### Message
|
||||
A single, self-contained sentence describing one specific facet of the error (e.g. ``field `filed` not found``), distinct from the top-level [Message](#message). Prefer one detail per distinct problem over concatenating several into one message.
|
||||
|
||||
#### Suggestions
|
||||
The suggestions tied to that specific detail — typically a ``did you mean: `x` `` correction for the value the detail is about. These are distinct from the error-wide [Suggestions](#suggestions) below: detail-scoped suggestions never leak into the top-level list.
|
||||
|
||||
```go
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
|
||||
WithAdditional("field `field` not found")
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
|
||||
WithSuggestiveAdditional("field `filed` not found", "did you mean: `field`")
|
||||
```
|
||||
|
||||
### Retry
|
||||
Carries the `delay` the client should wait before retrying, set with `WithRetryAfter`. It is `null` when the error is not retryable.
|
||||
|
||||
```go
|
||||
return errors.NewTimeoutf(CodeSlow, "upstream timed out").
|
||||
WithRetryAfter(5 * time.Second)
|
||||
```
|
||||
|
||||
### Suggestions
|
||||
`WithSuggestions` sets the error-wide `suggestions` list — hints about the error as a whole (e.g. "narrow the time range window"), as opposed to suggestions tied to a single detail. Prefer the builders in [pkg/errors/suggestions.go](/pkg/errors/suggestions.go) over hand-writing the strings so the phrasing stays consistent:
|
||||
|
||||
- `NewSuggestionsOnLevenshteinDistance(invalidInput, noun, validInputs)` — returns a ``did you mean: `x` `` correction (when a close typo match exists) followed by the valid-references list.
|
||||
- `NewValidReferences(noun, values...)` — formats a capped list as ``valid <noun> are `a`, `b` `` (e.g. `"valid fields are"`, `"valid keys are"`). Returns `""` for an empty set.
|
||||
- `NewSuggestionsFromFunc(produce)` — wraps a caller-computed correction string as a one-element ``did you mean: `x` `` slice (or nil when it returns `""`), for callers with their own matching strategy.
|
||||
|
||||
`noun` names the kind of value being suggested. Use one of the exported `Noun*` constants (`errors.NounFields`, `errors.NounKeys`, `errors.NounServices`, …) so the wording stays uniform across the codebase.
|
||||
|
||||
```go
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
|
||||
WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field, errors.NounFields, validFields)...)
|
||||
```
|
||||
|
||||
## Show me some examples
|
||||
|
||||
### Using the error
|
||||
|
||||
@@ -2142,16 +2142,21 @@ export interface ErrorsResponseerroradditionalDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message?: string;
|
||||
message: string;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
suggestions?: string[];
|
||||
suggestions: string[] | null;
|
||||
}
|
||||
|
||||
export interface ErrorsResponseretryjsonDTO {
|
||||
delay?: TimeDurationDTO;
|
||||
}
|
||||
export type ErrorsResponseretryjsonDTOAnyOf = {
|
||||
delay: TimeDurationDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type ErrorsResponseretryjsonDTO = ErrorsResponseretryjsonDTOAnyOf | null;
|
||||
|
||||
export interface ErrorsJSONDTO {
|
||||
/**
|
||||
@@ -2159,26 +2164,26 @@ export interface ErrorsJSONDTO {
|
||||
*/
|
||||
code: string;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
errors?: ErrorsResponseerroradditionalDTO[];
|
||||
errors: ErrorsResponseerroradditionalDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
retry?: ErrorsResponseretryjsonDTO;
|
||||
retry: ErrorsResponseretryjsonDTO | null;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
suggestions?: string[];
|
||||
suggestions: string[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type?: string;
|
||||
type: string;
|
||||
/**
|
||||
* @type string
|
||||
* @type string,null
|
||||
*/
|
||||
url?: string;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface AuthtypesOrgSessionContextDTO {
|
||||
|
||||
@@ -84,7 +84,7 @@ func TestWithSuggestiveAdditional(t *testing.T) {
|
||||
assert.Equal(t, []responseerroradditional{
|
||||
{Message: "field `filed` not found", Suggestions: []string{"did you mean: `field`"}},
|
||||
}, j.Errors)
|
||||
assert.Nil(t, j.Suggestions, "detail-scoped suggestions must not leak into the error-wide list")
|
||||
assert.Empty(t, j.Suggestions, "detail-scoped suggestions must not leak into the error-wide list")
|
||||
}
|
||||
|
||||
func TestWithRetryAfter(t *testing.T) {
|
||||
@@ -106,7 +106,12 @@ func TestAsJSONBaseError(t *testing.T) {
|
||||
assert.Equal(t, "bad_input", j.Code)
|
||||
assert.Equal(t, "field foo is bad", j.Message)
|
||||
assert.Equal(t, "https://docs/bad_input", j.Url)
|
||||
assert.Equal(t, []responseerroradditional{{Message: "hint1"}, {Message: "hint2"}}, j.Errors)
|
||||
// A detail with no suggestions carries a nil slice (the suggestions field is
|
||||
// nullable, so it marshals to null rather than []).
|
||||
assert.Equal(t, []responseerroradditional{
|
||||
{Message: "hint1"},
|
||||
{Message: "hint2"},
|
||||
}, j.Errors)
|
||||
|
||||
// InvalidInput auto-applies the after_fix policy via NewInvalidInputf — but
|
||||
// New (bare constructor) does not. The retry block should reflect that.
|
||||
@@ -157,9 +162,13 @@ func TestAsJSONRetryBlock(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAsJSONOptionalFieldsOmittedWhenEmpty(t *testing.T) {
|
||||
func TestAsJSONEmptyWhenNoneSet(t *testing.T) {
|
||||
// errors and suggestions are nullable in the OpenAPI spec, so AsJSON leaves
|
||||
// them empty when the error carries none (they marshal to null / []).
|
||||
j := AsJSON(New(TypeInternal, MustNewCode("boom"), "boom"))
|
||||
assert.Nil(t, j.Suggestions, "no suggestions set => Suggestions must be nil so json omitempty drops it")
|
||||
|
||||
assert.Empty(t, j.Suggestions)
|
||||
assert.Empty(t, j.Errors)
|
||||
}
|
||||
|
||||
func TestWithStacktrace(t *testing.T) {
|
||||
|
||||
@@ -7,31 +7,34 @@ import (
|
||||
)
|
||||
|
||||
type JSON struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Type string `json:"type" required:"true"`
|
||||
Code string `json:"code" required:"true"`
|
||||
Message string `json:"message" required:"true"`
|
||||
Url string `json:"url,omitempty"`
|
||||
Errors []responseerroradditional `json:"errors,omitempty"`
|
||||
Retry *responseretryjson `json:"retry,omitempty"`
|
||||
Suggestions []string `json:"suggestions,omitempty"`
|
||||
Url string `json:"url" required:"true" nullable:"true"`
|
||||
Errors []responseerroradditional `json:"errors" required:"true" nullable:"true"`
|
||||
Retry *responseretryjson `json:"retry" required:"true" nullable:"true"`
|
||||
Suggestions []string `json:"suggestions" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
type responseretryjson struct {
|
||||
Delay time.Duration `json:"delay"`
|
||||
Delay time.Duration `json:"delay" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type responseerroradditional struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
Suggestions []string `json:"suggestions,omitempty"`
|
||||
Message string `json:"message" required:"true"`
|
||||
Suggestions []string `json:"suggestions" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
func AsJSON(cause error) *JSON {
|
||||
// See if this is an instance of the base error or not
|
||||
t, c, m, _, u, a := Unwrapb(cause)
|
||||
|
||||
rea := make([]responseerroradditional, len(a))
|
||||
for k, v := range a {
|
||||
rea[k] = responseerroradditional{Message: v.message, Suggestions: v.suggestions}
|
||||
var rea []responseerroradditional
|
||||
if len(a) > 0 {
|
||||
rea = make([]responseerroradditional, len(a))
|
||||
for k, v := range a {
|
||||
rea[k] = responseerroradditional{Message: v.message, Suggestions: v.suggestions}
|
||||
}
|
||||
}
|
||||
|
||||
var retry *responseretryjson
|
||||
@@ -54,9 +57,12 @@ func AsURLValues(cause error) url.Values {
|
||||
// See if this is an instance of the base error or not
|
||||
_, c, m, _, u, a := Unwrapb(cause)
|
||||
|
||||
rea := make([]responseerroradditional, len(a))
|
||||
for k, v := range a {
|
||||
rea[k] = responseerroradditional{Message: v.message, Suggestions: v.suggestions}
|
||||
var rea []responseerroradditional
|
||||
if len(a) > 0 {
|
||||
rea = make([]responseerroradditional, len(a))
|
||||
for k, v := range a {
|
||||
rea[k] = responseerroradditional{Message: v.message, Suggestions: v.suggestions}
|
||||
}
|
||||
}
|
||||
|
||||
errors, err := json.Marshal(rea)
|
||||
|
||||
@@ -5,6 +5,18 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Nouns name the kind of value a suggestion refers to. Pass one to
|
||||
// NewValidReferences / NewSuggestionsOnLevenshteinDistance to phrase the
|
||||
// "valid <noun> are ..." list consistently across the codebase.
|
||||
const (
|
||||
NounFields = "fields"
|
||||
NounKeys = "keys"
|
||||
NounServices = "services"
|
||||
NounQueryTypes = "query types"
|
||||
NounSignals = "signals"
|
||||
NounReferences = "references"
|
||||
)
|
||||
|
||||
const (
|
||||
typoSuggestionThreshold = 0.75
|
||||
// maxValidReferences caps how many valid references are listed so
|
||||
@@ -13,17 +25,18 @@ const (
|
||||
maxValidReferences = 20
|
||||
)
|
||||
|
||||
// SuggestionsOnLevenshteinDistance returns a "did you mean" correction (only
|
||||
// NewSuggestionsOnLevenshteinDistance returns a "did you mean" correction (only
|
||||
// when a close match at least typoSuggestionThreshold similar exists) followed
|
||||
// by the valid-references list.
|
||||
func SuggestionsOnLevenshteinDistance(invalidInput string, validInputs []string) []string {
|
||||
// by the valid-references list. noun names the kind of value being suggested
|
||||
// (e.g. "fields", "keys") and is used to phrase the valid-references list.
|
||||
func NewSuggestionsOnLevenshteinDistance(invalidInput string, noun string, validInputs []string) []string {
|
||||
suggestions := make([]string, 0, 2)
|
||||
|
||||
if match, ok := ClosestLevenshteinMatch(invalidInput, validInputs); ok {
|
||||
suggestions = append(suggestions, didYouMean(match))
|
||||
}
|
||||
|
||||
if refs := ValidReferences(validInputs...); refs != "" {
|
||||
if refs := NewValidReferences(noun, validInputs...); refs != "" {
|
||||
suggestions = append(suggestions, refs)
|
||||
}
|
||||
|
||||
@@ -52,10 +65,10 @@ func ClosestLevenshteinMatch(input string, candidates []string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// SuggestionsFromFunc formats the string produce returns as a one-element
|
||||
// NewSuggestionsFromFunc formats the string produce returns as a one-element
|
||||
// "did you mean: `x`" slice, or nil when it returns the empty string (so callers
|
||||
// with their own matching strategy compose into a suggestions list cleanly).
|
||||
func SuggestionsFromFunc(produce func() string) []string {
|
||||
func NewSuggestionsFromFunc(produce func() string) []string {
|
||||
s := produce()
|
||||
if s == "" {
|
||||
return nil
|
||||
@@ -64,12 +77,12 @@ func SuggestionsFromFunc(produce func() string) []string {
|
||||
return []string{didYouMean(s)}
|
||||
}
|
||||
|
||||
// ValidReferences formats values as "valid references: `a`, `b`", capped at
|
||||
// maxValidReferences with a "(+N more)" suffix. Each value is rendered as its
|
||||
// own string, an Enum() element's StringValue(), or fmt.Sprint as a fallback.
|
||||
// It returns "" when there are no values, so callers don't surface a bare
|
||||
// "valid references: " with nothing after it.
|
||||
func ValidReferences[T any](values ...T) string {
|
||||
// NewValidReferences formats values as "valid <noun> are `a`, `b`" (e.g. noun
|
||||
// "fields", "functions", "keys"), capped at maxValidReferences with a "(+N more)"
|
||||
// suffix. Each value is rendered as its own string, an Enum() element's
|
||||
// StringValue(), or fmt.Sprint as a fallback. It returns "" when there are no
|
||||
// values, so callers don't surface a bare "valid <noun> are" with nothing after it.
|
||||
func NewValidReferences[T any](noun string, values ...T) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
@@ -97,7 +110,7 @@ func ValidReferences[T any](values ...T) string {
|
||||
quoted[i] = "`" + r + "`"
|
||||
}
|
||||
|
||||
out := "valid references: " + strings.Join(quoted, ", ")
|
||||
out := "valid " + noun + " are " + strings.Join(quoted, ", ")
|
||||
if truncated > 0 {
|
||||
out += fmt.Sprintf(" (+%d more)", truncated)
|
||||
}
|
||||
|
||||
@@ -6,26 +6,28 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestValidReferences(t *testing.T) {
|
||||
// An empty set returns "" so callers don't surface a bare "valid references: ".
|
||||
assert.Equal(t, "", ValidReferences[string]())
|
||||
func TestNewValidReferences(t *testing.T) {
|
||||
// An empty set returns "" so callers don't surface a bare "valid <noun> are".
|
||||
assert.Equal(t, "", NewValidReferences[string](NounFields))
|
||||
|
||||
assert.Equal(t, "valid references: `a`, `b`", ValidReferences("a", "b"))
|
||||
// The noun phrases the list, e.g. "valid fields are", "valid keys are".
|
||||
assert.Equal(t, "valid fields are `a`, `b`", NewValidReferences(NounFields, "a", "b"))
|
||||
assert.Equal(t, "valid keys are `a`, `b`", NewValidReferences(NounKeys, "a", "b"))
|
||||
}
|
||||
|
||||
func TestSuggestionsOnLevenshteinDistance(t *testing.T) {
|
||||
// No valid inputs => no suggestions at all (no bare "valid references: ").
|
||||
assert.Empty(t, SuggestionsOnLevenshteinDistance("foo", nil))
|
||||
func TestNewSuggestionsOnLevenshteinDistance(t *testing.T) {
|
||||
// No valid inputs => no suggestions at all (no bare "valid <noun> are").
|
||||
assert.Empty(t, NewSuggestionsOnLevenshteinDistance("foo", NounFields, nil))
|
||||
|
||||
// Close match => did-you-mean plus the valid-references list.
|
||||
assert.Equal(t,
|
||||
[]string{"did you mean: `name`", "valid references: `name`, `color`"},
|
||||
SuggestionsOnLevenshteinDistance("nam", []string{"name", "color"}),
|
||||
[]string{"did you mean: `name`", "valid fields are `name`, `color`"},
|
||||
NewSuggestionsOnLevenshteinDistance("nam", NounFields, []string{"name", "color"}),
|
||||
)
|
||||
|
||||
// No close match => valid-references list only.
|
||||
assert.Equal(t,
|
||||
[]string{"valid references: `name`, `color`"},
|
||||
SuggestionsOnLevenshteinDistance("zzzzz", []string{"name", "color"}),
|
||||
[]string{"valid fields are `name`, `color`"},
|
||||
NewSuggestionsOnLevenshteinDistance("zzzzz", NounFields, []string{"name", "color"}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ package binding
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/reflectutil"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -76,7 +76,7 @@ func (b *jsonBinding) BindBody(body io.Reader, obj any, opts ...BindBodyOption)
|
||||
|
||||
return errors.
|
||||
NewInvalidInputf(errors.CodeInvalidInput, message, field).
|
||||
WithSuggestions(errors.SuggestionsOnLevenshteinDistance(field, JSONFieldNames(obj))...)
|
||||
WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field, errors.NounFields, reflectutil.JSONFieldNames(obj))...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,37 +86,6 @@ func (b *jsonBinding) BindBody(body io.Reader, obj any, opts ...BindBodyOption)
|
||||
return nil
|
||||
}
|
||||
|
||||
// JSONFieldNames returns the JSON field names of a struct (or pointer to one),
|
||||
// skipping fields tagged "-" or without a json tag.
|
||||
func JSONFieldNames(v any) []string {
|
||||
var fields []string
|
||||
|
||||
t := reflect.TypeOf(v)
|
||||
if t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
|
||||
if t.Kind() != reflect.Struct {
|
||||
return fields
|
||||
}
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
jsonTag := field.Tag.Get("json")
|
||||
|
||||
if jsonTag == "" || jsonTag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldName := strings.Split(jsonTag, ",")[0]
|
||||
if fieldName != "" {
|
||||
fields = append(fields, fieldName)
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// extractUnknownField pulls fieldname out of a `json: unknown field "fieldname"`
|
||||
// decoder message, or returns "" when the message has no quoted field.
|
||||
func extractUnknownField(errMsg string) string {
|
||||
|
||||
@@ -90,21 +90,21 @@ func TestJSONBinding_BindBody_UnknownFieldSuggestions(t *testing.T) {
|
||||
body: `{"shape":"round"}`,
|
||||
opts: []BindBodyOption{WithDisallowUnknownFields(true)},
|
||||
message: `unknown field "shape"`,
|
||||
suggestions: []string{"valid references: `name`, `color`"},
|
||||
suggestions: []string{"valid fields are `name`, `color`"},
|
||||
},
|
||||
{
|
||||
name: "WithContext",
|
||||
body: `{"shape":"round"}`,
|
||||
opts: []BindBodyOption{WithDisallowUnknownFields(true), WithUnknownFieldContext("widget spec")},
|
||||
message: `unknown field "shape" in widget spec`,
|
||||
suggestions: []string{"valid references: `name`, `color`"},
|
||||
suggestions: []string{"valid fields are `name`, `color`"},
|
||||
},
|
||||
{
|
||||
name: "NearMatch",
|
||||
body: `{"nam":"x"}`,
|
||||
opts: []BindBodyOption{WithDisallowUnknownFields(true)},
|
||||
message: `unknown field "nam"`,
|
||||
suggestions: []string{"did you mean: `name`", "valid references: `name`, `color`"},
|
||||
suggestions: []string{"did you mean: `name`", "valid fields are `name`, `color`"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -99,13 +99,13 @@ func TestError(t *testing.T) {
|
||||
name: "AlreadyExists",
|
||||
statusCode: http.StatusConflict,
|
||||
err: errors.New(errors.TypeAlreadyExists, errors.MustNewCode("already_exists"), "already exists").WithUrl("https://already_exists"),
|
||||
expected: []byte(`{"status":"error","error":{"type":"already-exists","code":"already_exists","message":"already exists","url":"https://already_exists"}}`),
|
||||
expected: []byte(`{"status":"error","error":{"type":"already-exists","code":"already_exists","message":"already exists","url":"https://already_exists","errors":null,"retry":null,"suggestions":null}}`),
|
||||
},
|
||||
"/unauthenticated": {
|
||||
name: "Unauthenticated",
|
||||
statusCode: http.StatusUnauthorized,
|
||||
err: errors.New(errors.TypeUnauthenticated, errors.MustNewCode("not_allowed"), "not allowed").WithUrl("https://unauthenticated").WithAdditional("a1", "a2"),
|
||||
expected: []byte(`{"status":"error","error":{"type":"unauthenticated","code":"not_allowed","message":"not allowed","url":"https://unauthenticated","errors":[{"message":"a1"},{"message":"a2"}]}}`),
|
||||
expected: []byte(`{"status":"error","error":{"type":"unauthenticated","code":"not_allowed","message":"not allowed","url":"https://unauthenticated","errors":[{"message":"a1","suggestions":null},{"message":"a2","suggestions":null}],"retry":null,"suggestions":null}}`),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -177,8 +177,8 @@ func TestErrorRetryAfterHeader(t *testing.T) {
|
||||
name: "BareErrorNoHeaderNoRetryBlock",
|
||||
err: errors.New(errors.TypeInternal, errors.MustNewCode("boom"), "boom"),
|
||||
wantRetryAfter: "",
|
||||
wantBodyContains: `"code":"boom"`,
|
||||
wantBodyNotContains: `"retry"`,
|
||||
wantBodyContains: `"retry":null`,
|
||||
wantBodyNotContains: `"delay"`,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ func CollisionHandledFinalExpr(
|
||||
// - it is not a static field
|
||||
// - the next best thing to do is see if there is a typo
|
||||
// and suggest a correction
|
||||
wrappedErr := errors.WithSuggestiveAdditionalf(fieldForErr, errors.SuggestionsOnLevenshteinDistance(field.Name, maps.Keys(keys)), "field `%s` not found", field.Name)
|
||||
wrappedErr := errors.WithSuggestiveAdditionalf(fieldForErr, errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys)), "field `%s` not found", field.Name)
|
||||
return "", nil, wrappedErr
|
||||
} else {
|
||||
for _, key := range keysForField {
|
||||
|
||||
@@ -300,7 +300,7 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
var suggestions []string
|
||||
if len(v.invalid) == 1 {
|
||||
inv := v.invalid[0]
|
||||
suggestions = errors.SuggestionsFromFunc(func() string {
|
||||
suggestions = errors.NewSuggestionsFromFunc(func() string {
|
||||
match, ok := errors.ClosestLevenshteinMatch(inv, validKeys)
|
||||
if !ok || strings.Contains(original, inv+"(") || strings.Contains(match, "(") {
|
||||
return ""
|
||||
@@ -309,7 +309,7 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
})
|
||||
}
|
||||
|
||||
suggestions = append(suggestions, errors.ValidReferences(validKeys...))
|
||||
suggestions = append(suggestions, errors.NewValidReferences(errors.NounReferences, validKeys...))
|
||||
havingErr := errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"Invalid references in `Having` expression: [%s]",
|
||||
@@ -339,7 +339,7 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
// multiple errors are surfaced as one additional detail each. If the parser
|
||||
// produced no message (rare), the top-level message stands on its own.
|
||||
if len(allSyntaxErrors) == 1 && len(msgs) == 1 {
|
||||
suggestions := errors.SuggestionsFromFunc(func() string {
|
||||
suggestions := errors.NewSuggestionsFromFunc(func() string {
|
||||
return havingSuggestion(allSyntaxErrors[0], original)
|
||||
})
|
||||
|
||||
|
||||
@@ -593,7 +593,7 @@ func TestRewriteForLogsAndTraces_InOperator(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [ghost]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, count(), total]"},
|
||||
wantSuggestions: []string{"valid references: `__result`, `__result0`, `count()`, `total`"},
|
||||
wantSuggestions: []string{"valid references are `__result`, `__result0`, `count()`, `total`"},
|
||||
},
|
||||
{
|
||||
name: "IN with end bracked missing",
|
||||
@@ -655,7 +655,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [unknown_alias]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, count(), total]"},
|
||||
wantSuggestions: []string{"valid references: `__result`, `__result0`, `count()`, `total`"},
|
||||
wantSuggestions: []string{"valid references are `__result`, `__result0`, `count()`, `total`"},
|
||||
},
|
||||
{
|
||||
name: "typo in identifier suggests closest match",
|
||||
@@ -666,7 +666,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [totol]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, count(), total]"},
|
||||
wantSuggestions: []string{"did you mean: `total > 100`", "valid references: `__result`, `__result0`, `count()`, `total`"},
|
||||
wantSuggestions: []string{"did you mean: `total > 100`", "valid references are `__result`, `__result0`, `count()`, `total`"},
|
||||
},
|
||||
{
|
||||
name: "expression not in column map",
|
||||
@@ -677,7 +677,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [sum]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, count()]"},
|
||||
wantSuggestions: []string{"valid references: `__result`, `__result0`, `count()`"},
|
||||
wantSuggestions: []string{"valid references are `__result`, `__result0`, `count()`"},
|
||||
},
|
||||
{
|
||||
name: "one valid one invalid reference",
|
||||
@@ -688,7 +688,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [ghost]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, count(), total]"},
|
||||
wantSuggestions: []string{"valid references: `__result`, `__result0`, `count()`, `total`"},
|
||||
wantSuggestions: []string{"valid references are `__result`, `__result0`, `count()`, `total`"},
|
||||
},
|
||||
{
|
||||
name: "__result ambiguous with multiple aggregations",
|
||||
@@ -700,7 +700,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [__result]",
|
||||
wantAdditional: []string{"Valid references are: [__result0, __result1, count(), sum(bytes)]"},
|
||||
wantSuggestions: []string{"did you mean: `__result0 > 100`", "valid references: `__result0`, `__result1`, `count()`, `sum(bytes)`"},
|
||||
wantSuggestions: []string{"did you mean: `__result0 > 100`", "valid references are `__result0`, `__result1`, `count()`, `sum(bytes)`"},
|
||||
},
|
||||
{
|
||||
name: "out-of-range __result_N index",
|
||||
@@ -711,7 +711,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [__result_9]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, count()]"},
|
||||
wantSuggestions: []string{"did you mean: `__result > 100`", "valid references: `__result`, `__result0`, `count()`"},
|
||||
wantSuggestions: []string{"did you mean: `__result > 100`", "valid references are `__result`, `__result0`, `count()`"},
|
||||
},
|
||||
{
|
||||
name: "__result_1 out of range for single aggregation",
|
||||
@@ -722,7 +722,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [__result_1]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, count()]"},
|
||||
wantSuggestions: []string{"did you mean: `__result > 100`", "valid references: `__result`, `__result0`, `count()`"},
|
||||
wantSuggestions: []string{"did you mean: `__result > 100`", "valid references are `__result`, `__result0`, `count()`"},
|
||||
},
|
||||
{
|
||||
name: "cascaded function calls",
|
||||
@@ -733,7 +733,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [sum]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, count()]"},
|
||||
wantSuggestions: []string{"valid references: `__result`, `__result0`, `count()`"},
|
||||
wantSuggestions: []string{"valid references are `__result`, `__result0`, `count()`"},
|
||||
},
|
||||
{
|
||||
name: "function call with multiple args not in column map",
|
||||
@@ -744,7 +744,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [sum]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, sum(a)]"},
|
||||
wantSuggestions: []string{"valid references: `__result`, `__result0`, `sum(a)`"},
|
||||
wantSuggestions: []string{"valid references are `__result`, `__result0`, `sum(a)`"},
|
||||
},
|
||||
{
|
||||
name: "unquoted string value treated as unknown identifier",
|
||||
@@ -755,7 +755,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [xyz]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, sum(bytes)]"},
|
||||
wantSuggestions: []string{"valid references: `__result`, `__result0`, `sum(bytes)`"},
|
||||
wantSuggestions: []string{"valid references are `__result`, `__result0`, `sum(bytes)`"},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1030,7 +1030,7 @@ func TestRewriteForMetrics(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [wrong_metric]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, sum(cpu_usage)]"},
|
||||
wantSuggestions: []string{"valid references: `__result`, `__result0`, `sum(cpu_usage)`"},
|
||||
wantSuggestions: []string{"valid references are `__result`, `__result0`, `sum(cpu_usage)`"},
|
||||
},
|
||||
// --- Error: string literal (not allowed in HAVING) ---
|
||||
{
|
||||
@@ -1077,7 +1077,7 @@ func TestRewriteForMetrics(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [count]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, sum(cpu_usage)]"},
|
||||
wantSuggestions: []string{"valid references: `__result`, `__result0`, `sum(cpu_usage)`"},
|
||||
wantSuggestions: []string{"valid references are `__result`, `__result0`, `sum(cpu_usage)`"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
38
pkg/reflectutil/reflectutil.go
Normal file
38
pkg/reflectutil/reflectutil.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Package reflectutil holds small reflection helpers shared across packages.
|
||||
package reflectutil
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// JSONFieldNames returns the JSON field names of a struct (or pointer to one),
|
||||
// skipping fields tagged "-" or without a json tag.
|
||||
func JSONFieldNames(v any) []string {
|
||||
var fields []string
|
||||
|
||||
t := reflect.TypeOf(v)
|
||||
if t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
|
||||
if t.Kind() != reflect.Struct {
|
||||
return fields
|
||||
}
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
jsonTag := field.Tag.Get("json")
|
||||
|
||||
if jsonTag == "" || jsonTag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldName := strings.Split(jsonTag, ",")[0]
|
||||
if fieldName != "" {
|
||||
fields = append(fields, fieldName)
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
field.FieldContext = telemetrytypes.FieldContextLog
|
||||
fieldExpression, _ = m.FieldFor(ctx, tsStart, tsEnd, field)
|
||||
} else {
|
||||
wrappedErr := errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.SuggestionsOnLevenshteinDistance(field.Name, maps.Keys(keys))...)
|
||||
wrappedErr := errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
|
||||
return "", wrappedErr
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -263,7 +263,7 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
// - it is not a static field
|
||||
// - the next best thing to do is see if there is a typo
|
||||
// and suggest a correction
|
||||
wrappedErr := errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.SuggestionsOnLevenshteinDistance(field.Name, maps.Keys(keys))...)
|
||||
wrappedErr := errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
|
||||
return "", wrappedErr
|
||||
}
|
||||
} else if len(keysForField) == 1 {
|
||||
|
||||
@@ -91,7 +91,7 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
// - it is not a static field
|
||||
// - the next best thing to do is see if there is a typo
|
||||
// and suggest a correction
|
||||
wrappedErr := errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.SuggestionsOnLevenshteinDistance(field.Name, maps.Keys(keys))...)
|
||||
wrappedErr := errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
|
||||
return "", wrappedErr
|
||||
}
|
||||
} else if len(keysForField) == 1 {
|
||||
|
||||
@@ -368,7 +368,7 @@ func (m *defaultFieldMapper) ColumnExpressionFor(
|
||||
// - it is not a static field
|
||||
// - the next best thing to do is see if there is a typo
|
||||
// and suggest a correction
|
||||
wrappedErr := errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.SuggestionsOnLevenshteinDistance(field.Name, maps.Keys(keys))...)
|
||||
wrappedErr := errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
|
||||
return "", wrappedErr
|
||||
}
|
||||
} else if len(keysForField) == 1 {
|
||||
|
||||
@@ -99,5 +99,5 @@ func NewServiceID(provider CloudProviderType, service string) (ServiceID, error)
|
||||
|
||||
return ServiceID{}, errors.NewInvalidInputf(ErrCodeInvalidServiceID,
|
||||
"invalid service id %q for %s cloud provider", service, provider.StringValue()).
|
||||
WithSuggestions(errors.SuggestionsOnLevenshteinDistance(service, validServices)...)
|
||||
WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(service, errors.NounServices, validServices)...)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/SigNoz/govaluate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/reflectutil"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -152,7 +153,7 @@ func (q *QueryEnvelope) UnmarshalJSON(data []byte) error {
|
||||
shadow.Type,
|
||||
).WithAdditional(
|
||||
"Valid query types are: builder_query, builder_sub_query, builder_formula, builder_join, builder_trace_operator, promql, clickhouse_sql",
|
||||
).WithSuggestions(errors.ValidReferences(QueryType{}.Enum()...))
|
||||
).WithSuggestions(errors.NewValidReferences(errors.NounQueryTypes, QueryType{}.Enum()...))
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -196,7 +197,7 @@ func UnmarshalBuilderQueryBySignal(data []byte) (any, error) {
|
||||
errors.CodeInvalidInput,
|
||||
"invalid signal %q",
|
||||
header.Signal.StringValue(),
|
||||
).WithSuggestions(errors.ValidReferences(telemetrytypes.Signal{}.Enum()...))
|
||||
).WithSuggestions(errors.NewValidReferences(errors.NounSignals, telemetrytypes.Signal{}.Enum()...))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +230,7 @@ func (c *CompositeQuery) UnmarshalJSON(data []byte) error {
|
||||
|
||||
// Valid field names are derived from the struct itself so this stays in
|
||||
// sync with the schema (and the generated OpenAPI spec) automatically.
|
||||
fieldNames := binding.JSONFieldNames((*CompositeQuery)(nil))
|
||||
fieldNames := reflectutil.JSONFieldNames((*CompositeQuery)(nil))
|
||||
validFields := make(map[string]bool, len(fieldNames))
|
||||
for _, f := range fieldNames {
|
||||
validFields[f] = true
|
||||
@@ -243,7 +244,7 @@ func (c *CompositeQuery) UnmarshalJSON(data []byte) error {
|
||||
field,
|
||||
).WithAdditional(
|
||||
"Valid fields are: " + strings.Join(fieldNames, ", "),
|
||||
).WithSuggestions(errors.SuggestionsOnLevenshteinDistance(field, fieldNames)...)
|
||||
).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field, errors.NounFields, fieldNames)...)
|
||||
return unknownFieldErr
|
||||
}
|
||||
}
|
||||
@@ -556,7 +557,7 @@ func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
|
||||
|
||||
// Valid field names are derived from the struct itself so this stays in
|
||||
// sync with the schema (and the generated OpenAPI spec) automatically.
|
||||
fieldNames := binding.JSONFieldNames((*QueryRangeRequest)(nil))
|
||||
fieldNames := reflectutil.JSONFieldNames((*QueryRangeRequest)(nil))
|
||||
validFields := make(map[string]bool, len(fieldNames))
|
||||
for _, f := range fieldNames {
|
||||
validFields[f] = true
|
||||
@@ -570,7 +571,7 @@ func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
|
||||
field,
|
||||
).WithAdditional(
|
||||
"Valid fields are: " + strings.Join(fieldNames, ", "),
|
||||
).WithSuggestions(errors.SuggestionsOnLevenshteinDistance(field, fieldNames)...)
|
||||
).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field, errors.NounFields, fieldNames)...)
|
||||
return unknownFieldErr
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,7 +518,7 @@ func (q *QueryBuilderQuery[T]) validateOrderByForAggregation() error {
|
||||
orderId,
|
||||
).WithAdditional(
|
||||
fmt.Sprintf("For aggregation queries, order by can only reference group by keys, aggregation aliases/expressions, or aggregation indices. Valid keys are: %s", strings.Join(validKeys, ", ")),
|
||||
).WithSuggestions(errors.SuggestionsOnLevenshteinDistance(orderKey, validKeys)...)
|
||||
).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(orderKey, errors.NounKeys, validKeys)...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,7 +712,7 @@ func validateQueryEnvelope(envelope QueryEnvelope, opts ...ValidationOption) err
|
||||
envelope.Type,
|
||||
).WithAdditional(
|
||||
"Valid query types are: builder_query, builder_sub_query, builder_formula, builder_join, promql, clickhouse_sql, trace_operator",
|
||||
).WithSuggestions(errors.ValidReferences(QueryType{}.Enum()...))
|
||||
).WithSuggestions(errors.NewValidReferences(errors.NounQueryTypes, QueryType{}.Enum()...))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user