Compare commits

...

16 Commits

Author SHA1 Message Date
Nikhil Soni
594bcd4b6f test(querier): check traces filters use their skip indexes
Asserts the index is listed in the preview's granule steps rather than
counting pruned granules; bool paths cannot be indexed.

Assisted-by: Claude Opus 5.5
2026-10-15 18:54:51 +05:30
Nikhil Soni
e558ebf5bb test(promote): drop pruning test index on teardown
Assisted-by: Claude Opus 5.5
2026-10-15 18:05:42 +05:30
Nikhil Soni
5fb02c1c45 test(promote): post index paths inline in pruning test
Assisted-by: Claude Opus 5.5
2026-10-15 18:05:42 +05:30
Nikhil Soni
080e5ee8eb test(promote): check index pruning through the promote api
Assisted-by: Claude Opus 5.5
2026-10-15 18:05:42 +05:30
Nikhil Soni
1596fe89f2 test(promote): assert traces number index prunes granules in preview
The promote suite runs with trace attributes JSON on, without which traces
indexes are never read.

Assisted-by: Claude Opus 5.5
2026-10-15 18:05:42 +05:30
Nikhil Soni
e72a3443d3 test(promote): check index against querier filters from implpromote
Keeps querybuilder out of promotetypes imports.

Assisted-by: Claude Opus 5.5
2026-10-15 18:05:42 +05:30
Nikhil Soni
b61a300a4a chore: add note for gotcha on float typecast 2026-10-15 18:05:42 +05:30
Nikhil Soni
84eaf5b2cc feat(promote): index traces numbers on the querier's number read
A p::Int64 index failed the whole insert on a non-numeric string.

Assisted-by: Claude Opus 5.5
2026-10-15 18:05:42 +05:30
Nikhil Soni
d08d79548d feat(querier): read traces json numbers as toFloat64OrNull
A numeric type or numeric string reads as Float64, like the attributes_number map. The
collision cast leaves that read bare so its skip index matches.

Assisted-by: Claude Opus 5.5
2026-10-15 18:05:42 +05:30
Nikhil Soni
3b6c91eb0d chore: regenerate openapi spec and api clients
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xjfc9SJXQ6QXk35zd9oNW
2026-10-15 18:05:40 +05:30
Nikhil Soni
f45110230f refactor(promote): name the materialized index request body postable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xjfc9SJXQ6QXk35zd9oNW
2026-10-15 18:05:40 +05:30
Nikhil Soni
d868adbceb chore: regenerate openapi spec and api clients
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xjfc9SJXQ6QXk35zd9oNW
2026-10-15 18:05:40 +05:30
Nikhil Soni
bdc8787c57 feat(promote): take materialized params in the body and index any materialized key
Materialized keys skip the path checks meant for user input, so keys
with special characters get their json index too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xjfc9SJXQ6QXk35zd9oNW
2026-10-15 18:05:40 +05:30
Nikhil Soni
a5cbddd02f feat(promote): mirror column index settings on materialized json indexes
Paths whose index type or path cannot be indexed are logged and left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xjfc9SJXQ6QXk35zd9oNW
2026-10-15 18:05:40 +05:30
Nikhil Soni
0c02759640 chore: regenerate openapi spec and api clients
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xjfc9SJXQ6QXk35zd9oNW
2026-10-15 18:05:40 +05:30
Nikhil Soni
10b7888788 feat(promote): index materialized attributes on their json sub-columns
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xjfc9SJXQ6QXk35zd9oNW
2026-10-15 18:05:40 +05:30
30 changed files with 1234 additions and 82 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,)]

View File

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

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