Compare commits

..

33 Commits

Author SHA1 Message Date
Nikhil Soni
5c5a4a7a3f Merge remote-tracking branch 'origin/main' into ns/scope 2026-08-11 17:34:30 +05:30
Nikhil Soni
5bf6fd9192 fix(savedview): handle old invalid data in specs (#12477)
## Summary
- Handle malformed selectedFields in the extradata in the migration and
new migration to fix in the already migrated cases.
- Restructure saved-view create/update/get payloads so
`schemaVersion`/`spec` are top-level (unwrapping the old `data`
nesting), matching how dashboards and rules shape their wire types.
- Publish `schemaVersion` as an `enum: [v2]`
- Make `display` and `selectedFields` optional in the OpenAPI schema
- Declare `409` on `CreateSavedView`
- Require `minItems: 1` on `queries`

New API contract in [below
comment](https://github.com/SigNoz/signoz/pull/12477#issuecomment-5230041074),
follow up on https://github.com/SigNoz/signoz/pull/12342
Closes https://github.com/SigNoz/engineering-pod/issues/4651

Notes to reviewer: 
- Please pay attention to the last case in above linked comment for
partial display field updates.
- Still assuming that [migration
046](6372af75a6/pkg/sqlmigration/046_update_dashboard_alert_and_saved_view_v5.go (L233))
has already migrated all the views to v5 QB format and don't need to do
that now.
- Breaking change: queries are not validated in the v1 APIs as well, so
any incorrect query will be rejected

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 11:01:00 +00:00
Nikhil Soni
883e9492d6 Merge remote-tracking branch 'origin/main' into ns/scope 2026-08-10 15:01:20 +05:30
Nikhil Soni
ab3b88966e Merge remote-tracking branch 'origin/main' into ns/scope
# Conflicts:
#	pkg/telemetryschema/tracestelemetryschema/field_mapper.go
#	tests/conftest.py
#	tests/integration/tests/querier/04_traces.py
2026-08-05 16:44:41 +05:30
Nikhil Soni
08ebc37109 fix: handle fixed scope.name and scope.version fields to work without scope 2026-07-02 19:04:41 +05:30
Nikhil Soni
aa5a1c5e62 chore: update collector version 2026-07-02 14:46:07 +05:30
Nikhil Soni
7b34a47ac5 chore: run formatting on tests 2026-07-02 14:41:33 +05:30
Nikhil Soni
b4b2d7bb66 test: add test to show cross context matching 2026-06-24 18:34:38 +05:30
Nikhil Soni
e16416475b refactor: drop unused fields 2026-06-24 18:07:47 +05:30
Nikhil Soni
0ea7c1ae6e test: add more cases for scope name 2026-06-24 18:05:50 +05:30
Nikhil Soni
a023c8ed4a test: add integration test for scope fields 2026-06-24 15:25:17 +05:30
Nikhil Soni
a73ae62cd1 Merge remote-tracking branch 'origin/main' into ns/scope 2026-06-24 13:02:18 +05:30
Nikhil Soni
ec6fb58052 chore: add more tests 2026-06-24 12:37:42 +05:30
Nikhil Soni
d3d13eb7ff fix: remove handling of normalized properties for scope
Otherwise it will be impossible to query if scope attribute also
exists with same name - name and version
2026-05-21 11:19:22 +05:30
Nikhil Soni
782de2b210 fix: use correct error type for internal issues 2026-05-20 15:32:26 +05:30
Nikhil Soni
d3c38693f3 fix: allow 'scope.' prefix for keys with other context 2026-05-20 15:30:25 +05:30
Nikhil Soni
8791df3697 fix: avoid removing context prefix to support attr with prefix 2026-05-19 19:14:23 +05:30
Nikhil Soni
eb719c3d0d fix: use key selector with context prefix 2026-05-19 18:56:00 +05:30
Nikhil Soni
f10435c210 Merge remote-tracking branch 'origin' into ns/scope 2026-05-19 13:55:36 +05:30
Nikhil Soni
f3f1e9cb59 chore: add tests for denormalized field name as well 2026-05-19 13:55:25 +05:30
Nikhil Soni
d0370ce3ef fix: handle fields with included context for scope (select clause) 2026-05-14 17:02:56 +05:30
Nikhil Soni
d169761e65 Merge remote-tracking branch 'origin/main' into ns/scope 2026-05-14 11:50:33 +05:30
Nikhil Soni
87864ef5d4 chore: remove duplicates from .gitignore 2026-05-11 15:45:32 +05:30
Nikhil Soni
2e0bc8998e chore: use name as key name for scope instead of scope.name 2026-05-11 15:40:45 +05:30
Nikhil Soni
7e1f4aa50d Merge remote-tracking branch 'origin/main' into ns/scope 2026-05-11 14:27:19 +05:30
Nikhil Soni
35da39247c Merge branch 'main' into ns/scope 2026-05-07 17:41:11 +05:30
Nikhil Soni
ceccc47a34 fix: fix test for case without resource filter 2026-05-07 16:04:03 +05:30
Nikhil Soni
23da5e22ec Merge branch 'main' into ns/scope 2026-05-07 13:27:34 +05:30
Nikhil Soni
4c1b479149 chore: add tests for scope fields 2026-04-28 20:27:10 +05:30
Nikhil Soni
f72204a8b2 refactor: simplify field mapper for scope 2026-04-28 20:26:37 +05:30
Nikhil Soni
deb3f385fa chore: remove underscore version of scope fields 2026-04-23 10:26:55 +05:30
Nikhil Soni
77ce5f86b1 fix: use scope as json field instead with name and version 2026-04-23 01:15:02 +05:30
Nikhil Soni
ff211de441 feat: add support for scope fields in traces 2026-04-14 10:45:08 +05:30
34 changed files with 1884 additions and 540 deletions

View File

@@ -61,6 +61,7 @@ jobs:
- querierauthz
- role
- rootuser
- savedview
- serviceaccount
- spanmapper
- querier_json_body

View File

@@ -7880,17 +7880,20 @@ components:
type: string
SavedviewtypesPostableSavedView:
properties:
data:
$ref: '#/components/schemas/SavedviewtypesSavedViewData'
generateName:
type: boolean
name:
type: string
schemaVersion:
$ref: '#/components/schemas/SavedviewtypesSchemaVersion'
source:
$ref: '#/components/schemas/SavedviewtypesSource'
spec:
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
required:
- source
- data
- schemaVersion
- spec
type: object
SavedviewtypesSavedView:
properties:
@@ -7899,14 +7902,16 @@ components:
type: string
createdBy:
type: string
data:
$ref: '#/components/schemas/SavedviewtypesSavedViewData'
id:
type: string
name:
type: string
schemaVersion:
$ref: '#/components/schemas/SavedviewtypesSchemaVersion'
source:
$ref: '#/components/schemas/SavedviewtypesSource'
spec:
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
updatedAt:
format: date-time
type: string
@@ -7914,14 +7919,6 @@ components:
type: string
required:
- id
type: object
SavedviewtypesSavedViewData:
properties:
schemaVersion:
type: string
spec:
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
required:
- schemaVersion
- spec
type: object
@@ -7936,7 +7933,10 @@ components:
queries:
items:
$ref: '#/components/schemas/Querybuildertypesv5QueryEnvelope'
minItems: 1
type: array
requestType:
$ref: '#/components/schemas/Querybuildertypesv5RequestType'
selectedFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
@@ -7944,10 +7944,13 @@ components:
required:
- displayName
- panelType
- requestType
- queries
- selectedFields
- display
type: object
SavedviewtypesSchemaVersion:
enum:
- v2
type: string
SavedviewtypesSource:
enum:
- traces
@@ -7957,13 +7960,16 @@ components:
type: string
SavedviewtypesUpdatableSavedView:
properties:
data:
$ref: '#/components/schemas/SavedviewtypesSavedViewData'
schemaVersion:
$ref: '#/components/schemas/SavedviewtypesSchemaVersion'
source:
$ref: '#/components/schemas/SavedviewtypesSource'
spec:
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
required:
- source
- data
- schemaVersion
- spec
type: object
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
properties:
@@ -22776,6 +22782,12 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:

View File

@@ -8991,8 +8991,17 @@ export enum SavedviewtypesPanelTypeDTO {
list = 'list',
trace = 'trace',
}
export enum SavedviewtypesSchemaVersionDTO {
v2 = 'v2',
}
export enum SavedviewtypesSourceDTO {
traces = 'traces',
logs = 'logs',
metrics = 'metrics',
meter = 'meter',
}
export interface SavedviewtypesSavedViewSpecDTO {
display: SavedviewtypesDisplayDTO;
display?: SavedviewtypesDisplayDTO;
/**
* @type string
*/
@@ -9002,28 +9011,14 @@ export interface SavedviewtypesSavedViewSpecDTO {
* @type array
*/
queries: Querybuildertypesv5QueryEnvelopeDTO[];
requestType: Querybuildertypesv5RequestTypeDTO;
/**
* @type array
*/
selectedFields: TelemetrytypesTelemetryFieldKeyDTO[];
selectedFields?: TelemetrytypesTelemetryFieldKeyDTO[];
}
export interface SavedviewtypesSavedViewDataDTO {
/**
* @type string
*/
schemaVersion: string;
spec: SavedviewtypesSavedViewSpecDTO;
}
export enum SavedviewtypesSourceDTO {
traces = 'traces',
logs = 'logs',
metrics = 'metrics',
meter = 'meter',
}
export interface SavedviewtypesPostableSavedViewDTO {
data: SavedviewtypesSavedViewDataDTO;
/**
* @type boolean
*/
@@ -9032,7 +9027,9 @@ export interface SavedviewtypesPostableSavedViewDTO {
* @type string
*/
name?: string;
schemaVersion: SavedviewtypesSchemaVersionDTO;
source: SavedviewtypesSourceDTO;
spec: SavedviewtypesSavedViewSpecDTO;
}
export interface SavedviewtypesSavedViewDTO {
@@ -9045,7 +9042,6 @@ export interface SavedviewtypesSavedViewDTO {
* @type string
*/
createdBy?: string;
data?: SavedviewtypesSavedViewDataDTO;
/**
* @type string
*/
@@ -9054,7 +9050,9 @@ export interface SavedviewtypesSavedViewDTO {
* @type string
*/
name?: string;
schemaVersion: SavedviewtypesSchemaVersionDTO;
source?: SavedviewtypesSourceDTO;
spec: SavedviewtypesSavedViewSpecDTO;
/**
* @type string
* @format date-time
@@ -9067,8 +9065,9 @@ export interface SavedviewtypesSavedViewDTO {
}
export interface SavedviewtypesUpdatableSavedViewDTO {
data: SavedviewtypesSavedViewDataDTO;
schemaVersion: SavedviewtypesSchemaVersionDTO;
source: SavedviewtypesSourceDTO;
spec: SavedviewtypesSavedViewSpecDTO;
}
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {

View File

@@ -387,42 +387,4 @@ describe('useOptionsMenu', () => {
expect(remaining).toHaveLength(seedColumns.length);
});
});
describe('fieldsSelector.value drops legacy columns without a name', () => {
it('excludes entries missing name while keeping valid columns', () => {
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: { data: { data: { keys: {} } } },
isFetching: false,
});
(usePreferenceContext as jest.Mock).mockReturnValue({
traces: {
preferences: {
columns: [
{ name: 'body', fieldContext: 'log' },
{ key: 'legacy-key-no-name', fieldContext: 'log' },
{ name: 'timestamp', fieldContext: 'log' },
],
formatting: { format: 'table', maxLines: 1, fontSize: 'small' },
},
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
logs: {
preferences: { columns: [], formatting: {} },
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
});
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
}),
);
const fields = result.current.config.fieldsSelector?.value ?? [];
expect(fields.map((f) => f.name)).toStrictEqual(['body', 'timestamp']);
});
});
});

View File

@@ -399,7 +399,7 @@ const useOptionsMenu = ({
onReorder: reorderSelectColumns,
},
fieldsSelector: {
value: preferences?.columns?.filter((item) => has(item, 'name')) ?? [],
value: preferences?.columns ?? [],
onFieldsChange: updateColumns,
},
format: {

View File

@@ -51,7 +51,7 @@ func (provider *provider) addSavedViewRoutes(router *mux.Router) error {
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceSavedView.Scope(coretypes.VerbCreate)}),
},

View File

@@ -39,26 +39,28 @@ type legacyExtraData struct {
func newPostableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.PostableSavedView {
var legacy legacyExtraData
if v.ExtraData != "" {
// Best-effort: malformed/older extraData shapes never fail the request
// Best-effort: malformed/older extraData shapes never fail the request.
_ = json.Unmarshal([]byte(v.ExtraData), &legacy)
}
panelType := savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))}
return savedviewtypes.PostableSavedView{
GenerateName: true,
Source: savedviewtypes.Source{String: valuer.NewString(v.SourcePage)},
Data: savedviewtypes.SavedViewData{
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: v.Name,
PanelType: savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))},
Queries: v.CompositeQuery.Queries,
SelectedFields: legacy.SelectColumns,
Display: savedviewtypes.Display{
MaxLines: legacy.MaxLines,
FontSize: legacy.FontSize,
Format: legacy.Format,
Color: legacy.Color,
},
GenerateName: true,
Source: savedviewtypes.Source{String: valuer.NewString(v.SourcePage)},
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: v.Name,
PanelType: panelType,
// v1 has no requestType concept of its own -- fall back to the panelType-derived guess.
RequestType: savedviewtypes.LegacyRequestTypeForPanelType(panelType),
Queries: v.CompositeQuery.Queries,
SelectedFields: legacy.SelectColumns,
Display: savedviewtypes.Display{
MaxLines: legacy.MaxLines,
FontSize: legacy.FontSize,
Format: legacy.Format,
Color: legacy.Color,
},
},
}
@@ -68,25 +70,27 @@ func newPostableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.Postable
func newUpdatableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.UpdatableSavedView {
var legacy legacyExtraData
if v.ExtraData != "" {
// Best-effort: malformed/older extraData shapes never fail the request
// Best-effort: malformed/older extraData shapes never fail the request.
_ = json.Unmarshal([]byte(v.ExtraData), &legacy)
}
panelType := savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))}
return savedviewtypes.UpdatableSavedView{
Source: savedviewtypes.Source{String: valuer.NewString(v.SourcePage)},
Data: savedviewtypes.SavedViewData{
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: v.Name,
PanelType: savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))},
Queries: v.CompositeQuery.Queries,
SelectedFields: legacy.SelectColumns,
Display: savedviewtypes.Display{
MaxLines: legacy.MaxLines,
FontSize: legacy.FontSize,
Format: legacy.Format,
Color: legacy.Color,
},
Source: savedviewtypes.Source{String: valuer.NewString(v.SourcePage)},
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: v.Name,
PanelType: panelType,
// v1 has no requestType concept of its own -- fall back to the panelType-derived guess.
RequestType: savedviewtypes.LegacyRequestTypeForPanelType(panelType),
Queries: v.CompositeQuery.Queries,
SelectedFields: legacy.SelectColumns,
Display: savedviewtypes.Display{
MaxLines: legacy.MaxLines,
FontSize: legacy.FontSize,
Format: legacy.Format,
Color: legacy.Color,
},
},
}
@@ -95,11 +99,11 @@ func newUpdatableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.Updatab
// newLegacyViewFromSavedView renders a v2 SavedView back into the v1 shape.
func newLegacyViewFromSavedView(v *savedviewtypes.SavedView) (*v3.SavedView, error) {
extraData, err := json.Marshal(legacyExtraData{
Color: v.Data.Spec.Display.Color,
SelectColumns: v.Data.Spec.SelectedFields,
Format: v.Data.Spec.Display.Format,
MaxLines: v.Data.Spec.Display.MaxLines,
FontSize: v.Data.Spec.Display.FontSize,
Color: v.Spec.Display.Color,
SelectColumns: v.Spec.SelectedFields,
Format: v.Spec.Display.Format,
MaxLines: v.Spec.Display.MaxLines,
FontSize: v.Spec.Display.FontSize,
})
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in marshalling extra data")
@@ -107,17 +111,17 @@ func newLegacyViewFromSavedView(v *savedviewtypes.SavedView) (*v3.SavedView, err
return &v3.SavedView{
ID: v.ID,
Name: v.Data.Spec.DisplayName,
Name: v.Spec.DisplayName,
CreatedAt: v.CreatedAt,
CreatedBy: v.CreatedBy,
UpdatedAt: v.UpdatedAt,
UpdatedBy: v.UpdatedBy,
SourcePage: v.Source.StringValue(),
CompositeQuery: &v3.CompositeQuery{
PanelType: v3.PanelType(v.Data.Spec.PanelType.StringValue()),
PanelType: v3.PanelType(v.Spec.PanelType.StringValue()),
// Saved views are only ever created from the explorer's builder mode.
QueryType: v3.QueryTypeBuilder,
Queries: v.Data.Spec.Queries,
Queries: v.Spec.Queries,
},
ExtraData: string(extraData),
}, nil
@@ -156,7 +160,14 @@ func (handler *handler) Create(w http.ResponseWriter, r *http.Request) {
return
}
uuid, err := handler.module.CreateView(ctx, claims.OrgID, newPostableSavedViewFromLegacyView(&view))
postable := newPostableSavedViewFromLegacyView(&view)
if err := postable.Validate(); err != nil {
render.Error(w, err)
return
}
uuid, err := handler.module.CreateView(ctx, claims.OrgID, postable)
if err != nil {
render.Error(w, err)
return
@@ -224,8 +235,14 @@ func (handler *handler) Update(w http.ResponseWriter, r *http.Request) {
return
}
err = handler.module.UpdateView(ctx, claims.OrgID, viewUUID, newUpdatableSavedViewFromLegacyView(&view))
if err != nil {
updatable := newUpdatableSavedViewFromLegacyView(&view)
if err := updatable.Validate(); err != nil {
render.Error(w, err)
return
}
if err := handler.module.UpdateView(ctx, claims.OrgID, viewUUID, updatable); err != nil {
render.Error(w, err)
return
}

View File

@@ -42,13 +42,14 @@ func TestNewPostableSavedViewFromLegacyView(t *testing.T) {
assert.Empty(t, postable.Name, "v1 has no slug concept -- name must always be generated")
assert.True(t, postable.GenerateName, "v1 has no slug concept -- name must always be generated")
assert.Equal(t, "my view", postable.Data.Spec.DisplayName)
assert.Equal(t, "my view", postable.Spec.DisplayName)
assert.Equal(t, savedviewtypes.SourceLogs, postable.Source)
assert.Equal(t, savedviewtypes.SavedViewSchemaVersion, postable.Data.SchemaVersion)
assert.Equal(t, savedviewtypes.PanelTypeGraph, postable.Data.Spec.PanelType)
assert.Equal(t, legacy.CompositeQuery.Queries, postable.Data.Spec.Queries)
assert.Equal(t, []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}}, postable.Data.Spec.SelectedFields)
assert.Equal(t, savedviewtypes.Display{MaxLines: 10, FontSize: "large", Format: "table", Color: "blue"}, postable.Data.Spec.Display)
assert.Equal(t, savedviewtypes.SavedViewSchemaVersion, postable.SchemaVersion)
assert.Equal(t, savedviewtypes.PanelTypeGraph, postable.Spec.PanelType)
assert.Equal(t, qbtypes.RequestTypeTimeSeries, postable.Spec.RequestType, "graph panel type must map to the time_series request type")
assert.Equal(t, legacy.CompositeQuery.Queries, postable.Spec.Queries)
assert.Equal(t, []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}}, postable.Spec.SelectedFields)
assert.Equal(t, savedviewtypes.Display{MaxLines: 10, FontSize: "large", Format: "table", Color: "blue"}, postable.Spec.Display)
})
t.Run("empty extra data leaves display and selected fields zero-valued", func(t *testing.T) {
@@ -64,8 +65,9 @@ func TestNewPostableSavedViewFromLegacyView(t *testing.T) {
postable := newPostableSavedViewFromLegacyView(legacy)
assert.Equal(t, savedviewtypes.Display{}, postable.Data.Spec.Display)
assert.Nil(t, postable.Data.Spec.SelectedFields)
assert.Equal(t, savedviewtypes.PanelTypeTable, postable.Spec.PanelType)
assert.Equal(t, savedviewtypes.Display{}, postable.Spec.Display)
assert.Nil(t, postable.Spec.SelectedFields)
})
t.Run("malformed extra data is ignored, not an error", func(t *testing.T) {
@@ -81,8 +83,48 @@ func TestNewPostableSavedViewFromLegacyView(t *testing.T) {
postable := newPostableSavedViewFromLegacyView(legacy)
assert.Equal(t, "malformed extra data", postable.Data.Spec.DisplayName)
assert.Equal(t, savedviewtypes.Display{}, postable.Data.Spec.Display)
assert.Equal(t, "malformed extra data", postable.Spec.DisplayName)
assert.Equal(t, savedviewtypes.PanelTypeList, postable.Spec.PanelType)
assert.Equal(t, savedviewtypes.Display{}, postable.Spec.Display)
})
t.Run("legacy validation gap: empty builderQueries map with no queries", func(t *testing.T) {
legacy := &v3.SavedView{
Name: "no real queries",
SourcePage: "logs",
CompositeQuery: &v3.CompositeQuery{
PanelType: v3.PanelTypeGraph,
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{},
},
}
require.NoError(t, legacy.Validate(), "the legacy CompositeQuery check is expected to miss this")
postable := newPostableSavedViewFromLegacyView(legacy)
assert.Error(t, postable.Validate(), "the converted postable must catch what the legacy check missed")
})
t.Run("list panel query with no aggregation is valid", func(t *testing.T) {
legacy := &v3.SavedView{
Name: "raw list view",
SourcePage: "traces",
CompositeQuery: &v3.CompositeQuery{
PanelType: v3.PanelTypeList,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "db_name = 'two'"},
},
}},
},
}
postable := newPostableSavedViewFromLegacyView(legacy)
assert.Equal(t, qbtypes.RequestTypeRaw, postable.Spec.RequestType, "list panel type must map to the raw request type")
assert.NoError(t, postable.Validate(), "a raw list query must not be required to carry an aggregation")
})
}
@@ -99,24 +141,23 @@ func TestNewUpdatableSavedViewFromLegacyView(t *testing.T) {
updatable := newUpdatableSavedViewFromLegacyView(legacy)
assert.Equal(t, "renamed view", updatable.Data.Spec.DisplayName)
assert.Equal(t, "renamed view", updatable.Spec.DisplayName)
assert.Equal(t, savedviewtypes.SourceTraces, updatable.Source)
assert.Equal(t, qbtypes.RequestTypeScalar, updatable.Spec.RequestType, "table panel type must map to the scalar request type")
}
func TestNewLegacyViewFromSavedView(t *testing.T) {
now := time.Now()
savedView := &savedviewtypes.SavedView{
Name: "my-view-abc123ef",
Source: savedviewtypes.SourceLogs,
Data: savedviewtypes.SavedViewData{
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: "my view",
PanelType: savedviewtypes.PanelTypeGraph,
Queries: testQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
Display: savedviewtypes.Display{MaxLines: 10, FontSize: "large", Format: "table", Color: "blue"},
},
Name: "my-view-abc123ef",
Source: savedviewtypes.SourceLogs,
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: "my view",
PanelType: savedviewtypes.PanelTypeGraph,
Queries: testQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
Display: savedviewtypes.Display{MaxLines: 10, FontSize: "large", Format: "table", Color: "blue"},
},
}
savedView.ID = valuer.GenerateUUID()
@@ -129,7 +170,7 @@ func TestNewLegacyViewFromSavedView(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, savedView.ID, legacy.ID)
assert.Equal(t, savedView.Data.Spec.DisplayName, legacy.Name)
assert.Equal(t, savedView.Spec.DisplayName, legacy.Name)
assert.Equal(t, savedView.CreatedAt, legacy.CreatedAt)
assert.Equal(t, savedView.CreatedBy, legacy.CreatedBy)
assert.Equal(t, savedView.UpdatedAt, legacy.UpdatedAt)
@@ -137,20 +178,20 @@ func TestNewLegacyViewFromSavedView(t *testing.T) {
assert.Equal(t, "logs", legacy.SourcePage)
assert.Equal(t, v3.PanelTypeGraph, legacy.CompositeQuery.PanelType)
assert.Equal(t, v3.QueryTypeBuilder, legacy.CompositeQuery.QueryType)
assert.Equal(t, savedView.Data.Spec.Queries, legacy.CompositeQuery.Queries)
assert.Equal(t, savedView.Spec.Queries, legacy.CompositeQuery.Queries)
var extra legacyExtraData
require.NoError(t, json.Unmarshal([]byte(legacy.ExtraData), &extra))
assert.Equal(t, "blue", extra.Color)
assert.Equal(t, savedView.Data.Spec.SelectedFields, extra.SelectColumns)
assert.Equal(t, savedView.Spec.SelectedFields, extra.SelectColumns)
assert.Equal(t, "table", extra.Format)
assert.Equal(t, 10, extra.MaxLines)
assert.Equal(t, "large", extra.FontSize)
}
func TestNewLegacyViewsFromSavedViews(t *testing.T) {
a := &savedviewtypes.SavedView{Name: "a-slug", Source: savedviewtypes.SourceLogs, Data: savedviewtypes.SavedViewData{Spec: savedviewtypes.SavedViewSpec{DisplayName: "a", PanelType: savedviewtypes.PanelTypeGraph, Queries: testQueries()}}}
b := &savedviewtypes.SavedView{Name: "b-slug", Source: savedviewtypes.SourceTraces, Data: savedviewtypes.SavedViewData{Spec: savedviewtypes.SavedViewSpec{DisplayName: "b", PanelType: savedviewtypes.PanelTypeTable, Queries: testQueries()}}}
a := &savedviewtypes.SavedView{Name: "a-slug", Source: savedviewtypes.SourceLogs, Spec: savedviewtypes.SavedViewSpec{DisplayName: "a", PanelType: savedviewtypes.PanelTypeGraph, Queries: testQueries()}}
b := &savedviewtypes.SavedView{Name: "b-slug", Source: savedviewtypes.SourceTraces, Spec: savedviewtypes.SavedViewSpec{DisplayName: "b", PanelType: savedviewtypes.PanelTypeTable, Queries: testQueries()}}
legacyViews, err := newLegacyViewsFromSavedViews([]*savedviewtypes.SavedView{a, b})
require.NoError(t, err)
@@ -167,17 +208,15 @@ func TestNewLegacyViewsFromSavedViews(t *testing.T) {
// slug (Name) is deliberately NOT part of this contract -- v1 never sees it.
func TestLegacyViewRoundTrip(t *testing.T) {
original := &savedviewtypes.SavedView{
Name: "round-trip-abc123ef",
Source: savedviewtypes.SourceMetrics,
Data: savedviewtypes.SavedViewData{
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: "round trip",
PanelType: savedviewtypes.PanelTypeTable,
Queries: testQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
Display: savedviewtypes.Display{MaxLines: 5, FontSize: "small", Format: "list", Color: "red"},
},
Name: "round-trip-abc123ef",
Source: savedviewtypes.SourceMetrics,
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: "round trip",
PanelType: savedviewtypes.PanelTypeTable,
Queries: testQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
Display: savedviewtypes.Display{MaxLines: 5, FontSize: "small", Format: "list", Color: "red"},
},
}
@@ -188,10 +227,37 @@ func TestLegacyViewRoundTrip(t *testing.T) {
assert.Empty(t, roundTripped.Name)
assert.True(t, roundTripped.GenerateName)
assert.Equal(t, original.Data.Spec.DisplayName, roundTripped.Data.Spec.DisplayName)
assert.Equal(t, original.Spec.DisplayName, roundTripped.Spec.DisplayName)
assert.Equal(t, original.Source, roundTripped.Source)
assert.Equal(t, original.Data.Spec.PanelType, roundTripped.Data.Spec.PanelType)
assert.Equal(t, original.Data.Spec.Queries, roundTripped.Data.Spec.Queries)
assert.Equal(t, original.Data.Spec.SelectedFields, roundTripped.Data.Spec.SelectedFields)
assert.Equal(t, original.Data.Spec.Display, roundTripped.Data.Spec.Display)
assert.Equal(t, original.Spec.Queries, roundTripped.Spec.Queries)
assert.Equal(t, original.Spec.SelectedFields, roundTripped.Spec.SelectedFields)
assert.Equal(t, original.Spec.PanelType, roundTripped.Spec.PanelType)
assert.Equal(t, original.Spec.Display, roundTripped.Spec.Display)
}
func TestLegacyViewRoundTrip_EmptySelectedFieldsAndDisplay(t *testing.T) {
original := &savedviewtypes.SavedView{
Name: "round-trip-empty-abc123ef",
Source: savedviewtypes.SourceMetrics,
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: "round trip empty",
PanelType: savedviewtypes.PanelTypeTable,
Queries: testQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
},
}
legacy, err := newLegacyViewFromSavedView(original)
require.NoError(t, err)
var extra legacyExtraData
require.NoError(t, json.Unmarshal([]byte(legacy.ExtraData), &extra))
assert.Nil(t, extra.SelectColumns, "omitempty drops an empty selectColumns from extraData entirely")
roundTripped := newPostableSavedViewFromLegacyView(legacy)
assert.Empty(t, roundTripped.Spec.SelectedFields, "empty, not necessarily non-nil, on this leg of the round trip")
assert.Equal(t, savedviewtypes.PanelTypeTable, roundTripped.Spec.PanelType)
assert.Equal(t, savedviewtypes.Display{}, roundTripped.Spec.Display)
}

View File

@@ -19,7 +19,11 @@ func NewModule(store savedviewtypes.Store) savedview.Module {
}
func (module *module) GetViewsForFilters(ctx context.Context, orgID string, source savedviewtypes.Source, name string) ([]*savedviewtypes.SavedView, error) {
return module.store.List(ctx, orgID, source, name)
storables, err := module.store.List(ctx, orgID, source, name)
if err != nil {
return nil, err
}
return savedviewtypes.NewSavedViewsFromStorableSavedViews(storables), nil
}
func (module *module) CreateView(ctx context.Context, orgID string, view savedviewtypes.PostableSavedView) (valuer.UUID, error) {
@@ -30,14 +34,19 @@ func (module *module) CreateView(ctx context.Context, orgID string, view savedvi
dbView := view.ToSavedView(orgID, claims.Email)
if err := module.store.Create(ctx, dbView); err != nil {
if err := module.store.Create(ctx, savedviewtypes.NewStorableSavedView(dbView)); err != nil {
return valuer.UUID{}, err
}
return dbView.ID, nil
}
func (module *module) GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*savedviewtypes.SavedView, error) {
return module.store.Get(ctx, orgID, uuid)
storable, err := module.store.Get(ctx, orgID, uuid)
if err != nil {
return nil, err
}
return storable.ToSavedView(), nil
}
func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view savedviewtypes.UpdatableSavedView) error {
@@ -46,7 +55,8 @@ func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.
return errors.NewInternalf(errors.CodeInternal, "error in getting email from context")
}
return module.store.Update(ctx, view.ToSavedView(uuid, orgID, claims.Email))
dbView := view.ToSavedView(uuid, orgID, claims.Email)
return module.store.Update(ctx, savedviewtypes.NewStorableSavedView(dbView))
}
func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.UUID) error {
@@ -54,10 +64,10 @@ func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.
}
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
savedViews, err := module.store.List(ctx, orgID.StringValue(), savedviewtypes.Source{}, "")
storables, err := module.store.List(ctx, orgID.StringValue(), savedviewtypes.Source{}, "")
if err != nil {
return nil, err
}
return savedviewtypes.NewStatsFromSavedViews(savedViews), nil
return savedviewtypes.NewStatsFromStorableSavedViews(storables), nil
}

View File

@@ -28,24 +28,23 @@ func newTestStore() (savedview.Module, *savedviewtypestest.StoreTest) {
func testPostableSavedView(name string, source savedviewtypes.Source) savedviewtypes.PostableSavedView {
return savedviewtypes.PostableSavedView{
Name: name,
Source: source,
Data: savedviewtypes.SavedViewData{
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: name,
PanelType: savedviewtypes.PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
},
Name: name,
Source: source,
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: name,
PanelType: savedviewtypes.PanelTypeGraph,
RequestType: qbtypes.RequestTypeTimeSeries,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
},
},
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
},
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
},
}
}
@@ -53,8 +52,9 @@ func testPostableSavedView(name string, source savedviewtypes.Source) savedviewt
func testUpdatableSavedView(displayName string, source savedviewtypes.Source) savedviewtypes.UpdatableSavedView {
postable := testPostableSavedView(displayName, source)
return savedviewtypes.UpdatableSavedView{
Source: postable.Source,
Data: postable.Data,
Source: postable.Source,
SchemaVersion: postable.SchemaVersion,
Spec: postable.Spec,
}
}
@@ -93,7 +93,22 @@ func TestModule_CreateAndGetView(t *testing.T) {
assert.Equal(t, savedviewtypes.SourceLogs, got.Source)
assert.Equal(t, "creator@signoz.io", got.CreatedBy)
assert.Equal(t, "creator@signoz.io", got.UpdatedBy)
assert.Equal(t, savedviewtypes.PanelTypeGraph, got.Data.Spec.PanelType)
assert.Equal(t, savedviewtypes.PanelTypeGraph, got.Spec.PanelType)
require.NoError(t, st.AssertExpectations())
}
// A duplicate-name insert failure must surface as errors.TypeAlreadyExists, not a generic internal error.
func TestModule_CreateView_DuplicateNameIsConflict(t *testing.T) {
m, st := newTestStore()
orgID := valuer.GenerateUUID().StringValue()
ctx := contextWithClaims(orgID, "creator@signoz.io")
st.ExpectCreateError(errors.Newf(errors.TypeInternal, errors.CodeInternal, "UNIQUE constraint failed: saved_view.org_id, saved_view.name"))
_, err := m.CreateView(ctx, orgID, testPostableSavedView("same-name", savedviewtypes.SourceLogs))
require.Error(t, err)
assert.True(t, errors.Ast(err, errors.TypeAlreadyExists), "expected an already-exists error, got %v", err)
require.NoError(t, st.AssertExpectations())
}
@@ -138,21 +153,21 @@ func TestModule_UpdateView(t *testing.T) {
existingName := existing.Name
updated := testUpdatableSavedView("renamed", savedviewtypes.SourceTraces)
updated.Data.Spec.PanelType = savedviewtypes.PanelTypeTable
updated.Spec.PanelType = savedviewtypes.PanelTypeTable
st.ExpectUpdate(orgID, id, 1)
require.NoError(t, m.UpdateView(contextWithClaims(orgID, "updater@signoz.io"), orgID, id, updated))
stored := testSavedView(orgID, id, "updater@signoz.io", testPostableSavedView("renamed", savedviewtypes.SourceTraces))
stored.Name = existingName
stored.Data.Spec.PanelType = savedviewtypes.PanelTypeTable
stored.Spec.PanelType = savedviewtypes.PanelTypeTable
st.ExpectGet(orgID, id, stored)
got, err := m.GetView(contextWithClaims(orgID, "creator@signoz.io"), orgID, id)
require.NoError(t, err)
assert.Equal(t, existingName, got.Name, "name must not change on update")
assert.Equal(t, "renamed", got.Data.Spec.DisplayName)
assert.Equal(t, "renamed", got.Spec.DisplayName)
assert.Equal(t, savedviewtypes.SourceTraces, got.Source)
assert.Equal(t, savedviewtypes.PanelTypeTable, got.Data.Spec.PanelType)
assert.Equal(t, savedviewtypes.PanelTypeTable, got.Spec.PanelType)
assert.Equal(t, "updater@signoz.io", got.UpdatedBy)
require.NoError(t, st.AssertExpectations())

View File

@@ -6,7 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -18,32 +17,31 @@ func NewStore(sqlstore sqlstore.SQLStore) savedviewtypes.Store {
return &store{sqlstore: sqlstore}
}
func (store *store) Create(ctx context.Context, view *savedviewtypes.SavedView) error {
_, err := store.sqlstore.BunDB().NewInsert().Model(view).Exec(ctx)
func (store *store) Create(ctx context.Context, storable *savedviewtypes.StorableSavedView) error {
_, err := store.sqlstore.BunDB().NewInsert().Model(storable).Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "saved view with name %s already exists", view.Name)
return store.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "saved view with name %s already exists", storable.Name)
}
return nil
}
func (store *store) Get(ctx context.Context, orgID string, id valuer.UUID) (*savedviewtypes.SavedView, error) {
var view savedviewtypes.SavedView
err := store.sqlstore.BunDB().NewSelect().Model(&view).Where("org_id = ? AND id = ?", orgID, id.StringValue()).Scan(ctx)
func (store *store) Get(ctx context.Context, orgID string, id valuer.UUID) (*savedviewtypes.StorableSavedView, error) {
var storable savedviewtypes.StorableSavedView
err := store.sqlstore.BunDB().NewSelect().Model(&storable).Where("org_id = ? AND id = ?", orgID, id.StringValue()).Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", id.StringValue())
}
normalizeSelectedFields(&view)
return &view, nil
return &storable, nil
}
func (store *store) Update(ctx context.Context, view *savedviewtypes.SavedView) error {
func (store *store) Update(ctx context.Context, storable *savedviewtypes.StorableSavedView) error {
res, err := store.sqlstore.BunDB().NewUpdate().
Model(&savedviewtypes.SavedView{}).
Model((*savedviewtypes.StorableSavedView)(nil)).
Set("updated_at = ?, updated_by = ?, source = ?, data = ?",
view.UpdatedAt, view.UpdatedBy, view.Source, view.Data).
Where("id = ?", view.ID.StringValue()).
Where("org_id = ?", view.OrgID).
storable.UpdatedAt, storable.UpdatedBy, storable.Source, storable.Data).
Where("id = ?", storable.ID.StringValue()).
Where("org_id = ?", storable.OrgID).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "error in updating saved view")
@@ -54,7 +52,7 @@ func (store *store) Update(ctx context.Context, view *savedviewtypes.SavedView)
return errors.WrapInternalf(err, errors.CodeInternal, "error in verifying the updated saved view")
}
if rowsAffected == 0 {
return errors.NewNotFoundf(savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", view.ID.StringValue())
return errors.NewNotFoundf(savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", storable.ID.StringValue())
}
return nil
@@ -62,7 +60,7 @@ func (store *store) Update(ctx context.Context, view *savedviewtypes.SavedView)
func (store *store) Delete(ctx context.Context, orgID string, id valuer.UUID) error {
res, err := store.sqlstore.BunDB().NewDelete().
Model(&savedviewtypes.SavedView{}).
Model((*savedviewtypes.StorableSavedView)(nil)).
Where("id = ?", id.StringValue()).
Where("org_id = ?", orgID).
Exec(ctx)
@@ -81,9 +79,9 @@ func (store *store) Delete(ctx context.Context, orgID string, id valuer.UUID) er
return nil
}
func (store *store) List(ctx context.Context, orgID string, source savedviewtypes.Source, name string) ([]*savedviewtypes.SavedView, error) {
var views []*savedviewtypes.SavedView
q := store.sqlstore.BunDB().NewSelect().Model(&views).
func (store *store) List(ctx context.Context, orgID string, source savedviewtypes.Source, name string) ([]*savedviewtypes.StorableSavedView, error) {
var storables []*savedviewtypes.StorableSavedView
q := store.sqlstore.BunDB().NewSelect().Model(&storables).
Where("org_id = ?", orgID).
Where("name LIKE ?", "%"+name+"%")
if !source.IsZero() {
@@ -94,16 +92,5 @@ func (store *store) List(ctx context.Context, orgID string, source savedviewtype
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in getting saved views")
}
for _, view := range views {
normalizeSelectedFields(view)
}
return views, nil
}
// normalizeSelectedFields fixes up a scanned row's nil SelectedFields.
func normalizeSelectedFields(view *savedviewtypes.SavedView) {
if view.Data.Spec.SelectedFields == nil {
view.Data.Spec.SelectedFields = []telemetrytypes.TelemetryFieldKey{}
}
return storables, nil
}

View File

@@ -56,6 +56,17 @@ func QueryStringToKeysSelectors(query string) []*telemetrytypes.FieldKeySelector
FieldDataType: key.FieldDataType,
})
}
// todo(tushar): consider reverting changes done to this method in below PR to avoid scope specific checks
// https://github.com/SigNoz/signoz/issues/11374
if key.FieldContext == telemetrytypes.FieldContextScope {
keys = append(keys, &telemetrytypes.FieldKeySelector{
Name: key.FieldContext.StringValue() + "." + key.Name,
Signal: key.Signal,
FieldContext: telemetrytypes.FieldContextUnspecified, // this allows 'scope.' prefix for keys with other context as well
FieldDataType: key.FieldDataType,
})
}
}
}

View File

@@ -72,6 +72,23 @@ func TestQueryToKeys(t *testing.T) {
},
},
},
{
query: `scope.version = '1.0.0'`,
expectedKeys: []telemetrytypes.FieldKeySelector{
{
Name: "version",
Signal: telemetrytypes.SignalUnspecified,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
{
Name: "scope.version",
Signal: telemetrytypes.SignalUnspecified,
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
},
},
}
for _, testCase := range testCases {

View File

@@ -237,6 +237,8 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddDashboardTuplesFactory(sqlstore),
sqlmigration.NewRestructureSavedViewSpecFactory(sqlstore, sqlschema),
sqlmigration.NewAddSavedViewTuplesFactory(sqlstore),
sqlmigration.NewFixSavedViewSelectedFieldsFactory(sqlstore),
sqlmigration.NewBackfillSavedViewRequestTypeFactory(sqlstore),
)
}

View File

@@ -0,0 +1,220 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
// knownQueryTypes mirrors the discriminator values qbtypes.QueryType currently defines.
var knownQueryTypes = map[string]bool{
"builder_query": true,
"builder_ai_query": true,
"builder_formula": true,
"builder_sub_query": true,
"builder_join": true,
"builder_trace_operator": true,
"clickhouse_sql": true,
"promql": true,
}
// specFieldZeroValueJSON is the JSON to substitute for a spec key that fails to unmarshal.
var specFieldZeroValueJSON = map[string]string{
"displayName": `""`,
"panelType": `""`,
"queries": `[]`,
"selectedFields": `[]`,
"display": `{}`,
}
// storableSavedViewData is the shape of the `saved_view` table this migration repairs.
type storableSavedViewData struct {
bun.BaseModel `bun:"table:saved_view"`
ID string `bun:"id,pk,type:text"`
Data string `bun:"data,type:text"`
}
// queryEnvelope mirrors minimal required qbtypes.QueryEnvelope.
type queryEnvelope struct {
Type string `json:"type"`
Spec json.RawMessage `json:"spec"`
}
// telemetryFieldKey mirrors telemetrytypes.TelemetryFieldKey's JSON-visible fields.
// Signal/FieldContext/FieldDataType are plain strings to test UnmarshalJSON.
type telemetryFieldKey struct {
Name string `json:"name"`
Description string `json:"description"`
Unit string `json:"unit"`
Signal string `json:"signal"`
FieldContext string `json:"fieldContext"`
FieldDataType string `json:"fieldDataType"`
}
// fixDisplay mirrors savedviewtypes.Display.
type fixDisplay struct {
MaxLines int `json:"maxLines"`
FontSize string `json:"fontSize"`
Format string `json:"format"`
Color string `json:"color"`
}
// fixSpec mirrors savedviewtypes.SavedViewSpec.
type fixSpec struct {
DisplayName string `json:"displayName"`
PanelType string `json:"panelType"`
Queries []queryEnvelope `json:"queries"`
SelectedFields []telemetryFieldKey `json:"selectedFields"`
Display fixDisplay `json:"display"`
}
// fixData mirrors savedviewtypes.SavedViewData.
type fixData struct {
SchemaVersion string `json:"schemaVersion"`
Spec fixSpec `json:"spec"`
}
type fixSavedViewSelectedFields struct {
sqlstore sqlstore.SQLStore
settings factory.ProviderSettings
}
func NewFixSavedViewSelectedFieldsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("fix_saved_view_selected_fields"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &fixSavedViewSelectedFields{sqlstore: sqlstore, settings: ps}, nil
})
}
func (migration *fixSavedViewSelectedFields) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *fixSavedViewSelectedFields) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var rows []*storableSavedViewData
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
var repaired, deleted int
for _, row := range rows {
fixedData, blanked, ok := repairSavedViewData(row.Data)
if ok && len(blanked) == 0 {
// already scans cleanly field-by-field -- nothing to repair.
continue
}
if !ok {
migration.settings.Logger.WarnContext(ctx, "saved view data could not be repaired field-by-field, deleting the row", slog.String("saved_view_id", row.ID), slog.String("raw_data", row.Data))
if _, err := tx.NewDelete().Model((*storableSavedViewData)(nil)).Where("id = ?", row.ID).Exec(ctx); err != nil {
return err
}
deleted++
continue
}
repaired++
migration.settings.Logger.WarnContext(ctx, "repaired saved view data by blanking fields that failed to unmarshal", slog.String("saved_view_id", row.ID), slog.Any("fields_blanked", blanked))
if _, err := tx.NewUpdate().Model((*storableSavedViewData)(nil)).Set("data = ?", fixedData).Where("id = ?", row.ID).Exec(ctx); err != nil {
return err
}
}
migration.settings.Logger.InfoContext(ctx, "checked saved views for unreadable data", slog.Int("total", len(rows)), slog.Int("repaired", repaired), slog.Int("deleted", deleted))
return tx.Commit()
}
func (migration *fixSavedViewSelectedFields) Down(context.Context, *bun.DB) error {
return nil
}
// specFieldUnmarshalsCleanly reports whether value can be unmarshalled into
// the expected shape of the given savedviewtypes.SavedViewSpec JSON key.
func specFieldUnmarshalsCleanly(key string, value json.RawMessage) bool {
switch key {
case "displayName", "panelType":
var s string
return json.Unmarshal(value, &s) == nil
case "queries":
var q []queryEnvelope
if err := json.Unmarshal(value, &q); err != nil {
return false
}
if q == nil {
// a JSON null unmarshals into a nil slice with no error; treat it as unclean so it
// gets blanked to [] rather than shipping "queries": null against a nullable:false schema.
return false
}
for _, e := range q {
if !knownQueryTypes[e.Type] || len(e.Spec) == 0 {
return false
}
}
return true
case "selectedFields":
var f []telemetryFieldKey
if err := json.Unmarshal(value, &f); err != nil {
return false
}
// same null-vs-[] gap as "queries" above: blank a JSON null to [] instead of leaving it.
return f != nil
case "display":
var d fixDisplay
return json.Unmarshal(value, &d) == nil
default:
return true
}
}
// repairSavedViewData tries to make data unmarshal cleanly by blanking, one key at a time,
// whichever top-level spec fields fail to unmarshal into their expected shape.
func repairSavedViewData(data string) (fixed string, blanked []string, ok bool) {
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(data), &raw); err != nil {
return "", nil, false
}
var spec map[string]json.RawMessage
if err := json.Unmarshal(raw["spec"], &spec); err != nil {
return "", nil, false
}
for key, value := range spec {
if specFieldUnmarshalsCleanly(key, value) {
continue
}
spec[key] = json.RawMessage(specFieldZeroValueJSON[key])
blanked = append(blanked, key)
}
fixedSpec, err := json.Marshal(spec)
if err != nil {
return "", nil, false
}
raw["spec"] = fixedSpec
fixedData, err := json.Marshal(raw)
if err != nil {
return "", nil, false
}
// verify the fix actually round-trips before writing it.
if err := json.Unmarshal(fixedData, new(fixData)); err != nil {
return "", nil, false
}
return string(fixedData), blanked, true
}

View File

@@ -0,0 +1,152 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
// panelTypeToRequestType mirrors savedviewtypes.LegacyRequestTypeForPanelType.
var panelTypeToRequestType = map[string]string{
"list": "raw",
"trace": "trace",
"graph": "time_series",
}
// storableSavedViewRow is the shape of the `saved_view` table this migration repairs.
type storableSavedViewRow struct {
bun.BaseModel `bun:"table:saved_view"`
ID string `bun:"id,pk,type:text"`
Data string `bun:"data,type:text"`
}
// viewSpec mirrors savedviewtypes.SavedViewSpec, used only to verify the fix round-trips.
type viewSpec struct {
DisplayName string `json:"displayName"`
PanelType string `json:"panelType"`
RequestType string `json:"requestType"`
Queries json.RawMessage `json:"queries"`
SelectedFields json.RawMessage `json:"selectedFields"`
Display json.RawMessage `json:"display"`
}
type viewData struct {
SchemaVersion string `json:"schemaVersion"`
Spec viewSpec `json:"spec"`
}
type savedViewRequestType struct {
sqlstore sqlstore.SQLStore
settings factory.ProviderSettings
}
func NewBackfillSavedViewRequestTypeFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("backfill_view_request_type"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &savedViewRequestType{sqlstore: sqlstore, settings: ps}, nil
})
}
func (migration *savedViewRequestType) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *savedViewRequestType) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var rows []*storableSavedViewRow
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
var migrated, skipped int
for _, row := range rows {
fixedData, ok := backfillSavedViewRequestType(row.Data)
if !ok {
migration.settings.Logger.WarnContext(ctx, "saved view data could not be repaired, leaving it untouched", slog.String("saved_view_id", row.ID), slog.String("raw_data", row.Data))
skipped++
continue
}
if fixedData == "" {
// already has a requestType -- nothing to do.
continue
}
migrated++
if _, err := tx.NewUpdate().Model((*storableSavedViewRow)(nil)).Set("data = ?", fixedData).Where("id = ?", row.ID).Exec(ctx); err != nil {
return err
}
}
migration.settings.Logger.InfoContext(ctx, "backfilled saved view requestType from panelType", slog.Int("total", len(rows)), slog.Int("migrated", migrated), slog.Int("skipped", skipped))
return tx.Commit()
}
func (migration *savedViewRequestType) Down(context.Context, *bun.DB) error {
return nil
}
// backfillSavedViewRequestType sets spec.requestType from spec.panelType when absent, leaving
// panelType where it already is. Returns ok=false if data can't be parsed at all, and fixed="" if
// there's nothing to do (requestType already set).
func backfillSavedViewRequestType(data string) (fixed string, ok bool) {
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(data), &raw); err != nil {
return "", false
}
var spec map[string]json.RawMessage
if err := json.Unmarshal(raw["spec"], &spec); err != nil {
return "", false
}
if requestTypeRaw, ok := spec["requestType"]; ok && string(requestTypeRaw) != `""` {
return "", true
}
var panelType string
if panelTypeRaw, ok := spec["panelType"]; ok {
if err := json.Unmarshal(panelTypeRaw, &panelType); err != nil {
return "", false
}
}
requestType, known := panelTypeToRequestType[panelType]
if !known {
requestType = "scalar"
}
requestTypeJSON, err := json.Marshal(requestType)
if err != nil {
return "", false
}
spec["requestType"] = requestTypeJSON
fixedSpec, err := json.Marshal(spec)
if err != nil {
return "", false
}
raw["spec"] = fixedSpec
fixedData, err := json.Marshal(raw)
if err != nil {
return "", false
}
// verify the fix actually round-trips before writing it.
if err := json.Unmarshal(fixedData, new(viewData)); err != nil {
return "", false
}
return string(fixedData), true
}

View File

@@ -373,6 +373,94 @@ func TestStatementBuilder(t *testing.T) {
},
expectedErr: nil,
},
{
name: "scope.name filter and group by",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.TraceAggregation{
{
Expression: "count()",
},
},
Filter: &qbtypes.Filter{
Expression: "scope.name = 'opentelemetry-io'",
},
Limit: 10,
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "scope.name",
FieldContext: telemetrytypes.FieldContextScope,
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __limit_cte AS (SELECT toString(multiIf(scope.name::String IS NOT NULL, scope.name::String, NULL)) AS `scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.name::String = ? AND scope.name::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `scope.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(scope.name::String IS NOT NULL, scope.name::String, NULL)) AS `scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.name::String = ? AND scope.name::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`scope.name`) GLOBAL IN (SELECT `scope.name` FROM __limit_cte) GROUP BY ts, `scope.name`",
Args: []any{"opentelemetry-io", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "opentelemetry-io", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
expectedErr: nil,
},
{
name: "scope.version filter with scope.name group by",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.TraceAggregation{
{
Expression: "count()",
},
},
Filter: &qbtypes.Filter{
Expression: "scope.version = '1.0.0'",
},
Limit: 10,
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "scope.name",
FieldContext: telemetrytypes.FieldContextScope,
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __limit_cte AS (SELECT toString(multiIf(scope.name::String IS NOT NULL, scope.name::String, NULL)) AS `scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `scope.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(scope.name::String IS NOT NULL, scope.name::String, NULL)) AS `scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`scope.name`) GLOBAL IN (SELECT `scope.name` FROM __limit_cte) GROUP BY ts, `scope.name`",
Args: []any{"1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
expectedErr: nil,
},
{
name: "scope.version filter only (no scope field in group by)",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.TraceAggregation{
{
Expression: "count()",
},
},
Filter: &qbtypes.Filter{
Expression: "scope.version = '1.0.0'",
},
Limit: 10,
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
},
}
fl := flaggertest.New(t)
@@ -799,6 +887,52 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
},
expectedErr: nil,
},
{
name: "List query with scope filter only (no scope in select or group by)",
requestType: qbtypes.RequestTypeRaw,
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{
"scope.version": {
{
Name: "scope.version",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
},
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Filter: &qbtypes.Filter{
Expression: "scope.version = '1.0.0'",
},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, trace_state AS `trace_state`, parent_span_id AS `parent_span_id`, flags AS `flags`, name AS `name`, kind AS `kind`, kind_string AS `kind_string`, duration_nano AS `duration_nano`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
},
{
// Regression test: scope.version in selectFields with no metadata (isColumn=true filters it out)
// must still produce scope.version::String, not scope.attributes.version::String
name: "scope.version in selectFields only, no metadata (intrinsic field fallback)",
requestType: qbtypes.RequestTypeRaw,
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{},
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Filter: &qbtypes.Filter{},
SelectFields: []telemetrytypes.TelemetryFieldKey{
{Name: "scope.version", FieldContext: telemetrytypes.FieldContextUnspecified},
},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, scope.version::String AS `scope.version` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
},
}
for _, c := range cases {

View File

@@ -180,7 +180,7 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
`CASE
// WHEN tagType = 'spanfield' THEN 1
WHEN tagType = 'resource' THEN 2
// WHEN tagType = 'scope' THEN 3
WHEN tagType = 'scope' THEN 3
WHEN tagType = 'tag' THEN 4
ELSE 5
END as priority`,

View File

@@ -121,6 +121,20 @@ var (
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
"scope.name": {
Name: "scope.name",
Description: "Instrumentation scope name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
"scope.version": {
Name: "scope.version",
Description: "Instrumentation scope version",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
}
IntrinsicFieldsDeprecated = map[string]telemetrytypes.TelemetryFieldKey{
"traceID": {

View File

@@ -52,6 +52,7 @@ var (
ValueType: schema.ColumnTypeString,
}},
"resource": {Name: "resource", Type: schema.JSONColumnType{}},
"scope": {Name: "scope", Type: schema.JSONColumnType{}},
"events": {Name: "events", Type: schema.ArrayColumnType{
ElementType: schema.ColumnTypeString,
@@ -176,7 +177,7 @@ func (m *fieldMapper) getColumn(
case telemetrytypes.FieldContextResource:
return []*schema.Column{indexV3Columns["resource"], indexV3Columns["resources_string"]}, nil
case telemetrytypes.FieldContextScope:
return []*schema.Column{}, qbtypes.ErrColumnNotFound
return []*schema.Column{indexV3Columns["scope"]}, nil
case telemetrytypes.FieldContextAttribute:
switch key.FieldDataType {
case telemetrytypes.FieldDataTypeString:
@@ -287,14 +288,24 @@ func (m *fieldMapper) resolveColumnExprs(
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
// json is only supported for resource context as of now
if key.FieldContext != telemetrytypes.FieldContextResource {
return nil, nil, nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource context fields are supported for json columns, got %s", key.FieldContext.String)
}
// have to add ::string as clickHouse throws an error :- data types Variant/Dynamic are not allowed in GROUP BY
// once clickHouse dependency is updated, we need to check if we can remove it.
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
switch key.FieldContext {
case telemetrytypes.FieldContextResource:
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
case telemetrytypes.FieldContextScope:
switch key.Name {
case "scope.name", "scope.version":
exprs = append(exprs, fmt.Sprintf("%s::String", key.Name))
existExprs = append(existExprs, fmt.Sprintf("%s IS NOT NULL", key.Name))
default:
exprs = append(exprs, fmt.Sprintf("%s.attributes.`%s`::String", columnName, key.Name))
existExprs = append(existExprs, fmt.Sprintf("%s.attributes.`%s` IS NOT NULL", columnName, key.Name))
}
default:
return nil, nil, nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource and scope context fields are supported for json columns, got %s", key.FieldContext.String)
}
case schema.ColumnTypeEnumString,
schema.ColumnTypeEnumUInt64,
schema.ColumnTypeEnumUInt32,

View File

@@ -83,6 +83,33 @@ func TestGetFieldKeyName(t *testing.T) {
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists`, `resource_string_deployment$$environment`, NULL)",
expectedError: nil,
},
{
name: "Scope field - scope.name",
key: telemetrytypes.TelemetryFieldKey{
Name: "scope.name",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedResult: "scope.name::String",
expectedError: nil,
},
{
name: "Scope field - scope.version",
key: telemetrytypes.TelemetryFieldKey{
Name: "scope.version",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedResult: "scope.version::String",
expectedError: nil,
},
{
name: "Scope field - custom attribute",
key: telemetrytypes.TelemetryFieldKey{
Name: "custom.attr",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedResult: "scope.attributes.`custom.attr`::String",
expectedError: nil,
},
{
// Query like `attribute.attribute_string:string` should resolve to `attributes_string['attribute_string']`.
name: "Attribute key whose name collides with contextual map column resolves as a map lookup",

View File

@@ -113,6 +113,20 @@ func BuildCompleteFieldKeyMap(releaseTime time.Time) map[string][]*telemetrytype
FieldDataType: telemetrytypes.FieldDataTypeBool,
},
},
"scope.name": {
{
Name: "scope.name",
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
"scope.version": {
{
Name: "scope.version",
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
}
for _, keys := range keysMap {
for _, key := range keys {

View File

@@ -7,6 +7,8 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"k8s.io/apimachinery/pkg/util/validation"
@@ -28,27 +30,76 @@ var (
)
type SavedView struct {
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID string `json:"-"`
Name string `json:"name"`
Source Source `json:"source"`
SchemaVersion SchemaVersion `json:"schemaVersion" required:"true"`
Spec SavedViewSpec `json:"spec" required:"true"`
}
type StorableSavedView struct {
bun.BaseModel `bun:"table:saved_view"`
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID string `json:"-" bun:"org_id,notnull"`
Name string `json:"name" bun:"name,type:text,notnull"`
Source Source `json:"source" bun:"source,type:text,notnull"`
Data SavedViewData `json:"data" bun:"data,type:text,notnull"`
OrgID string `bun:"org_id,notnull"`
Name string `bun:"name,type:text,notnull"`
Source Source `bun:"source,type:text,notnull"`
Data SavedViewData `bun:"data,type:text,notnull"`
}
func (s *StorableSavedView) ToSavedView() *SavedView {
spec := s.Data.Spec
if spec.Queries == nil {
spec.Queries = []qbtypes.QueryEnvelope{}
}
if spec.SelectedFields == nil {
spec.SelectedFields = []telemetrytypes.TelemetryFieldKey{}
}
return &SavedView{
Identifiable: s.Identifiable,
TimeAuditable: s.TimeAuditable,
UserAuditable: s.UserAuditable,
OrgID: s.OrgID,
Name: s.Name,
Source: s.Source,
SchemaVersion: SchemaVersion{valuer.NewString(s.Data.SchemaVersion)},
Spec: spec,
}
}
func NewStorableSavedView(view *SavedView) *StorableSavedView {
return &StorableSavedView{
Identifiable: view.Identifiable,
TimeAuditable: view.TimeAuditable,
UserAuditable: view.UserAuditable,
OrgID: view.OrgID,
Name: view.Name,
Source: view.Source,
Data: SavedViewData{
SchemaVersion: view.SchemaVersion.StringValue(),
Spec: view.Spec,
},
}
}
type PostableSavedView struct {
Name string `json:"name"`
GenerateName bool `json:"generateName"`
Source Source `json:"source" required:"true"`
Data SavedViewData `json:"data" required:"true"`
Name string `json:"name"`
GenerateName bool `json:"generateName"`
Source Source `json:"source" required:"true"`
SchemaVersion SchemaVersion `json:"schemaVersion" required:"true"`
Spec SavedViewSpec `json:"spec" required:"true"`
}
type UpdatableSavedView struct {
Source Source `json:"source" required:"true"`
Data SavedViewData `json:"data" required:"true"`
Source Source `json:"source" required:"true"`
SchemaVersion SchemaVersion `json:"schemaVersion" required:"true"`
Spec SavedViewSpec `json:"spec" required:"true"`
}
type ListSavedViewsParams struct {
@@ -83,7 +134,7 @@ func (postable PostableSavedView) ToSavedView(orgID string, createdBy string) *S
name := postable.Name
if postable.GenerateName {
name = generateSavedViewName(postable.Data.Spec.DisplayName)
name = generateSavedViewName(postable.Spec.DisplayName)
}
return &SavedView{
@@ -93,7 +144,8 @@ func (postable PostableSavedView) ToSavedView(orgID string, createdBy string) *S
OrgID: orgID,
Name: name,
Source: postable.Source,
Data: postable.Data,
SchemaVersion: postable.SchemaVersion,
Spec: postable.Spec,
}
}
@@ -106,7 +158,8 @@ func (updatable UpdatableSavedView) ToSavedView(id valuer.UUID, orgID string, up
UserAuditable: types.UserAuditable{UpdatedBy: updatedBy},
OrgID: orgID,
Source: updatable.Source,
Data: updatable.Data,
SchemaVersion: updatable.SchemaVersion,
Spec: updatable.Spec,
}
}
@@ -117,8 +170,11 @@ func (p *PostableSavedView) Validate() error {
if err := p.Source.Validate(); err != nil {
return err
}
if err := p.SchemaVersion.Validate(); err != nil {
return err
}
return p.Data.Validate()
return p.Spec.Validate()
}
func (p *PostableSavedView) validateName() error {
@@ -135,8 +191,11 @@ func (u *UpdatableSavedView) Validate() error {
if err := u.Source.Validate(); err != nil {
return err
}
if err := u.SchemaVersion.Validate(); err != nil {
return err
}
return u.Data.Validate()
return u.Spec.Validate()
}
func (p *ListSavedViewsParams) Validate() error {
@@ -147,7 +206,17 @@ func (p *ListSavedViewsParams) Validate() error {
return p.Source.Validate()
}
func NewStatsFromSavedViews(savedViews []*SavedView) map[string]any {
// NewSavedViewsFromStorableSavedViews converts scanned rows to their domain shape.
func NewSavedViewsFromStorableSavedViews(storableSavedViews []*StorableSavedView) []*SavedView {
savedViews := make([]*SavedView, len(storableSavedViews))
for idx, storableSavedView := range storableSavedViews {
savedViews[idx] = storableSavedView.ToSavedView()
}
return savedViews
}
func NewStatsFromStorableSavedViews(savedViews []*StorableSavedView) map[string]any {
stats := make(map[string]any)
for _, savedView := range savedViews {
key := "savedview.source." + strings.ToLower(savedView.Source.StringValue()) + ".count"

View File

@@ -4,29 +4,28 @@ import (
"strings"
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/util/validation"
)
func validPostableSavedView() PostableSavedView {
return PostableSavedView{
Name: "my-view",
Source: SourceLogs,
Data: SavedViewData{
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()},
},
Name: "my-view",
Source: SourceLogs,
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, RequestType: qbtypes.RequestTypeTimeSeries, Queries: validQueries()},
}
}
func validUpdatableSavedView() UpdatableSavedView {
return UpdatableSavedView{
Source: SourceLogs,
Data: SavedViewData{
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()},
},
Source: SourceLogs,
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, RequestType: qbtypes.RequestTypeTimeSeries, Queries: validQueries()},
}
}
@@ -69,7 +68,7 @@ func TestPostableSavedViewValidate(t *testing.T) {
t.Run("invalid saved view data is rejected", func(t *testing.T) {
view := validPostableSavedView()
view.Data.SchemaVersion = "v1"
view.SchemaVersion = SchemaVersion{valuer.NewString("v1")}
assert.Error(t, view.Validate())
})
@@ -100,9 +99,15 @@ func TestPostableSavedViewValidate(t *testing.T) {
t.Run("empty displayName is rejected", func(t *testing.T) {
view := validPostableSavedView()
view.Data.Spec.DisplayName = ""
view.Spec.DisplayName = ""
assert.ErrorContains(t, view.Validate(), "displayName is required")
})
t.Run("missing requestType is rejected", func(t *testing.T) {
view := validPostableSavedView()
view.Spec.RequestType = qbtypes.RequestType{}
assert.ErrorContains(t, view.Validate(), "requestType is required")
})
}
func TestUpdatableSavedViewValidate(t *testing.T) {
@@ -119,9 +124,15 @@ func TestUpdatableSavedViewValidate(t *testing.T) {
t.Run("empty displayName is rejected", func(t *testing.T) {
view := validUpdatableSavedView()
view.Data.Spec.DisplayName = ""
view.Spec.DisplayName = ""
assert.ErrorContains(t, view.Validate(), "displayName is required")
})
t.Run("missing requestType is rejected", func(t *testing.T) {
view := validUpdatableSavedView()
view.Spec.RequestType = qbtypes.RequestType{}
assert.ErrorContains(t, view.Validate(), "requestType is required")
})
}
func TestListSavedViewsParamsValidate(t *testing.T) {
@@ -153,7 +164,8 @@ func TestNewSavedView(t *testing.T) {
assert.Equal(t, "creator@signoz.io", savedView.UpdatedBy)
assert.Equal(t, view.Name, savedView.Name)
assert.Equal(t, view.Source, savedView.Source)
assert.Equal(t, view.Data, savedView.Data)
assert.Equal(t, view.SchemaVersion, savedView.SchemaVersion)
assert.Equal(t, view.Spec, savedView.Spec)
assert.False(t, savedView.CreatedAt.IsZero())
assert.Equal(t, savedView.CreatedAt, savedView.UpdatedAt)
}
@@ -163,14 +175,14 @@ func TestNewSavedView_GeneratesNameWhenEmpty(t *testing.T) {
view := validPostableSavedView()
view.Name = ""
view.GenerateName = true
view.Data.Spec.DisplayName = "My View!"
view.Spec.DisplayName = "My View!"
savedView := view.ToSavedView(orgID, "creator@signoz.io")
assert.NotEmpty(t, savedView.Name)
assert.Empty(t, validation.IsDNS1123Label(savedView.Name), "generated name must be a valid DNS-1123 label")
assert.True(t, strings.HasPrefix(savedView.Name, "my-view-"))
assert.Equal(t, "My View!", savedView.Data.Spec.DisplayName)
assert.Equal(t, "My View!", savedView.Spec.DisplayName)
}
func TestGenerateSavedViewName(t *testing.T) {
@@ -212,17 +224,95 @@ func TestGenerateSavedViewName(t *testing.T) {
})
}
func TestNewStatsFromSavedViews(t *testing.T) {
views := []*SavedView{
func TestStorableSavedView_ToSavedView(t *testing.T) {
t.Run("round trip preserves populated fields", func(t *testing.T) {
view := &SavedView{
Name: "my-view",
Source: SourceLogs,
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeGraph,
RequestType: qbtypes.RequestTypeTimeSeries,
Queries: validQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
},
}
view.OrgID = valuer.GenerateUUID().StringValue()
roundTripped := NewStorableSavedView(view).ToSavedView()
assert.Equal(t, view.OrgID, roundTripped.OrgID)
assert.Equal(t, view.Name, roundTripped.Name)
assert.Equal(t, view.Source, roundTripped.Source)
assert.Equal(t, view.SchemaVersion, roundTripped.SchemaVersion)
assert.Equal(t, view.Spec, roundTripped.Spec)
})
t.Run("nil selectedFields normalizes to an empty slice, not nil", func(t *testing.T) {
storable := &StorableSavedView{
Data: SavedViewData{
SchemaVersion: SavedViewSchemaVersion.StringValue(),
Spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeGraph,
Queries: validQueries(),
SelectedFields: nil,
},
},
}
view := storable.ToSavedView()
assert.NotNil(t, view.Spec.SelectedFields)
assert.Empty(t, view.Spec.SelectedFields)
})
t.Run("nil queries normalizes to an empty slice, not nil", func(t *testing.T) {
storable := &StorableSavedView{
Data: SavedViewData{
SchemaVersion: SavedViewSchemaVersion.StringValue(),
Spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeGraph,
Queries: nil,
},
},
}
view := storable.ToSavedView()
assert.NotNil(t, view.Spec.Queries)
assert.Empty(t, view.Spec.Queries)
})
}
func TestNewStatsFromStorableSavedViews(t *testing.T) {
storables := []*StorableSavedView{
{Source: SourceLogs},
{Source: SourceLogs},
{Source: SourceTraces},
}
stats := NewStatsFromSavedViews(views)
stats := NewStatsFromStorableSavedViews(storables)
assert.Equal(t, int64(3), stats["savedview.count"])
assert.Equal(t, int64(2), stats["savedview.source.logs.count"])
assert.Equal(t, int64(1), stats["savedview.source.traces.count"])
assert.NotContains(t, stats, "savedview.source.metrics.count")
}
func TestNewSavedViewsFromStorableSavedViews(t *testing.T) {
storables := []*StorableSavedView{
{Name: "a", Source: SourceLogs, Data: SavedViewData{SchemaVersion: SavedViewSchemaVersion.StringValue(), Spec: SavedViewSpec{DisplayName: "a", PanelType: PanelTypeGraph, Queries: validQueries()}}},
{Name: "b", Source: SourceTraces, Data: SavedViewData{SchemaVersion: SavedViewSchemaVersion.StringValue(), Spec: SavedViewSpec{DisplayName: "b", PanelType: PanelTypeTable, Queries: validQueries()}}},
}
views := NewSavedViewsFromStorableSavedViews(storables)
require.Len(t, views, 2)
assert.Equal(t, "a", views[0].Name)
assert.Equal(t, SourceLogs, views[0].Source)
assert.Equal(t, "b", views[1].Name)
assert.Equal(t, SourceTraces, views[1].Source)
}

View File

@@ -28,7 +28,7 @@ func (t *StoreTest) Store() savedviewtypes.Store { return t.store }
func (t *StoreTest) Mock() sqlmock.Sqlmock { return t.mock }
func savedViewRow(view *savedviewtypes.SavedView) []driver.Value {
data, _ := json.Marshal(view.Data)
data, _ := json.Marshal(savedviewtypes.NewStorableSavedView(view).Data)
return []driver.Value{
view.ID.StringValue(),
view.CreatedAt,
@@ -47,6 +47,12 @@ func (t *StoreTest) ExpectCreate() {
t.mock.ExpectExec(`INSERT INTO "saved_view"`).WillReturnResult(sqlmock.NewResult(1, 1))
}
// ExpectCreateError sets up the SQL expectation for a Create call whose insert
// fails, e.g. on a UNIQUE(org_id, name) violation.
func (t *StoreTest) ExpectCreateError(err error) {
t.mock.ExpectExec(`INSERT INTO "saved_view"`).WillReturnError(err)
}
// ExpectGet sets up the SQL expectation for a Get call. Pass view = nil to
// simulate a not-found row.
func (t *StoreTest) ExpectGet(orgID string, id valuer.UUID, view *savedviewtypes.SavedView) {

View File

@@ -8,7 +8,7 @@ import (
)
// SavedViewSchemaVersion is the only schemaVersion currently.
const SavedViewSchemaVersion = "v2"
var SavedViewSchemaVersion = SchemaVersion{valuer.NewString("v2")}
var (
PanelTypeValue = PanelType{valuer.NewString("value")}
@@ -30,9 +30,10 @@ type Display struct {
type SavedViewSpec struct {
DisplayName string `json:"displayName" required:"true"`
PanelType PanelType `json:"panelType" required:"true"`
Queries []qbtypes.QueryEnvelope `json:"queries" required:"true" nullable:"false"`
SelectedFields []telemetrytypes.TelemetryFieldKey `json:"selectedFields" required:"true" nullable:"false"`
Display Display `json:"display" required:"true"`
RequestType qbtypes.RequestType `json:"requestType" required:"true"`
Queries []qbtypes.QueryEnvelope `json:"queries" required:"true" nullable:"false" minItems:"1"`
SelectedFields []telemetrytypes.TelemetryFieldKey `json:"selectedFields" nullable:"false"`
Display Display `json:"display"`
}
// SavedViewData is what's persisted as saved view data.
@@ -41,6 +42,11 @@ type SavedViewData struct {
Spec SavedViewSpec `json:"spec" required:"true"`
}
// SchemaVersion has v2 as the only value currently.
type SchemaVersion struct {
valuer.String
}
// PanelType is the explore-page panel a saved view renders as.
type PanelType struct {
valuer.String
@@ -65,6 +71,17 @@ func (p PanelType) Validate() error {
}
}
func (SchemaVersion) Enum() []any {
return []any{SavedViewSchemaVersion}
}
func (s SchemaVersion) Validate() error {
if s != SavedViewSchemaVersion {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "schemaVersion must be %q, got %q", SavedViewSchemaVersion.StringValue(), s.StringValue())
}
return nil
}
func (s *SavedViewSpec) Validate() error {
if s.DisplayName == "" {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "displayName is required")
@@ -72,14 +89,23 @@ func (s *SavedViewSpec) Validate() error {
if err := s.PanelType.Validate(); err != nil {
return err
}
return (&qbtypes.CompositeQuery{Queries: s.Queries}).Validate()
}
func (d *SavedViewData) Validate() error {
if d.SchemaVersion != SavedViewSchemaVersion {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "schemaVersion must be %q, got %q", SavedViewSchemaVersion, d.SchemaVersion)
if s.RequestType.IsZero() {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "requestType is required")
}
return d.Spec.Validate()
return (&qbtypes.CompositeQuery{Queries: s.Queries}).Validate(qbtypes.GetValidationOptions(s.RequestType)...)
}
// LegacyRequestTypeForPanelType exists only for the v1 legacy API.
func LegacyRequestTypeForPanelType(p PanelType) qbtypes.RequestType {
switch p {
case PanelTypeList:
return qbtypes.RequestTypeRaw
case PanelTypeTrace:
return qbtypes.RequestTypeTrace
case PanelTypeGraph:
return qbtypes.RequestTypeTimeSeries
default:
return qbtypes.RequestTypeScalar
}
}

View File

@@ -1,12 +1,15 @@
package savedviewtypes
import (
"encoding/json"
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validQueries() []qbtypes.QueryEnvelope {
@@ -56,35 +59,124 @@ func TestSavedViewSpecValidate(t *testing.T) {
}{
{
name: "valid spec",
spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()},
spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, RequestType: qbtypes.RequestTypeTimeSeries, Queries: validQueries()},
expectError: false,
},
{
name: "empty display name is rejected",
spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries()},
spec: SavedViewSpec{RequestType: qbtypes.RequestTypeTimeSeries, Queries: validQueries()},
expectError: true,
},
{
name: "invalid panel type is rejected before queries are checked",
spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelType{valuer.NewString("bogus")}, Queries: validQueries()},
name: "invalid panel type is rejected",
spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelType{valuer.NewString("bogus")}, RequestType: qbtypes.RequestTypeTimeSeries, Queries: validQueries()},
expectError: true,
},
{
name: "unset panel type is rejected",
spec: SavedViewSpec{DisplayName: "My View", RequestType: qbtypes.RequestTypeTimeSeries, Queries: validQueries()},
expectError: true,
},
{
name: "missing requestType is rejected",
spec: SavedViewSpec{DisplayName: "My View", Queries: validQueries()},
expectError: true,
},
{
name: "no queries is rejected",
spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph},
spec: SavedViewSpec{DisplayName: "My View", RequestType: qbtypes.RequestTypeTimeSeries},
expectError: true,
},
{
name: "selected fields and display are not required",
name: "selectedFields and display populated is still valid",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeTable,
RequestType: qbtypes.RequestTypeScalar,
Queries: validQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
Display: Display{MaxLines: 3, FontSize: "small", Format: "table", Color: "blue"},
},
expectError: false,
},
{
name: "nil selectedFields is valid -- selectedFields itself is not required",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeValue,
RequestType: qbtypes.RequestTypeScalar,
Queries: validQueries(),
SelectedFields: nil,
},
expectError: false,
},
{
name: "empty (non-nil) selectedFields is valid",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeValue,
RequestType: qbtypes.RequestTypeScalar,
Queries: validQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
},
expectError: false,
},
{
name: "zero-value display is valid",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeValue,
RequestType: qbtypes.RequestTypeScalar,
Queries: validQueries(),
Display: Display{},
},
expectError: false,
},
{
name: "list panel query with no aggregation is valid",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeList,
RequestType: qbtypes.RequestTypeRaw,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
},
}},
},
expectError: false,
},
{
name: "trace panel query with no aggregation is valid",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeTrace,
RequestType: qbtypes.RequestTypeTrace,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
},
}},
},
expectError: false,
},
{
name: "graph panel query with no aggregation is still rejected",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeGraph,
RequestType: qbtypes.RequestTypeTimeSeries,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
},
}},
},
expectError: true,
},
}
for _, c := range cases {
@@ -99,37 +191,65 @@ func TestSavedViewSpecValidate(t *testing.T) {
}
}
func TestSavedViewDataValidate(t *testing.T) {
func TestSavedViewSpecValidate_RequestTypeIsIndependentOfPanelType(t *testing.T) {
// RequestType, not PanelType, governs which aggregation rules apply -- nothing
// derives one from the other inside Validate.
spec := SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeGraph,
RequestType: qbtypes.RequestTypeRaw,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
},
}},
}
assert.NoError(t, spec.Validate())
spec.RequestType = qbtypes.RequestTypeTimeSeries
assert.Error(t, spec.Validate())
}
func TestSavedViewSpecJSONUnmarshal_OptionalFields(t *testing.T) {
base := `"displayName":"My View","panelType":"value","requestType":"scalar","queries":[{"type":"builder_query","spec":{"signal":"logs","aggregations":[{"expression":"count()"}]}}]`
cases := []struct {
name string
data SavedViewData
expectError bool
name string
json string
}{
{
name: "valid data",
data: SavedViewData{SchemaVersion: SavedViewSchemaVersion, Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()}},
expectError: false,
},
{
name: "wrong schema version is rejected",
data: SavedViewData{SchemaVersion: "v1", Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()}},
expectError: true,
},
{
name: "empty schema version is rejected",
data: SavedViewData{Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()}},
expectError: true,
},
{
name: "invalid spec is rejected",
data: SavedViewData{SchemaVersion: SavedViewSchemaVersion, Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph}},
expectError: true,
},
{name: "selectedFields and display omitted entirely", json: `{` + base + `}`},
{name: "selectedFields and display explicitly null", json: `{` + base + `,"selectedFields":null,"display":null}`},
{name: "selectedFields empty array, display empty object", json: `{` + base + `,"selectedFields":[],"display":{}}`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := c.data.Validate()
var spec SavedViewSpec
err := json.Unmarshal([]byte(c.json), &spec)
require.NoError(t, err)
assert.NoError(t, spec.Validate())
assert.Empty(t, spec.SelectedFields)
assert.Equal(t, Display{}, spec.Display)
})
}
}
func TestSchemaVersionValidate(t *testing.T) {
cases := []struct {
name string
schemaVersion SchemaVersion
expectError bool
}{
{name: "valid schema version", schemaVersion: SavedViewSchemaVersion, expectError: false},
{name: "wrong schema version is rejected", schemaVersion: SchemaVersion{valuer.NewString("v1")}, expectError: true},
{name: "empty schema version is rejected", schemaVersion: SchemaVersion{}, expectError: true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := c.schemaVersion.Validate()
if c.expectError {
assert.Error(t, err)
} else {

View File

@@ -7,9 +7,9 @@ import (
)
type Store interface {
Create(ctx context.Context, view *SavedView) error
Get(ctx context.Context, orgID string, id valuer.UUID) (*SavedView, error)
Update(ctx context.Context, view *SavedView) error
Create(ctx context.Context, view *StorableSavedView) error
Get(ctx context.Context, orgID string, id valuer.UUID) (*StorableSavedView, error)
Update(ctx context.Context, view *StorableSavedView) error
Delete(ctx context.Context, orgID string, id valuer.UUID) error
List(ctx context.Context, orgID string, source Source, name string) ([]*SavedView, error)
List(ctx context.Context, orgID string, source Source, name string) ([]*StorableSavedView, error)
}

View File

@@ -999,6 +999,8 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
"cloud.provider": "integration",
"cloud.account.id": "000",
"trace_id": "corrupt_data",
"scope_name": "corrupt_data",
"scope.scope.name": "corrupt_data",
},
attributes={
"net.transport": "IP.TCP",
@@ -1007,7 +1009,10 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
"http.request.method": "POST",
"http.response.status_code": "200",
"timestamp": "corrupt_data",
"version": "1.0.0",
"scope.scope.version": "1.0.0",
},
scope={"name": "io.signoz.http.server", "version": "2.0.0"},
),
Traces(
timestamp=now - timedelta(seconds=3.5),
@@ -1027,12 +1032,24 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
"cloud.provider": "integration",
"cloud.account.id": "000",
"timestamp": "corrupt_data",
"scope.attributes.name": "corrupt_data",
},
attributes={
"db.name": "integration",
"db.operation": "SELECT",
"db.statement": "SELECT * FROM integration",
"trace_d": "corrupt_data",
"scope.attributes.version": "corrupt_data",
},
scope={
"name": "io.opentelemetry.contrib.http",
"version": "1.0.0",
"attributes": {
"telemetry.sdk.language": "cpp",
"name": "not-the-real-name",
"version": "not-the-real-version",
"attributes": "literally-a-key-named-attributes",
},
},
),
Traces(
@@ -1053,12 +1070,15 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
"cloud.provider": "integration",
"cloud.account.id": "000",
"duration_nano": "corrupt_data",
"scope.scope.attributes.version": "corrupt_data",
},
attributes={
"http.request.method": "PATCH",
"http.status_code": "404",
"id": "1",
"scope.scope.version": "corrupt_data",
},
scope={"name": "io.signoz.http.client", "version": "2.0.0"},
),
Traces(
timestamp=now - timedelta(seconds=1),
@@ -1077,6 +1097,7 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
"host.name": "linux-001",
"cloud.provider": "integration",
"cloud.account.id": "001",
"scope.scope.version": "corrupt_data",
},
attributes={
"message.type": "SENT",
@@ -1084,7 +1105,10 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
"messaging.message.id": "001",
"duration_nano": "corrupt_data",
"id": 1,
"scope": "corrupt_data",
"scope.attributes.name": "corrupt_data",
},
scope={"name": "io.signoz.messaging", "version": "3.0.0"},
),
]

View File

@@ -13,15 +13,14 @@ def _body(name: str, source: str = "logs") -> dict:
return {
"name": name,
"source": source,
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": name,
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": name,
"panelType": "table",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
}

View File

@@ -302,6 +302,7 @@ class Traces(ABC):
db_operation: str
has_error: bool
is_remote: str
scope_json: dict[str, Any]
resource: list[TracesResource]
tag_attributes: list[TracesTagAttributes]
@@ -327,6 +328,7 @@ class Traces(ABC):
links: list[TracesLink] = [],
trace_state: str = "",
flags: np.uint32 = 0,
scope: dict[str, Any] = {},
resource_write_mode: Literal["legacy_only", "dual_write"] = "dual_write",
) -> None:
if timestamp is None:
@@ -408,6 +410,33 @@ class Traces(ABC):
# Calculate resource fingerprint
self.resource_fingerprint = LogsOrTracesFingerprint(self.resources_string).calculate()
# Process scope mirroring the InstrumentationScope on the OTLP span.
scope_name = scope.get("name", "")
scope_version = scope.get("version", "")
scope_string = {k: str(v) for k, v in scope.get("attributes", {}).items()}
self.scope_json = {
"name": scope_name,
"version": scope_version,
"attributes": scope_string,
}
scope_keys = {"scope.name": scope_name, "scope.version": scope_version}
scope_keys.update(scope_string)
for k, v in scope_keys.items():
if v == "":
continue
self.tag_attributes.append(
TracesTagAttributes(
timestamp=timestamp,
tag_key=k,
tag_type="scope",
tag_data_type="string",
string_value=v,
number_value=None,
)
)
self.attribute_keys.append(TracesResourceOrAttributeKeys(name=k, datatype="string", tag_type="scope"))
# Process attributes by type and populate custom fields
self.attribute_string = {}
self.attributes_number = {}
@@ -659,6 +688,7 @@ class Traces(ABC):
self.has_error,
self.is_remote,
self.resource_json,
self.scope_json,
],
dtype=object,
)
@@ -689,6 +719,7 @@ class Traces(ABC):
attributes=data.get("attributes", {}),
trace_state=data.get("trace_state", ""),
flags=data.get("flags", 0),
scope=data.get("scope", {}),
)
@classmethod
@@ -828,6 +859,7 @@ def insert_traces_to_clickhouse(conn, traces: list[Traces]) -> None:
"has_error",
"is_remote",
"resource",
"scope",
],
data=[trace.np_arr() for trace in traces],
)

View File

@@ -1240,6 +1240,13 @@ def test_traces_list_span_scope(
lambda x: {"duration_nano": int(x[1].duration_nano), "span_id": x[1].span_id, "timestamp": format_timestamp(x[1].timestamp), "trace_id": x[1].trace_id},
id="select_attribute_duration_order_intrinsic",
),
# Case 9: filter on the intrinsic scope.version. Only x[1] should match.
pytest.param(
BuilderQuery(signal="traces", name="A", select_fields=[TelemetryFieldKey("timestamp")], filter_expression="scope.version = '1.0.0'", limit=1),
HTTPStatus.OK,
lambda x: {"span_id": x[1].span_id, "timestamp": format_timestamp(x[1].timestamp), "trace_id": x[1].trace_id},
id="filter_scope_version",
),
],
)
def test_traces_list_with_corrupt_data(
@@ -1283,6 +1290,156 @@ def test_traces_list_with_corrupt_data(
assert get_rows(response)[0]["data"] == expected(traces)
@pytest.mark.parametrize(
"filter_expression,expected_indices",
[
# Intrinsic scope.name / scope.version resolve to the JSON sub-columns.
pytest.param("scope.name = 'io.signoz.payment'", [1], id="intrinsic_scope_name"),
pytest.param("scope.version = '2.3.1'", [0], id="intrinsic_scope_version"),
# A scope attribute resolves against the scope JSON column's attributes.
pytest.param("scope.telemetry.sdk.language = 'python'", [1], id="scope_attribute"),
# `env.tier` is a span attribute on span 0 and a scope attribute on
# span 1. Unprefixed -> no explicit context, so it is checked in every
# applicable context (attribute OR scope) and both spans match.
pytest.param("env.tier = 'gold'", [0, 1], id="bare_cross_context"),
# The explicit `scope.` prefix forces scope context only, so span 0's
# span attribute is ignored — only span 1 matches.
pytest.param("scope.env.tier = 'gold'", [1], id="scope_prefixed_cross_context"),
# `scope.name` matches BOTH the intrinsic scope.name field (span 0) and a
# scope attribute literally named `name` (span 1's scope attribute
# name='io.signoz.checkout').
pytest.param("scope.name = 'io.signoz.checkout'", [0, 1], id="scope_name_collision"),
# `scope.name` also matches a span attribute literally named `scope.name`
# (attribute context) — span 2 carries attribute scope.name='attr-scope-name'.
pytest.param("scope.name = 'attr-scope-name'", [2], id="scope_name_attribute_collision"),
# An unprefixed `name` resolves to the intrinsic span `name` column and a
# `name` scope attribute, but NOT the scope.name field. Span 2's span
# name and span 1's scope attribute `name` both equal 'io.signoz.checkout';
# span 0's scope.name field equals it too but is NOT matched.
pytest.param("name = 'io.signoz.checkout'", [1, 2], id="bare_name_excludes_scope_name_field"),
# A value that no resolvable key holds (scope.name/scope.version field,
# a `name`/`version` scope attribute, or a same-named attribute/resource)
# returns nothing.
pytest.param("scope.version = 'corrupt_data'", [], id="scope_version_no_match"),
pytest.param("scope.name = 'corrupt_data'", [], id="scope_name_no_match"),
],
)
def test_traces_list_with_scope_filter(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
filter_expression: str,
expected_indices: list[int],
) -> None:
"""
Setup three spans with different scope key resolution:
- x[0]: scope.name/version 'io.signoz.checkout'/'2.3.1'; span attribute
env.tier='gold'.
- x[1]: scope.name/version 'io.signoz.payment'/'4.5.6'; scope attributes
telemetry.sdk.language='python', env.tier='gold', and a `name` scope
attribute colliding with x[0]'s scope.name value.
- x[2]: span name 'io.signoz.checkout' (colliding with x[0]'s scope.name
value) and a span attribute literally named `scope.name`.
Tests:
- Filtering on scope.name / scope.version / a scope attribute.
- An unprefixed key is resolved across contexts (scope checked alongside
attribute / intrinsic), while a `scope.`-prefixed key is scope-only.
- `scope.name` hits the intrinsic field, a `name` scope attribute, and a
span attribute `scope.name` (cross-context), while a bare `name` hits
the span name column (and a `name` scope attribute) but never the
scope.name field.
"""
now = datetime.now(tz=UTC).replace(microsecond=0)
trace_id = TraceIdGenerator.trace_id()
span_ids = [TraceIdGenerator.span_id() for _ in range(3)]
traces = [
Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=2),
trace_id=trace_id,
span_id=span_ids[0],
parent_span_id="",
name="GET /checkout",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "checkout"},
attributes={"http.request.method": "GET", "env.tier": "gold"},
scope={
"name": "io.signoz.checkout",
"version": "2.3.1",
"attributes": {"telemetry.sdk.language": "go"},
},
),
Traces(
timestamp=now - timedelta(seconds=2),
duration=timedelta(seconds=1),
trace_id=trace_id,
span_id=span_ids[1],
parent_span_id="",
name="POST /pay",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "payment"},
attributes={"http.request.method": "POST"},
# env.tier is a scope attribute here (cross-context with span 0);
# `name` is a scope attribute colliding with span 0's scope.name.
scope={
"name": "io.signoz.payment",
"version": "4.5.6",
"attributes": {
"telemetry.sdk.language": "python",
"env.tier": "gold",
"name": "io.signoz.checkout",
},
},
),
Traces(
timestamp=now - timedelta(seconds=1),
duration=timedelta(seconds=1),
trace_id=trace_id,
span_id=span_ids[2],
parent_span_id="",
# span name collides with span 0's scope.name value
name="io.signoz.checkout",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "probe"},
# a span attribute named `scope.name`
attributes={"scope.name": "attr-scope-name"},
scope={"name": "span-gamma", "version": "9.9.9"},
),
]
insert_traces(traces)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms, end_ms = _query_window(now)
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type=RequestType.RAW,
queries=[
BuilderQuery(
signal="traces",
name="A",
select_fields=[TelemetryFieldKey("timestamp")],
filter_expression=filter_expression,
limit=10,
).to_dict()
],
)
assert response.status_code == HTTPStatus.OK, response.text
got_span_ids = {row["data"]["span_id"] for row in get_rows(response)}
expected_span_ids = {traces[i].span_id for i in expected_indices}
assert got_span_ids == expected_span_ids
@pytest.mark.parametrize("surface", ["filter", "select", "order"])
def test_traces_list_unknown_span_context_synthesizes(
signoz: types.SigNoz,

View File

@@ -26,15 +26,14 @@ def test_create_rejects_wrong_schema_version(
"name": "my-view",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v9",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v9",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -59,15 +58,14 @@ def test_create_rejects_invalid_panel_type(
"name": "my-view",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "bogus",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "bogus",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -91,15 +89,14 @@ def test_create_rejects_empty_queries(
"name": "my-view",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -127,15 +124,14 @@ def test_create_rejects_empty_display_name(
"name": "my-view",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -160,15 +156,14 @@ def test_create_rejects_invalid_source(
"name": "my-view",
"generateName": False,
"source": "bogus",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -192,15 +187,14 @@ def test_create_rejects_invalid_name(
"name": "Not A Valid Slug",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -226,15 +220,14 @@ def test_create_rejects_empty_name_without_generate_name(
"name": "",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -261,15 +254,14 @@ def test_create_rejects_name_when_generate_name_is_true(
"name": "explicit-name",
"generateName": True,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -298,15 +290,14 @@ def test_create_rejects_unknown_field(
"name": "my-view",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"unknownfield": "boom",
},
@@ -366,15 +357,14 @@ def test_update_missing_view_returns_not_found(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
json={
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -402,15 +392,14 @@ def test_update_rejects_name_field(
"name": "update-rejects-name-field",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers=headers,
@@ -424,15 +413,14 @@ def test_update_rejects_name_field(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
json={
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"name": "update-rejects-name-field",
},
@@ -485,15 +473,14 @@ def test_saved_view_lifecycle(
"name": "lc-logs-overview",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "lc-logs-overview",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "lc-logs-overview",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers=headers,
@@ -508,15 +495,14 @@ def test_saved_view_lifecycle(
"name": "lc-traces-overview",
"generateName": False,
"source": "traces",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "lc-traces-overview",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "lc-traces-overview",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers=headers,
@@ -535,9 +521,9 @@ def test_saved_view_lifecycle(
got = response.json()["data"]
assert got["id"] == view_id
assert got["name"] == "lc-logs-overview"
assert got["data"]["spec"]["displayName"] == "lc-logs-overview"
assert got["spec"]["displayName"] == "lc-logs-overview"
assert got["source"] == "logs"
assert got["data"]["spec"]["panelType"] == "table"
assert got["spec"]["panelType"] == "table"
# ── list filters by source and name ──────────────────────────────
response = requests.get(
@@ -564,15 +550,14 @@ def test_saved_view_lifecycle(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
json={
"source": "metrics",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "lc-logs-overview-renamed",
"panelType": "graph",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "lc-logs-overview-renamed",
"requestType": "time_series",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "graph",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers=headers,
@@ -588,9 +573,9 @@ def test_saved_view_lifecycle(
assert response.status_code == HTTPStatus.OK, response.text
updated = response.json()["data"]
assert updated["name"] == "lc-logs-overview", "name is immutable"
assert updated["data"]["spec"]["displayName"] == "lc-logs-overview-renamed"
assert updated["spec"]["displayName"] == "lc-logs-overview-renamed"
assert updated["source"] == "metrics"
assert updated["data"]["spec"]["panelType"] == "graph"
assert updated["spec"]["panelType"] == "graph"
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
@@ -621,15 +606,14 @@ def test_empty_name_derives_a_slug_from_display_name(
"name": "",
"generateName": True,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My Generated View!",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "My Generated View!",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers=headers,
@@ -646,7 +630,7 @@ def test_empty_name_derives_a_slug_from_display_name(
)
assert response.status_code == HTTPStatus.OK, response.text
got = response.json()["data"]
assert got["data"]["spec"]["displayName"] == "My Generated View!"
assert got["spec"]["displayName"] == "My Generated View!"
assert got["name"].startswith("my-generated-view-")
assert got["name"] != "my-generated-view-", "expected a random suffix, not just the slugified prefix"
finally:
@@ -681,15 +665,14 @@ def test_create_roundtrip_preserves_zero_values(
"name": "create-zero-values",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "create-zero-values",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "create-zero-values",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers=headers,
@@ -705,10 +688,11 @@ def test_create_roundtrip_preserves_zero_values(
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
spec = response.json()["data"]["data"]["spec"]
spec = response.json()["data"]["spec"]
query = spec["queries"][0]["spec"]
cases = [
("panelType preserved", spec["panelType"], "table"),
("maxLines 0", spec["display"]["maxLines"], 0),
("fontSize empty", spec["display"]["fontSize"], ""),
("format empty", spec["display"]["format"], ""),
@@ -727,28 +711,30 @@ def test_create_roundtrip_preserves_zero_values(
)
def test_selected_fields_omitted_on_create_reads_back_as_empty_list_not_null(
def test_selected_fields_and_display_omitted_on_create_read_back_as_empty_defaults(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Neither selectedFields nor display is required. Omitting both entirely
must not 400 or leave either null on read-back: selectedFields defaults to
an empty list, display to its zero-value object. panelType is a separate,
required, top-level field and is supplied here so the create succeeds."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"name": "omitted-selected-fields",
"name": "omitted-selected-fields-and-display",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "omitted-selected-fields",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "omitted-selected-fields-and-display",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"panelType": "table",
},
},
headers=headers,
@@ -764,7 +750,108 @@ def test_selected_fields_omitted_on_create_reads_back_as_empty_list_not_null(
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["data"]["spec"]["selectedFields"] == []
spec = response.json()["data"]["spec"]
assert spec["selectedFields"] == []
assert spec["display"] == {"maxLines": 0, "fontSize": "", "format": "", "color": ""}
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
def test_selected_fields_and_display_explicit_null_on_create(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""JSON null decodes as a no-op onto a non-pointer Go field (struct/slice), so
an explicit null is expected to behave identically to omitting the field."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"name": "null-selected-fields-and-display",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "null-selected-fields-and-display",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"panelType": "table",
"selectedFields": None,
"display": None,
},
},
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
view_id = response.json()["data"]["id"]
try:
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
spec = response.json()["data"]["spec"]
assert spec["selectedFields"] == []
assert spec["display"] == {"maxLines": 0, "fontSize": "", "format": "", "color": ""}
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
def test_create_with_partial_display_defaults_missing_fields(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""display's fields are each independently optional -- sending only one
(color) must not 400, and the fields left unset must default to their own
zero value rather than being rejected or dropped."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"name": "partial-display-color-only",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "partial-display-color-only",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"panelType": "table",
"selectedFields": [],
"display": {"color": "test"},
},
},
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
view_id = response.json()["data"]["id"]
try:
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["spec"]["display"] == {"maxLines": 0, "fontSize": "", "format": "", "color": "test"}
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
@@ -793,15 +880,14 @@ def test_update_does_not_corrupt_zero_values(
"name": "update-zero-values",
"generateName": False,
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "update-zero-values",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": True, "legend": "Custom Legend"}}],
"selectedFields": [{"name": "service.name"}],
"display": {"maxLines": 25, "fontSize": "large", "format": "table", "color": "blue"},
},
"schemaVersion": "v2",
"spec": {
"displayName": "update-zero-values",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": True, "legend": "Custom Legend"}}],
"selectedFields": [{"name": "service.name"}],
"panelType": "table",
"display": {"maxLines": 25, "fontSize": "large", "format": "table", "color": "blue"},
},
},
headers=headers,
@@ -817,7 +903,7 @@ def test_update_does_not_corrupt_zero_values(
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
spec = response.json()["data"]["data"]["spec"]
spec = response.json()["data"]["spec"]
assert spec["display"]["maxLines"] == 25
# signal/fieldContext/fieldDataType always serialize on TelemetryFieldKey
# (no omitempty -- see pkg/types/telemetrytypes/field.go), so an entry sent
@@ -830,15 +916,14 @@ def test_update_does_not_corrupt_zero_values(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
json={
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "update-zero-values",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "update-zero-values",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers=headers,
@@ -853,7 +938,7 @@ def test_update_does_not_corrupt_zero_values(
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
spec = response.json()["data"]["data"]["spec"]
spec = response.json()["data"]["spec"]
query = spec["queries"][0]["spec"]
cases = [
@@ -873,3 +958,71 @@ def test_update_does_not_corrupt_zero_values(
headers=headers,
timeout=5,
)
def test_update_with_partial_display_replaces_whole_object(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Update is a whole-object replace, not a merge: sending only "color" on
update must not preserve the previous fontSize/format/maxLines -- those
reset to their zero value exactly as if display had been sent in full."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"name": "update-partial-display",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "update-partial-display",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 10, "fontSize": "large", "format": "table", "color": "blue"},
},
},
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
view_id = response.json()["data"]["id"]
try:
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
json={
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "update-partial-display",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"panelType": "table",
"display": {"color": "green"},
},
},
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["spec"]["display"] == {"maxLines": 0, "fontSize": "", "format": "", "color": "green"}
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)

View File

@@ -112,15 +112,14 @@ def test_write_forbidden_without_grant(
signoz.self.host_configs["8080"].get(f"{SAVED_VIEW_BASE}/{target_id}"),
json={
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": _SAVED_VIEW_FGA_TARGET_NAME,
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": _SAVED_VIEW_FGA_TARGET_NAME,
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -133,15 +132,14 @@ def test_write_forbidden_without_grant(
json={
"name": "saved-view-fga-create-attempt",
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "saved-view-fga-create-attempt",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": "saved-view-fga-create-attempt",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -212,15 +210,14 @@ def test_update_scoped_to_granted_view(
token = get_token(_SAVED_VIEW_FGA_CUSTOM_USER_EMAIL, _SAVED_VIEW_FGA_CUSTOM_USER_PASSWORD)
updated_body = {
"source": "logs",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": _SAVED_VIEW_FGA_TARGET_NAME,
"panelType": "graph",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
"schemaVersion": "v2",
"spec": {
"displayName": _SAVED_VIEW_FGA_TARGET_NAME,
"requestType": "time_series",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"panelType": "graph",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
}