Compare commits

...

2 Commits

Author SHA1 Message Date
Tushar Vats
51e7b89e41 fix: unit test 2026-06-16 20:50:25 +05:30
Tushar Vats
b5922f8818 chore: mark required and nullable in json tag, renamed methods, and added more functionality 2026-06-16 20:33:06 +05:30
21 changed files with 233 additions and 126 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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"}),
)
}

View File

@@ -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 {

View File

@@ -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`"},
},
}

View File

@@ -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"`,
},
}

View File

@@ -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 {

View File

@@ -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)
})

View File

@@ -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)`"},
},
}

View 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
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)...)
}

View File

@@ -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
}
}

View File

@@ -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()...))
}
}