mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-24 15:00:41 +01:00
Compare commits
3 Commits
main
...
nv/schema-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52a8163fc1 | ||
|
|
05258534ed | ||
|
|
1ef23f015e |
@@ -1592,13 +1592,6 @@ components:
|
||||
required:
|
||||
- config
|
||||
type: object
|
||||
CommonDisplay:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
CommonJSONRef:
|
||||
properties:
|
||||
$ref:
|
||||
@@ -2732,11 +2725,6 @@ components:
|
||||
type: object
|
||||
DashboardtypesDashboardSpec:
|
||||
properties:
|
||||
datasources:
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/DashboardtypesDatasourceSpec'
|
||||
nullable: true
|
||||
type: object
|
||||
display:
|
||||
$ref: '#/components/schemas/DashboardtypesDisplay'
|
||||
duration:
|
||||
@@ -2801,39 +2789,6 @@ components:
|
||||
required:
|
||||
- version
|
||||
type: object
|
||||
DashboardtypesDatasourcePlugin:
|
||||
discriminator:
|
||||
mapping:
|
||||
signoz/Datasource: '#/components/schemas/DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpec'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpec'
|
||||
type: object
|
||||
DashboardtypesDatasourcePluginKind:
|
||||
enum:
|
||||
- signoz/Datasource
|
||||
type: string
|
||||
DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/Datasource
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesSigNozDatasourceSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesDatasourceSpec:
|
||||
properties:
|
||||
default:
|
||||
type: boolean
|
||||
display:
|
||||
$ref: '#/components/schemas/CommonDisplay'
|
||||
plugin:
|
||||
$ref: '#/components/schemas/DashboardtypesDatasourcePlugin'
|
||||
type: object
|
||||
DashboardtypesDisplay:
|
||||
properties:
|
||||
description:
|
||||
@@ -3610,8 +3565,6 @@ components:
|
||||
required:
|
||||
- queryValue
|
||||
type: object
|
||||
DashboardtypesSigNozDatasourceSpec:
|
||||
type: object
|
||||
DashboardtypesSource:
|
||||
enum:
|
||||
- user
|
||||
|
||||
@@ -3236,17 +3236,6 @@ export interface CloudintegrationtypesUpdatableServiceDTO {
|
||||
config: CloudintegrationtypesServiceConfigDTO;
|
||||
}
|
||||
|
||||
export interface CommonDisplayDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface CommonJSONRefDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -3990,44 +3979,6 @@ export interface DashboardtypesDashboardPanelRefDTO {
|
||||
panelName: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTOKind {
|
||||
'signoz/Datasource' = 'signoz/Datasource',
|
||||
}
|
||||
export interface DashboardtypesSigNozDatasourceSpecDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTO {
|
||||
/**
|
||||
* @enum signoz/Datasource
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTOKind;
|
||||
spec: DashboardtypesSigNozDatasourceSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesDatasourcePluginDTO =
|
||||
DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTO;
|
||||
|
||||
export interface DashboardtypesDatasourceSpecDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
default?: boolean;
|
||||
display?: CommonDisplayDTO;
|
||||
plugin?: DashboardtypesDatasourcePluginDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesDashboardSpecDTODatasourcesAnyOf = {
|
||||
[key: string]: DashboardtypesDatasourceSpecDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesDashboardSpecDTODatasources =
|
||||
DashboardtypesDashboardSpecDTODatasourcesAnyOf | null;
|
||||
|
||||
export enum DashboardtypesPanelKindDTO {
|
||||
Panel = 'Panel',
|
||||
}
|
||||
@@ -4807,10 +4758,6 @@ export type DashboardtypesVariableDTO =
|
||||
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO;
|
||||
|
||||
export interface DashboardtypesDashboardSpecDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
datasources?: DashboardtypesDashboardSpecDTODatasources;
|
||||
display: DashboardtypesDisplayDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -4886,9 +4833,6 @@ export interface DashboardtypesDashboardViewDTO {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesDatasourcePluginKindDTO {
|
||||
'signoz/Datasource' = 'signoz/Datasource',
|
||||
}
|
||||
export interface TagtypesGettableTagDTO {
|
||||
/**
|
||||
* @type string
|
||||
|
||||
@@ -89,8 +89,7 @@ func (c *conditionBuilder) conditionForArrayFunction(
|
||||
for i, v := range list {
|
||||
vals[i] = legacyCoerceNeedle(v, elemType)
|
||||
}
|
||||
// Pin the needle array type to the haystack; scalar fallback below coerces value-level.
|
||||
arrayCond := fmt.Sprintf("%s(%s, %s)", operator.FunctionName(), arrayExpr, castNeedleArray(elemType, sb.Var(vals)))
|
||||
arrayCond := fmt.Sprintf("%s(%s, %s)", operator.FunctionName(), arrayExpr, sb.Var(vals))
|
||||
if !hasScalar {
|
||||
return arrayCond, nil
|
||||
}
|
||||
@@ -114,27 +113,6 @@ func (c *conditionBuilder) conditionForArrayFunction(
|
||||
return fmt.Sprintf("(%s OR ifNull(%s, false))", arrayCond, sb.And(sb.E(scalarExpr, typedNeedle), scalarGuard)), nil
|
||||
}
|
||||
|
||||
// castNeedleArray pins an Int64 needle array to Array(Int64) so it matches the Array(Nullable(Int64))
|
||||
// haystack; without it a needle >= 2^32 binds as Array(UInt64) and hasAny/hasAll error (code 386).
|
||||
func castNeedleArray(elemType telemetrytypes.FieldDataType, arg string) string {
|
||||
if elemType == telemetrytypes.FieldDataTypeInt64 {
|
||||
return fmt.Sprintf("CAST(%s AS Array(Int64))", arg)
|
||||
}
|
||||
return arg
|
||||
}
|
||||
|
||||
// firstTokenSeparator returns the first char of s that hasToken treats as a token separator
|
||||
// (anything other than an ASCII letter or digit), and whether one was found.
|
||||
func firstTokenSeparator(s string) (string, bool) {
|
||||
for _, r := range s {
|
||||
isAlphaNum := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||||
if !isAlphaNum {
|
||||
return string(r), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// conditionForHasToken builds a hasToken full-text search over the body column, resolving the
|
||||
// column from the key name + use_json_body flag.
|
||||
func (c *conditionBuilder) conditionForHasToken(
|
||||
@@ -151,19 +129,11 @@ func (c *conditionBuilder) conditionForHasToken(
|
||||
}
|
||||
|
||||
// hasToken matches string tokens only.
|
||||
needleStr, ok := needle.(string)
|
||||
if !ok {
|
||||
if _, ok := needle.(string); !ok {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"function `hasToken` expects value parameter to be a string").WithUrl(hasTokenFunctionDocURL)
|
||||
}
|
||||
|
||||
// A multi-token needle makes CH hasToken error (code 36); reject up front as a 400. Both modes flow here.
|
||||
if sep, found := firstTokenSeparator(needleStr); found {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"function `hasToken` matches a single whole token, but %q contains the separator %q; use a substring filter (e.g. `body CONTAINS '%s'`) to search across separators",
|
||||
needleStr, sep, needleStr).WithUrl(hasTokenFunctionDocURL)
|
||||
}
|
||||
|
||||
bodyJSONEnabled := c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID))
|
||||
|
||||
if !bodyJSONEnabled {
|
||||
|
||||
@@ -104,23 +104,6 @@ func TestFilterExprLogsBodyJSON(t *testing.T) {
|
||||
expectedArgs: []any{int64(200), int64(200)},
|
||||
expectedErrorContains: "",
|
||||
},
|
||||
{
|
||||
// Big-int needle CAST to Array(Int64) to match the haystack (else 386).
|
||||
category: "json",
|
||||
query: `hasAny(body.ids, ['9007199254740993', '9007199254740994'])`,
|
||||
shouldPass: true,
|
||||
expectedQuery: `WHERE (hasAny(JSONExtract(JSON_QUERY(body, '$."ids"[*]'), 'Array(Nullable(Int64))'), CAST(? AS Array(Int64))) OR ifNull((JSONExtract(JSON_VALUE(body, '$."ids"'), 'Nullable(Int64)') IN (?, ?) AND JSONType(body, 'ids') NOT IN ('Array', 'Object', 'Null')), false))`,
|
||||
expectedArgs: []any{[]any{int64(9007199254740993), int64(9007199254740994)}, int64(9007199254740993), int64(9007199254740994)},
|
||||
expectedErrorContains: "",
|
||||
},
|
||||
{
|
||||
category: "json",
|
||||
query: `hasAll(body.ids, ['9007199254740993', '9007199254740994'])`,
|
||||
shouldPass: true,
|
||||
expectedQuery: `WHERE (hasAll(JSONExtract(JSON_QUERY(body, '$."ids"[*]'), 'Array(Nullable(Int64))'), CAST(? AS Array(Int64))) OR ifNull(((JSONExtract(JSON_VALUE(body, '$."ids"'), 'Nullable(Int64)') = ? AND JSONExtract(JSON_VALUE(body, '$."ids"'), 'Nullable(Int64)') = ?) AND JSONType(body, 'ids') NOT IN ('Array', 'Object', 'Null')), false))`,
|
||||
expectedArgs: []any{[]any{int64(9007199254740993), int64(9007199254740994)}, int64(9007199254740993), int64(9007199254740994)},
|
||||
expectedErrorContains: "",
|
||||
},
|
||||
{
|
||||
category: "json",
|
||||
query: "body.message = hello",
|
||||
|
||||
@@ -1561,19 +1561,6 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
expectedArgs: []any{"download"},
|
||||
expectedErrorContains: "function `hasToken` expects value parameter to be a string",
|
||||
},
|
||||
// A multi-token needle (separator/whitespace) is a clean 400, not a CH execution error.
|
||||
{
|
||||
category: "hasTokenUnderscoreNeedle",
|
||||
query: "hasToken(body, \"user_id\")",
|
||||
shouldPass: false,
|
||||
expectedErrorContains: "function `hasToken` matches a single whole token",
|
||||
},
|
||||
{
|
||||
category: "hasTokenWhitespaceNeedle",
|
||||
query: "hasToken(body, \"production node\")",
|
||||
shouldPass: false,
|
||||
expectedErrorContains: "function `hasToken` matches a single whole token",
|
||||
},
|
||||
// extra / mis-shaped value arguments are rejected, not silently dropped.
|
||||
{
|
||||
category: "hasExtraArgs",
|
||||
|
||||
@@ -137,13 +137,6 @@ func NewAccountFromStorable(storableAccount *StorableCloudIntegration) (*Account
|
||||
return nil, err
|
||||
}
|
||||
account.Config.Azure = azureConfig
|
||||
case CloudProviderTypeGCP:
|
||||
gcpConfig := new(GCPAccountConfig)
|
||||
err := json.Unmarshal([]byte(storableAccount.Config), gcpConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account.Config.GCP = gcpConfig
|
||||
}
|
||||
|
||||
if storableAccount.LastAgentReport != nil {
|
||||
|
||||
@@ -17,16 +17,17 @@ import (
|
||||
// DashboardSpec is the SigNoz dashboard v2 spec shape. It mirrors
|
||||
// dashboard.Spec (Perses) field-for-field, except every common.Plugin
|
||||
// occurrence is replaced with a typed SigNoz plugin whose OpenAPI schema is a
|
||||
// per-site discriminated oneOf.
|
||||
// per-site discriminated oneOf. Perses's datasources field is deliberately
|
||||
// dropped: SigNoz never reads it (queries carry their own signal/source), so
|
||||
// the drift test allowlists it as an intentional omission.
|
||||
type DashboardSpec struct {
|
||||
Display Display `json:"display" required:"true"`
|
||||
Datasources map[string]*DatasourceSpec `json:"datasources,omitzero"`
|
||||
Variables []Variable `json:"variables" required:"true" nullable:"false"`
|
||||
Panels map[string]*Panel `json:"panels" required:"true" nullable:"false"`
|
||||
Layouts []Layout `json:"layouts" required:"true" nullable:"false"`
|
||||
Duration common.DurationString `json:"duration"`
|
||||
RefreshInterval common.DurationString `json:"refreshInterval"`
|
||||
Links []Link `json:"links,omitzero"`
|
||||
Display Display `json:"display" required:"true"`
|
||||
Variables []Variable `json:"variables" required:"true" nullable:"false"`
|
||||
Panels map[string]*Panel `json:"panels" required:"true" nullable:"false"`
|
||||
Layouts []Layout `json:"layouts" required:"true" nullable:"false"`
|
||||
Duration common.DurationString `json:"duration"`
|
||||
RefreshInterval common.DurationString `json:"refreshInterval"`
|
||||
Links []Link `json:"links,omitzero"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -106,7 +107,7 @@ func (d *DashboardSpec) validatePanels() error {
|
||||
}
|
||||
panelKind := panel.Spec.Plugin.Kind
|
||||
if len(panel.Spec.Queries) != 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query", path)
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query, found %d", path, len(panel.Spec.Queries))
|
||||
}
|
||||
allowed := allowedQueryKinds[panelKind]
|
||||
for qi, q := range panel.Spec.Queries {
|
||||
@@ -269,8 +270,8 @@ func (d *DashboardSpec) validateLayouts() error {
|
||||
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
|
||||
}
|
||||
if grid.Display != nil {
|
||||
if n := utf8.RuneCountInString(grid.Display.Title); n > MaxDisplayNameLen {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.display.title: layout name must be at most %d characters, got %d", li, MaxDisplayNameLen, n)
|
||||
if n := utf8.RuneCountInString(grid.Display.Title); n > MaxLayoutTitleLen {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts[%d].spec.display.title: layout name must be at most %d characters, got %d", li, MaxLayoutTitleLen, n)
|
||||
}
|
||||
}
|
||||
if err := validateGridLayoutGeometry(grid, li); err != nil {
|
||||
|
||||
@@ -30,7 +30,7 @@ const basePostableJSON = `{
|
||||
"spec": {
|
||||
"name": "service",
|
||||
"allowAllValue": true,
|
||||
"allowMultiple": false,
|
||||
"allowMultiple": true,
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {"name": "service.name", "signal": "metrics"}
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestInvalidateNotAJSON(t *testing.T) {
|
||||
// TestUnmarshalErrorPreservesNestedMessage guards the wrap on dec.Decode in
|
||||
// DashboardSpec.UnmarshalJSON. The wrap stamps a consistent type/code on
|
||||
// decode failures, but must not smother the rich messages produced by nested
|
||||
// UnmarshalJSON methods (panel/query/variable/datasource plugin envelopes).
|
||||
// UnmarshalJSON methods (panel/query/variable plugin envelopes).
|
||||
func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
@@ -91,7 +91,7 @@ func TestValidateOnlyVariables(t *testing.T) {
|
||||
"spec": {
|
||||
"name": "service",
|
||||
"allowAllValue": true,
|
||||
"allowMultiple": false,
|
||||
"allowMultiple": true,
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {
|
||||
@@ -237,6 +237,12 @@ func TestInvalidateListVariableCrossFields(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "customAllValue cannot be set")
|
||||
})
|
||||
|
||||
t.Run("allowAllValue without allowMultiple", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"allowAllValue": true, "allowMultiple": false,`))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "allowAllValue cannot be set")
|
||||
})
|
||||
|
||||
t.Run("list defaultValue without allowMultiple", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["a", "b"],`))
|
||||
require.Error(t, err)
|
||||
@@ -441,20 +447,6 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
}`,
|
||||
wantContain: "FakeVariable",
|
||||
},
|
||||
{
|
||||
name: "unknown datasource plugin",
|
||||
data: `{
|
||||
"datasources": {
|
||||
"ds1": {
|
||||
"default": true,
|
||||
"plugin": {"kind": "FakeDatasource", "spec": {}}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "FakeDatasource",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -1739,55 +1731,61 @@ func TestInvalidateDuplicatePanelReference(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "spec.layouts[0].spec.items[1].content")
|
||||
}
|
||||
|
||||
// Every display name — dashboard, panel, variable — and the grid layout title is
|
||||
// bounded at MaxDisplayNameLen. The name is one over the limit in each case, and
|
||||
// the message reads "<json path>: <field> name must be at most ...", pairing the
|
||||
// locatable path (like the other spec errors) with a human field label.
|
||||
// Every display name — dashboard, panel, variable — is bounded at MaxDisplayNameLen,
|
||||
// while the grid layout title has its own, larger bound (MaxLayoutTitleLen). The name
|
||||
// is one over the relevant limit in each case, and the message reads "<json path>:
|
||||
// <field> name must be at most ...", pairing the locatable path (like the other spec
|
||||
// errors) with a human field label.
|
||||
func TestInvalidateDisplayNameTooLong(t *testing.T) {
|
||||
tooLong := strings.Repeat("x", MaxDisplayNameLen+1)
|
||||
lengthMsg := fmt.Sprintf("must be at most %d characters, got %d", MaxDisplayNameLen, MaxDisplayNameLen+1)
|
||||
|
||||
testCases := []struct {
|
||||
scenario string
|
||||
dashboardJSON string
|
||||
expectedPath string
|
||||
expectedLabel string
|
||||
scenario string
|
||||
limit int
|
||||
dashboardJSONFmt string
|
||||
expectedPath string
|
||||
expectedLabel string
|
||||
}{
|
||||
{
|
||||
scenario: "dashboard display name",
|
||||
dashboardJSON: `{"display": {"name": "` + tooLong + `"}, "links": [], "layouts": []}`,
|
||||
expectedLabel: "dashboard",
|
||||
expectedPath: "spec.display.name",
|
||||
scenario: "dashboard display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"display": {"name": "%s"}, "links": [], "layouts": []}`,
|
||||
expectedLabel: "dashboard",
|
||||
expectedPath: "spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "panel display name",
|
||||
dashboardJSON: `{"panels": {"p1": {"kind": "Panel", "spec": {"links": [],"display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "links": [], "layouts": []}`,
|
||||
expectedLabel: "panel",
|
||||
expectedPath: "spec.panels.p1.spec.display.name",
|
||||
scenario: "panel display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"panels": {"p1": {"kind": "Panel", "spec": {"links": [], "display": {"name": "%s"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "links": [], "layouts": []}`,
|
||||
expectedLabel: "panel",
|
||||
expectedPath: "spec.panels.p1.spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "list variable display name",
|
||||
dashboardJSON: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "links": [], "layouts": []}`,
|
||||
expectedLabel: "variable",
|
||||
expectedPath: "spec.variables[0].spec.display.name",
|
||||
scenario: "list variable display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "%s"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "links": [], "layouts": []}`,
|
||||
expectedLabel: "variable",
|
||||
expectedPath: "spec.variables[0].spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "text variable display name",
|
||||
dashboardJSON: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "` + tooLong + `"}}}], "links": [], "layouts": []}`,
|
||||
expectedLabel: "variable",
|
||||
expectedPath: "spec.variables[0].spec.display.name",
|
||||
scenario: "text variable display name",
|
||||
limit: MaxDisplayNameLen,
|
||||
dashboardJSONFmt: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "%s"}}}], "links": [], "layouts": []}`,
|
||||
expectedLabel: "variable",
|
||||
expectedPath: "spec.variables[0].spec.display.name",
|
||||
},
|
||||
{
|
||||
scenario: "layout title",
|
||||
dashboardJSON: `{"links": [], "layouts": [{"kind": "Grid", "spec": {"display": {"title": "` + tooLong + `"}, "items": []}}]}`,
|
||||
expectedLabel: "layout",
|
||||
expectedPath: "spec.layouts[0].spec.display.title",
|
||||
scenario: "layout title",
|
||||
limit: MaxLayoutTitleLen,
|
||||
dashboardJSONFmt: `{"links": [], "layouts": [{"kind": "Grid", "spec": {"display": {"title": "%s"}, "items": []}}]}`,
|
||||
expectedLabel: "layout",
|
||||
expectedPath: "spec.layouts[0].spec.display.title",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.scenario, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard([]byte(testCase.dashboardJSON))
|
||||
tooLong := strings.Repeat("x", testCase.limit+1)
|
||||
lengthMsg := fmt.Sprintf("must be at most %d characters, got %d", testCase.limit, testCase.limit+1)
|
||||
_, err := unmarshalDashboard(fmt.Appendf(nil, testCase.dashboardJSONFmt, tooLong))
|
||||
require.Error(t, err)
|
||||
// Message is "<path>: <label> name must be at most N characters, got M".
|
||||
want := testCase.expectedPath + ": " + testCase.expectedLabel + " name " + lengthMsg
|
||||
|
||||
@@ -6,12 +6,12 @@ package dashboardtypes
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
"github.com/perses/spec/go/datasource"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -21,22 +21,27 @@ func TestDashboardSpecMatchesPerses(t *testing.T) {
|
||||
name string
|
||||
ours reflect.Type
|
||||
perses reflect.Type
|
||||
// omit lists json fields intentionally dropped from our type that Perses
|
||||
// still has, so their absence is not reported as drift.
|
||||
omit []string
|
||||
}{
|
||||
{"DashboardSpec", typeOf[DashboardSpec](), typeOf[dashboard.Spec]()},
|
||||
{"Panel", typeOf[Panel](), typeOf[dashboard.Panel]()},
|
||||
{"PanelSpec", typeOf[PanelSpec](), typeOf[dashboard.PanelSpec]()},
|
||||
{"Query", typeOf[Query](), typeOf[dashboard.Query]()},
|
||||
{"QuerySpec", typeOf[QuerySpec](), typeOf[dashboard.QuerySpec]()},
|
||||
{"DatasourceSpec", typeOf[DatasourceSpec](), typeOf[datasource.Spec]()},
|
||||
{"Variable", typeOf[Variable](), typeOf[dashboard.Variable]()},
|
||||
{"ListVariableSpec", typeOf[ListVariableSpec](), typeOf[dashboard.ListVariableSpec]()},
|
||||
{"TextVariableSpec", typeOf[TextVariableSpec](), typeOf[dashboard.TextVariableSpec]()},
|
||||
{"Layout", typeOf[Layout](), typeOf[dashboard.Layout]()},
|
||||
{name: "DashboardSpec", ours: typeOf[DashboardSpec](), perses: typeOf[dashboard.Spec](), omit: []string{"datasources"}},
|
||||
{name: "Panel", ours: typeOf[Panel](), perses: typeOf[dashboard.Panel]()},
|
||||
{name: "PanelSpec", ours: typeOf[PanelSpec](), perses: typeOf[dashboard.PanelSpec]()},
|
||||
{name: "Query", ours: typeOf[Query](), perses: typeOf[dashboard.Query]()},
|
||||
{name: "QuerySpec", ours: typeOf[QuerySpec](), perses: typeOf[dashboard.QuerySpec]()},
|
||||
{name: "Variable", ours: typeOf[Variable](), perses: typeOf[dashboard.Variable]()},
|
||||
{name: "ListVariableSpec", ours: typeOf[ListVariableSpec](), perses: typeOf[dashboard.ListVariableSpec]()},
|
||||
{name: "TextVariableSpec", ours: typeOf[TextVariableSpec](), perses: typeOf[dashboard.TextVariableSpec]()},
|
||||
{name: "Layout", ours: typeOf[Layout](), perses: typeOf[dashboard.Layout]()},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
missing, extra := drift(c.ours, c.perses)
|
||||
for _, f := range c.omit {
|
||||
missing = slices.DeleteFunc(missing, func(m string) bool { return m == f })
|
||||
}
|
||||
|
||||
assert.Empty(t, missing,
|
||||
"DashboardSpec (%s) is missing json fields present on Perses %s — upstream likely added or renamed a field",
|
||||
|
||||
@@ -215,54 +215,6 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
|
||||
return restrictKindToOneValue(s, v.Kind)
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Datasource plugin
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type DatasourcePlugin struct {
|
||||
Kind DatasourcePluginKind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (DatasourcePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return markDiscriminator(s, "kind", map[string]string{
|
||||
string(DatasourceKindSigNoz): schemaRef("DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
func (p *DatasourcePlugin) UnmarshalJSON(data []byte) error {
|
||||
kind, specJSON, err := extractKindAndSpec(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
factory, ok := datasourcePluginSpecs[DatasourcePluginKind(kind)]
|
||||
if !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown datasource plugin kind %q; allowed values: %s", kind, allowedValuesForKind(slices.Sorted(maps.Keys(datasourcePluginSpecs))))
|
||||
}
|
||||
spec, err := decodeSpec(specJSON, factory(), kind)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Kind = DatasourcePluginKind(kind)
|
||||
p.Spec = *spec
|
||||
return nil
|
||||
}
|
||||
|
||||
func (DatasourcePlugin) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
DatasourcePluginVariant[SigNozDatasourceSpec]{Kind: string(DatasourceKindSigNoz)},
|
||||
}
|
||||
}
|
||||
|
||||
type DatasourcePluginVariant[S any] struct {
|
||||
Kind string `json:"kind" required:"true"`
|
||||
Spec S `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (v DatasourcePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return restrictKindToOneValue(s, v.Kind)
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -290,10 +242,6 @@ var (
|
||||
VariableKindQuery: func() any { return new(QueryVariableSpec) },
|
||||
VariableKindCustom: func() any { return new(CustomVariableSpec) },
|
||||
}
|
||||
datasourcePluginSpecs = map[DatasourcePluginKind]func() any{
|
||||
DatasourceKindSigNoz: func() any { return new(SigNozDatasourceSpec) },
|
||||
}
|
||||
|
||||
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
|
||||
@@ -16,10 +16,14 @@ import (
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
)
|
||||
|
||||
// MaxDisplayNameLen bounds every human-readable display name — dashboard, panel,
|
||||
// and variable display names, plus the grid layout title.
|
||||
// MaxDisplayNameLen bounds the human-readable display names — dashboard, panel,
|
||||
// and variable. The grid layout title has its own, larger bound (MaxLayoutTitleLen).
|
||||
const MaxDisplayNameLen = 128
|
||||
|
||||
// MaxLayoutTitleLen bounds a grid layout title. It is larger than MaxDisplayNameLen
|
||||
// because v1 section (row) titles ran longer.
|
||||
const MaxLayoutTitleLen = 256
|
||||
|
||||
type Display struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
// Description always serializes ("" included) so a create -> GET round-trip
|
||||
@@ -34,16 +38,6 @@ func (d Display) Validate(label, path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Datasource
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type DatasourceSpec struct {
|
||||
Display *common.Display `json:"display,omitempty"`
|
||||
Default bool `json:"default"`
|
||||
Plugin DatasourcePlugin `json:"plugin"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Panel
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -231,6 +225,9 @@ func (s *ListVariableSpec) validate(path string) error {
|
||||
if s.CustomAllValue != "" && !s.AllowAllValue {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: customAllValue cannot be set if allowAllValue is not set to true", path)
|
||||
}
|
||||
if s.AllowAllValue && !s.AllowMultiple {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: allowAllValue cannot be set if allowMultiple is not set to true", path)
|
||||
}
|
||||
if s.DefaultValue != nil && len(s.DefaultValue.SliceValues) > 0 && !s.AllowMultiple {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: defaultValue cannot be a list if allowMultiple is not set to true", path)
|
||||
}
|
||||
|
||||
@@ -179,21 +179,6 @@ func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList}
|
||||
}
|
||||
|
||||
type DatasourcePluginKind string
|
||||
|
||||
const (
|
||||
DatasourceKindSigNoz DatasourcePluginKind = "signoz/Datasource"
|
||||
)
|
||||
|
||||
func (DatasourcePluginKind) Enum() []any {
|
||||
return []any{DatasourceKindSigNoz}
|
||||
}
|
||||
|
||||
// SigNozDatasourceSpec is the (empty) signoz/Datasource plugin spec. Naming the
|
||||
// type gives the variant a concrete, non-nullable spec schema instead of an
|
||||
// inline free-form one.
|
||||
type SigNozDatasourceSpec struct{}
|
||||
|
||||
type TimeSeriesPanelSpec struct {
|
||||
Visualization TimeSeriesVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
|
||||
11
pkg/types/dashboardtypes/testdata/perses.json
vendored
11
pkg/types/dashboardtypes/testdata/perses.json
vendored
@@ -4,15 +4,6 @@
|
||||
"description": "Trying to cover as many concepts here as possible"
|
||||
},
|
||||
"duration": "1h",
|
||||
"datasources": {
|
||||
"SigNozDatasource": {
|
||||
"default": true,
|
||||
"plugin": {
|
||||
"kind": "signoz/Datasource",
|
||||
"spec": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
@@ -22,7 +13,7 @@
|
||||
"name": "serviceName"
|
||||
},
|
||||
"allowAllValue": true,
|
||||
"allowMultiple": false,
|
||||
"allowMultiple": true,
|
||||
"sort": "none",
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
|
||||
@@ -3,15 +3,6 @@
|
||||
"name": "NV dashboard with sections",
|
||||
"description": ""
|
||||
},
|
||||
"datasources": {
|
||||
"SigNozDatasource": {
|
||||
"default": true,
|
||||
"plugin": {
|
||||
"kind": "signoz/Datasource",
|
||||
"spec": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"panels": {
|
||||
"b424e23b": {
|
||||
|
||||
47
tests/fixtures/cloudintegrations.py
vendored
47
tests/fixtures/cloudintegrations.py
vendored
@@ -1,7 +1,6 @@
|
||||
"""Fixtures for cloud integration tests."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
@@ -21,29 +20,6 @@ from fixtures.logger import setup_logger
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
# Per-provider config shape.
|
||||
@dataclass(frozen=True)
|
||||
class ProviderAccountSpec:
|
||||
# provider slug used in the URL path and config key (e.g. "aws", "gcp").
|
||||
provider: str
|
||||
# params for the account created by default.
|
||||
initial_params: dict
|
||||
# params for the config an update (PUT) test sends.
|
||||
updated_params: dict
|
||||
# params -> the provider-keyed `config` block for a POST/PUT body.
|
||||
build_config: Callable[[dict], dict]
|
||||
# params -> the full config block the API is expected to return under
|
||||
# config[provider] on GET/list. This may differ from what build_config sends:
|
||||
# e.g. AWS accepts deploymentRegion on POST but the API does not echo it back.
|
||||
expected_config: Callable[[dict], dict]
|
||||
# id shown in parametrized test names; defaults to the provider slug.
|
||||
id: str = field(default="")
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.id:
|
||||
object.__setattr__(self, "id", self.provider)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def deprecated_create_cloud_integration_account(
|
||||
request: pytest.FixtureRequest,
|
||||
@@ -121,26 +97,19 @@ def create_cloud_integration_account(
|
||||
cloud_provider: str = "aws",
|
||||
deployment_region: str = "us-east-1",
|
||||
regions: list[str] | None = None,
|
||||
config: dict | None = None,
|
||||
) -> dict:
|
||||
# `config`, when given, is the fully-formed provider-keyed config block
|
||||
# (e.g. built via a ProviderAccountSpec.build_config) and is used as-is.
|
||||
# Otherwise fall back to the AWS shape built from deployment_region/regions,
|
||||
# preserving existing AWS callers.
|
||||
if config is None:
|
||||
if regions is None:
|
||||
regions = ["us-east-1"]
|
||||
config = {
|
||||
cloud_provider: {
|
||||
"deploymentRegion": deployment_region,
|
||||
"regions": regions,
|
||||
}
|
||||
}
|
||||
if regions is None:
|
||||
regions = ["us-east-1"]
|
||||
|
||||
endpoint = f"/api/v1/cloud_integrations/{cloud_provider}/accounts"
|
||||
|
||||
request_payload = {
|
||||
"config": config,
|
||||
"config": {
|
||||
cloud_provider: {
|
||||
"deploymentRegion": deployment_region,
|
||||
"regions": regions,
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"sigNozApiURL": "https://test-deployment.test.signoz.cloud",
|
||||
"sigNozApiKey": "test-api-key-789",
|
||||
|
||||
@@ -2,60 +2,16 @@ import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
|
||||
from fixtures.cloudintegrations import (
|
||||
ProviderAccountSpec,
|
||||
simulate_agent_checkin,
|
||||
)
|
||||
from fixtures.cloudintegrations import simulate_agent_checkin
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
AWS_ACCOUNT_SPEC = ProviderAccountSpec(
|
||||
provider="aws",
|
||||
initial_params={"deployment_region": "us-east-1", "regions": ["us-east-1"]},
|
||||
updated_params={"deployment_region": "us-east-1", "regions": ["us-east-1", "us-west-2", "eu-west-1"]},
|
||||
build_config=lambda p: {"aws": {"deploymentRegion": p["deployment_region"], "regions": p["regions"]}},
|
||||
expected_config=lambda p: {"regions": p["regions"]},
|
||||
)
|
||||
|
||||
GCP_ACCOUNT_SPEC = ProviderAccountSpec(
|
||||
provider="gcp",
|
||||
initial_params={
|
||||
"deployment_project_id": "signoz-test-project",
|
||||
"deployment_region": "us-central1",
|
||||
"project_ids": ["signoz-test-project"],
|
||||
},
|
||||
updated_params={
|
||||
"deployment_project_id": "signoz-test-project",
|
||||
"deployment_region": "us-central1",
|
||||
"project_ids": ["signoz-test-project", "signoz-test-project-2"],
|
||||
},
|
||||
build_config=lambda p: {
|
||||
"gcp": {
|
||||
"deploymentProjectId": p["deployment_project_id"],
|
||||
"deploymentRegion": p["deployment_region"],
|
||||
"projectIds": p["project_ids"],
|
||||
}
|
||||
},
|
||||
expected_config=lambda p: {
|
||||
"deploymentProjectId": p["deployment_project_id"],
|
||||
"deploymentRegion": p["deployment_region"],
|
||||
"projectIds": p["project_ids"],
|
||||
},
|
||||
)
|
||||
|
||||
PROVIDER_ACCOUNT_SPECS = [AWS_ACCOUNT_SPEC, GCP_ACCOUNT_SPEC]
|
||||
|
||||
provider_spec = pytest.mark.parametrize(
|
||||
"spec",
|
||||
PROVIDER_ACCOUNT_SPECS,
|
||||
ids=[s.id for s in PROVIDER_ACCOUNT_SPECS],
|
||||
)
|
||||
CLOUD_PROVIDER = "aws"
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
@@ -68,18 +24,16 @@ def test_apply_license(
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_list_accounts_empty(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
spec: ProviderAccountSpec,
|
||||
) -> None:
|
||||
"""List accounts returns an empty list when no accounts have checked in."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -92,30 +46,24 @@ def test_list_accounts_empty(
|
||||
assert len(data["accounts"]) == 0, "accounts list should be empty when no accounts have checked in"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_list_accounts_after_checkin(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
spec: ProviderAccountSpec,
|
||||
) -> None:
|
||||
"""List accounts returns an account, with its provider config, after check-in."""
|
||||
"""List accounts returns an account after it has checked in."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(
|
||||
admin_token,
|
||||
spec.provider,
|
||||
config=spec.build_config(spec.initial_params),
|
||||
)
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER, regions=["us-east-1"])
|
||||
account_id = account["id"]
|
||||
provider_account_id = str(uuid.uuid4())
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, provider_account_id)
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, provider_account_id)
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -126,31 +74,25 @@ def test_list_accounts_after_checkin(
|
||||
found = next((a for a in data["accounts"] if a["id"] == account_id), None)
|
||||
assert found is not None, f"Account {account_id} should appear in list after check-in"
|
||||
assert found["providerAccountId"] == provider_account_id, "providerAccountId should match"
|
||||
assert found["config"][spec.provider] == spec.expected_config(spec.initial_params), "config should match account config"
|
||||
assert found["config"]["aws"]["regions"] == ["us-east-1"], "regions should match account config"
|
||||
assert found["agentReport"] is not None, "agentReport should be present after check-in"
|
||||
assert found["removedAt"] is None, "removedAt should be null for a live account"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_get_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
spec: ProviderAccountSpec,
|
||||
) -> None:
|
||||
"""Get a specific account by ID returns the account with its provider config."""
|
||||
"""Get a specific account by ID returns the account with correct fields."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(
|
||||
admin_token,
|
||||
spec.provider,
|
||||
config=spec.build_config(spec.initial_params),
|
||||
)
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER, regions=["us-east-1", "eu-west-1"])
|
||||
account_id = account["id"]
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -159,22 +101,23 @@ def test_get_account(
|
||||
|
||||
data = response.json()["data"]
|
||||
assert data["id"] == account_id, "id should match"
|
||||
assert data["config"][spec.provider] == spec.expected_config(spec.initial_params), "config should match"
|
||||
assert data["config"]["aws"]["regions"] == [
|
||||
"us-east-1",
|
||||
"eu-west-1",
|
||||
], "regions should match"
|
||||
assert data["removedAt"] is None, "removedAt should be null"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_get_account_not_found(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
spec: ProviderAccountSpec,
|
||||
) -> None:
|
||||
"""Get a non-existent account returns 404."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{uuid.uuid4()}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{uuid.uuid4()}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -182,52 +125,46 @@ def test_get_account_not_found(
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_update_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
spec: ProviderAccountSpec,
|
||||
) -> None:
|
||||
"""Update account config and verify the change is persisted via GET."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(
|
||||
admin_token,
|
||||
spec.provider,
|
||||
config=spec.build_config(spec.initial_params),
|
||||
)
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER, regions=["us-east-1"])
|
||||
account_id = account["id"]
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
updated_regions = ["us-east-1", "us-west-2", "eu-west-1"]
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": spec.build_config(spec.updated_params)},
|
||||
json={"config": {"aws": {"regions": updated_regions}}},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204, got {response.status_code}"
|
||||
|
||||
get_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert get_response.status_code == HTTPStatus.OK
|
||||
assert get_response.json()["data"]["config"][spec.provider] == spec.expected_config(spec.updated_params), "Config should reflect the update"
|
||||
assert get_response.json()["data"]["config"]["aws"]["regions"] == updated_regions, "Regions should reflect the update"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_update_account_after_checkin_preserves_connected_status(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
spec: ProviderAccountSpec,
|
||||
) -> None:
|
||||
"""Updating config after agent check-in must not remove the account from the connected list.
|
||||
|
||||
@@ -238,21 +175,17 @@ def test_update_account_after_checkin_preserves_connected_status(
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# 1. Create account
|
||||
account = create_cloud_integration_account(
|
||||
admin_token,
|
||||
spec.provider,
|
||||
config=spec.build_config(spec.initial_params),
|
||||
)
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER, regions=["us-east-1"])
|
||||
account_id = account["id"]
|
||||
provider_account_id = str(uuid.uuid4())
|
||||
|
||||
# 2. Agent checks in — sets account_id and last_agent_report
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, provider_account_id)
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, provider_account_id)
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
# 3. Verify the account appears in the connected list
|
||||
list_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -262,17 +195,18 @@ def test_update_account_after_checkin_preserves_connected_status(
|
||||
assert found_before is not None, "Account should be listed after check-in"
|
||||
|
||||
# 4. Update account config
|
||||
updated_regions = ["us-east-1", "us-west-2"]
|
||||
update_response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": spec.build_config(spec.updated_params)},
|
||||
json={"config": {"aws": {"regions": updated_regions}}},
|
||||
timeout=10,
|
||||
)
|
||||
assert update_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204, got {update_response.status_code}"
|
||||
|
||||
# 5. Verify the account still appears in the connected list with correct fields
|
||||
list_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -282,33 +216,27 @@ def test_update_account_after_checkin_preserves_connected_status(
|
||||
assert found_after is not None, "Account must still be listed after config update (account_id should not be reset)"
|
||||
assert found_after["providerAccountId"] == provider_account_id, "providerAccountId should be preserved after update"
|
||||
assert found_after["agentReport"] is not None, "agentReport should be preserved after update"
|
||||
assert found_after["config"][spec.provider] == spec.expected_config(spec.updated_params), "Config should reflect the update"
|
||||
assert found_after["config"]["aws"]["regions"] == updated_regions, "Config should reflect the update"
|
||||
assert found_after["removedAt"] is None, "removedAt should still be null"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_disconnect_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
spec: ProviderAccountSpec,
|
||||
) -> None:
|
||||
"""Disconnect an account removes it from the connected list."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(
|
||||
admin_token,
|
||||
spec.provider,
|
||||
config=spec.build_config(spec.initial_params),
|
||||
)
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account_id = account["id"]
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -316,7 +244,7 @@ def test_disconnect_account(
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204, got {response.status_code}"
|
||||
|
||||
list_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -324,18 +252,16 @@ def test_disconnect_account(
|
||||
assert not any(a["id"] == account_id for a in accounts), "Disconnected account should not appear in the connected list"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_disconnect_account_idempotent(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
spec: ProviderAccountSpec,
|
||||
) -> None:
|
||||
"""Disconnect on a non-existent account ID returns 204 (blind update, no existence check)."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{uuid.uuid4()}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{uuid.uuid4()}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
@@ -196,6 +196,70 @@ def test_create_rejects_long_display_name(
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
assert "spec.display.name: dashboard name must be at most 128 characters" in response.json()["error"]["message"]
|
||||
|
||||
# A grid layout title has its own, larger bound of 256 characters; one over
|
||||
# must be rejected.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "long-layout-title",
|
||||
"spec": {
|
||||
"display": {"name": "Long Layout Title"},
|
||||
"links": [],
|
||||
"layouts": [{"kind": "Grid", "spec": {"display": {"title": "x" * 257}, "items": []}}],
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
assert "spec.layouts[0].spec.display.title: layout name must be at most 256 characters" in response.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_create_rejects_all_value_without_multiselect(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# A list variable cannot offer an "all" value unless it also allows selecting
|
||||
# multiple values — allowAllValue without allowMultiple must be rejected.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "all-without-multi",
|
||||
"spec": {
|
||||
"display": {"name": "All Without Multi"},
|
||||
"links": [],
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "svc",
|
||||
"allowAllValue": True,
|
||||
"allowMultiple": False,
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {"name": "service.name", "signal": "metrics"},
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "dashboard_invalid_input"
|
||||
assert "allowAllValue cannot be set" in response.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_create_rejects_invalid_grid_layout(
|
||||
signoz: SigNoz,
|
||||
@@ -1708,8 +1772,8 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
|
||||
|
||||
dashboard = {
|
||||
"schemaVersion": "v6",
|
||||
# image (dashboard-level) and spec duration/refreshInterval/datasources
|
||||
# each round-trip their zero value ("" / {}) rather than being dropped.
|
||||
# image (dashboard-level) and spec duration/refreshInterval each
|
||||
# round-trip their zero value ("") rather than being dropped.
|
||||
"image": "",
|
||||
"name": "roundtrip-zero-values",
|
||||
"tags": [],
|
||||
@@ -1717,7 +1781,6 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
|
||||
"display": {"name": "Roundtrip Zero Values", "description": ""},
|
||||
"duration": "",
|
||||
"refreshInterval": "",
|
||||
"datasources": {},
|
||||
"variables": [
|
||||
# TextVariable: constant false must echo back (not be dropped).
|
||||
{"kind": "TextVariable", "spec": {"display": {"name": "tv"}, "value": "x", "constant": False, "name": "tv"}},
|
||||
@@ -1903,7 +1966,6 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
|
||||
("dashboard image empty", result_data["image"], ""),
|
||||
("spec duration empty", result_spec["duration"], ""),
|
||||
("spec refreshInterval empty", result_spec["refreshInterval"], ""),
|
||||
("spec empty datasources round-trip", result_spec["datasources"], {}),
|
||||
]
|
||||
for description, actual, expected in roundtrip_cases:
|
||||
assert actual == expected, description
|
||||
|
||||
@@ -1178,11 +1178,12 @@ def test_logs_json_body_hastoken_separator_needle_errors(
|
||||
export_json_types: Callable[[list[Logs]], None],
|
||||
needle: str,
|
||||
) -> None:
|
||||
"""Same defect as the flag-off path. In JSON mode hasToken searches body.message via
|
||||
ClickHouse hasToken(LOWER(body.message), LOWER(?)), which rejects a needle containing
|
||||
whitespace or separator characters (`.`/`_`/`-`/space) with error code 36 at execution. The
|
||||
needle is validated up front, so a multi-token needle is a clean 400 (hasToken matches a single
|
||||
whole token) rather than a raw execution 500."""
|
||||
"""KNOWN BUG (currently 500) — same defect as the flag-off path. In JSON mode hasToken searches
|
||||
body.message via ClickHouse hasToken(LOWER(body.message), LOWER(?)), which rejects a needle
|
||||
containing whitespace or separator characters (`.`/`_`/`-`/space) with error code 36. The needle
|
||||
is passed straight through, so the query fails at execution and the raw error surfaces as a 500
|
||||
instead of a 400 / graceful fallback. Flip to 400 (or a fallback match) once the needle is
|
||||
validated."""
|
||||
now = datetime.now(tz=UTC)
|
||||
logs = [
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "app-service"}, body_v2=json.dumps({"message": "client 192.168.1.1 user_id error-code production node"}), body_promoted="", severity_text="INFO"),
|
||||
@@ -1198,8 +1199,8 @@ def test_logs_json_body_hastoken_separator_needle_errors(
|
||||
request_type=RequestType.RAW,
|
||||
queries=[build_raw_query("A", "logs", order=[build_order_by("timestamp")], limit=100, filter_expression=f"hasToken(body, '{needle}')")],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, f"{needle}: expected 400, got {response.status_code}: {response.text}"
|
||||
assert "matches a single whole token" in response.text, response.text
|
||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, f"{needle}: expected 500, got {response.status_code}: {response.text}"
|
||||
assert "Needle must not contain whitespace or separator characters" in response.text, response.text
|
||||
|
||||
|
||||
def test_logs_json_body_unregistered_path_warns(
|
||||
|
||||
@@ -1197,11 +1197,13 @@ def test_logs_json_body_hastoken_separator_needle_errors(
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
needle: str,
|
||||
) -> None:
|
||||
"""hasToken maps to ClickHouse hasToken(LOWER(body), LOWER(?)), which rejects a needle
|
||||
containing whitespace or separator characters (`.`/`_`/`-`/space) with error code 36 at
|
||||
execution. The querier validates the needle up front and returns a clean 400 (hasToken matches
|
||||
a single whole token) instead of letting the raw CH error surface as a 500. Common needles like
|
||||
an IP, `user_id` or a UUID hit this."""
|
||||
"""KNOWN BUG (currently 500). hasToken maps to ClickHouse hasToken(LOWER(body), LOWER(?)),
|
||||
which rejects a needle containing whitespace or separator characters (`.`/`_`/`-`/space) with
|
||||
error code 36 ("Needle must not contain whitespace or separator characters"). The querier
|
||||
passes the user needle straight through, so the query fails during execution and the raw CH
|
||||
error surfaces as a 500 rather than a 400 / graceful fallback. Common needles like an IP,
|
||||
`user_id` or a UUID hit this. Flip this to 400 (or a fallback match) once the needle is
|
||||
validated."""
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs(
|
||||
[
|
||||
@@ -1217,8 +1219,8 @@ def test_logs_json_body_hastoken_separator_needle_errors(
|
||||
request_type=RequestType.RAW,
|
||||
queries=[build_raw_query("A", "logs", order=[build_order_by("timestamp")], limit=100, filter_expression=f"hasToken(body, '{needle}')")],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, f"{needle}: expected 400, got {response.status_code}: {response.text}"
|
||||
assert "matches a single whole token" in response.text, response.text
|
||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, f"{needle}: expected 500, got {response.status_code}: {response.text}"
|
||||
assert "Needle must not contain whitespace or separator characters" in response.text, response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -1228,18 +1230,20 @@ def test_logs_json_body_hastoken_separator_needle_errors(
|
||||
pytest.param("hasAll", id="hasall"),
|
||||
],
|
||||
)
|
||||
def test_logs_json_body_hasany_hasall_large_quoted_int_matches(
|
||||
def test_logs_json_body_hasany_hasall_large_quoted_int_errors(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
func: str,
|
||||
) -> None:
|
||||
"""A quoted integer needle >= 2^32 in hasAny/hasAll used to 500: the body array is extracted as
|
||||
Array(Nullable(Int64)) but the needle array bound as a concrete Array(UInt64), and CH has no
|
||||
Int64/UInt64 supertype at the array-vs-array level (code 386). The needle array is now CAST to
|
||||
Array(Int64) to match the extraction, so it returns 200 with an exact match like has() (asserted
|
||||
below as a contrast)."""
|
||||
"""KNOWN BUG (currently 500). A quoted integer needle >= 2^32 in hasAny/hasAll fails with
|
||||
ClickHouse code 386 ("no supertype for types UInt64, Int64") -> 500. The body array is
|
||||
extracted as Array(Nullable(Int64)), but the needle array is bound as Array(UInt64) for values
|
||||
that don't fit UInt32, and CH has no Int64/UInt64 supertype at the array-vs-array type level.
|
||||
Single has() is unaffected because scalar-vs-array coercion is value-level (asserted below as a
|
||||
contrast). When fixed (bind the needle array as Int64) this should return 200 with an exact
|
||||
match like has()."""
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs(
|
||||
[
|
||||
@@ -1250,7 +1254,7 @@ def test_logs_json_body_hasany_hasall_large_quoted_int_matches(
|
||||
start_ms = int((now - timedelta(seconds=10)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
|
||||
# hasAny/hasAll with a quoted big int (>= 2^32) -> 200 with an exact match (needle CAST to Int64).
|
||||
# hasAny/hasAll with a quoted big int (>= 2^32) -> 500 (no supertype).
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
@@ -1259,8 +1263,7 @@ def test_logs_json_body_hasany_hasall_large_quoted_int_matches(
|
||||
request_type=RequestType.RAW,
|
||||
queries=[build_raw_query("A", "logs", order=[build_order_by("timestamp")], limit=100, filter_expression=f"{func}(body.id, ['9007199254740993'])")],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"{func}: expected 200, got {response.status_code}: {response.text}"
|
||||
assert len(get_rows(response)) == 1, f"{func} with a quoted big int should match exactly one log"
|
||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, f"{func}: expected 500, got {response.status_code}: {response.text}"
|
||||
|
||||
# Contrast: single has() with the same quoted big int works (exact Int64 match, 1 row).
|
||||
response = make_query_request(
|
||||
|
||||
Reference in New Issue
Block a user