Compare commits

..

1 Commits

Author SHA1 Message Date
aks07
cbe3107866 feat: filter fields with no name in field selector 2026-08-11 17:35:39 +05:30
34 changed files with 538 additions and 1882 deletions

View File

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

View File

@@ -7880,20 +7880,17 @@ 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
- schemaVersion
- spec
- data
type: object
SavedviewtypesSavedView:
properties:
@@ -7902,16 +7899,14 @@ 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
@@ -7919,6 +7914,14 @@ components:
type: string
required:
- id
type: object
SavedviewtypesSavedViewData:
properties:
schemaVersion:
type: string
spec:
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
required:
- schemaVersion
- spec
type: object
@@ -7933,10 +7936,7 @@ components:
queries:
items:
$ref: '#/components/schemas/Querybuildertypesv5QueryEnvelope'
minItems: 1
type: array
requestType:
$ref: '#/components/schemas/Querybuildertypesv5RequestType'
selectedFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
@@ -7944,13 +7944,10 @@ components:
required:
- displayName
- panelType
- requestType
- queries
- selectedFields
- display
type: object
SavedviewtypesSchemaVersion:
enum:
- v2
type: string
SavedviewtypesSource:
enum:
- traces
@@ -7960,16 +7957,13 @@ components:
type: string
SavedviewtypesUpdatableSavedView:
properties:
schemaVersion:
$ref: '#/components/schemas/SavedviewtypesSchemaVersion'
data:
$ref: '#/components/schemas/SavedviewtypesSavedViewData'
source:
$ref: '#/components/schemas/SavedviewtypesSource'
spec:
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
required:
- source
- schemaVersion
- spec
- data
type: object
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
properties:
@@ -22782,12 +22776,6 @@ 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,17 +8991,8 @@ 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
*/
@@ -9011,14 +9002,28 @@ 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
*/
@@ -9027,9 +9032,7 @@ export interface SavedviewtypesPostableSavedViewDTO {
* @type string
*/
name?: string;
schemaVersion: SavedviewtypesSchemaVersionDTO;
source: SavedviewtypesSourceDTO;
spec: SavedviewtypesSavedViewSpecDTO;
}
export interface SavedviewtypesSavedViewDTO {
@@ -9042,6 +9045,7 @@ export interface SavedviewtypesSavedViewDTO {
* @type string
*/
createdBy?: string;
data?: SavedviewtypesSavedViewDataDTO;
/**
* @type string
*/
@@ -9050,9 +9054,7 @@ export interface SavedviewtypesSavedViewDTO {
* @type string
*/
name?: string;
schemaVersion: SavedviewtypesSchemaVersionDTO;
source?: SavedviewtypesSourceDTO;
spec: SavedviewtypesSavedViewSpecDTO;
/**
* @type string
* @format date-time
@@ -9065,9 +9067,8 @@ export interface SavedviewtypesSavedViewDTO {
}
export interface SavedviewtypesUpdatableSavedViewDTO {
schemaVersion: SavedviewtypesSchemaVersionDTO;
data: SavedviewtypesSavedViewDataDTO;
source: SavedviewtypesSourceDTO;
spec: SavedviewtypesSavedViewSpecDTO;
}
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {

View File

@@ -387,4 +387,42 @@ 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 ?? [],
value: preferences?.columns?.filter((item) => has(item, 'name')) ?? [],
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, http.StatusConflict},
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceSavedView.Scope(coretypes.VerbCreate)}),
},

View File

@@ -39,28 +39,26 @@ 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)},
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,
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,
},
},
},
}
@@ -70,27 +68,25 @@ 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)},
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,
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,
},
},
},
}
@@ -99,11 +95,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.Spec.Display.Color,
SelectColumns: v.Spec.SelectedFields,
Format: v.Spec.Display.Format,
MaxLines: v.Spec.Display.MaxLines,
FontSize: v.Spec.Display.FontSize,
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,
})
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in marshalling extra data")
@@ -111,17 +107,17 @@ func newLegacyViewFromSavedView(v *savedviewtypes.SavedView) (*v3.SavedView, err
return &v3.SavedView{
ID: v.ID,
Name: v.Spec.DisplayName,
Name: v.Data.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.Spec.PanelType.StringValue()),
PanelType: v3.PanelType(v.Data.Spec.PanelType.StringValue()),
// Saved views are only ever created from the explorer's builder mode.
QueryType: v3.QueryTypeBuilder,
Queries: v.Spec.Queries,
Queries: v.Data.Spec.Queries,
},
ExtraData: string(extraData),
}, nil
@@ -160,14 +156,7 @@ func (handler *handler) Create(w http.ResponseWriter, r *http.Request) {
return
}
postable := newPostableSavedViewFromLegacyView(&view)
if err := postable.Validate(); err != nil {
render.Error(w, err)
return
}
uuid, err := handler.module.CreateView(ctx, claims.OrgID, postable)
uuid, err := handler.module.CreateView(ctx, claims.OrgID, newPostableSavedViewFromLegacyView(&view))
if err != nil {
render.Error(w, err)
return
@@ -235,14 +224,8 @@ func (handler *handler) Update(w http.ResponseWriter, r *http.Request) {
return
}
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 {
err = handler.module.UpdateView(ctx, claims.OrgID, viewUUID, newUpdatableSavedViewFromLegacyView(&view))
if err != nil {
render.Error(w, err)
return
}

View File

@@ -42,14 +42,13 @@ 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.Spec.DisplayName)
assert.Equal(t, "my view", postable.Data.Spec.DisplayName)
assert.Equal(t, savedviewtypes.SourceLogs, postable.Source)
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)
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)
})
t.Run("empty extra data leaves display and selected fields zero-valued", func(t *testing.T) {
@@ -65,9 +64,8 @@ func TestNewPostableSavedViewFromLegacyView(t *testing.T) {
postable := newPostableSavedViewFromLegacyView(legacy)
assert.Equal(t, savedviewtypes.PanelTypeTable, postable.Spec.PanelType)
assert.Equal(t, savedviewtypes.Display{}, postable.Spec.Display)
assert.Nil(t, postable.Spec.SelectedFields)
assert.Equal(t, savedviewtypes.Display{}, postable.Data.Spec.Display)
assert.Nil(t, postable.Data.Spec.SelectedFields)
})
t.Run("malformed extra data is ignored, not an error", func(t *testing.T) {
@@ -83,48 +81,8 @@ func TestNewPostableSavedViewFromLegacyView(t *testing.T) {
postable := newPostableSavedViewFromLegacyView(legacy)
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")
assert.Equal(t, "malformed extra data", postable.Data.Spec.DisplayName)
assert.Equal(t, savedviewtypes.Display{}, postable.Data.Spec.Display)
})
}
@@ -141,23 +99,24 @@ func TestNewUpdatableSavedViewFromLegacyView(t *testing.T) {
updatable := newUpdatableSavedViewFromLegacyView(legacy)
assert.Equal(t, "renamed view", updatable.Spec.DisplayName)
assert.Equal(t, "renamed view", updatable.Data.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,
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,
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"},
},
},
}
savedView.ID = valuer.GenerateUUID()
@@ -170,7 +129,7 @@ func TestNewLegacyViewFromSavedView(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, savedView.ID, legacy.ID)
assert.Equal(t, savedView.Spec.DisplayName, legacy.Name)
assert.Equal(t, savedView.Data.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)
@@ -178,20 +137,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.Spec.Queries, legacy.CompositeQuery.Queries)
assert.Equal(t, savedView.Data.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.Spec.SelectedFields, extra.SelectColumns)
assert.Equal(t, savedView.Data.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, 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()}}
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()}}}
legacyViews, err := newLegacyViewsFromSavedViews([]*savedviewtypes.SavedView{a, b})
require.NoError(t, err)
@@ -208,15 +167,17 @@ 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,
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,
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"},
},
},
}
@@ -227,37 +188,10 @@ func TestLegacyViewRoundTrip(t *testing.T) {
assert.Empty(t, roundTripped.Name)
assert.True(t, roundTripped.GenerateName)
assert.Equal(t, original.Spec.DisplayName, roundTripped.Spec.DisplayName)
assert.Equal(t, original.Data.Spec.DisplayName, roundTripped.Data.Spec.DisplayName)
assert.Equal(t, original.Source, roundTripped.Source)
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)
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)
}

View File

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

View File

@@ -28,23 +28,24 @@ func newTestStore() (savedview.Module, *savedviewtypestest.StoreTest) {
func testPostableSavedView(name string, source savedviewtypes.Source) savedviewtypes.PostableSavedView {
return savedviewtypes.PostableSavedView{
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()"}},
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()"}},
},
},
},
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
},
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
},
}
}
@@ -52,9 +53,8 @@ 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,
SchemaVersion: postable.SchemaVersion,
Spec: postable.Spec,
Source: postable.Source,
Data: postable.Data,
}
}
@@ -93,22 +93,7 @@ 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.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)
assert.Equal(t, savedviewtypes.PanelTypeGraph, got.Data.Spec.PanelType)
require.NoError(t, st.AssertExpectations())
}
@@ -153,21 +138,21 @@ func TestModule_UpdateView(t *testing.T) {
existingName := existing.Name
updated := testUpdatableSavedView("renamed", savedviewtypes.SourceTraces)
updated.Spec.PanelType = savedviewtypes.PanelTypeTable
updated.Data.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.Spec.PanelType = savedviewtypes.PanelTypeTable
stored.Data.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.Spec.DisplayName)
assert.Equal(t, "renamed", got.Data.Spec.DisplayName)
assert.Equal(t, savedviewtypes.SourceTraces, got.Source)
assert.Equal(t, savedviewtypes.PanelTypeTable, got.Spec.PanelType)
assert.Equal(t, savedviewtypes.PanelTypeTable, got.Data.Spec.PanelType)
assert.Equal(t, "updater@signoz.io", got.UpdatedBy)
require.NoError(t, st.AssertExpectations())

View File

@@ -6,6 +6,7 @@ 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"
)
@@ -17,31 +18,32 @@ func NewStore(sqlstore sqlstore.SQLStore) savedviewtypes.Store {
return &store{sqlstore: sqlstore}
}
func (store *store) Create(ctx context.Context, storable *savedviewtypes.StorableSavedView) error {
_, err := store.sqlstore.BunDB().NewInsert().Model(storable).Exec(ctx)
func (store *store) Create(ctx context.Context, view *savedviewtypes.SavedView) error {
_, err := store.sqlstore.BunDB().NewInsert().Model(view).Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "saved view with name %s already exists", storable.Name)
return store.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "saved view with name %s already exists", view.Name)
}
return nil
}
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)
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)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", id.StringValue())
}
return &storable, nil
normalizeSelectedFields(&view)
return &view, nil
}
func (store *store) Update(ctx context.Context, storable *savedviewtypes.StorableSavedView) error {
func (store *store) Update(ctx context.Context, view *savedviewtypes.SavedView) error {
res, err := store.sqlstore.BunDB().NewUpdate().
Model((*savedviewtypes.StorableSavedView)(nil)).
Model(&savedviewtypes.SavedView{}).
Set("updated_at = ?, updated_by = ?, source = ?, data = ?",
storable.UpdatedAt, storable.UpdatedBy, storable.Source, storable.Data).
Where("id = ?", storable.ID.StringValue()).
Where("org_id = ?", storable.OrgID).
view.UpdatedAt, view.UpdatedBy, view.Source, view.Data).
Where("id = ?", view.ID.StringValue()).
Where("org_id = ?", view.OrgID).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "error in updating saved view")
@@ -52,7 +54,7 @@ func (store *store) Update(ctx context.Context, storable *savedviewtypes.Storabl
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", storable.ID.StringValue())
return errors.NewNotFoundf(savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", view.ID.StringValue())
}
return nil
@@ -60,7 +62,7 @@ func (store *store) Update(ctx context.Context, storable *savedviewtypes.Storabl
func (store *store) Delete(ctx context.Context, orgID string, id valuer.UUID) error {
res, err := store.sqlstore.BunDB().NewDelete().
Model((*savedviewtypes.StorableSavedView)(nil)).
Model(&savedviewtypes.SavedView{}).
Where("id = ?", id.StringValue()).
Where("org_id = ?", orgID).
Exec(ctx)
@@ -79,9 +81,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.StorableSavedView, error) {
var storables []*savedviewtypes.StorableSavedView
q := store.sqlstore.BunDB().NewSelect().Model(&storables).
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).
Where("org_id = ?", orgID).
Where("name LIKE ?", "%"+name+"%")
if !source.IsZero() {
@@ -92,5 +94,16 @@ func (store *store) List(ctx context.Context, orgID string, source savedviewtype
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in getting saved views")
}
return storables, nil
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{}
}
}

View File

@@ -56,17 +56,6 @@ 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,23 +72,6 @@ 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,8 +237,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddDashboardTuplesFactory(sqlstore),
sqlmigration.NewRestructureSavedViewSpecFactory(sqlstore, sqlschema),
sqlmigration.NewAddSavedViewTuplesFactory(sqlstore),
sqlmigration.NewFixSavedViewSelectedFieldsFactory(sqlstore),
sqlmigration.NewBackfillSavedViewRequestTypeFactory(sqlstore),
)
}

View File

@@ -1,220 +0,0 @@
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

@@ -1,152 +0,0 @@
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,94 +373,6 @@ 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)
@@ -887,52 +799,6 @@ 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,20 +121,6 @@ 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,7 +52,6 @@ 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,
@@ -177,7 +176,7 @@ func (m *fieldMapper) getColumn(
case telemetrytypes.FieldContextResource:
return []*schema.Column{indexV3Columns["resource"], indexV3Columns["resources_string"]}, nil
case telemetrytypes.FieldContextScope:
return []*schema.Column{indexV3Columns["scope"]}, nil
return []*schema.Column{}, qbtypes.ErrColumnNotFound
case telemetrytypes.FieldContextAttribute:
switch key.FieldDataType {
case telemetrytypes.FieldDataTypeString:
@@ -288,24 +287,14 @@ 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.
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)
}
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 schema.ColumnTypeEnumString,
schema.ColumnTypeEnumUInt64,
schema.ColumnTypeEnumUInt32,

View File

@@ -83,33 +83,6 @@ 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,20 +113,6 @@ 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,8 +7,6 @@ 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"
@@ -30,76 +28,27 @@ 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 `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,
},
}
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"`
}
type PostableSavedView struct {
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"`
Name string `json:"name"`
GenerateName bool `json:"generateName"`
Source Source `json:"source" required:"true"`
Data SavedViewData `json:"data" required:"true"`
}
type UpdatableSavedView struct {
Source Source `json:"source" required:"true"`
SchemaVersion SchemaVersion `json:"schemaVersion" required:"true"`
Spec SavedViewSpec `json:"spec" required:"true"`
Source Source `json:"source" required:"true"`
Data SavedViewData `json:"data" required:"true"`
}
type ListSavedViewsParams struct {
@@ -134,7 +83,7 @@ func (postable PostableSavedView) ToSavedView(orgID string, createdBy string) *S
name := postable.Name
if postable.GenerateName {
name = generateSavedViewName(postable.Spec.DisplayName)
name = generateSavedViewName(postable.Data.Spec.DisplayName)
}
return &SavedView{
@@ -144,8 +93,7 @@ func (postable PostableSavedView) ToSavedView(orgID string, createdBy string) *S
OrgID: orgID,
Name: name,
Source: postable.Source,
SchemaVersion: postable.SchemaVersion,
Spec: postable.Spec,
Data: postable.Data,
}
}
@@ -158,8 +106,7 @@ func (updatable UpdatableSavedView) ToSavedView(id valuer.UUID, orgID string, up
UserAuditable: types.UserAuditable{UpdatedBy: updatedBy},
OrgID: orgID,
Source: updatable.Source,
SchemaVersion: updatable.SchemaVersion,
Spec: updatable.Spec,
Data: updatable.Data,
}
}
@@ -170,11 +117,8 @@ 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.Spec.Validate()
return p.Data.Validate()
}
func (p *PostableSavedView) validateName() error {
@@ -191,11 +135,8 @@ 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.Spec.Validate()
return u.Data.Validate()
}
func (p *ListSavedViewsParams) Validate() error {
@@ -206,17 +147,7 @@ func (p *ListSavedViewsParams) Validate() error {
return p.Source.Validate()
}
// 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 {
func NewStatsFromSavedViews(savedViews []*SavedView) map[string]any {
stats := make(map[string]any)
for _, savedView := range savedViews {
key := "savedview.source." + strings.ToLower(savedView.Source.StringValue()) + ".count"

View File

@@ -4,28 +4,29 @@ 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,
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, RequestType: qbtypes.RequestTypeTimeSeries, Queries: validQueries()},
Name: "my-view",
Source: SourceLogs,
Data: SavedViewData{
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()},
},
}
}
func validUpdatableSavedView() UpdatableSavedView {
return UpdatableSavedView{
Source: SourceLogs,
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, RequestType: qbtypes.RequestTypeTimeSeries, Queries: validQueries()},
Source: SourceLogs,
Data: SavedViewData{
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()},
},
}
}
@@ -68,7 +69,7 @@ func TestPostableSavedViewValidate(t *testing.T) {
t.Run("invalid saved view data is rejected", func(t *testing.T) {
view := validPostableSavedView()
view.SchemaVersion = SchemaVersion{valuer.NewString("v1")}
view.Data.SchemaVersion = "v1"
assert.Error(t, view.Validate())
})
@@ -99,15 +100,9 @@ func TestPostableSavedViewValidate(t *testing.T) {
t.Run("empty displayName is rejected", func(t *testing.T) {
view := validPostableSavedView()
view.Spec.DisplayName = ""
view.Data.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) {
@@ -124,15 +119,9 @@ func TestUpdatableSavedViewValidate(t *testing.T) {
t.Run("empty displayName is rejected", func(t *testing.T) {
view := validUpdatableSavedView()
view.Spec.DisplayName = ""
view.Data.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) {
@@ -164,8 +153,7 @@ 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.SchemaVersion, savedView.SchemaVersion)
assert.Equal(t, view.Spec, savedView.Spec)
assert.Equal(t, view.Data, savedView.Data)
assert.False(t, savedView.CreatedAt.IsZero())
assert.Equal(t, savedView.CreatedAt, savedView.UpdatedAt)
}
@@ -175,14 +163,14 @@ func TestNewSavedView_GeneratesNameWhenEmpty(t *testing.T) {
view := validPostableSavedView()
view.Name = ""
view.GenerateName = true
view.Spec.DisplayName = "My View!"
view.Data.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.Spec.DisplayName)
assert.Equal(t, "My View!", savedView.Data.Spec.DisplayName)
}
func TestGenerateSavedViewName(t *testing.T) {
@@ -224,95 +212,17 @@ func TestGenerateSavedViewName(t *testing.T) {
})
}
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{
func TestNewStatsFromSavedViews(t *testing.T) {
views := []*SavedView{
{Source: SourceLogs},
{Source: SourceLogs},
{Source: SourceTraces},
}
stats := NewStatsFromStorableSavedViews(storables)
stats := NewStatsFromSavedViews(views)
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(savedviewtypes.NewStorableSavedView(view).Data)
data, _ := json.Marshal(view.Data)
return []driver.Value{
view.ID.StringValue(),
view.CreatedAt,
@@ -47,12 +47,6 @@ 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.
var SavedViewSchemaVersion = SchemaVersion{valuer.NewString("v2")}
const SavedViewSchemaVersion = "v2"
var (
PanelTypeValue = PanelType{valuer.NewString("value")}
@@ -30,10 +30,9 @@ type Display struct {
type SavedViewSpec struct {
DisplayName string `json:"displayName" required:"true"`
PanelType PanelType `json:"panelType" 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"`
Queries []qbtypes.QueryEnvelope `json:"queries" required:"true" nullable:"false"`
SelectedFields []telemetrytypes.TelemetryFieldKey `json:"selectedFields" required:"true" nullable:"false"`
Display Display `json:"display" required:"true"`
}
// SavedViewData is what's persisted as saved view data.
@@ -42,11 +41,6 @@ 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
@@ -71,17 +65,6 @@ 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")
@@ -89,23 +72,14 @@ func (s *SavedViewSpec) Validate() error {
if err := s.PanelType.Validate(); err != nil {
return err
}
if s.RequestType.IsZero() {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "requestType is required")
}
return (&qbtypes.CompositeQuery{Queries: s.Queries}).Validate(qbtypes.GetValidationOptions(s.RequestType)...)
return (&qbtypes.CompositeQuery{Queries: s.Queries}).Validate()
}
// 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
func (d *SavedViewData) Validate() error {
if d.SchemaVersion != SavedViewSchemaVersion {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "schemaVersion must be %q, got %q", SavedViewSchemaVersion, d.SchemaVersion)
}
return d.Spec.Validate()
}

View File

@@ -1,15 +1,12 @@
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 {
@@ -59,124 +56,35 @@ func TestSavedViewSpecValidate(t *testing.T) {
}{
{
name: "valid spec",
spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, RequestType: qbtypes.RequestTypeTimeSeries, Queries: validQueries()},
spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()},
expectError: false,
},
{
name: "empty display name is rejected",
spec: SavedViewSpec{RequestType: qbtypes.RequestTypeTimeSeries, Queries: validQueries()},
spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries()},
expectError: true,
},
{
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()},
name: "invalid panel type is rejected before queries are checked",
spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelType{valuer.NewString("bogus")}, Queries: validQueries()},
expectError: true,
},
{
name: "no queries is rejected",
spec: SavedViewSpec{DisplayName: "My View", RequestType: qbtypes.RequestTypeTimeSeries},
spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph},
expectError: true,
},
{
name: "selectedFields and display populated is still valid",
name: "selected fields and display are not required",
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 {
@@ -191,65 +99,37 @@ func TestSavedViewSpecValidate(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()"}]}}]`
func TestSavedViewDataValidate(t *testing.T) {
cases := []struct {
name string
json string
name string
data SavedViewData
expectError bool
}{
{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":{}}`},
{
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,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
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()
err := c.data.Validate()
if c.expectError {
assert.Error(t, err)
} else {

View File

@@ -7,9 +7,9 @@ import (
)
type Store interface {
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
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
Delete(ctx context.Context, orgID string, id valuer.UUID) error
List(ctx context.Context, orgID string, source Source, name string) ([]*StorableSavedView, error)
List(ctx context.Context, orgID string, source Source, name string) ([]*SavedView, error)
}

View File

@@ -999,8 +999,6 @@ 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",
@@ -1009,10 +1007,7 @@ 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),
@@ -1032,24 +1027,12 @@ 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(
@@ -1070,15 +1053,12 @@ 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),
@@ -1097,7 +1077,6 @@ 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",
@@ -1105,10 +1084,7 @@ 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,14 +13,15 @@ def _body(name: str, source: str = "logs") -> dict:
return {
"name": name,
"source": source,
"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": ""},
"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": ""},
},
},
}

View File

@@ -302,7 +302,6 @@ class Traces(ABC):
db_operation: str
has_error: bool
is_remote: str
scope_json: dict[str, Any]
resource: list[TracesResource]
tag_attributes: list[TracesTagAttributes]
@@ -328,7 +327,6 @@ 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:
@@ -410,33 +408,6 @@ 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 = {}
@@ -688,7 +659,6 @@ class Traces(ABC):
self.has_error,
self.is_remote,
self.resource_json,
self.scope_json,
],
dtype=object,
)
@@ -719,7 +689,6 @@ class Traces(ABC):
attributes=data.get("attributes", {}),
trace_state=data.get("trace_state", ""),
flags=data.get("flags", 0),
scope=data.get("scope", {}),
)
@classmethod
@@ -859,7 +828,6 @@ 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,13 +1240,6 @@ 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(
@@ -1290,156 +1283,6 @@ 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,14 +26,15 @@ def test_create_rejects_wrong_schema_version(
"name": "my-view",
"generateName": False,
"source": "logs",
"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": ""},
"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": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -58,14 +59,15 @@ def test_create_rejects_invalid_panel_type(
"name": "my-view",
"generateName": False,
"source": "logs",
"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": ""},
"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": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -89,14 +91,15 @@ def test_create_rejects_empty_queries(
"name": "my-view",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -124,14 +127,15 @@ def test_create_rejects_empty_display_name(
"name": "my-view",
"generateName": False,
"source": "logs",
"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": ""},
"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": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -156,14 +160,15 @@ def test_create_rejects_invalid_source(
"name": "my-view",
"generateName": False,
"source": "bogus",
"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": ""},
"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": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -187,14 +192,15 @@ def test_create_rejects_invalid_name(
"name": "Not A Valid Slug",
"generateName": False,
"source": "logs",
"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": ""},
"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": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -220,14 +226,15 @@ def test_create_rejects_empty_name_without_generate_name(
"name": "",
"generateName": False,
"source": "logs",
"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": ""},
"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": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -254,14 +261,15 @@ def test_create_rejects_name_when_generate_name_is_true(
"name": "explicit-name",
"generateName": True,
"source": "logs",
"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": ""},
"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": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -290,14 +298,15 @@ def test_create_rejects_unknown_field(
"name": "my-view",
"generateName": False,
"source": "logs",
"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": ""},
"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": ""},
},
},
"unknownfield": "boom",
},
@@ -357,14 +366,15 @@ def test_update_missing_view_returns_not_found(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
json={
"source": "logs",
"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": ""},
"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": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -392,14 +402,15 @@ def test_update_rejects_name_field(
"name": "update-rejects-name-field",
"generateName": False,
"source": "logs",
"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": ""},
"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": ""},
},
},
},
headers=headers,
@@ -413,14 +424,15 @@ def test_update_rejects_name_field(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
json={
"source": "logs",
"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": ""},
"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": ""},
},
},
"name": "update-rejects-name-field",
},
@@ -473,14 +485,15 @@ def test_saved_view_lifecycle(
"name": "lc-logs-overview",
"generateName": False,
"source": "logs",
"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": ""},
"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": ""},
},
},
},
headers=headers,
@@ -495,14 +508,15 @@ def test_saved_view_lifecycle(
"name": "lc-traces-overview",
"generateName": False,
"source": "traces",
"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": ""},
"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": ""},
},
},
},
headers=headers,
@@ -521,9 +535,9 @@ def test_saved_view_lifecycle(
got = response.json()["data"]
assert got["id"] == view_id
assert got["name"] == "lc-logs-overview"
assert got["spec"]["displayName"] == "lc-logs-overview"
assert got["data"]["spec"]["displayName"] == "lc-logs-overview"
assert got["source"] == "logs"
assert got["spec"]["panelType"] == "table"
assert got["data"]["spec"]["panelType"] == "table"
# ── list filters by source and name ──────────────────────────────
response = requests.get(
@@ -550,14 +564,15 @@ def test_saved_view_lifecycle(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
json={
"source": "metrics",
"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": ""},
"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": ""},
},
},
},
headers=headers,
@@ -573,9 +588,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["spec"]["displayName"] == "lc-logs-overview-renamed"
assert updated["data"]["spec"]["displayName"] == "lc-logs-overview-renamed"
assert updated["source"] == "metrics"
assert updated["spec"]["panelType"] == "graph"
assert updated["data"]["spec"]["panelType"] == "graph"
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
@@ -606,14 +621,15 @@ def test_empty_name_derives_a_slug_from_display_name(
"name": "",
"generateName": True,
"source": "logs",
"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": ""},
"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": ""},
},
},
},
headers=headers,
@@ -630,7 +646,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["spec"]["displayName"] == "My Generated View!"
assert got["data"]["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:
@@ -665,14 +681,15 @@ def test_create_roundtrip_preserves_zero_values(
"name": "create-zero-values",
"generateName": False,
"source": "logs",
"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": ""},
"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": ""},
},
},
},
headers=headers,
@@ -688,11 +705,10 @@ def test_create_roundtrip_preserves_zero_values(
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
spec = response.json()["data"]["spec"]
spec = response.json()["data"]["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"], ""),
@@ -711,30 +727,28 @@ def test_create_roundtrip_preserves_zero_values(
)
def test_selected_fields_and_display_omitted_on_create_read_back_as_empty_defaults(
def test_selected_fields_omitted_on_create_reads_back_as_empty_list_not_null(
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-and-display",
"name": "omitted-selected-fields",
"generateName": False,
"source": "logs",
"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",
"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": ""},
},
},
},
headers=headers,
@@ -750,108 +764,7 @@ def test_selected_fields_and_display_omitted_on_create_read_back_as_empty_defaul
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_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"}
assert response.json()["data"]["data"]["spec"]["selectedFields"] == []
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
@@ -880,14 +793,15 @@ def test_update_does_not_corrupt_zero_values(
"name": "update-zero-values",
"generateName": False,
"source": "logs",
"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"},
"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"},
},
},
},
headers=headers,
@@ -903,7 +817,7 @@ def test_update_does_not_corrupt_zero_values(
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
spec = response.json()["data"]["spec"]
spec = response.json()["data"]["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
@@ -916,14 +830,15 @@ def test_update_does_not_corrupt_zero_values(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
json={
"source": "logs",
"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": ""},
"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": ""},
},
},
},
headers=headers,
@@ -938,7 +853,7 @@ def test_update_does_not_corrupt_zero_values(
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
spec = response.json()["data"]["spec"]
spec = response.json()["data"]["data"]["spec"]
query = spec["queries"][0]["spec"]
cases = [
@@ -958,71 +873,3 @@ 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,14 +112,15 @@ def test_write_forbidden_without_grant(
signoz.self.host_configs["8080"].get(f"{SAVED_VIEW_BASE}/{target_id}"),
json={
"source": "logs",
"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": ""},
"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": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -132,14 +133,15 @@ def test_write_forbidden_without_grant(
json={
"name": "saved-view-fga-create-attempt",
"source": "logs",
"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": ""},
"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": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -210,14 +212,15 @@ 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",
"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": ""},
"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": ""},
},
},
}