mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-15 14:40:41 +01:00
Compare commits
16 Commits
main
...
ns/trace-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
594bcd4b6f | ||
|
|
e558ebf5bb | ||
|
|
5fb02c1c45 | ||
|
|
080e5ee8eb | ||
|
|
1596fe89f2 | ||
|
|
e72a3443d3 | ||
|
|
b61a300a4a | ||
|
|
84eaf5b2cc | ||
|
|
d08d79548d | ||
|
|
3b6c91eb0d | ||
|
|
f45110230f | ||
|
|
d868adbceb | ||
|
|
bdc8787c57 | ||
|
|
a5cbddd02f | ||
|
|
0c02759640 | ||
|
|
10b7888788 |
@@ -8046,6 +8046,31 @@ components:
|
||||
- metric
|
||||
- value
|
||||
type: object
|
||||
PromotetypesIndexMaterializedPathsResult:
|
||||
properties:
|
||||
indexed:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
skipped:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- indexed
|
||||
- skipped
|
||||
type: object
|
||||
PromotetypesPostableIndexMaterializedPaths:
|
||||
properties:
|
||||
dryRun:
|
||||
type: boolean
|
||||
signal:
|
||||
type: string
|
||||
required:
|
||||
- signal
|
||||
type: object
|
||||
PromotetypesPromotePath:
|
||||
properties:
|
||||
context:
|
||||
@@ -14067,6 +14092,67 @@ paths:
|
||||
tags:
|
||||
- promote
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/promoted_paths/materialized:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint indexes the JSON sub-columns of materialized paths
|
||||
whose columns are indexed and returns the indexed and skipped paths.
|
||||
operationId: IndexMaterializedPaths
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PromotetypesPostableIndexMaterializedPaths'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/PromotetypesIndexMaterializedPathsResult'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- logs-field:update
|
||||
- traces-field:update
|
||||
- tokenizer:
|
||||
- logs-field:update
|
||||
- traces-field:update
|
||||
summary: Index materialized paths
|
||||
tags:
|
||||
- promote
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/roles:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -18,8 +18,10 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
IndexMaterializedPaths200,
|
||||
ListPromotedPaths200,
|
||||
ListPromotedPathsParams,
|
||||
PromotetypesPostableIndexMaterializedPathsDTO,
|
||||
PromotetypesPromotePathDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -230,3 +232,87 @@ export const usePromotePaths = <
|
||||
> => {
|
||||
return useMutation(getPromotePathsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint indexes the JSON sub-columns of materialized paths whose columns are indexed and returns the indexed and skipped paths.
|
||||
* @summary Index materialized paths
|
||||
*/
|
||||
export const indexMaterializedPaths = (
|
||||
promotetypesPostableIndexMaterializedPathsDTO?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<IndexMaterializedPaths200>({
|
||||
url: `/api/v1/promoted_paths/materialized`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: promotetypesPostableIndexMaterializedPathsDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getIndexMaterializedPathsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['indexMaterializedPaths'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return indexMaterializedPaths(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type IndexMaterializedPathsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>
|
||||
>;
|
||||
export type IndexMaterializedPathsMutationBody =
|
||||
| BodyType<PromotetypesPostableIndexMaterializedPathsDTO>
|
||||
| undefined;
|
||||
export type IndexMaterializedPathsMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Index materialized paths
|
||||
*/
|
||||
export const useIndexMaterializedPaths = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof indexMaterializedPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getIndexMaterializedPathsMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -9878,6 +9878,28 @@ export interface PromotetypesPromotePathDTO {
|
||||
signal: string;
|
||||
}
|
||||
|
||||
export interface PromotetypesIndexMaterializedPathsResultDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
indexed: PromotetypesPromotePathDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
skipped: PromotetypesPromotePathDTO[] | null;
|
||||
}
|
||||
|
||||
export interface PromotetypesPostableIndexMaterializedPathsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
dryRun?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
signal: string;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5AggregationMetaDTO {
|
||||
/**
|
||||
* @type array
|
||||
@@ -13248,6 +13270,14 @@ export type ListPromotedPaths200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type IndexMaterializedPaths200 = {
|
||||
data: PromotetypesIndexMaterializedPathsResultDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRoles200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -84,5 +84,26 @@ func (provider *provider) addPromoteRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/promoted_paths/materialized", handler.New(provider.authzMiddleware.CheckResources(provider.promoteHandler.IndexMaterializedPaths, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName), handler.OpenAPIDef{
|
||||
ID: "IndexMaterializedPaths",
|
||||
Tags: []string{"promote"},
|
||||
Summary: "Index materialized paths",
|
||||
Description: "This endpoint indexes the JSON sub-columns of materialized paths whose columns are indexed and returns the indexed and skipped paths.",
|
||||
Request: new(promotetypes.PostableIndexMaterializedPaths),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(promotetypes.IndexMaterializedPathsResult),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceLogsField.Scope(coretypes.VerbUpdate), coretypes.ResourceMetaResourceTracesField.Scope(coretypes.VerbUpdate)}),
|
||||
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
Resources: promotetypes.IndexMaterializedPathsResources,
|
||||
}))).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -72,3 +72,23 @@ func (h *handler) ListPromotedPaths(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
render.Success(w, http.StatusOK, paths)
|
||||
}
|
||||
|
||||
func (h *handler) IndexMaterializedPaths(w http.ResponseWriter, r *http.Request) {
|
||||
var req promotetypes.PostableIndexMaterializedPaths
|
||||
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
paths, err := h.module.IndexMaterializedPaths(r.Context(), req)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, paths)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package implpromote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
@@ -22,10 +24,15 @@ var (
|
||||
type module struct {
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
settings factory.ScopedProviderSettings
|
||||
}
|
||||
|
||||
func NewModule(metadataStore telemetrytypes.MetadataStore, telemetrystore telemetrystore.TelemetryStore) promote.Module {
|
||||
return &module{metadataStore: metadataStore, telemetryStore: telemetrystore}
|
||||
func NewModule(metadataStore telemetrytypes.MetadataStore, telemetrystore telemetrystore.TelemetryStore, providerSettings factory.ProviderSettings) promote.Module {
|
||||
return &module{
|
||||
metadataStore: metadataStore,
|
||||
telemetryStore: telemetrystore,
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/promote/implpromote"),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) ListPromotedPaths(ctx context.Context, filters promotetypes.ListPromotedPathsFilters) ([]promotetypes.PromotePath, error) {
|
||||
@@ -101,6 +108,71 @@ func (m *module) listPromotedPaths(ctx context.Context, target promotetypes.Targ
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (m *module) IndexMaterializedPaths(ctx context.Context, req promotetypes.PostableIndexMaterializedPaths) (*promotetypes.IndexMaterializedPathsResult, error) {
|
||||
target, err := req.Target()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keys, err := m.metadataStore.GetMaterializedKeys(ctx, target.Entry.Signal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
paths := []*promotetypes.PromotePath{}
|
||||
names := []string{}
|
||||
for _, key := range keys {
|
||||
if key.FieldContext != target.Entry.FieldContext || len(key.Indexes) == 0 {
|
||||
continue
|
||||
}
|
||||
path, err := promotetypes.NewMaterializedPromotePath(target, key)
|
||||
if err != nil {
|
||||
m.settings.Logger().WarnContext(ctx, "skipping materialized path that cannot be indexed", slog.String("path", key.Name), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
paths = append(paths, path)
|
||||
names = append(names, path.Path)
|
||||
}
|
||||
|
||||
result := &promotetypes.IndexMaterializedPathsResult{Indexed: []*promotetypes.PromotePath{}, Skipped: []*promotetypes.PromotePath{}}
|
||||
if len(paths) > 0 {
|
||||
existing, err := m.metadataStore.ListJSONIndexes(ctx, target.JSONIndexLookup(), names...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existingByPath := map[string][]promotetypes.WrappedIndex{}
|
||||
for _, index := range existing {
|
||||
existingByPath[index.Name] = append(existingByPath[index.Name], promotetypes.WrappedIndex{FieldDataType: index.FieldDataType, Type: index.IndexType, Granularity: index.Granularity})
|
||||
}
|
||||
for _, path := range paths {
|
||||
if indexes, ok := existingByPath[path.Path]; ok {
|
||||
path.Indexes = indexes
|
||||
result.Skipped = append(result.Skipped, path)
|
||||
continue
|
||||
}
|
||||
result.Indexed = append(result.Indexed, path)
|
||||
}
|
||||
}
|
||||
|
||||
if !req.DryRun && len(result.Indexed) > 0 {
|
||||
if err := m.promotePaths(ctx, target, result.Indexed...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
indexed := make([]string, 0, len(result.Indexed))
|
||||
for _, path := range result.Indexed {
|
||||
indexed = append(indexed, path.Path)
|
||||
}
|
||||
skipped := make([]string, 0, len(result.Skipped))
|
||||
for _, path := range result.Skipped {
|
||||
skipped = append(skipped, path.Path)
|
||||
}
|
||||
m.settings.Logger().InfoContext(ctx, "indexed materialized paths", slog.String("signal", req.Signal), slog.Bool("dry_run", req.DryRun), slog.Int("indexed_count", len(indexed)), slog.Any("indexed", indexed), slog.Int("skipped_count", len(skipped)), slog.Any("skipped", skipped))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *module) PromotePaths(ctx context.Context, paths ...*promotetypes.PromotePath) error {
|
||||
byTarget := map[promotetypes.Target][]*promotetypes.PromotePath{}
|
||||
targets := []promotetypes.Target{}
|
||||
|
||||
@@ -4,14 +4,20 @@ import (
|
||||
"context"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sqlmock "github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
|
||||
"github.com/SigNoz/signoz/pkg/types/promotetypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
)
|
||||
@@ -71,7 +77,7 @@ func TestPromotePaths(t *testing.T) {
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
store := telemetrytypestest.NewMockMetadataStore()
|
||||
m := NewModule(store, nil)
|
||||
m := NewModule(store, nil, factorytest.NewSettings())
|
||||
|
||||
err := m.PromotePaths(ctx, testCase.paths...)
|
||||
if testCase.wantErr {
|
||||
@@ -194,7 +200,7 @@ func TestPromotePathsCreatesIndexes(t *testing.T) {
|
||||
ts := telemetrystoretest.New(telemetrystore.Config{}, sqlmock.QueryMatcherRegexp)
|
||||
store := telemetrytypestest.NewMockMetadataStore()
|
||||
store.PromotedPathsMap[telemetrytypes.SignalLogs] = testCase.promoted
|
||||
m := NewModule(store, ts)
|
||||
m := NewModule(store, ts, factorytest.NewSettings())
|
||||
|
||||
ts.Mock().ExpectExec("ADD INDEX (.+)" + regexp.QuoteMeta(testCase.wantDDLColumn)).WillReturnError(nil)
|
||||
require.NoError(t, m.PromotePaths(ctx, testCase.path))
|
||||
@@ -390,7 +396,7 @@ func TestListPromotedPaths(t *testing.T) {
|
||||
store := telemetrytypestest.NewMockMetadataStore()
|
||||
store.PromotedPathsMap = testCase.promoted
|
||||
store.LogsJSONIndexes = testCase.indexes
|
||||
m := NewModule(store, nil)
|
||||
m := NewModule(store, nil, factorytest.NewSettings())
|
||||
|
||||
paths, err := m.ListPromotedPaths(ctx, testCase.filters)
|
||||
require.NoError(t, err)
|
||||
@@ -408,3 +414,135 @@ func TestListPromotedPaths(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexMaterializedPaths(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
bloomIndex := []telemetrytypes.TelemetryFieldKeySkipIndex{{IndexType: "bloom_filter", Granularity: 4}}
|
||||
materializedKeys := []*telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "user.id", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString, Indexes: bloomIndex},
|
||||
{Name: "tenant.id", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "retry.count", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeNumber, Indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{{IndexType: "minmax", Granularity: 1}}},
|
||||
{Name: "k8s.pod.name", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString, Indexes: bloomIndex},
|
||||
}
|
||||
userIDDDL := "`attributes.user.id_String_bloom_filter` attributes.`user.id`::String TYPE bloom_filter GRANULARITY 4"
|
||||
retryCountDDL := "`attributes.retry.count_Float64_minmax` toFloat64OrNull(attributes.`retry.count`::String) TYPE minmax GRANULARITY 1"
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
req promotetypes.PostableIndexMaterializedPaths
|
||||
keys []*telemetrytypes.TelemetryFieldKey
|
||||
existing []telemetrytypes.TelemetryFieldKeySkipIndex
|
||||
wantIndexed []string
|
||||
wantSkipped []string
|
||||
wantDDL []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "DryRun_NoDDL",
|
||||
req: promotetypes.PostableIndexMaterializedPaths{Signal: "traces", DryRun: true},
|
||||
keys: materializedKeys,
|
||||
wantIndexed: []string{"user.id", "retry.count"},
|
||||
wantSkipped: []string{},
|
||||
},
|
||||
{
|
||||
name: "Run_IndexesBaseColumn",
|
||||
req: promotetypes.PostableIndexMaterializedPaths{Signal: "traces"},
|
||||
keys: materializedKeys,
|
||||
wantIndexed: []string{"user.id", "retry.count"},
|
||||
wantSkipped: []string{},
|
||||
wantDDL: []string{userIDDDL, retryCountDDL},
|
||||
},
|
||||
{
|
||||
name: "AnyIndexBuilt_Skipped",
|
||||
req: promotetypes.PostableIndexMaterializedPaths{Signal: "traces"},
|
||||
keys: materializedKeys,
|
||||
existing: []telemetrytypes.TelemetryFieldKeySkipIndex{{Name: "user.id", FieldContext: telemetrytypes.FieldContextAttribute, IndexType: "ngrambf_v1(4, 1024, 2, 0)"}},
|
||||
wantIndexed: []string{"retry.count"},
|
||||
wantSkipped: []string{"user.id"},
|
||||
wantDDL: []string{retryCountDDL},
|
||||
},
|
||||
{
|
||||
name: "UnsupportedIndexType_LoggedAndLeftOut",
|
||||
req: promotetypes.PostableIndexMaterializedPaths{Signal: "traces"},
|
||||
keys: append([]*telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "user.email", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString, Indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{{IndexType: "text(tokenizer = 'splitByNonAlpha')", Granularity: 1}}},
|
||||
}, materializedKeys...),
|
||||
wantIndexed: []string{"user.id", "retry.count"},
|
||||
wantSkipped: []string{},
|
||||
wantDDL: []string{userIDDDL, retryCountDDL},
|
||||
},
|
||||
{
|
||||
name: "LogsSignal_NoAttributeTarget_Rejected",
|
||||
req: promotetypes.PostableIndexMaterializedPaths{Signal: "logs"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "MissingSignal_Rejected",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
ts := telemetrystoretest.New(telemetrystore.Config{}, sqlmock.QueryMatcherRegexp)
|
||||
store := telemetrytypestest.NewMockMetadataStore()
|
||||
store.MaterializedKeys = testCase.keys
|
||||
store.LogsJSONIndexes = testCase.existing
|
||||
m := NewModule(store, ts, factorytest.NewSettings())
|
||||
for _, ddl := range testCase.wantDDL {
|
||||
ts.Mock().ExpectExec("ADD INDEX (.+)" + regexp.QuoteMeta(ddl)).WillReturnError(nil)
|
||||
}
|
||||
|
||||
result, err := m.IndexMaterializedPaths(ctx, testCase.req)
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
indexed := []string{}
|
||||
for _, path := range result.Indexed {
|
||||
assert.False(t, path.Promote)
|
||||
indexed = append(indexed, path.Path)
|
||||
}
|
||||
skipped := []string{}
|
||||
for _, path := range result.Skipped {
|
||||
skipped = append(skipped, path.Path)
|
||||
}
|
||||
assert.Equal(t, testCase.wantIndexed, indexed)
|
||||
assert.Equal(t, testCase.wantSkipped, skipped)
|
||||
assert.NoError(t, ts.Mock().ExpectationsWereMet())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An index is used only when a filter's left-hand side is its exact expression.
|
||||
func TestTracesNumberIndexExpr_MatchesQuerierFilters(t *testing.T) {
|
||||
release := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
queryInfo := qbtypes.QueryInfo{StartNs: uint64(release.AddDate(0, 6, 0).UnixNano()), EndNs: uint64(release.AddDate(0, 7, 0).UnixNano()), TraceAttrsJSONOn: true}
|
||||
key := telemetrytypes.TelemetryFieldKey{Name: "http.status_code", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeNumber, Evolutions: tracestelemetryschema.MockAttributeEvolutionData(release)}
|
||||
indexExpr := promotetypes.NewTracesAttributesTarget().IndexExpression("attributes", "http.status_code", telemetrytypes.Float64.StringValue())
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
operator qbtypes.FilterOperator
|
||||
value any
|
||||
want string
|
||||
}{
|
||||
{name: "Equal", operator: qbtypes.FilterOperatorEqual, value: float64(200), want: indexExpr + " = ?"},
|
||||
{name: "In", operator: qbtypes.FilterOperatorIn, value: []any{float64(200), float64(404)}, want: indexExpr + " = ? OR " + indexExpr + " = ?"},
|
||||
{name: "GreaterThan", operator: qbtypes.FilterOperatorGreaterThan, value: float64(200), want: indexExpr + " > ?"},
|
||||
{name: "Between", operator: qbtypes.FilterOperatorBetween, value: []any{float64(200), float64(299)}, want: indexExpr + " BETWEEN ? AND ?"},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
conditions, _, err := querybuilder.Conditions(context.Background(), queryInfo, tracestelemetryschema.NewStorage(), &key, testCase.operator, testCase.value, map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {&key}}, false, sb)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conditions...)
|
||||
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
assert.Contains(t, sql, testCase.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,12 @@ import (
|
||||
type Module interface {
|
||||
ListPromotedPaths(ctx context.Context, filters promotetypes.ListPromotedPathsFilters) ([]promotetypes.PromotePath, error)
|
||||
PromotePaths(ctx context.Context, paths ...*promotetypes.PromotePath) error
|
||||
|
||||
IndexMaterializedPaths(ctx context.Context, req promotetypes.PostableIndexMaterializedPaths) (*promotetypes.IndexMaterializedPathsResult, error)
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
PromotePaths(w http.ResponseWriter, r *http.Request)
|
||||
ListPromotedPaths(w http.ResponseWriter, r *http.Request)
|
||||
IndexMaterializedPaths(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7,6 +7,7 @@ import (
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
"github.com/SigNoz/signoz/pkg/clickhousesql"
|
||||
@@ -135,6 +136,8 @@ func DataTypeCollisionHandledFieldName(key *telemetrytypes.TelemetryFieldKey, va
|
||||
// This following is one way to workaround it
|
||||
|
||||
// if the key is a number, the value is a string, we will let clickHouse handle the conversion
|
||||
// and clickhouse float64 has a 53-bit mantissa, so values above 2^53 rounds to the nearest representable double
|
||||
// breaking the equality filters for large integers
|
||||
case float32, float64:
|
||||
tblFieldName = castFloatHack(tblFieldName)
|
||||
case string:
|
||||
@@ -207,14 +210,22 @@ func ColumnIsTemporal(col *schema.Column) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func castFloat(col string) string { return fmt.Sprintf("toFloat64OrNull(%s)", col) }
|
||||
func castFloatHack(col string) string { return fmt.Sprintf("toFloat64(%s)", col) }
|
||||
func castString(col string) string { return fmt.Sprintf("toString(%s)", col) }
|
||||
func castBool(col string) string { return fmt.Sprintf("accurateCastOrNull(%s, 'Bool')", col) }
|
||||
func castFloat(col string) string { return fmt.Sprintf("toFloat64OrNull(%s)", col) }
|
||||
func castString(col string) string { return fmt.Sprintf("toString(%s)", col) }
|
||||
func castBool(col string) string { return fmt.Sprintf("accurateCastOrNull(%s, 'Bool')", col) }
|
||||
func accurateCastFloat(col string) string {
|
||||
return fmt.Sprintf("accurateCastOrNull(%s, 'Float64')", col)
|
||||
}
|
||||
|
||||
// castFloatHack leaves a read already cast to Float64 bare: the wrapper would hide it from
|
||||
// its skip index.
|
||||
func castFloatHack(col string) string {
|
||||
if strings.HasPrefix(col, "toFloat64OrNull(") {
|
||||
return col
|
||||
}
|
||||
return fmt.Sprintf("toFloat64(%s)", col)
|
||||
}
|
||||
|
||||
func allFloats(in []any) bool {
|
||||
for _, x := range in {
|
||||
if _, ok := x.(float64); !ok {
|
||||
|
||||
@@ -155,7 +155,7 @@ func NewModules(
|
||||
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
|
||||
MetricReductionRule: metricReductionRule,
|
||||
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
|
||||
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
|
||||
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore, providerSettings),
|
||||
ServiceAccount: serviceAccount,
|
||||
ServiceAccountGetter: serviceAccountGetter,
|
||||
LogsPipeline: impllogspipeline.NewModule(sqlstore),
|
||||
|
||||
@@ -38,15 +38,22 @@ var logsBodyPromotedEntry = promotetypes.NewLogsBodyTarget().Entry
|
||||
|
||||
var logsBodyIndexLookup = promotetypes.NewLogsBodyTarget().JSONIndexLookup()
|
||||
|
||||
// ClickHouse stores a `col.path::Type` skip index expression as CAST(col.path, 'Type').
|
||||
var simpleJSONSubColumnIndexExprRe = regexp.MustCompile(`^CAST\((?P<expr>[^()]+), '(?P<type>.+)'\)$`)
|
||||
// ClickHouse stores a `col.path::Type` skip index expression as CAST(col.path, 'Type');
|
||||
// a backtick-quoted path may hold any character, parentheses included.
|
||||
var simpleJSONSubColumnIndexExprRe = regexp.MustCompile("^CAST\\((?P<expr>(?:[^()`]|`[^`]*`)+), '(?P<type>.+)'\\)$")
|
||||
|
||||
// unfoldJSONSubColumnIndexExpr accepts both the folded (lower/assumeNotNull)
|
||||
// and the bare type-cast expression forms.
|
||||
// ClickHouse stores a traces number index expression with its ::String unfolded to CAST.
|
||||
var jsonNumberSubColumnIndexExprRe = regexp.MustCompile(`^toFloat64OrNull\(CAST\((?P<expr>[^()]+), 'String'\)\)$`)
|
||||
|
||||
// unfoldJSONSubColumnIndexExpr accepts the folded (lower/assumeNotNull), the
|
||||
// traces number and the bare type-cast expression forms.
|
||||
func unfoldJSONSubColumnIndexExpr(expr string) (string, string, error) {
|
||||
if columnExpr, columnType, err := schemamigrator.UnfoldJSONSubColumnIndexExpr(expr); err == nil {
|
||||
return columnExpr, columnType, nil
|
||||
}
|
||||
if matches := jsonNumberSubColumnIndexExprRe.FindStringSubmatch(expr); matches != nil {
|
||||
return matches[1], telemetrytypes.Float64.StringValue(), nil
|
||||
}
|
||||
matches := simpleJSONSubColumnIndexExprRe.FindStringSubmatch(expr)
|
||||
if matches == nil {
|
||||
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid expression: %s", expr)
|
||||
@@ -198,7 +205,8 @@ func (t *telemetryMetaStore) ListJSONIndexes(ctx context.Context, lookup telemet
|
||||
|
||||
columnExpr, columnType, err := unfoldJSONSubColumnIndexExpr(expr)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to unfold JSON sub column index expression: %s", expr)
|
||||
t.logger.WarnContext(ctx, "skipping JSON sub column index with unrecognized expression", slog.String("index", name), slog.String("expr", expr), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
|
||||
fdt, found := telemetrytypes.MappingJSONDataTypeToFieldDataType[columnType]
|
||||
|
||||
@@ -92,6 +92,17 @@ func TestUnfoldJSONSubColumnIndexExpr(t *testing.T) {
|
||||
wantColumn: "attributes_promoted.status",
|
||||
wantType: "Int64",
|
||||
},
|
||||
{
|
||||
name: "TracesNumber",
|
||||
expr: "toFloat64OrNull(CAST(attributes.`http.status_code`, 'String'))",
|
||||
wantColumn: "attributes.`http.status_code`",
|
||||
wantType: "Float64",
|
||||
},
|
||||
{
|
||||
name: "TracesNumber_NotAStringCast",
|
||||
expr: "toFloat64OrNull(CAST(attributes.a, 'Int64'))",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "UnrecognizedForm",
|
||||
expr: "toString(attributes.status)",
|
||||
@@ -137,9 +148,25 @@ func TestUnfoldJSONSubColumnIndexExprTracesRoundTrip(t *testing.T) {
|
||||
name: "PlainKey_UnquotedInStorage",
|
||||
column: "attributes_promoted",
|
||||
path: "status",
|
||||
jsonDataType: "Int64",
|
||||
wantExpr: "attributes_promoted.`status`::Int64",
|
||||
storedExpr: "CAST(attributes_promoted.status, 'Int64')",
|
||||
jsonDataType: "String",
|
||||
wantExpr: "attributes_promoted.`status`::String",
|
||||
storedExpr: "CAST(attributes_promoted.status, 'String')",
|
||||
},
|
||||
{
|
||||
name: "DottedNumber_BackticksPreservedInStorage",
|
||||
column: "attributes",
|
||||
path: "http.status_code",
|
||||
jsonDataType: "Float64",
|
||||
wantExpr: "toFloat64OrNull(attributes.`http.status_code`::String)",
|
||||
storedExpr: "toFloat64OrNull(CAST(attributes.`http.status_code`, 'String'))",
|
||||
},
|
||||
{
|
||||
name: "PlainNumber_UnquotedInStorage",
|
||||
column: "attributes_promoted",
|
||||
path: "latency",
|
||||
jsonDataType: "Float64",
|
||||
wantExpr: "toFloat64OrNull(attributes_promoted.`latency`::String)",
|
||||
storedExpr: "toFloat64OrNull(CAST(attributes_promoted.latency, 'String'))",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,76 @@ func NewTelemetryMetaStore(
|
||||
return t
|
||||
}
|
||||
|
||||
// GetMaterializedKeys sets each key's Indexes to the skip indexes built on its column alone.
|
||||
func (t *telemetryMetaStore) GetMaterializedKeys(ctx context.Context, signal telemetrytypes.Signal) ([]*telemetrytypes.TelemetryFieldKey, error) {
|
||||
var keys []*telemetrytypes.TelemetryFieldKey
|
||||
var dbName, localTableName string
|
||||
var err error
|
||||
switch signal {
|
||||
case telemetrytypes.SignalTraces:
|
||||
keys, err = t.tracesTblStatementToFieldKeys(ctx)
|
||||
dbName, localTableName = t.tracesDBName, tracestelemetryschema.SpanIndexV3LocalTableName
|
||||
case telemetrytypes.SignalLogs:
|
||||
keys, err = t.logsTblStatementToFieldKeys(ctx)
|
||||
dbName, localTableName = t.logsDBName, logstelemetryschema.LogsV2LocalTableName
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "materialized keys are not supported for signal %s", signal.StringValue())
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
indexes, err := t.listColumnSkipIndexes(ctx, signal, dbName, localTableName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, key := range keys {
|
||||
for _, index := range indexes[strings.Trim(telemetrytypes.FieldKeyToMaterializedColumnName(key), "`")] {
|
||||
index.Name = key.Name
|
||||
index.FieldContext = key.FieldContext
|
||||
index.FieldDataType = key.FieldDataType
|
||||
key.Indexes = append(key.Indexes, index)
|
||||
}
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// listColumnSkipIndexes keys each index by its expression with one pair of
|
||||
// quoting backticks removed, so only an index on a single column matches a
|
||||
// column name.
|
||||
func (t *telemetryMetaStore) listColumnSkipIndexes(ctx context.Context, signal telemetrytypes.Signal, dbName, localTableName string) (map[string][]telemetrytypes.TelemetryFieldKeySkipIndex, error) {
|
||||
ctx = withTelemetryContext(ctx, signal, "listColumnSkipIndexes")
|
||||
sb := sqlbuilder.Select("name", "type_full", "expr", "granularity").From(SkipIndexTableName)
|
||||
sb.Where(sb.Equal("database", dbName), sb.Equal("table", localTableName))
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to load skip indexes of %s.%s", dbName, localTableName)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
indexes := map[string][]telemetrytypes.TelemetryFieldKeySkipIndex{}
|
||||
for rows.Next() {
|
||||
var name, typeFull, expr string
|
||||
var granularity uint64
|
||||
if err := rows.Scan(&name, &typeFull, &expr, &granularity); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan skip index")
|
||||
}
|
||||
column := strings.TrimSuffix(strings.TrimPrefix(expr, "`"), "`")
|
||||
indexes[column] = append(indexes[column], telemetrytypes.TelemetryFieldKeySkipIndex{
|
||||
BaseColumn: column,
|
||||
IndexName: name,
|
||||
IndexType: typeFull,
|
||||
IndexExpression: expr,
|
||||
Granularity: int(granularity),
|
||||
})
|
||||
}
|
||||
|
||||
return indexes, rows.Err()
|
||||
}
|
||||
|
||||
// tracesTblStatementToFieldKeys returns materialised attribute/resource/scope keys from the traces table.
|
||||
func (t *telemetryMetaStore) tracesTblStatementToFieldKeys(ctx context.Context) ([]*telemetrytypes.TelemetryFieldKey, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
|
||||
@@ -273,7 +273,7 @@ func (m *storage) resolveColumnExprs(
|
||||
}
|
||||
case telemetrytypes.FieldContextAttribute:
|
||||
path := fmt.Sprintf("%s.%s", columnName, clickhousesql.Identifier(key.Name))
|
||||
expr, existExpr := attributeJSONValueExpr(path, key.FieldDataType)
|
||||
expr, existExpr := AttributeJSONValueExpr(path, key.FieldDataType)
|
||||
exprs = append(exprs, expr)
|
||||
existExprs = append(existExprs, existExpr)
|
||||
default:
|
||||
@@ -330,20 +330,22 @@ func attributeColumnEvolutionRegistered(key *telemetrytypes.TelemetryFieldKey, c
|
||||
return false
|
||||
}
|
||||
|
||||
// attributeJSONValueExpr renders the value expression for a span attribute read from the JSON
|
||||
// AttributeJSONValueExpr renders the value expression for a span attribute read from the JSON
|
||||
// column along with its per-type existence guard.
|
||||
// Numeric and bool gate a crash-safe accurateCastOrNull by dynamicType: the cast alone coerces
|
||||
// across domains (bool true reads 1, '200' reads 200, 200.5 reads true), so the read is
|
||||
// restricted to values stored as that type — the per-type separation the typed maps gave
|
||||
// structurally. Being NULL-capable, the gated read itself is the existence guard (present AS
|
||||
// THIS TYPE). Other reads are total (::String folds absent to ” on the raw path), so the
|
||||
// guard is presence on the raw path.
|
||||
func attributeJSONValueExpr(path string, dataType telemetrytypes.FieldDataType) (string, string) {
|
||||
// Numeric reads through the string form so that a value stored as a number or as a numeric
|
||||
// string reads alike, like the attributes_number map did. Bool gates a crash-safe
|
||||
// accurateCastOrNull by dynamicType: the cast alone coerces across domains (200.5 reads
|
||||
// true), so the read is restricted to values stored as that type — the per-type separation the
|
||||
// typed maps gave structurally. Being NULL-capable, the numeric and bool reads are their own
|
||||
// existence guard (present AS THIS TYPE). Other reads are total (::String folds absent to ” on
|
||||
// the raw path), so the guard is presence on the raw path. Skip indexes are built over the value
|
||||
// expression.
|
||||
func AttributeJSONValueExpr(path string, dataType telemetrytypes.FieldDataType) (string, string) {
|
||||
switch dataType {
|
||||
case telemetrytypes.FieldDataTypeInt64,
|
||||
telemetrytypes.FieldDataTypeFloat64,
|
||||
telemetrytypes.FieldDataTypeNumber:
|
||||
expr := fmt.Sprintf("if(dynamicType(%s) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(%s, 'Float64'), NULL)", path, path) // all numeric types to float64 like attributes_number map.
|
||||
expr := fmt.Sprintf("toFloat64OrNull(%s::String)", path)
|
||||
return expr, expr + " IS NOT NULL"
|
||||
case telemetrytypes.FieldDataTypeBool:
|
||||
expr := fmt.Sprintf("if(dynamicType(%s) = 'Bool', accurateCastOrNull(%s, 'Bool'), NULL)", path, path)
|
||||
|
||||
@@ -57,9 +57,9 @@ func TestFieldForAttributeJSONEvolution(t *testing.T) {
|
||||
{"string after -> json", telemetrytypes.FieldDataTypeString, attrWindowAfter, "attributes.`user.id`::String"},
|
||||
{"string straddle -> dual", telemetrytypes.FieldDataTypeString, attrWindowStraddle, "multiIf(attributes.`user.id` IS NOT NULL, attributes.`user.id`::String, mapContains(attributes_string, 'user.id'), attributes_string['user.id'], NULL)"},
|
||||
{"number before -> map", telemetrytypes.FieldDataTypeNumber, attrWindowBefore, "attributes_number['user.id']"},
|
||||
{"number after -> json", telemetrytypes.FieldDataTypeNumber, attrWindowAfter, "if(dynamicType(attributes.`user.id`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`user.id`, 'Float64'), NULL)"},
|
||||
{"number straddle -> dual", telemetrytypes.FieldDataTypeNumber, attrWindowStraddle, "multiIf(if(dynamicType(attributes.`user.id`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`user.id`, 'Float64'), NULL) IS NOT NULL, if(dynamicType(attributes.`user.id`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`user.id`, 'Float64'), NULL), mapContains(attributes_number, 'user.id'), attributes_number['user.id'], NULL)"},
|
||||
{"int64 after -> json", telemetrytypes.FieldDataTypeInt64, attrWindowAfter, "if(dynamicType(attributes.`user.id`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`user.id`, 'Float64'), NULL)"},
|
||||
{"number after -> json", telemetrytypes.FieldDataTypeNumber, attrWindowAfter, "toFloat64OrNull(attributes.`user.id`::String)"},
|
||||
{"number straddle -> dual", telemetrytypes.FieldDataTypeNumber, attrWindowStraddle, "multiIf(toFloat64OrNull(attributes.`user.id`::String) IS NOT NULL, toFloat64OrNull(attributes.`user.id`::String), mapContains(attributes_number, 'user.id'), attributes_number['user.id'], NULL)"},
|
||||
{"int64 after -> json", telemetrytypes.FieldDataTypeInt64, attrWindowAfter, "toFloat64OrNull(attributes.`user.id`::String)"},
|
||||
{"bool before -> map", telemetrytypes.FieldDataTypeBool, attrWindowBefore, "attributes_bool['user.id']"},
|
||||
{"bool after -> json", telemetrytypes.FieldDataTypeBool, attrWindowAfter, "if(dynamicType(attributes.`user.id`) = 'Bool', accurateCastOrNull(attributes.`user.id`, 'Bool'), NULL)"},
|
||||
{"bool straddle -> dual", telemetrytypes.FieldDataTypeBool, attrWindowStraddle, "multiIf(if(dynamicType(attributes.`user.id`) = 'Bool', accurateCastOrNull(attributes.`user.id`, 'Bool'), NULL) IS NOT NULL, if(dynamicType(attributes.`user.id`) = 'Bool', accurateCastOrNull(attributes.`user.id`, 'Bool'), NULL), mapContains(attributes_bool, 'user.id'), attributes_bool['user.id'], NULL)"},
|
||||
@@ -180,7 +180,7 @@ func TestConditionForAttributeJSON(t *testing.T) {
|
||||
name: "greater than number",
|
||||
key: attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo),
|
||||
operator: qbtypes.FilterOperatorGreaterThan, value: float64(200),
|
||||
expected: "toFloat64(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL)) > ?",
|
||||
expected: "toFloat64OrNull(attributes.`http.status_code`::String) > ?",
|
||||
},
|
||||
{
|
||||
name: "ilike string",
|
||||
@@ -216,7 +216,31 @@ func TestConditionForAttributeJSON(t *testing.T) {
|
||||
name: "between number",
|
||||
key: attrKey("latency", telemetrytypes.FieldDataTypeNumber, evo),
|
||||
operator: qbtypes.FilterOperatorBetween, value: []any{float64(1), float64(9)},
|
||||
expected: "toFloat64(if(dynamicType(attributes.`latency`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`latency`, 'Float64'), NULL)) BETWEEN ? AND ?",
|
||||
expected: "toFloat64OrNull(attributes.`latency`::String) BETWEEN ? AND ?",
|
||||
},
|
||||
{
|
||||
name: "In_Number",
|
||||
key: attrKey("http.status_code", telemetrytypes.FieldDataTypeNumber, evo),
|
||||
operator: qbtypes.FilterOperatorIn, value: []any{float64(200), float64(404)},
|
||||
expected: "(toFloat64OrNull(attributes.`http.status_code`::String) = ? OR toFloat64OrNull(attributes.`http.status_code`::String) = ?)",
|
||||
},
|
||||
{
|
||||
name: "GreaterThan_NumericString",
|
||||
key: attrKey("http.status_code", telemetrytypes.FieldDataTypeNumber, evo),
|
||||
operator: qbtypes.FilterOperatorGreaterThan, value: "200",
|
||||
expected: "toFloat64OrNull(attributes.`http.status_code`::String) > ?",
|
||||
},
|
||||
{
|
||||
name: "Equal_NonNumericString_CastsToString",
|
||||
key: attrKey("http.status_code", telemetrytypes.FieldDataTypeNumber, evo),
|
||||
operator: qbtypes.FilterOperatorEqual, value: "ok",
|
||||
expected: "toString(toFloat64OrNull(attributes.`http.status_code`::String)) = ?",
|
||||
},
|
||||
{
|
||||
name: "Equal_Bool_CastsToString",
|
||||
key: attrKey("http.status_code", telemetrytypes.FieldDataTypeNumber, evo),
|
||||
operator: qbtypes.FilterOperatorEqual, value: true,
|
||||
expected: "toString(toFloat64OrNull(attributes.`http.status_code`::String)) = ?",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -271,7 +295,7 @@ func TestColumnExpressionForAttributeJSON(t *testing.T) {
|
||||
key := attrKey("latency", telemetrytypes.FieldDataTypeNumber, evo)
|
||||
got, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &key, telemetrytypes.FieldDataTypeFloat64, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "multiIf(if(dynamicType(attributes.`latency`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`latency`, 'Float64'), NULL) IS NOT NULL, toFloat64(if(dynamicType(attributes.`latency`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`latency`, 'Float64'), NULL)), mapContains(attributes_number, 'attribute.latency'), toFloat64(attributes_number['attribute.latency']), NULL)", got)
|
||||
assert.Equal(t, "multiIf(toFloat64OrNull(attributes.`latency`::String) IS NOT NULL, toFloat64OrNull(attributes.`latency`::String), mapContains(attributes_number, 'attribute.latency'), toFloat64(attributes_number['attribute.latency']), NULL)", got)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -314,7 +338,7 @@ func TestConditionForAttributeJSONTypeCollision(t *testing.T) {
|
||||
sb.Where(sb.Or(conds...))
|
||||
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
assert.Contains(t, sql, "toFloat64OrNull(attributes.`http.status_code`::String) = ?")
|
||||
assert.Contains(t, sql, "toFloat64(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL)) = ?")
|
||||
assert.Contains(t, sql, "toFloat64OrNull(attributes.`http.status_code`::String) = ?")
|
||||
assert.Contains(t, sql, "attributes.`http.status_code` IS NOT NULL")
|
||||
assert.NotEmpty(t, warnings, "a colliding name must surface the ambiguity warning")
|
||||
}
|
||||
@@ -339,7 +363,7 @@ func TestColumnExpressionForAttributeJSONTypeCollision(t *testing.T) {
|
||||
got, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &ref, telemetrytypes.FieldDataTypeString, fieldKeys)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
"multiIf(attributes.`http.status_code` IS NOT NULL, attributes.`http.status_code`::String, if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL) IS NOT NULL, toString(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL)), NULL)",
|
||||
"multiIf(attributes.`http.status_code` IS NOT NULL, attributes.`http.status_code`::String, toFloat64OrNull(attributes.`http.status_code`::String) IS NOT NULL, toString(toFloat64OrNull(attributes.`http.status_code`::String)), NULL)",
|
||||
got)
|
||||
}
|
||||
|
||||
@@ -363,7 +387,7 @@ func TestColumnExpressionForAttributeJSONTypeCollisionNumericAgg(t *testing.T) {
|
||||
got, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &ref, telemetrytypes.FieldDataTypeFloat64, fieldKeys)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
"multiIf(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL) IS NOT NULL, toFloat64(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL)), attributes.`http.status_code` IS NOT NULL, toFloat64OrNull(attributes.`http.status_code`::String), NULL)",
|
||||
"multiIf(toFloat64OrNull(attributes.`http.status_code`::String) IS NOT NULL, toFloat64OrNull(attributes.`http.status_code`::String), attributes.`http.status_code` IS NOT NULL, toFloat64OrNull(attributes.`http.status_code`::String), NULL)",
|
||||
got)
|
||||
}
|
||||
|
||||
@@ -409,7 +433,7 @@ func TestColumnForUnspecifiedAttributeNoBranchFlip(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestConditionForAttributeJSONNegativeOperatorParity pins Map parity for numeric/bool attributes.
|
||||
// The value reads an absent key as NULL (accurateCastOrNull, or the straddle multiIf else); a
|
||||
// The value reads an absent key as NULL (toFloat64OrNull, or the straddle multiIf else); a
|
||||
// positive operator excludes such a row via the exists guard, but a negative operator has no guard,
|
||||
// so the condition builder folds the NULL to the Map's type zero (ifNull) for negatives only.
|
||||
// String needs no fold — ::String already reads absent as ”. The fold rides the attributes
|
||||
@@ -432,7 +456,7 @@ func TestConditionForAttributeJSONNegativeOperatorParity(t *testing.T) {
|
||||
t.Run("not equal number after -> NULL folded to 0", func(t *testing.T) {
|
||||
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
|
||||
sql := build(t, key, attrWindowAfter, qbtypes.FilterOperatorNotEqual, float64(200))
|
||||
assert.Contains(t, sql, "toFloat64(ifNull(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL), 0)) <> ?")
|
||||
assert.Contains(t, sql, "toFloat64(ifNull(toFloat64OrNull(attributes.`http.status_code`::String), 0)) <> ?")
|
||||
})
|
||||
|
||||
t.Run("not equal bool after -> NULL folded to false", func(t *testing.T) {
|
||||
@@ -447,27 +471,27 @@ func TestConditionForAttributeJSONNegativeOperatorParity(t *testing.T) {
|
||||
t.Run("not equal number after, string value -> string cast over folded read, no type mismatch", func(t *testing.T) {
|
||||
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
|
||||
sql := build(t, key, attrWindowAfter, qbtypes.FilterOperatorNotEqual, "teapot")
|
||||
assert.Contains(t, sql, "toString(ifNull(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL), 0)) <> ?")
|
||||
assert.Contains(t, sql, "toString(ifNull(toFloat64OrNull(attributes.`http.status_code`::String), 0)) <> ?")
|
||||
assert.NotContains(t, sql, "ifNull(toString(")
|
||||
})
|
||||
|
||||
t.Run("equal number after -> not folded, exists guard excludes absent", func(t *testing.T) {
|
||||
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
|
||||
sql := build(t, key, attrWindowAfter, qbtypes.FilterOperatorEqual, float64(0))
|
||||
assert.Contains(t, sql, "toFloat64(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL)) = ?")
|
||||
assert.Contains(t, sql, "toFloat64OrNull(attributes.`http.status_code`::String) = ?")
|
||||
assert.NotContains(t, sql, "ifNull")
|
||||
})
|
||||
|
||||
t.Run("not in number after -> each operand folded to 0", func(t *testing.T) {
|
||||
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
|
||||
sql := build(t, key, attrWindowAfter, qbtypes.FilterOperatorNotIn, []any{float64(200), float64(404)})
|
||||
assert.Contains(t, sql, "(toFloat64(ifNull(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL), 0)) <> ? AND toFloat64(ifNull(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL), 0)) <> ?)")
|
||||
assert.Contains(t, sql, "(toFloat64(ifNull(toFloat64OrNull(attributes.`http.status_code`::String), 0)) <> ? AND toFloat64(ifNull(toFloat64OrNull(attributes.`http.status_code`::String), 0)) <> ?)")
|
||||
})
|
||||
|
||||
t.Run("not equal number straddle -> whole multiIf folded", func(t *testing.T) {
|
||||
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
|
||||
sql := build(t, key, attrWindowStraddle, qbtypes.FilterOperatorNotEqual, float64(200))
|
||||
assert.Contains(t, sql, "toFloat64(ifNull(multiIf(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL) IS NOT NULL, if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL), mapContains(attributes_number, 'http.status_code'), attributes_number['http.status_code'], NULL), 0)) <> ?")
|
||||
assert.Contains(t, sql, "toFloat64(ifNull(multiIf(toFloat64OrNull(attributes.`http.status_code`::String) IS NOT NULL, toFloat64OrNull(attributes.`http.status_code`::String), mapContains(attributes_number, 'http.status_code'), attributes_number['http.status_code'], NULL), 0)) <> ?")
|
||||
})
|
||||
|
||||
t.Run("not equal number before -> harmless fold over the map read", func(t *testing.T) {
|
||||
@@ -509,7 +533,7 @@ func TestConditionForAttributeJSONStraddleAbsentKeyExclusion(t *testing.T) {
|
||||
return sql
|
||||
}
|
||||
|
||||
guard := "multiIf(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL) IS NOT NULL, if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL), mapContains(attributes_number, 'http.status_code'), attributes_number['http.status_code'], NULL) IS NOT NULL"
|
||||
guard := "multiIf(toFloat64OrNull(attributes.`http.status_code`::String) IS NOT NULL, toFloat64OrNull(attributes.`http.status_code`::String), mapContains(attributes_number, 'http.status_code'), attributes_number['http.status_code'], NULL) IS NOT NULL"
|
||||
|
||||
t.Run("equal zero takes no guard: the absent read is NULL", func(t *testing.T) {
|
||||
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
|
||||
|
||||
49
pkg/types/promotetypes/materialized.go
Normal file
49
pkg/types/promotetypes/materialized.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package promotetypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
type IndexMaterializedPathsResult struct {
|
||||
Indexed []*PromotePath `json:"indexed" required:"true"`
|
||||
Skipped []*PromotePath `json:"skipped" required:"true"`
|
||||
}
|
||||
|
||||
type PostableIndexMaterializedPaths struct {
|
||||
Signal string `json:"signal" required:"true"`
|
||||
DryRun bool `json:"dryRun"`
|
||||
}
|
||||
|
||||
func (p *PostableIndexMaterializedPaths) Validate() error {
|
||||
if p.Signal == "" {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "signal is required")
|
||||
}
|
||||
_, err := p.Target()
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *PostableIndexMaterializedPaths) Target() (Target, error) {
|
||||
return NewTargetFromText(p.Signal, telemetrytypes.FieldContextAttribute.StringValue())
|
||||
}
|
||||
|
||||
// NewMaterializedPromotePath mirrors the type and granularity of the key's
|
||||
// column indexes. Only the indexes are validated: the key was materialized on
|
||||
// purpose, so the path checks for user input, such as cardinality, do not apply.
|
||||
func NewMaterializedPromotePath(target Target, key *telemetrytypes.TelemetryFieldKey) (*PromotePath, error) {
|
||||
indexes := make([]WrappedIndex, 0, len(key.Indexes))
|
||||
for _, index := range key.Indexes {
|
||||
indexes = append(indexes, WrappedIndex{FieldDataType: key.FieldDataType, Type: index.IndexType, Granularity: index.Granularity})
|
||||
}
|
||||
path := &PromotePath{
|
||||
Signal: target.Entry.Signal.StringValue(),
|
||||
Context: target.Entry.FieldContext.StringValue(),
|
||||
Path: key.Name,
|
||||
Promote: false,
|
||||
Indexes: indexes,
|
||||
}
|
||||
if err := path.validateAndSetIndexDefaults(target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
101
pkg/types/promotetypes/materialized_test.go
Normal file
101
pkg/types/promotetypes/materialized_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package promotetypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
func TestNewMaterializedPromotePath(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
key *telemetrytypes.TelemetryFieldKey
|
||||
wantIndexes []WrappedIndex
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "StringPath_MirrorsColumnIndex",
|
||||
key: &telemetrytypes.TelemetryFieldKey{Name: "user.id", FieldDataType: telemetrytypes.FieldDataTypeString, Indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{
|
||||
{IndexType: "bloom_filter", Granularity: 4},
|
||||
}},
|
||||
wantIndexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "bloom_filter", Granularity: 4, JSONDataType: telemetrytypes.String}},
|
||||
},
|
||||
{
|
||||
name: "StringPath_MirrorsEveryColumnIndex",
|
||||
key: &telemetrytypes.TelemetryFieldKey{Name: "tenant.id", FieldDataType: telemetrytypes.FieldDataTypeString, Indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{
|
||||
{IndexType: "tokenbf_v1(10240, 3, 0)", Granularity: 1},
|
||||
{IndexType: "bloom_filter(0.01)", Granularity: 2},
|
||||
}},
|
||||
wantIndexes: []WrappedIndex{
|
||||
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "tokenbf_v1(10240, 3, 0)", Granularity: 1, JSONDataType: telemetrytypes.String},
|
||||
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "bloom_filter(0.01)", Granularity: 2, JSONDataType: telemetrytypes.String},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NumberPath_Float64Index",
|
||||
key: &telemetrytypes.TelemetryFieldKey{Name: "retry.count", FieldDataType: telemetrytypes.FieldDataTypeNumber, Indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{
|
||||
{IndexType: "minmax", Granularity: 1},
|
||||
}},
|
||||
wantIndexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeNumber, Type: "minmax", Granularity: 1, JSONDataType: telemetrytypes.Float64}},
|
||||
},
|
||||
{
|
||||
name: "UnsupportedIndexType_Rejected",
|
||||
key: &telemetrytypes.TelemetryFieldKey{Name: "user.email", FieldDataType: telemetrytypes.FieldDataTypeString, Indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{
|
||||
{IndexType: "text(tokenizer = 'splitByNonAlpha')", Granularity: 1},
|
||||
}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "BoolPath_Rejected",
|
||||
key: &telemetrytypes.TelemetryFieldKey{Name: "user.active", FieldDataType: telemetrytypes.FieldDataTypeBool, Indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{
|
||||
{IndexType: "bloom_filter", Granularity: 1},
|
||||
}},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
path, err := NewMaterializedPromotePath(NewTracesAttributesTarget(), testCase.key)
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, &PromotePath{
|
||||
Signal: "traces",
|
||||
Context: "attribute",
|
||||
Path: testCase.key.Name,
|
||||
Promote: false,
|
||||
Indexes: testCase.wantIndexes,
|
||||
}, path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostableIndexMaterializedPathsValidate(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
req PostableIndexMaterializedPaths
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "TracesSignal_Valid", req: PostableIndexMaterializedPaths{Signal: "traces", DryRun: true}},
|
||||
{name: "MissingSignal_Rejected", req: PostableIndexMaterializedPaths{}, wantErr: true},
|
||||
{name: "UnknownSignal_Rejected", req: PostableIndexMaterializedPaths{Signal: "events"}, wantErr: true},
|
||||
{name: "LogsSignal_NoAttributeTarget_Rejected", req: PostableIndexMaterializedPaths{Signal: "logs"}, wantErr: true},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
err := testCase.req.Validate()
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,15 @@ func ListPromotedPathsResources(ec coretypes.ExtractorContext) ([]coretypes.Reso
|
||||
return fieldResources(signals)
|
||||
}
|
||||
|
||||
func IndexMaterializedPathsResources(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
|
||||
signalText := gjson.GetBytes(ec.RequestBody, "signal").String()
|
||||
signal, ok := telemetrytypes.SignalFromText(signalText)
|
||||
if !ok {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid signal: %s", signalText)
|
||||
}
|
||||
return fieldResources([]telemetrytypes.Signal{signal})
|
||||
}
|
||||
|
||||
func fieldResources(signals []telemetrytypes.Signal) ([]coretypes.ResourceWithID, error) {
|
||||
resources := make([]coretypes.ResourceWithID, 0, len(signals))
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
@@ -112,3 +112,45 @@ func TestListPromotedPathsResources(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexMaterializedPathsResources(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
want []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "TracesSignal_TracesField",
|
||||
body: `{"signal":"traces","dryRun":true}`,
|
||||
want: []string{"traces-field"},
|
||||
},
|
||||
{
|
||||
name: "LogsSignal_LogsField",
|
||||
body: `{"signal":"logs"}`,
|
||||
want: []string{"logs-field"},
|
||||
},
|
||||
{
|
||||
name: "MissingSignal_Rejected",
|
||||
body: `{}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "UnsupportedSignal_Rejected",
|
||||
body: `{"signal":"metrics"}`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
resources, err := IndexMaterializedPathsResources(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.want, resourceKinds(resources))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,14 +27,24 @@ func (t Target) BaseColumnPrefix() string { return t.BaseColumn + "." }
|
||||
func (t Target) PromotedColumnPrefix() string { return t.PromotedColumn() + "." }
|
||||
|
||||
// IndexExpression folds logs strings to lower case over assumeNotNull for
|
||||
// case-insensitive LIKE searches; traces indexes are a bare type cast.
|
||||
// case-insensitive LIKE searches; traces indexes are the querier's read, which
|
||||
// an index matches only as that exact text.
|
||||
func (t Target) IndexExpression(column, path, jsonDataType string) string {
|
||||
switch t.Entry.Signal {
|
||||
case telemetrytypes.SignalLogs:
|
||||
if t.Entry.Signal == telemetrytypes.SignalLogs {
|
||||
return schemamigrator.JSONSubColumnIndexExpr(column, path, jsonDataType)
|
||||
default:
|
||||
return simpleJSONSubColumnIndexExpr(column, path, jsonDataType)
|
||||
}
|
||||
subColumn := fmt.Sprintf("%s.%s", column, clickhousesql.Identifier(path))
|
||||
expr, _ := tracestelemetryschema.AttributeJSONValueExpr(subColumn, telemetrytypes.MappingJSONDataTypeToFieldDataType[jsonDataType])
|
||||
return expr
|
||||
}
|
||||
|
||||
// IndexDataType folds traces Int64 into Float64, the one type the querier
|
||||
// reads every traces number as.
|
||||
func (t Target) IndexDataType(jsonDataType telemetrytypes.JSONDataType) telemetrytypes.JSONDataType {
|
||||
if t.Entry.Signal != telemetrytypes.SignalLogs && jsonDataType == telemetrytypes.Int64 {
|
||||
return telemetrytypes.Float64
|
||||
}
|
||||
return jsonDataType
|
||||
}
|
||||
|
||||
func (t Target) JSONIndexLookup() telemetrytypes.JSONIndexLookup {
|
||||
@@ -121,14 +131,6 @@ func TargetFor(signal telemetrytypes.Signal, context telemetrytypes.FieldContext
|
||||
return Target{}, false
|
||||
}
|
||||
|
||||
// simpleJSONSubColumnIndexExpr renders column.`path`::Type with the path
|
||||
// quoted as one identifier, the form trace queries read the sub-column with;
|
||||
// the cast unwraps the Nullable the sub-column access returns, which bloom
|
||||
// filter indexes reject.
|
||||
func simpleJSONSubColumnIndexExpr(column, path, jsonDataType string) string {
|
||||
return fmt.Sprintf("%s.%s::%s", column, clickhousesql.Identifier(path), jsonDataType)
|
||||
}
|
||||
|
||||
// reservedPathPrefix returns the target prefix path carries, if any: the base
|
||||
// or promoted column prefix, or the logs body search alias, which is stripped
|
||||
// from queries before metadata lookup and can never match.
|
||||
|
||||
@@ -3,6 +3,7 @@ package promotetypes
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -47,6 +48,22 @@ func TestIndexExpression(t *testing.T) {
|
||||
jsonDataType: "String",
|
||||
want: "attributes.`user-name`::String",
|
||||
},
|
||||
{
|
||||
name: "TracesFloat64_QuerierNumberRead",
|
||||
target: NewTracesAttributesTarget(),
|
||||
column: "attributes",
|
||||
path: "http.status_code",
|
||||
jsonDataType: "Float64",
|
||||
want: "toFloat64OrNull(attributes.`http.status_code`::String)",
|
||||
},
|
||||
{
|
||||
name: "TracesInt64_QuerierNumberRead",
|
||||
target: NewTracesAttributesTarget(),
|
||||
column: "attributes_promoted",
|
||||
path: "retry.count",
|
||||
jsonDataType: "Int64",
|
||||
want: "toFloat64OrNull(attributes_promoted.`retry.count`::String)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
@@ -55,3 +72,22 @@ func TestIndexExpression(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexDataType(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
target Target
|
||||
jsonDataType telemetrytypes.JSONDataType
|
||||
want telemetrytypes.JSONDataType
|
||||
}{
|
||||
{name: "TracesInt64_Float64", target: NewTracesAttributesTarget(), jsonDataType: telemetrytypes.Int64, want: telemetrytypes.Float64},
|
||||
{name: "TracesString_Unchanged", target: NewTracesAttributesTarget(), jsonDataType: telemetrytypes.String, want: telemetrytypes.String},
|
||||
{name: "LogsInt64_Unchanged", target: NewLogsBodyTarget(), jsonDataType: telemetrytypes.Int64, want: telemetrytypes.Int64},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.want, testCase.target.IndexDataType(testCase.jsonDataType))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,29 +114,7 @@ func (i *PromotePath) ValidateAndSetDefaults(target Target) error {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cardinal paths can not be promoted or indexed")
|
||||
}
|
||||
|
||||
for idx, index := range i.Indexes {
|
||||
if index.Type == "" {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "index type is required")
|
||||
}
|
||||
if err := validateIndexType(index.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
if index.Granularity <= 0 {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "index granularity must be greater than 0")
|
||||
}
|
||||
|
||||
jsonDataType, ok := telemetrytypes.MappingFieldDataTypeToJSONDataType[index.FieldDataType]
|
||||
if !ok {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid column type: %s", index.FieldDataType)
|
||||
}
|
||||
if !jsonDataType.IndexSupported {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "index is not supported for column type: %s", index.FieldDataType)
|
||||
}
|
||||
|
||||
i.Indexes[idx].JSONDataType = jsonDataType
|
||||
}
|
||||
|
||||
return nil
|
||||
return i.validateAndSetIndexDefaults(target)
|
||||
}
|
||||
|
||||
func (index WrappedIndex) SkipIndexType() (schemamigrator.IndexType, error) {
|
||||
@@ -222,3 +200,29 @@ func validateIndexType(indexType string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *PromotePath) validateAndSetIndexDefaults(target Target) error {
|
||||
for idx, index := range i.Indexes {
|
||||
if index.Type == "" {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "index type is required")
|
||||
}
|
||||
if err := validateIndexType(index.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
if index.Granularity <= 0 {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "index granularity must be greater than 0")
|
||||
}
|
||||
|
||||
jsonDataType, ok := telemetrytypes.MappingFieldDataTypeToJSONDataType[index.FieldDataType]
|
||||
if !ok {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid column type: %s", index.FieldDataType)
|
||||
}
|
||||
if !jsonDataType.IndexSupported {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "index is not supported for column type: %s", index.FieldDataType)
|
||||
}
|
||||
|
||||
i.Indexes[idx].JSONDataType = target.IndexDataType(jsonDataType)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ type MetadataStore interface {
|
||||
// rows templated by entry; FieldName and ReleaseTime are set per path.
|
||||
PromotePaths(ctx context.Context, entry EvolutionEntry, paths ...string) error
|
||||
|
||||
// GetMaterializedKeys lists the keys materialized as dedicated columns.
|
||||
GetMaterializedKeys(ctx context.Context, signal Signal) ([]*TelemetryFieldKey, error)
|
||||
|
||||
// GetFirstSeenFromMetricMetadata gets the first seen timestamp for a metric metadata lookup key.
|
||||
GetFirstSeenFromMetricMetadata(ctx context.Context, lookupKeys []MetricMetadataLookupKey) (map[MetricMetadataLookupKey]int64, error)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ type MockMetadataStore struct {
|
||||
ReducedMap map[string]bool
|
||||
PromotedPathsMap map[telemetrytypes.Signal]map[string]bool
|
||||
LogsJSONIndexes []telemetrytypes.TelemetryFieldKeySkipIndex
|
||||
MaterializedKeys []*telemetrytypes.TelemetryFieldKey
|
||||
ColumnEvolutionMetadataMap map[string][]*telemetrytypes.EvolutionEntry
|
||||
LookupKeysMap map[telemetrytypes.MetricMetadataLookupKey]int64
|
||||
// StaticFields holds signal-specific intrinsic field definitions (e.g. logstelemetryschema.IntrinsicFields).
|
||||
@@ -376,6 +377,16 @@ func (m *MockMetadataStore) GetPromotedPaths(_ context.Context, entry telemetryt
|
||||
return m.PromotedPathsMap[entry.Signal], nil
|
||||
}
|
||||
|
||||
func (m *MockMetadataStore) GetMaterializedKeys(_ context.Context, signal telemetrytypes.Signal) ([]*telemetrytypes.TelemetryFieldKey, error) {
|
||||
keys := []*telemetrytypes.TelemetryFieldKey{}
|
||||
for _, key := range m.MaterializedKeys {
|
||||
if key.Signal == signal {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// ListJSONIndexes narrows the stored indexes to the lookup's field context like the real query.
|
||||
func (m *MockMetadataStore) ListJSONIndexes(ctx context.Context, lookup telemetrytypes.JSONIndexLookup, filters ...string) ([]telemetrytypes.TelemetryFieldKeySkipIndex, error) {
|
||||
indexes := []telemetrytypes.TelemetryFieldKeySkipIndex{}
|
||||
|
||||
@@ -32,6 +32,7 @@ pytest_plugins = [
|
||||
"fixtures.alerts",
|
||||
"fixtures.cloudintegrations",
|
||||
"fixtures.jsontypes",
|
||||
"fixtures.materialized",
|
||||
"fixtures.seeder",
|
||||
"fixtures.serviceaccount",
|
||||
"fixtures.role",
|
||||
|
||||
44
tests/fixtures/materialized.py
vendored
Normal file
44
tests/fixtures/materialized.py
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
from collections.abc import Callable, Generator
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
|
||||
# signal -> (database, local table, distributed table)
|
||||
MATERIALIZED_TABLES = {
|
||||
"traces": ("signoz_traces", "signoz_index_v3", "distributed_signoz_index_v3"),
|
||||
}
|
||||
|
||||
# data type -> (column type, attribute map)
|
||||
MATERIALIZED_COLUMN_TYPES = {
|
||||
"string": ("LowCardinality(String)", "attributes_string"),
|
||||
"number": ("Float64", "attributes_number"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(name="materialize_attribute", scope="function")
|
||||
def materialize_attribute(signoz: types.SigNoz) -> Generator[Callable[..., str]]:
|
||||
"""Teardown drops the columns and every index whose expression mentions a materialized key."""
|
||||
cluster = signoz.telemetrystore.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
|
||||
created: list[tuple[str, str, str]] = []
|
||||
|
||||
def materialize(signal: str, key: str, indexed: bool, data_type: str = "string") -> str:
|
||||
database, local_table, distributed_table = MATERIALIZED_TABLES[signal]
|
||||
column_type, attribute_map = MATERIALIZED_COLUMN_TYPES[data_type]
|
||||
column = f"attribute_{data_type}_{key.replace('.', '$$')}"
|
||||
created.append((signal, key, column))
|
||||
for table in (local_table, distributed_table):
|
||||
signoz.telemetrystore.conn.query(f"ALTER TABLE {database}.{table} ON CLUSTER '{cluster}' ADD COLUMN IF NOT EXISTS `{column}` {column_type} DEFAULT {attribute_map}['{key}']")
|
||||
if indexed:
|
||||
signoz.telemetrystore.conn.query(f"ALTER TABLE {database}.{local_table} ON CLUSTER '{cluster}' ADD INDEX IF NOT EXISTS `{column}_idx` `{column}` TYPE bloom_filter(0.01) GRANULARITY 64")
|
||||
return column
|
||||
|
||||
yield materialize
|
||||
|
||||
for signal, key, column in created:
|
||||
database, local_table, distributed_table = MATERIALIZED_TABLES[signal]
|
||||
indexes = signoz.telemetrystore.conn.query(f"SELECT name FROM system.data_skipping_indices WHERE database = '{database}' AND table = '{local_table}' AND (expr LIKE '%{key}%' OR expr LIKE '%{column}%')").result_rows
|
||||
for (index_name,) in indexes:
|
||||
signoz.telemetrystore.conn.query(f"ALTER TABLE {database}.{local_table} ON CLUSTER '{cluster}' DROP INDEX IF EXISTS `{index_name}`")
|
||||
for table in (local_table, distributed_table):
|
||||
signoz.telemetrystore.conn.query(f"ALTER TABLE {database}.{table} ON CLUSTER '{cluster}' DROP COLUMN IF EXISTS `{column}`")
|
||||
101
tests/integration/tests/promote/02_materialized.py
Normal file
101
tests/integration/tests/promote/02_materialized.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.traces import Traces
|
||||
|
||||
PROMOTED_PATHS = "/api/v1/promoted_paths"
|
||||
MATERIALIZED_PATH = "/api/v1/promoted_paths/materialized"
|
||||
PROBE_INDEX_EXPRS_QUERY = "SELECT expr FROM system.data_skipping_indices WHERE database = 'signoz_traces' AND table = 'signoz_index_v3' AND expr LIKE 'CAST(%materialized.%probe%'"
|
||||
|
||||
|
||||
def test_index_materialized_paths(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
materialize_attribute: Callable[..., str],
|
||||
) -> None:
|
||||
materialize_attribute("traces", "materialized.probe", True)
|
||||
materialize_attribute("traces", "materialized.unindexed_probe", False)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(MATERIALIZED_PATH),
|
||||
json={"signal": "traces", "dryRun": True},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
indexed = {path["path"]: path["indexes"] for path in resp.json()["data"]["indexed"]}
|
||||
assert indexed["materialized.probe"] == [{"fieldDataType": "string", "type": "bloom_filter(0.01)", "granularity": 64}]
|
||||
assert "materialized.unindexed_probe" not in indexed
|
||||
assert not signoz.telemetrystore.conn.query(PROBE_INDEX_EXPRS_QUERY).result_rows
|
||||
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(MATERIALIZED_PATH),
|
||||
json={"signal": "logs"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.BAD_REQUEST, resp.text
|
||||
assert not signoz.telemetrystore.conn.query(PROBE_INDEX_EXPRS_QUERY).result_rows
|
||||
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(MATERIALIZED_PATH),
|
||||
json={"signal": "traces"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
assert "materialized.probe" in {path["path"] for path in resp.json()["data"]["indexed"]}
|
||||
assert [row[0] for row in signoz.telemetrystore.conn.query(PROBE_INDEX_EXPRS_QUERY).result_rows] == ["CAST(attributes.`materialized.probe`, 'String')"]
|
||||
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(MATERIALIZED_PATH),
|
||||
json={"signal": "traces"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
assert "materialized.probe" not in {path["path"] for path in resp.json()["data"]["indexed"]}
|
||||
assert "materialized.probe" in {path["path"] for path in resp.json()["data"]["skipped"]}
|
||||
|
||||
|
||||
def test_index_materialized_number_path(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
materialize_attribute: Callable[..., str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
materialize_attribute("traces", "materialized.number_probe", True, "number")
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(MATERIALIZED_PATH),
|
||||
json={"signal": "traces"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
indexed = {path["path"]: path["indexes"] for path in resp.json()["data"]["indexed"]}
|
||||
assert indexed["materialized.number_probe"] == [{"fieldDataType": "number", "type": "bloom_filter(0.01)", "granularity": 64}]
|
||||
exprs = signoz.telemetrystore.conn.query("SELECT expr FROM system.data_skipping_indices WHERE database = 'signoz_traces' AND table = 'signoz_index_v3' AND expr LIKE '%materialized.number_probe%'").result_rows
|
||||
assert [row[0] for row in exprs] == ["toFloat64OrNull(CAST(attributes.`materialized.number_probe`, 'String'))"]
|
||||
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(PROMOTED_PATHS),
|
||||
params={"signal": "traces", "context": "attribute"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
listed = {path["path"]: path.get("indexes") for path in resp.json()["data"]}
|
||||
assert listed["materialized.number_probe"] == [{"fieldDataType": "float64", "type": "bloom_filter(0.01)", "granularity": 64}]
|
||||
|
||||
# a non-numeric value must not fail the insert on the numeric index
|
||||
insert_traces([Traces(attributes={"materialized.number_probe": 200}), Traces(attributes={"materialized.number_probe": "not-a-number"})])
|
||||
assert signoz.telemetrystore.conn.query("SELECT count() FROM signoz_traces.distributed_signoz_index_v3 WHERE attributes.`materialized.number_probe` IS NOT NULL").result_rows == [(2,)]
|
||||
@@ -335,6 +335,63 @@ def test_traces_attributes_json_collision_and_map_parity(
|
||||
assert set(series.keys()) == expected, label
|
||||
|
||||
|
||||
def test_traces_attributes_json_number_reads(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
seed_attribute_evolution: Callable[[str, datetime], None],
|
||||
) -> None:
|
||||
"""A JSON number reads like the attributes_number map: a numeric string as its number, an
|
||||
integer above 2^53 rounded, and a non-numeric string as no number."""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
evolution_time = datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=30)
|
||||
seed_attribute_evolution("traces", evolution_time)
|
||||
|
||||
hit = evolution_time + timedelta(minutes=5)
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=hit,
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name=route,
|
||||
attributes={"http.route": route, "app.code": code},
|
||||
attribute_write_mode="dual_write",
|
||||
)
|
||||
for route, code in [("/int", 404), ("/numeric_string", "404"), ("/big", 2**53 + 1), ("/text", "abc")]
|
||||
]
|
||||
)
|
||||
|
||||
start_ms = int((hit - timedelta(minutes=1)).timestamp() * 1000)
|
||||
end_ms = int((hit + timedelta(minutes=1)).timestamp() * 1000)
|
||||
|
||||
for label, filter_expression, expected in [
|
||||
("numeric_string_eq", "app.code = 404", {"/int", "/numeric_string"}),
|
||||
("big_int_rounded", f"app.code = {2**53}", {"/big"}),
|
||||
("text_not_a_number", "app.code > 0", {"/int", "/numeric_string", "/big"}),
|
||||
]:
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
request_type=RequestType.TIME_SERIES,
|
||||
queries=[
|
||||
build_traces_scalar_query(
|
||||
aggregations=[build_aggregation("count()")],
|
||||
group_by=[build_group_by_field("http.route", field_context="attribute")],
|
||||
filter_expression=filter_expression,
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, label
|
||||
aggregations = (response.json()["data"]["data"]["results"][0].get("aggregations")) or []
|
||||
series = index_series_by_label(aggregations[0]["series"], "http.route") if aggregations else {}
|
||||
assert set(series.keys()) == expected, label
|
||||
|
||||
|
||||
def test_traces_attributes_json_list_view(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
|
||||
94
tests/integration/tests/queriertraces/14_skip_indexes.py
Normal file
94
tests/integration/tests/queriertraces/14_skip_indexes.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.querier import RequestType, build_aggregation, build_traces_scalar_query, get_preview_statements, make_preview_query_request
|
||||
from fixtures.traces import ATTRIBUTE_JSON_ROLLOUT_TIME, Traces
|
||||
|
||||
PROMOTED_PATHS = "/api/v1/promoted_paths"
|
||||
MATERIALIZED_PATH = "/api/v1/promoted_paths/materialized"
|
||||
|
||||
|
||||
def test_traces_filters_use_skip_indexes(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
seed_attribute_evolution: Callable[[str, datetime], None],
|
||||
materialize_attribute: Callable[..., str],
|
||||
request: pytest.FixtureRequest,
|
||||
) -> None:
|
||||
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(PROMOTED_PATHS),
|
||||
json=[
|
||||
{"signal": "traces", "context": "attribute", "path": "index.number_probe", "indexes": [{"fieldDataType": "number", "type": "bloom_filter(0.01)", "granularity": 64}]},
|
||||
{"signal": "traces", "context": "attribute", "path": "index.string_probe", "indexes": [{"fieldDataType": "string", "type": "bloom_filter(0.01)", "granularity": 64}]},
|
||||
],
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CREATED, resp.text
|
||||
cluster = signoz.telemetrystore.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
|
||||
for index_name in ("attributes.index.number_probe_Float64_bloom_filter", "attributes.index.string_probe_String_bloom_filter"):
|
||||
request.addfinalizer(lambda index_name=index_name: signoz.telemetrystore.conn.query(f"ALTER TABLE signoz_traces.signoz_index_v3 ON CLUSTER '{cluster}' DROP INDEX IF EXISTS `{index_name}`"))
|
||||
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(PROMOTED_PATHS),
|
||||
json=[{"signal": "traces", "context": "attribute", "path": "index.bool_probe", "indexes": [{"fieldDataType": "bool", "type": "bloom_filter(0.01)", "granularity": 64}]}],
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.BAD_REQUEST, resp.text
|
||||
|
||||
materialize_attribute("traces", "materialized.index_probe", True)
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(MATERIALIZED_PATH),
|
||||
json={"signal": "traces"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_traces([Traces(timestamp=now - timedelta(seconds=10), attributes={"index.number_probe": 200, "index.string_probe": "probe", "materialized.index_probe": "probe", "http.route": "/probe", "rpc.method": "probe"})])
|
||||
|
||||
for filter_expression, index_name in [
|
||||
("index.number_probe = 200", "attributes.index.number_probe_Float64_bloom_filter"),
|
||||
("index.string_probe = 'probe'", "attributes.index.string_probe_String_bloom_filter"),
|
||||
("materialized.index_probe = 'probe'", "attributes.materialized.index_probe_String_bloom_filter"),
|
||||
("http.route = '/probe'", "attributes.http.route_String_bloom_filter"),
|
||||
("rpc.method = 'probe'", "attributes.rpc.method_String_bloom_filter"),
|
||||
("name = 'probe'", "idx_name"),
|
||||
("duration_nano > 100", "idx_duration"),
|
||||
("kind = 2", "idx_kind"),
|
||||
("kind_string = 'Server'", "idx_kind_string"),
|
||||
("status_code_string = 'Ok'", "idx_status_code_string"),
|
||||
("response_status_code = '200'", "idx_response_statusCode"),
|
||||
("http_method = 'GET'", "idx_http_method"),
|
||||
("http_url = 'probe'", "idx_http_url"),
|
||||
("http_host = 'probe'", "idx_http_host"),
|
||||
("trace_id = '0af7651916cd43dd8448eb211c80319c'", "idx_trace_id"),
|
||||
("span_id = 'b7ad6b7169203331'", "idx_span_id"),
|
||||
]:
|
||||
statements = get_preview_statements(
|
||||
make_preview_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
request_type=RequestType.SCALAR,
|
||||
# count() over primary-key filters is answered by the exact count projection, which reads no index
|
||||
queries=[build_traces_scalar_query(aggregations=[build_aggregation("sum(duration_nano)")], filter_expression=filter_expression)],
|
||||
),
|
||||
"A",
|
||||
)
|
||||
used = {step["name"] for statement in statements for read in (statement["granules"] or {}).get("reads", []) for step in read["steps"]}
|
||||
assert index_name in used, f"{filter_expression}: {used}"
|
||||
Reference in New Issue
Block a user