mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-21 13:40:38 +01:00
Compare commits
17 Commits
v0.134.0-c
...
issue_5267
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
453a524ff4 | ||
|
|
08f92df0a6 | ||
|
|
6e5ff46761 | ||
|
|
72448fa163 | ||
|
|
a45f6341da | ||
|
|
f534ea2467 | ||
|
|
d4843ecd8b | ||
|
|
477b81892d | ||
|
|
9604438723 | ||
|
|
e0a66b7a10 | ||
|
|
b01be1f31c | ||
|
|
c8a007cd8d | ||
|
|
205b23b830 | ||
|
|
68b166f75e | ||
|
|
20d8ca6d7d | ||
|
|
1e1be670f1 | ||
|
|
d1682f2ab6 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -58,6 +58,7 @@ jobs:
|
||||
- role
|
||||
- rootuser
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
|
||||
@@ -8084,6 +8084,19 @@ components:
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
SpantypesGettableSpanMapperTest:
|
||||
properties:
|
||||
collectorLogs:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
spans:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperTestSpan'
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
SpantypesGettableSpanMappers:
|
||||
properties:
|
||||
items:
|
||||
@@ -8180,6 +8193,39 @@ components:
|
||||
- name
|
||||
- condition
|
||||
type: object
|
||||
SpantypesPostableSpanMapperTest:
|
||||
properties:
|
||||
groups:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesPostableSpanMapperTestGroup'
|
||||
nullable: true
|
||||
type: array
|
||||
spans:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperTestSpan'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- spans
|
||||
- groups
|
||||
type: object
|
||||
SpantypesPostableSpanMapperTestGroup:
|
||||
properties:
|
||||
condition:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperGroupCondition'
|
||||
enabled:
|
||||
type: boolean
|
||||
mappers:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesPostableSpanMapper'
|
||||
nullable: true
|
||||
type: array
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- condition
|
||||
type: object
|
||||
SpantypesPostableTraceAggregations:
|
||||
properties:
|
||||
aggregations:
|
||||
@@ -8341,6 +8387,17 @@ components:
|
||||
- operation
|
||||
- priority
|
||||
type: object
|
||||
SpantypesSpanMapperTestSpan:
|
||||
properties:
|
||||
attributes:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
resource:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
type: object
|
||||
SpantypesUpdatableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -8558,7 +8615,6 @@ components:
|
||||
- resource
|
||||
- attribute
|
||||
- body
|
||||
- ""
|
||||
type: string
|
||||
TelemetrytypesFieldDataType:
|
||||
enum:
|
||||
@@ -8567,7 +8623,6 @@ components:
|
||||
- float64
|
||||
- int64
|
||||
- number
|
||||
- ""
|
||||
type: string
|
||||
TelemetrytypesGettableFieldKeys:
|
||||
properties:
|
||||
@@ -8599,7 +8654,6 @@ components:
|
||||
- traces
|
||||
- logs
|
||||
- metrics
|
||||
- ""
|
||||
type: string
|
||||
TelemetrytypesSource:
|
||||
enum:
|
||||
@@ -14074,6 +14128,69 @@ paths:
|
||||
summary: Update a span mapper
|
||||
tags:
|
||||
- spanmapper
|
||||
/api/v1/span_mapper_groups/test:
|
||||
post:
|
||||
deprecated: false
|
||||
description: Tests how span mappers would transform sample spans
|
||||
operationId: TestSpanMappers
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpantypesPostableSpanMapperTest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableSpanMapperTest'
|
||||
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
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Test span mappers against sample spans
|
||||
tags:
|
||||
- spanmapper
|
||||
/api/v1/stats:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -240,17 +240,17 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
|
||||
mock := mockStore.Mock()
|
||||
|
||||
// Mock the fingerprint query (for Prometheus label matching)
|
||||
// args: $1=metric_name (the __name__ matcher maps onto the column)
|
||||
mock.ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric").
|
||||
WithArgs("test_metric", "__name__", "test_metric").
|
||||
WillReturnRows(fingerprintRows)
|
||||
|
||||
// Mock the samples query (for Prometheus metric data)
|
||||
// args: metric_name IN (discovered names), subquery metric_name, start, end
|
||||
mock.ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
|
||||
WithArgs(
|
||||
"test_metric",
|
||||
"test_metric",
|
||||
"__name__",
|
||||
"test_metric",
|
||||
queryStart,
|
||||
queryEnd,
|
||||
).
|
||||
|
||||
@@ -3479,7 +3479,6 @@ export enum TelemetrytypesFieldContextDTO {
|
||||
resource = 'resource',
|
||||
attribute = 'attribute',
|
||||
body = 'body',
|
||||
'' = '',
|
||||
}
|
||||
export enum TelemetrytypesFieldDataTypeDTO {
|
||||
string = 'string',
|
||||
@@ -3487,13 +3486,11 @@ export enum TelemetrytypesFieldDataTypeDTO {
|
||||
float64 = 'float64',
|
||||
int64 = 'int64',
|
||||
number = 'number',
|
||||
'' = '',
|
||||
}
|
||||
export enum TelemetrytypesSignalDTO {
|
||||
traces = 'traces',
|
||||
logs = 'logs',
|
||||
metrics = 'metrics',
|
||||
'' = '',
|
||||
}
|
||||
export interface Querybuildertypesv5GroupByKeyDTO {
|
||||
/**
|
||||
@@ -9270,6 +9267,48 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
|
||||
items: SpantypesSpanMapperGroupDTO[];
|
||||
}
|
||||
|
||||
export type SpantypesSpanMapperTestSpanDTOAttributesAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesSpanMapperTestSpanDTOAttributes =
|
||||
SpantypesSpanMapperTestSpanDTOAttributesAnyOf | null;
|
||||
|
||||
export type SpantypesSpanMapperTestSpanDTOResourceAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type SpantypesSpanMapperTestSpanDTOResource =
|
||||
SpantypesSpanMapperTestSpanDTOResourceAnyOf | null;
|
||||
|
||||
export interface SpantypesSpanMapperTestSpanDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
attributes?: SpantypesSpanMapperTestSpanDTOAttributes;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
resource?: SpantypesSpanMapperTestSpanDTOResource;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableSpanMapperTestDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
collectorLogs?: string[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
spans?: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanMapperOperationDTO {
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
@@ -9611,6 +9650,33 @@ export interface SpantypesPostableSpanMapperGroupDTO {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperTestGroupDTO {
|
||||
condition: SpantypesSpanMapperGroupConditionDTO | null;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
mappers?: SpantypesPostableSpanMapperDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableSpanMapperTestDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
groups: SpantypesPostableSpanMapperTestGroupDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
spans: SpantypesSpanMapperTestSpanDTO[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesSpanAggregationDTO {
|
||||
aggregation: SpantypesSpanAggregationTypeDTO;
|
||||
field: TelemetrytypesTelemetryFieldKeyDTO;
|
||||
@@ -10961,6 +11027,14 @@ export type UpdateSpanMapperPathParameters = {
|
||||
groupId: string;
|
||||
mapperId: string;
|
||||
};
|
||||
export type TestSpanMappers200 = {
|
||||
data: SpantypesGettableSpanMapperTestDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetStats200Data = { [key: string]: unknown };
|
||||
|
||||
export type GetStats200 = {
|
||||
|
||||
@@ -30,8 +30,10 @@ import type {
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesPostableSpanMapperTestDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
TestSpanMappers200,
|
||||
UpdateSpanMapperGroupPathParameters,
|
||||
UpdateSpanMapperPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -780,3 +782,86 @@ export const useUpdateSpanMapper = <
|
||||
> => {
|
||||
return useMutation(getUpdateSpanMapperMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Tests how span mappers would transform sample spans
|
||||
* @summary Test span mappers against sample spans
|
||||
*/
|
||||
export const testSpanMappers = (
|
||||
spantypesPostableSpanMapperTestDTO?: BodyType<SpantypesPostableSpanMapperTestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<TestSpanMappers200>({
|
||||
url: `/api/v1/span_mapper_groups/test`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableSpanMapperTestDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getTestSpanMappersMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['testSpanMappers'];
|
||||
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 testSpanMappers>>,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return testSpanMappers(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type TestSpanMappersMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>
|
||||
>;
|
||||
export type TestSpanMappersMutationBody =
|
||||
| BodyType<SpantypesPostableSpanMapperTestDTO>
|
||||
| undefined;
|
||||
export type TestSpanMappersMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Test span mappers against sample spans
|
||||
*/
|
||||
export const useTestSpanMappers = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof testSpanMappers>>,
|
||||
TError,
|
||||
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getTestSpanMappersMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -247,6 +247,11 @@
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
|
||||
&,
|
||||
.ͼ1a {
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
|
||||
@@ -293,6 +293,11 @@ $max-recents-shown: 5;
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
|
||||
&,
|
||||
.ͼ1a {
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
.ant-table-cell {
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.attribute-name {
|
||||
@@ -34,12 +33,12 @@
|
||||
.attribute-pin {
|
||||
cursor: pointer;
|
||||
|
||||
padding: 14px 8px 8px;
|
||||
vertical-align: top;
|
||||
padding: 0;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
|
||||
.log-attribute-pin {
|
||||
padding: 0;
|
||||
padding: 8px;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -15,13 +15,11 @@
|
||||
.ant-tree-node-content-wrapper {
|
||||
user-select: text !important;
|
||||
cursor: text !important;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ant-tree-title {
|
||||
user-select: text !important;
|
||||
cursor: text !important;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,13 +16,7 @@ import { sortBy } from 'lodash-es';
|
||||
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
|
||||
|
||||
/** Telemetry signal — the generated enum (traces / logs / metrics). */
|
||||
// A query/variable signal is only logs/traces/metrics. TelemetrytypesSignalDTO
|
||||
// also carries the empty "any" value used on field keys, which is not a valid
|
||||
// query/variable signal, so exclude it here.
|
||||
export type TelemetrySignal =
|
||||
| TelemetrytypesSignalDTO.logs
|
||||
| TelemetrytypesSignalDTO.traces
|
||||
| TelemetrytypesSignalDTO.metrics;
|
||||
export type TelemetrySignal = TelemetrytypesSignalDTO;
|
||||
|
||||
/**
|
||||
* Signal selected in the dynamic-variable editor. `'all'` is UI-only (the
|
||||
|
||||
@@ -17,9 +17,6 @@ const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
|
||||
[TelemetrytypesSignalDTO.logs]: 'logs',
|
||||
[TelemetrytypesSignalDTO.traces]: 'traces',
|
||||
[TelemetrytypesSignalDTO.metrics]: 'metrics',
|
||||
// The empty "any" signal only appears on field keys, never on a panel query;
|
||||
// mapped for exhaustiveness.
|
||||
[TelemetrytypesSignalDTO['']]: '',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -105,8 +105,7 @@ export function useDrilldownDashboardVariables({
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: true,
|
||||
dynamicAttribute: fieldName,
|
||||
// `||` (not `??`): an empty "any" signal maps to All, same as unset.
|
||||
dynamicSignal: signal || DYNAMIC_SIGNAL_ALL,
|
||||
dynamicSignal: signal ?? DYNAMIC_SIGNAL_ALL,
|
||||
};
|
||||
try {
|
||||
await patchAsync(
|
||||
|
||||
6
go.mod
6
go.mod
@@ -9,7 +9,7 @@ require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/SigNoz/clickhouse-go-mock v0.14.0
|
||||
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
|
||||
github.com/SigNoz/signoz-otel-collector v0.144.3
|
||||
github.com/SigNoz/signoz-otel-collector v0.144.6
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1
|
||||
github.com/antonmedv/expr v1.15.3
|
||||
github.com/bytedance/sonic v1.14.1
|
||||
@@ -150,10 +150,6 @@ require (
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.opentelemetry.io/collector/client v1.54.0 // indirect
|
||||
go.opentelemetry.io/collector/config/configoptional v1.50.0 // indirect
|
||||
go.opentelemetry.io/collector/config/configretry v1.50.0 // indirect
|
||||
go.opentelemetry.io/collector/exporter/exporterhelper v0.144.0 // indirect
|
||||
go.opentelemetry.io/collector/internal/componentalias v0.148.0 // indirect
|
||||
go.opentelemetry.io/collector/pdata/xpdata v0.148.0 // indirect
|
||||
go.uber.org/goleak v1.3.0 // indirect
|
||||
|
||||
25
go.sum
25
go.sum
@@ -110,11 +110,13 @@ github.com/SigNoz/expr v1.17.7-beta h1:FyZkleM5dTQ0O6muQfwGpoH5A2ohmN/XTasRCO72g
|
||||
github.com/SigNoz/expr v1.17.7-beta/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd h1:Bk43AsDYe0fhkbj57eGXx8H3ZJ4zhmQXBnrW523ktj8=
|
||||
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd/go.mod h1:nxRcH/OEdM8QxzH37xkGzomr1O0JpYBRS6pwjsWW6Pc=
|
||||
github.com/SigNoz/signoz-otel-collector v0.144.3 h1:/7PPIqIpPsaWtrgnfHam2XVYP41ZlgEKLHzQO8oVxcA=
|
||||
github.com/SigNoz/signoz-otel-collector v0.144.3/go.mod h1:9pLVpcIQvUT88ZiNnZN/MI+XLruvwC+Xm2UzPmGjNfA=
|
||||
github.com/SigNoz/signoz-otel-collector v0.144.6 h1:AgblCIDlLKGtssS4ywvyEByH9fbJ7hFqZvPvUVJKwp8=
|
||||
github.com/SigNoz/signoz-otel-collector v0.144.6/go.mod h1:YRQw6ETaq7fm8ts4Itgb6wC0bVhc0+nC6PFI0MaiH9k=
|
||||
github.com/Yiling-J/theine-go v0.6.2 h1:1GeoXeQ0O0AUkiwj2S9Jc0Mzx+hpqzmqsJ4kIC4M9AY=
|
||||
github.com/Yiling-J/theine-go v0.6.2/go.mod h1:08QpMa5JZ2pKN+UJCRrCasWYO1IKCdl54Xa836rpmDU=
|
||||
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
|
||||
github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U=
|
||||
github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
@@ -124,6 +126,10 @@ github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vS
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/antchfx/xmlquery v1.5.0 h1:uAi+mO40ZWfyU6mlUBxRVvL6uBNZ6LMU4M3+mQIBV4c=
|
||||
github.com/antchfx/xmlquery v1.5.0/go.mod h1:lJfWRXzYMK1ss32zm1GQV3gMIW/HFey3xDZmkP1SuNc=
|
||||
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
|
||||
github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||
@@ -286,6 +292,8 @@ github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=
|
||||
github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
|
||||
github.com/elastic/go-grok v0.3.1 h1:WEhUxe2KrwycMnlvMimJXvzRa7DoByJB4PVUIE1ZD/U=
|
||||
github.com/elastic/go-grok v0.3.1/go.mod h1:n38ls8ZgOboZRgKcjMY8eFeZFMmcL9n2lP0iHhIDk64=
|
||||
github.com/elastic/lunes v0.2.0 h1:WI3bsdOTuaYXVe2DS1KbqA7u7FOHN4o8qJw80ZyZoQs=
|
||||
github.com/elastic/lunes v0.2.0/go.mod h1:u3W/BdONWTrh0JjNZ21C907dDc+cUZttZrGa625nf2k=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
@@ -461,6 +469,7 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
@@ -674,6 +683,8 @@ github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI
|
||||
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc=
|
||||
github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE=
|
||||
github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI=
|
||||
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
@@ -866,12 +877,18 @@ github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal v0.144.0/go.mod h1:O2rZKRXk1WeYhzfJBVXES/g7+PlIds/TzPZW/4NfTNA=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/internal/exp/metrics v0.148.0 h1:CiTjQE/Hh5xK2t56ogrDK4nl0+tJPNmASCs4zEYZ/xU=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/internal/exp/metrics v0.148.0/go.mod h1:WUFkzTiOpt7EYyL67gv1GOf3RD8qKWGtin3lY9LYzW4=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/internal/filter v0.144.0 h1:Ywu5mU4K5TMJigiXdyZloCRs/cq3/2OnoK3WjxNHWJo=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/internal/filter v0.144.0/go.mod h1:iebqlu6UvpiV1hO37r1sXA9fXaCaA8sQXilG0///xss=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl v0.144.0 h1:TMRTvQSAeeLtkKwSrqcbectxDRPiqB6yYM3IvjC75es=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl v0.144.0/go.mod h1:1HU0qJ4hFrphDebuBs3I4DPQ6zyBFGinQ5/bXEUM7pw=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatatest v0.148.0 h1:i12duJOl5VCb9mbb8FfZCaP2CjeXbNsbg82JjSe7sy8=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatatest v0.148.0/go.mod h1:jyw+QvkmCrF/oYy31O2ndb5KZZK4l+iR89msnV3LN/k=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatautil v0.148.0 h1:1TLg6YrS3Au6F7xw3ws2Njbwj13IMqPplvGFi+18fWs=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatautil v0.148.0/go.mod h1:P8hZEDIQk4REgUWyLhSVRHwTxK6KkifKfg36BmmQ/DI=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza v0.144.0 h1:EIAygME70IOdEwaSr6bA3Wcdp7hXEqRsGsVfrI5v8OA=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza v0.144.0/go.mod h1:3Y6ctEEwRg19B0jqsrQH6Hiquqte+zC0ZxpXLLSa5sA=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/processor/attributesprocessor v0.144.0 h1:gRk73SsIJv3q/HI0kxMRN5TiIiJj+MRxrz0GEYx3jZw=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/processor/attributesprocessor v0.144.0/go.mod h1:7b8ZcPdN6JSm6+LUq3rienoqHHiX60kyoOHDPCMM+L8=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/processor/deltatocumulativeprocessor v0.148.0 h1:xgD/kNGp/wWY+bwY599Pc01OamYN17phRiTP934bM5Y=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/processor/deltatocumulativeprocessor v0.148.0/go.mod h1:ZK7wvaefla9lB3bAW0rNKt7IzRPcTRQoOFqr4sZy/XM=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/processor/logstransformprocessor v0.144.0 h1:HbmpzTixpQG/xGhQuQoiJTXQPrixe+yivAsF6tl2o4g=
|
||||
@@ -1119,6 +1136,10 @@ github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GH
|
||||
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg=
|
||||
github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
|
||||
github.com/ua-parser/uap-go v0.0.0-20240611065828-3a4781585db6 h1:SIKIoA4e/5Y9ZOl0DCe3eVMLPOQzJxgZpfdHHeauNTM=
|
||||
github.com/ua-parser/uap-go v0.0.0-20240611065828-3a4781585db6/go.mod h1:BUbeWZiieNxAuuADTBNb3/aeje6on3DhU3rpWsQSB1E=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/uptrace/bun v1.2.9 h1:OOt2DlIcRUMSZPr6iXDFg/LaQd59kOxbAjpIVHddKRs=
|
||||
|
||||
@@ -51,6 +51,26 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/span_mapper_groups/test", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.spanMapperHandler.TestMappers),
|
||||
handler.OpenAPIDef{
|
||||
ID: "TestSpanMappers",
|
||||
Tags: []string{"spanmapper"},
|
||||
Summary: "Test span mappers against sample spans",
|
||||
Description: "Tests how span mappers would transform sample spans",
|
||||
Request: new(spantypes.PostableSpanMapperTest),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(spantypes.GettableSpanMapperTest),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/span_mapper_groups/{groupId}", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.spanMapperHandler.UpdateGroup),
|
||||
handler.OpenAPIDef{
|
||||
|
||||
@@ -273,6 +273,35 @@ func (h *handler) DeleteMapper(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// TestMappers handles POST /api/v1/span_mapper_groups/test.
|
||||
func (h *handler) TestMappers(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
req := new(spantypes.PostableSpanMapperTest)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
groups := spantypes.NewSpanMapperGroupsWithMappersFromPostable(orgID, req.Groups)
|
||||
out, collectorLogs, err := h.module.TestMappers(ctx, orgID, req.Spans, groups)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, &spantypes.GettableSpanMapperTest{Spans: out, CollectorLogs: collectorLogs})
|
||||
}
|
||||
|
||||
// groupIDFromPath extracts and validates the {id} or {groupId} path variable.
|
||||
func groupIDFromPath(r *http.Request) (valuer.UUID, error) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
@@ -105,6 +106,61 @@ func (module *module) DeleteMapper(ctx context.Context, orgID, groupID, id value
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxTestSpans bounds the input size: every test request boots a full
|
||||
// in-memory collector pipeline and is reachable with viewer access.
|
||||
const maxTestSpans = 100
|
||||
|
||||
func (module *module) TestMappers(ctx context.Context, orgID valuer.UUID, spans []spantypes.SpanMapperTestSpan, groups []*spantypes.SpanMapperGroupWithMappers) ([]spantypes.SpanMapperTestSpan, []string, error) {
|
||||
if len(spans) == 0 {
|
||||
return nil, nil, errors.New(errors.TypeInvalidInput, spantypes.ErrCodeMappingInvalidInput, "'spans' must contain at least one span")
|
||||
}
|
||||
if len(spans) > maxTestSpans {
|
||||
return nil, nil, errors.Newf(errors.TypeInvalidInput, spantypes.ErrCodeMappingInvalidInput, "'spans' must contain at most %d spans", maxTestSpans)
|
||||
}
|
||||
|
||||
resolved, err := module.backfillMappers(ctx, orgID, groups)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
out, collectorLogs, err := spantypes.SimulateSpanMappersProcessing(ctx, resolved, spans)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return out, collectorLogs, nil
|
||||
}
|
||||
|
||||
// backfillMappers loads saved mappers for any enabled group whose Mappers is
|
||||
// nil. Disabled groups are skipped: the simulation filters them out anyway,
|
||||
// so there is no point loading their mappers or failing on their names.
|
||||
func (module *module) backfillMappers(ctx context.Context, orgID valuer.UUID, groups []*spantypes.SpanMapperGroupWithMappers) ([]*spantypes.SpanMapperGroupWithMappers, error) {
|
||||
savedGroups, err := module.store.ListGroups(ctx, orgID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
savedByName := make(map[string]*spantypes.SpanMapperGroup, len(savedGroups))
|
||||
for _, g := range savedGroups {
|
||||
savedByName[g.Name] = g
|
||||
}
|
||||
|
||||
// For each group in the request, if Mappers is nil, load the saved mappers for that group name.
|
||||
for _, g := range groups {
|
||||
if g.Mappers != nil || !g.Group.Enabled {
|
||||
continue
|
||||
}
|
||||
saved, ok := savedByName[g.Group.Name]
|
||||
if !ok {
|
||||
return nil, errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "no saved group named %q to load mappers from; send 'mappers' for new or edited groups", g.Group.Name)
|
||||
}
|
||||
loaded, err := module.store.ListMappers(ctx, orgID, saved.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Mappers = loaded
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func (module *module) AgentFeatureType() agentConf.AgentFeatureType {
|
||||
return spantypes.SpanAttrMappingFeatureType
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ type Module interface {
|
||||
CreateMapper(ctx context.Context, orgID, groupID valuer.UUID, mapper *spantypes.SpanMapper) error
|
||||
UpdateMapper(ctx context.Context, orgID, groupID, id valuer.UUID, fieldContext spantypes.FieldContext, config *spantypes.SpanMapperConfig, enabled *bool, updatedBy string) error
|
||||
DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error
|
||||
TestMappers(ctx context.Context, orgID valuer.UUID, spans []spantypes.SpanMapperTestSpan, groups []*spantypes.SpanMapperGroupWithMappers) ([]spantypes.SpanMapperTestSpan, []string, error)
|
||||
}
|
||||
|
||||
// Handler defines the HTTP handler interface for mapping group and mapper endpoints.
|
||||
@@ -42,4 +43,5 @@ type Handler interface {
|
||||
CreateMapper(rw http.ResponseWriter, r *http.Request)
|
||||
UpdateMapper(rw http.ResponseWriter, r *http.Request)
|
||||
DeleteMapper(rw http.ResponseWriter, r *http.Request)
|
||||
TestMappers(rw http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
@@ -56,21 +56,19 @@ func (c *captureClient) Read(ctx context.Context, query *prompb.Query, _ bool) (
|
||||
}
|
||||
}
|
||||
|
||||
// Without executing the series lookup, only an exact-name selector's
|
||||
// metric name is known.
|
||||
var metricNames []string
|
||||
var metricName string
|
||||
for _, matcher := range query.Matchers {
|
||||
if matcher.Name == "__name__" && matcher.Type == prompb.LabelMatcher_EQ {
|
||||
metricNames = []string{matcher.Value}
|
||||
if matcher.Name == "__name__" {
|
||||
metricName = matcher.Value
|
||||
}
|
||||
}
|
||||
|
||||
// Build the executing path's queries, but only record them.
|
||||
sub, err := seriesLookupQuery(query, true)
|
||||
subQuery, args, err := c.queryToClickhouseQuery(ctx, query, metricName, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
samplesQuery, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricNames, sub)
|
||||
samplesQuery, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricName, subQuery, args)
|
||||
c.recorder.record(samplesQuery, samplesArgs)
|
||||
|
||||
return storage.EmptySeriesSet(), nil
|
||||
|
||||
@@ -4,7 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -14,7 +15,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/cespare/xxhash/v2"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
promValue "github.com/prometheus/prometheus/model/value"
|
||||
"github.com/prometheus/prometheus/prompb"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
@@ -56,13 +56,19 @@ func (client *client) Read(ctx context.Context, query *prompb.Query, sortSeries
|
||||
}
|
||||
}
|
||||
|
||||
lookup, err := seriesLookupQuery(query, false)
|
||||
var metricName string
|
||||
for _, matcher := range query.Matchers {
|
||||
if matcher.Name == "__name__" {
|
||||
metricName = matcher.Value
|
||||
}
|
||||
}
|
||||
|
||||
clickhouseQuery, args, err := client.queryToClickhouseQuery(ctx, query, metricName, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lookupSQL, lookupArgs := lookup.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
fingerprints, metricNames, err := client.getFingerprintsFromClickhouseQuery(ctx, lookupSQL, lookupArgs)
|
||||
fingerprints, err := client.getFingerprintsFromClickhouseQuery(ctx, clickhouseQuery, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -70,14 +76,13 @@ func (client *client) Read(ctx context.Context, query *prompb.Query, sortSeries
|
||||
return remote.FromQueryResult(sortSeries, new(prompb.QueryResult)), nil
|
||||
}
|
||||
|
||||
sub, err := seriesLookupQuery(query, true)
|
||||
clickhouseSubQuery, args, err := client.queryToClickhouseQuery(ctx, query, metricName, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
samplesSQL, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricNames, sub)
|
||||
|
||||
res := new(prompb.QueryResult)
|
||||
timeseries, err := client.querySamples(ctx, samplesSQL, samplesArgs, fingerprints)
|
||||
timeseries, err := client.querySamples(ctx, int64(query.StartTimestampMs), int64(query.EndTimestampMs), fingerprints, metricName, clickhouseSubQuery, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -121,115 +126,86 @@ func (c *client) ReadMultiple(ctx context.Context, queries []*prompb.Query, sort
|
||||
return storage.NewMergeSeriesSet(sets, 0, storage.ChainedSeriesMerge), nil
|
||||
}
|
||||
|
||||
// anchorRegex makes a pattern fully anchored, the way Prometheus compiles
|
||||
// matcher regexes; ClickHouse's match() would otherwise substring-match.
|
||||
func anchorRegex(pattern string) string {
|
||||
return "^(?:" + pattern + ")$"
|
||||
}
|
||||
|
||||
// seriesLookupQuery builds the time-series lookup. It returns a builder so
|
||||
// the samples query can embed it as a subquery with the args merged in
|
||||
// render order by the builder instead of hand-numbered placeholders.
|
||||
func seriesLookupQuery(query *prompb.Query, subQuery bool) (*sqlbuilder.SelectBuilder, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
func (client *client) queryToClickhouseQuery(_ context.Context, query *prompb.Query, metricName string, subQuery bool) (string, []any, error) {
|
||||
var clickHouseQuery string
|
||||
var conditions []string
|
||||
var argCount = 0
|
||||
var selectString = "fingerprint, any(labels)"
|
||||
if subQuery {
|
||||
sb.Select("fingerprint")
|
||||
} else {
|
||||
sb.Select("fingerprint", "any(labels)")
|
||||
argCount = 1
|
||||
selectString = "fingerprint"
|
||||
}
|
||||
|
||||
start, end, tableName := getStartAndEndAndTableName(query.StartTimestampMs, query.EndTimestampMs)
|
||||
sb.From(databaseName + "." + tableName)
|
||||
|
||||
sb.Where("temporality IN ['Cumulative', 'Unspecified']")
|
||||
var args []any
|
||||
conditions = append(conditions, fmt.Sprintf("metric_name = $%d", argCount+1))
|
||||
conditions = append(conditions, "temporality IN ['Cumulative', 'Unspecified']")
|
||||
// Inclusive upper bound: registration rows are hour-floored by the
|
||||
// exporter, so a series first registered in the hour starting exactly at
|
||||
// `end` would otherwise be invisible while its samples (<= end) are in
|
||||
// range.
|
||||
sb.Where(fmt.Sprintf("unix_milli >= %d AND unix_milli <= %d", start, end))
|
||||
conditions = append(conditions, fmt.Sprintf("unix_milli >= %d AND unix_milli <= %d", start, end))
|
||||
|
||||
args = append(args, metricName)
|
||||
for _, m := range query.Matchers {
|
||||
if m.Name == "__name__" {
|
||||
// __name__ maps onto the metric_name column per matcher type;
|
||||
// reducing regex/negated/absent name matchers to one equality
|
||||
// made such selectors silently return empty.
|
||||
switch m.Type {
|
||||
case prompb.LabelMatcher_EQ:
|
||||
sb.Where(sb.E("metric_name", m.Value))
|
||||
case prompb.LabelMatcher_NEQ:
|
||||
sb.Where(sb.NE("metric_name", m.Value))
|
||||
case prompb.LabelMatcher_RE:
|
||||
sb.Where(fmt.Sprintf("match(metric_name, %s)", sb.Var(anchorRegex(m.Value))))
|
||||
case prompb.LabelMatcher_NRE:
|
||||
sb.Where(fmt.Sprintf("not match(metric_name, %s)", sb.Var(anchorRegex(m.Value))))
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String())
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch m.Type {
|
||||
case prompb.LabelMatcher_EQ:
|
||||
sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) = %s", sb.Var(m.Name), sb.Var(m.Value)))
|
||||
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, $%d) = $%d", argCount+2, argCount+3))
|
||||
case prompb.LabelMatcher_NEQ:
|
||||
sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) != %s", sb.Var(m.Name), sb.Var(m.Value)))
|
||||
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, $%d) != $%d", argCount+2, argCount+3))
|
||||
case prompb.LabelMatcher_RE:
|
||||
sb.Where(fmt.Sprintf("match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value))))
|
||||
conditions = append(conditions, fmt.Sprintf("match(JSONExtractString(labels, $%d), $%d)", argCount+2, argCount+3))
|
||||
case prompb.LabelMatcher_NRE:
|
||||
sb.Where(fmt.Sprintf("not match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value))))
|
||||
conditions = append(conditions, fmt.Sprintf("not match(JSONExtractString(labels, $%d), $%d)", argCount+2, argCount+3))
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String())
|
||||
return "", nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String())
|
||||
}
|
||||
args = append(args, m.Name, m.Value)
|
||||
argCount += 2
|
||||
}
|
||||
|
||||
sb.GroupBy("fingerprint")
|
||||
return sb, nil
|
||||
whereClause := strings.Join(conditions, " AND ")
|
||||
|
||||
clickHouseQuery = fmt.Sprintf(`SELECT %s FROM %s.%s WHERE %s GROUP BY fingerprint`, selectString, databaseName, tableName, whereClause)
|
||||
|
||||
return clickHouseQuery, args, nil
|
||||
}
|
||||
|
||||
func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, query string, args []any) (map[uint64][]prompb.Label, []string, error) {
|
||||
func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, query string, args []any) (map[uint64][]prompb.Label, error) {
|
||||
ctx = client.withClickhousePrometheusContext(ctx, "getFingerprintsFromClickhouseQuery")
|
||||
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
fingerprints := make(map[uint64][]prompb.Label)
|
||||
nameSet := make(map[string]struct{})
|
||||
|
||||
var fingerprint uint64
|
||||
var labelString string
|
||||
for rows.Next() {
|
||||
if err = rows.Scan(&fingerprint, &labelString); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
labels, metricName, err := unmarshalLabels(labelString)
|
||||
labels, _, err := unmarshalLabels(labelString)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fingerprints[fingerprint] = labels
|
||||
if metricName != "" {
|
||||
nameSet[metricName] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metricNames := make([]string, 0, len(nameSet))
|
||||
for name := range nameSet {
|
||||
metricNames = append(metricNames, name)
|
||||
}
|
||||
sort.Strings(metricNames)
|
||||
|
||||
return fingerprints, metricNames, nil
|
||||
return fingerprints, nil
|
||||
}
|
||||
|
||||
// buildSamplesQuery renders the samples SQL for the series selected by
|
||||
// subQuery. The metric_name condition exists only for primary-key pruning;
|
||||
// the fingerprint filter already selects the right rows.
|
||||
// buildSamplesQuery renders the samples SQL (and args) that fetches data
|
||||
// points for the series selected by subQuery.
|
||||
//
|
||||
// Time bounds are inclusive on both ends because that is Prometheus's
|
||||
// storage contract: Select(mint, maxt) returns [start, end] and the engine
|
||||
@@ -239,29 +215,27 @@ func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, qu
|
||||
// its own model — toStartOfInterval buckets covering [t, t+step), where a
|
||||
// sample at `end` falls in an unrendered bucket and end-exclusive ranges
|
||||
// tile exactly across cached time slices.
|
||||
func buildSamplesQuery(start int64, end int64, metricNames []string, sub *sqlbuilder.SelectBuilder) (string, []any) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("metric_name", "fingerprint", "unix_milli", "value", "flags")
|
||||
sb.From(databaseName + "." + distributedSamplesV4)
|
||||
func buildSamplesQuery(start int64, end int64, metricName string, subQuery string, args []any) (string, []any) {
|
||||
argCount := len(args)
|
||||
|
||||
if len(metricNames) > 0 {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
}
|
||||
sb.Where(sb.In("metric_name", names...))
|
||||
}
|
||||
sb.Where(fmt.Sprintf("fingerprint GLOBAL IN (%s)", sb.Var(sub)))
|
||||
sb.Where(sb.GTE("unix_milli", start), sb.LTE("unix_milli", end))
|
||||
sb.OrderBy("fingerprint", "unix_milli")
|
||||
query := fmt.Sprintf(`
|
||||
SELECT metric_name, fingerprint, unix_milli, value, flags
|
||||
FROM %s.%s
|
||||
WHERE metric_name = $1 AND fingerprint GLOBAL IN (%s) AND unix_milli >= $%s AND unix_milli <= $%s ORDER BY fingerprint, unix_milli;`,
|
||||
databaseName, distributedSamplesV4, subQuery, strconv.Itoa(argCount+2), strconv.Itoa(argCount+3))
|
||||
query = strings.TrimSpace(query)
|
||||
|
||||
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
allArgs := append([]any{metricName}, args...)
|
||||
allArgs = append(allArgs, start, end)
|
||||
return query, allArgs
|
||||
}
|
||||
|
||||
func (client *client) querySamples(ctx context.Context, query string, args []any, fingerprints map[uint64][]prompb.Label) ([]*prompb.TimeSeries, error) {
|
||||
func (client *client) querySamples(ctx context.Context, start int64, end int64, fingerprints map[uint64][]prompb.Label, metricName string, subQuery string, args []any) ([]*prompb.TimeSeries, error) {
|
||||
ctx = client.withClickhousePrometheusContext(ctx, "querySamples")
|
||||
|
||||
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
query, allArgs := buildSamplesQuery(start, end, metricName, subQuery, args)
|
||||
|
||||
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, allArgs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -269,7 +243,6 @@ func (client *client) querySamples(ctx context.Context, query string, args []any
|
||||
|
||||
var res []*prompb.TimeSeries
|
||||
var ts *prompb.TimeSeries
|
||||
var metricName string
|
||||
var fingerprint, prevFingerprint uint64
|
||||
var timestampMs, prevTimestamp int64
|
||||
var value float64
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"github.com/prometheus/prometheus/prompb"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -30,7 +29,7 @@ func TestClient_QuerySamples(t *testing.T) {
|
||||
start int64
|
||||
end int64
|
||||
fingerprints map[uint64][]prompb.Label
|
||||
metricNames []string
|
||||
metricName string
|
||||
subQuery string
|
||||
args []any
|
||||
setupMock func(mock cmock.ClickConnMockCommon, args ...any)
|
||||
@@ -53,7 +52,7 @@ func TestClient_QuerySamples(t *testing.T) {
|
||||
{Name: "instance", Value: "localhost:9091"},
|
||||
},
|
||||
},
|
||||
metricNames: []string{"cpu_usage"},
|
||||
metricName: "cpu_usage",
|
||||
subQuery: "SELECT metric_name, fingerprint, unix_milli, value, flags",
|
||||
expectedTimeSeries: 2,
|
||||
expectError: false,
|
||||
@@ -98,10 +97,10 @@ func TestClient_QuerySamples(t *testing.T) {
|
||||
telemetryStore := telemetrystoretest.New(telemetrystore.Config{Provider: "clickhouse"}, sqlmock.QueryMatcherRegexp)
|
||||
readClient := client{telemetryStore: telemetryStore}
|
||||
if tt.setupMock != nil {
|
||||
tt.setupMock(telemetryStore.Mock(), "cpu_usage", tt.start, tt.end)
|
||||
tt.setupMock(telemetryStore.Mock(), tt.metricName, tt.start, tt.end)
|
||||
|
||||
}
|
||||
result, err := readClient.querySamples(ctx, tt.subQuery, []any{"cpu_usage", tt.start, tt.end}, tt.fingerprints)
|
||||
result, err := readClient.querySamples(ctx, tt.start, tt.end, tt.fingerprints, tt.metricName, tt.subQuery, tt.args)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
@@ -116,6 +115,101 @@ func TestClient_QuerySamples(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_getFingerprintsFromClickhouseQuery(t *testing.T) {
|
||||
cols := []cmock.ColumnType{
|
||||
{Name: "fingerprint", Type: "UInt64"},
|
||||
{Name: "labels", Type: "String"},
|
||||
}
|
||||
|
||||
sortLabels := func(ls []prompb.Label) {
|
||||
sort.Slice(ls, func(i, j int) bool {
|
||||
if ls[i].Name == ls[j].Name {
|
||||
return ls[i].Value < ls[j].Value
|
||||
}
|
||||
return ls[i].Name < ls[j].Name
|
||||
})
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
start, end int64
|
||||
metricName string
|
||||
subQuery string
|
||||
args []any
|
||||
setupMock func(m cmock.ClickConnMockCommon, args ...any)
|
||||
want map[uint64][]prompb.Label
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "happy-path - two fingerprints",
|
||||
start: 1000,
|
||||
end: 2000,
|
||||
metricName: "cpu_usage",
|
||||
subQuery: `SELECT fingerprint,labels`,
|
||||
// args slice is empty here, but test‑case still owns it
|
||||
args: []any{},
|
||||
|
||||
setupMock: func(m cmock.ClickConnMockCommon, args ...any) {
|
||||
rows := [][]any{
|
||||
{uint64(123), `{"t1":"s1","t2":"s2"}`},
|
||||
{uint64(234), `{"t1":"s1","t2":"s2","empty":""}`},
|
||||
}
|
||||
m.ExpectQuery(`SELECT fingerprint,labels`).WithArgs(args...).WillReturnRows(
|
||||
cmock.NewRows(cols, rows),
|
||||
)
|
||||
},
|
||||
|
||||
// No synthetic fingerprint label (#8563), empty-valued labels
|
||||
// dropped: both fingerprints present one labelset for
|
||||
// querySamples to merge.
|
||||
want: map[uint64][]prompb.Label{
|
||||
123: {
|
||||
{Name: "t1", Value: "s1"},
|
||||
{Name: "t2", Value: "s2"},
|
||||
},
|
||||
234: {
|
||||
{Name: "t1", Value: "s1"},
|
||||
{Name: "t2", Value: "s2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := telemetrystoretest.New(
|
||||
telemetrystore.Config{Provider: "clickhouse"},
|
||||
sqlmock.QueryMatcherRegexp,
|
||||
)
|
||||
|
||||
if tc.setupMock != nil {
|
||||
tc.setupMock(store.Mock(), tc.args...)
|
||||
}
|
||||
|
||||
c := client{telemetryStore: store}
|
||||
|
||||
got, err := c.getFingerprintsFromClickhouseQuery(ctx, tc.subQuery, tc.args)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
require.Nil(t, got)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, len(tc.want), len(got), "fingerprint map length mismatch")
|
||||
for fp, expLabels := range tc.want {
|
||||
gotLabels, ok := got[fp]
|
||||
require.Truef(t, ok, "missing fingerprint %d", fp)
|
||||
|
||||
sortLabels(expLabels)
|
||||
sortLabels(gotLabels)
|
||||
|
||||
assert.Equalf(t, expLabels, gotLabels, "labels mismatch for fingerprint %d", fp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for the duplicate-series class behind #8563: fingerprints
|
||||
// sharing one labelset must come back as one merged series, the higher
|
||||
// fingerprint winning equal timestamps.
|
||||
@@ -157,7 +251,7 @@ func TestClient_QuerySamplesMergesIdenticalLabelSets(t *testing.T) {
|
||||
WillReturnRows(cmock.NewRows(cols, values))
|
||||
|
||||
readClient := client{telemetryStore: telemetryStore}
|
||||
result, err := readClient.querySamples(ctx, "SELECT metric_name, fingerprint, unix_milli, value, flags", []any{"requests", int64(1000), int64(3000)}, fingerprints)
|
||||
result, err := readClient.querySamples(ctx, 1000, 3000, fingerprints, "requests", "SELECT metric_name, fingerprint, unix_milli, value, flags", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []*prompb.TimeSeries{
|
||||
@@ -178,188 +272,6 @@ func TestClient_QuerySamplesMergesIdenticalLabelSets(t *testing.T) {
|
||||
}, result)
|
||||
}
|
||||
|
||||
func TestClient_getFingerprintsFromClickhouseQuery(t *testing.T) {
|
||||
cols := []cmock.ColumnType{
|
||||
{Name: "fingerprint", Type: "UInt64"},
|
||||
{Name: "labels", Type: "String"},
|
||||
}
|
||||
|
||||
sortLabels := func(ls []prompb.Label) {
|
||||
sort.Slice(ls, func(i, j int) bool {
|
||||
if ls[i].Name == ls[j].Name {
|
||||
return ls[i].Value < ls[j].Value
|
||||
}
|
||||
return ls[i].Name < ls[j].Name
|
||||
})
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
start, end int64
|
||||
metricName string
|
||||
subQuery string
|
||||
args []any
|
||||
setupMock func(m cmock.ClickConnMockCommon, args ...any)
|
||||
want map[uint64][]prompb.Label
|
||||
wantNames []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "happy-path - two fingerprints",
|
||||
start: 1000,
|
||||
end: 2000,
|
||||
metricName: "cpu_usage",
|
||||
subQuery: `SELECT fingerprint,labels`,
|
||||
// args slice is empty here, but test‑case still owns it
|
||||
args: []any{},
|
||||
|
||||
setupMock: func(m cmock.ClickConnMockCommon, args ...any) {
|
||||
rows := [][]any{
|
||||
{uint64(123), `{"__name__":"cpu_usage","t1":"s1","t2":"s2"}`},
|
||||
{uint64(234), `{"__name__":"cpu_usage","t1":"s1","t2":"s2","empty":""}`},
|
||||
}
|
||||
m.ExpectQuery(`SELECT fingerprint,labels`).WithArgs(args...).WillReturnRows(
|
||||
cmock.NewRows(cols, rows),
|
||||
)
|
||||
},
|
||||
|
||||
// No synthetic fingerprint label (#8563), empty-valued labels
|
||||
// dropped: both fingerprints present one labelset for
|
||||
// querySamples to merge.
|
||||
want: map[uint64][]prompb.Label{
|
||||
123: {
|
||||
{Name: "__name__", Value: "cpu_usage"},
|
||||
{Name: "t1", Value: "s1"},
|
||||
{Name: "t2", Value: "s2"},
|
||||
},
|
||||
234: {
|
||||
{Name: "__name__", Value: "cpu_usage"},
|
||||
{Name: "t1", Value: "s1"},
|
||||
{Name: "t2", Value: "s2"},
|
||||
},
|
||||
},
|
||||
wantNames: []string{"cpu_usage"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := telemetrystoretest.New(
|
||||
telemetrystore.Config{Provider: "clickhouse"},
|
||||
sqlmock.QueryMatcherRegexp,
|
||||
)
|
||||
|
||||
if tc.setupMock != nil {
|
||||
tc.setupMock(store.Mock(), tc.args...)
|
||||
}
|
||||
|
||||
c := client{telemetryStore: store}
|
||||
|
||||
got, gotNames, err := c.getFingerprintsFromClickhouseQuery(ctx, tc.subQuery, tc.args)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
require.Nil(t, got)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantNames, gotNames, "discovered metric names mismatch")
|
||||
require.Equal(t, len(tc.want), len(got), "fingerprint map length mismatch")
|
||||
for fp, expLabels := range tc.want {
|
||||
gotLabels, ok := got[fp]
|
||||
require.Truef(t, ok, "missing fingerprint %d", fp)
|
||||
|
||||
sortLabels(expLabels)
|
||||
sortLabels(gotLabels)
|
||||
|
||||
assert.Equalf(t, expLabels, gotLabels, "labels mismatch for fingerprint %d", fp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for nameless/regex-name selectors silently returning empty:
|
||||
// the old code reduced every __name__ matcher to `metric_name = <value>`
|
||||
// (empty string when absent). Regexes must come out anchored — Prometheus
|
||||
// matcher semantics, while ClickHouse match() substring-matches.
|
||||
func TestQueryToClickhouseQueryNameMatchers(t *testing.T) {
|
||||
query := func(matchers ...*prompb.LabelMatcher) *prompb.Query {
|
||||
return &prompb.Query{StartTimestampMs: 0, EndTimestampMs: 1000, Matchers: matchers}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query *prompb.Query
|
||||
contains []string
|
||||
absent []string
|
||||
args []any
|
||||
}{
|
||||
{
|
||||
name: "exact name",
|
||||
query: query(&prompb.LabelMatcher{Type: prompb.LabelMatcher_EQ, Name: "__name__", Value: "cpu_usage"}),
|
||||
contains: []string{"metric_name = ?"},
|
||||
args: []any{"cpu_usage"},
|
||||
},
|
||||
{
|
||||
name: "regex name is anchored",
|
||||
query: query(&prompb.LabelMatcher{Type: prompb.LabelMatcher_RE, Name: "__name__", Value: ".+"}),
|
||||
contains: []string{"match(metric_name, ?)"},
|
||||
args: []any{"^(?:.+)$"},
|
||||
},
|
||||
{
|
||||
name: "nameless selector has no metric_name condition",
|
||||
query: query(
|
||||
&prompb.LabelMatcher{Type: prompb.LabelMatcher_EQ, Name: "job", Value: "api"},
|
||||
&prompb.LabelMatcher{Type: prompb.LabelMatcher_NRE, Name: "group", Value: "can.*"},
|
||||
),
|
||||
contains: []string{
|
||||
"JSONExtractString(labels, ?) = ?",
|
||||
"not match(JSONExtractString(labels, ?), ?)",
|
||||
},
|
||||
absent: []string{"metric_name"},
|
||||
args: []any{"job", "api", "group", "^(?:can.*)$"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
lookup, err := seriesLookupQuery(tt.query, false)
|
||||
require.NoError(t, err)
|
||||
sql, args := lookup.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
for _, want := range tt.contains {
|
||||
assert.Contains(t, sql, want)
|
||||
}
|
||||
for _, notWant := range tt.absent {
|
||||
assert.NotContains(t, sql, notWant)
|
||||
}
|
||||
assert.Equal(t, tt.args, args)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The samples query narrows by the metric names the lookup discovered and
|
||||
// embeds the series lookup as a subquery, the builder merging its args in
|
||||
// render order.
|
||||
func TestBuildSamplesQueryMetricNames(t *testing.T) {
|
||||
sub := sqlbuilder.NewSelectBuilder()
|
||||
sub.Select("fingerprint")
|
||||
sub.From("t")
|
||||
sub.Where(sub.E("k", "v"))
|
||||
|
||||
sql, args := buildSamplesQuery(5, 9, []string{"a_total", "b_total"}, sub)
|
||||
assert.Contains(t, sql, "metric_name IN (?, ?)")
|
||||
assert.Contains(t, sql, "fingerprint GLOBAL IN (SELECT fingerprint FROM t WHERE k = ?)")
|
||||
assert.Contains(t, sql, "unix_milli >= ? AND unix_milli <= ?")
|
||||
assert.Equal(t, []any{"a_total", "b_total", "v", int64(5), int64(9)}, args)
|
||||
|
||||
sub2 := sqlbuilder.NewSelectBuilder()
|
||||
sub2.Select("fingerprint")
|
||||
sub2.From("t")
|
||||
sql, args = buildSamplesQuery(5, 9, nil, sub2)
|
||||
assert.NotContains(t, sql, "metric_name IN")
|
||||
assert.Equal(t, []any{int64(5), int64(9)}, args)
|
||||
}
|
||||
|
||||
// Hash grouping must stay order-insensitive (stored JSON key order is not
|
||||
// canonical across fingerprints), and a 64-bit hash collision between
|
||||
// distinct labelsets must not merge them — splitByLabelSet is that guard.
|
||||
|
||||
@@ -237,7 +237,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
|
||||
|
||||
// Mock the fingerprint query (for Prometheus label matching)
|
||||
mock.ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric").
|
||||
WithArgs("test_metric", "__name__", "test_metric").
|
||||
WillReturnRows(fingerprintRows)
|
||||
|
||||
// Mock the samples query (for Prometheus metric data)
|
||||
@@ -245,6 +245,8 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
|
||||
WithArgs(
|
||||
"test_metric",
|
||||
"test_metric",
|
||||
"__name__",
|
||||
"test_metric",
|
||||
queryStart,
|
||||
queryEnd,
|
||||
).
|
||||
|
||||
@@ -925,18 +925,20 @@ func TestPromRuleUnitCombinations(t *testing.T) {
|
||||
}
|
||||
samplesRows := cmock.NewRows(samplesCols, samplesData)
|
||||
|
||||
// args: $1=metric_name (the __name__ matcher maps onto the column)
|
||||
// args: $1=metric_name, $2=label_name, $3=label_value
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric").
|
||||
WithArgs("test_metric", "__name__", "test_metric").
|
||||
WillReturnRows(fingerprintRows)
|
||||
|
||||
// args: $1=metric_name IN (discovered names), $2=metric_name (subquery), $3=start, $4=end
|
||||
// args: $1=metric_name (outer), $2=metric_name (subquery), $3=label_name, $4=label_value, $5=start, $6=end
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
|
||||
WithArgs(
|
||||
"test_metric",
|
||||
"test_metric",
|
||||
"__name__",
|
||||
"test_metric",
|
||||
queryStart,
|
||||
queryEnd,
|
||||
).
|
||||
@@ -1061,7 +1063,7 @@ func TestPromRuleNoData(t *testing.T) {
|
||||
// no rows == no data
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric").
|
||||
WithArgs("test_metric", "__name__", "test_metric").
|
||||
WillReturnRows(fingerprintRows)
|
||||
|
||||
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
|
||||
@@ -1271,7 +1273,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
|
||||
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric").
|
||||
WithArgs("test_metric", "__name__", "test_metric").
|
||||
WillReturnRows(fingerprintRows)
|
||||
|
||||
telemetryStore.Mock().
|
||||
@@ -1279,6 +1281,8 @@ func TestMultipleThresholdPromRule(t *testing.T) {
|
||||
WithArgs(
|
||||
"test_metric",
|
||||
"test_metric",
|
||||
"__name__",
|
||||
"test_metric",
|
||||
queryStart,
|
||||
queryEnd,
|
||||
).
|
||||
@@ -1435,12 +1439,12 @@ func TestPromRule_NoData(t *testing.T) {
|
||||
labelsJSON := `{"__name__":"test_metric"}`
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric").
|
||||
WithArgs("test_metric", "__name__", "test_metric").
|
||||
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
|
||||
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
|
||||
WithArgs("test_metric", "test_metric", queryStart, queryEnd).
|
||||
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart, queryEnd).
|
||||
WillReturnRows(cmock.NewRows(samplesCols, [][]any{}))
|
||||
|
||||
promProvider := prometheustest.New(
|
||||
@@ -1571,11 +1575,11 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
|
||||
queryStart1, queryEnd1 := calcQueryRange(t1)
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric").
|
||||
WithArgs("test_metric", "__name__", "test_metric").
|
||||
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
|
||||
WithArgs("test_metric", "test_metric", queryStart1, queryEnd1).
|
||||
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart1, queryEnd1).
|
||||
WillReturnRows(cmock.NewRows(samplesCols, [][]any{
|
||||
// Data points in the past relative to t1
|
||||
{"test_metric", fingerprint, baseTime.UnixMilli(), 100.0, uint32(0)},
|
||||
@@ -1587,11 +1591,11 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
|
||||
queryStart2, queryEnd2 := calcQueryRange(t2)
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric").
|
||||
WithArgs("test_metric", "__name__", "test_metric").
|
||||
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
|
||||
WithArgs("test_metric", "test_metric", queryStart2, queryEnd2).
|
||||
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart2, queryEnd2).
|
||||
WillReturnRows(cmock.NewRows(samplesCols, [][]any{})) // empty - no data
|
||||
|
||||
promProvider := prometheustest.New(
|
||||
@@ -1748,11 +1752,11 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
|
||||
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric").
|
||||
WithArgs("test_metric", "__name__", "test_metric").
|
||||
WillReturnRows(cmock.NewRows(fingerprintCols, fingerprintData))
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
|
||||
WithArgs("test_metric", "test_metric", queryStart, queryEnd).
|
||||
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart, queryEnd).
|
||||
WillReturnRows(cmock.NewRows(samplesCols, samplesData))
|
||||
promProvider := prometheustest.New(
|
||||
context.Background(),
|
||||
|
||||
@@ -181,15 +181,6 @@ func DataTypeCollisionHandledFieldName(key *telemetrytypes.TelemetryFieldKey, va
|
||||
// dynamic array elements will be default casted to string
|
||||
tblFieldName, value = castString(tblFieldName), toStrings(v)
|
||||
}
|
||||
case telemetrytypes.FieldDataTypeUnspecified:
|
||||
if operator == qbtypes.FilterOperatorUnknown {
|
||||
switch value.(type) {
|
||||
case float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, json.Number:
|
||||
tblFieldName = accurateCastFloat(tblFieldName)
|
||||
case string:
|
||||
tblFieldName = castString(tblFieldName)
|
||||
}
|
||||
}
|
||||
}
|
||||
return tblFieldName, value
|
||||
}
|
||||
|
||||
@@ -162,28 +162,18 @@ func (c *conditionBuilder) ConditionFor(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
keys := querybuilder.MatchingFieldKeys(key, fieldKeys)
|
||||
keys, warning := querybuilder.ResolveKeys(key, querybuilder.MatchingFieldKeys(key, fieldKeys))
|
||||
var warnings []string
|
||||
if warning != "" {
|
||||
warnings = append(warnings, warning)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
if _, isColumn := timeSeriesV4Columns[key.Name]; isColumn {
|
||||
keys = []*telemetrytypes.TelemetryFieldKey{key}
|
||||
} else {
|
||||
if len(fieldKeys[key.Name]) == 0 {
|
||||
warnings = append(warnings, fmt.Sprintf("label `%s` not found in metadata; check the label name for typos", key.Name))
|
||||
}
|
||||
keys = []*telemetrytypes.TelemetryFieldKey{
|
||||
telemetrytypes.NewTelemetryFieldKey(key.Name, telemetrytypes.FieldContextAttribute, key.FieldDataType),
|
||||
}
|
||||
if key.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
keys = append(keys, telemetrytypes.NewTelemetryFieldKey(
|
||||
key.FieldContext.StringValue()+"."+key.Name, telemetrytypes.FieldContextAttribute, key.FieldDataType))
|
||||
}
|
||||
}
|
||||
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
|
||||
}
|
||||
|
||||
conds := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, k, operator, value, sb)
|
||||
cond, err := c.conditionForKey(ctx, orgID, startNs, endNs, k, operator, value, sb)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -191,3 +181,21 @@ func (c *conditionBuilder) ConditionFor(
|
||||
}
|
||||
return conds, warnings, nil
|
||||
}
|
||||
|
||||
func (c *conditionBuilder) conditionForKey(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
startNs uint64,
|
||||
endNs uint64,
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
operator qbtypes.FilterOperator,
|
||||
value any,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
) (string, error) {
|
||||
condition, err := c.conditionFor(ctx, orgID, startNs, endNs, key, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return condition, nil
|
||||
}
|
||||
|
||||
@@ -307,86 +307,3 @@ func TestConditionForMultipleKeys(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionForKeyNotInMetadata(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
key telemetrytypes.TelemetryFieldKey
|
||||
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey
|
||||
operator qbtypes.FilterOperator
|
||||
value any
|
||||
expectedSQL []string
|
||||
expectWarn bool
|
||||
}{
|
||||
{
|
||||
name: "intrinsic metric_name full-text resolves without warning",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "metric_name", FieldContext: telemetrytypes.FieldContextMetric},
|
||||
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
|
||||
operator: qbtypes.FilterOperatorRegexp,
|
||||
value: "k8s",
|
||||
expectedSQL: []string{"match(metric_name, ?)"},
|
||||
expectWarn: false,
|
||||
},
|
||||
{
|
||||
name: "unknown label resolves to labels extract with a typo warning",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "foo", FieldContext: telemetrytypes.FieldContextUnspecified},
|
||||
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "bar",
|
||||
expectedSQL: []string{"JSONExtractString(labels, 'foo') = ?"},
|
||||
expectWarn: true,
|
||||
},
|
||||
{
|
||||
name: "context prefix that may be part of the name tries both readings",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "a.b.c", FieldContext: telemetrytypes.FieldContextScope},
|
||||
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "x",
|
||||
expectedSQL: []string{"JSONExtractString(labels, 'a.b.c') = ?", "JSONExtractString(labels, 'scope.a.b.c') = ?"},
|
||||
expectWarn: true,
|
||||
},
|
||||
{
|
||||
name: "unresolved metric-context name is treated as a label prefix",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "foo", FieldContext: telemetrytypes.FieldContextMetric},
|
||||
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "bar",
|
||||
expectedSQL: []string{"JSONExtractString(labels, 'foo') = ?", "JSONExtractString(labels, 'metric.foo') = ?"},
|
||||
expectWarn: true,
|
||||
},
|
||||
{
|
||||
name: "known label under a mismatched context collapses without warning",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "region", FieldContext: telemetrytypes.FieldContextResource},
|
||||
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"region": {{Name: "region", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString}},
|
||||
},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "us",
|
||||
expectedSQL: []string{"JSONExtractString(labels, 'region') = ?"},
|
||||
expectWarn: false,
|
||||
},
|
||||
}
|
||||
|
||||
fm := NewFieldMapper()
|
||||
conditionBuilder := NewConditionBuilder(fm)
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
cond, warnings, err := conditionBuilder.ConditionFor(ctx, valuer.UUID{}, 0, 0, &tc.key, tc.fieldKeys, qbtypes.ConditionBuilderOptions{}, tc.operator, tc.value, sb)
|
||||
require.NoError(t, err)
|
||||
sb.Where(cond...)
|
||||
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
for _, want := range tc.expectedSQL {
|
||||
assert.Contains(t, sql, want)
|
||||
}
|
||||
if tc.expectWarn {
|
||||
assert.NotEmpty(t, warnings)
|
||||
} else {
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,24 +49,23 @@ func (c *conditionBuilder) conditionFor(
|
||||
|
||||
// TODO(srikanthccv): maybe extend this to every possible attribute
|
||||
if key.Name == "duration_nano" || key.Name == "durationNano" { // QoL improvement
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if duration, err := time.ParseDuration(v); err == nil {
|
||||
if strDuration, ok := value.(string); ok {
|
||||
duration, err := time.ParseDuration(strDuration)
|
||||
if err == nil {
|
||||
value = duration.Nanoseconds()
|
||||
} else if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
value = int64(f)
|
||||
} else {
|
||||
return "", errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid duration value: %s", v)
|
||||
duration, err := strconv.ParseFloat(strDuration, 64)
|
||||
if err == nil {
|
||||
value = duration
|
||||
} else {
|
||||
return "", errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid duration value: %s", strDuration)
|
||||
}
|
||||
}
|
||||
case float64:
|
||||
value = int64(v)
|
||||
case float32:
|
||||
value = int64(v)
|
||||
}
|
||||
} else {
|
||||
fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, fieldExpression, operator)
|
||||
}
|
||||
|
||||
fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, fieldExpression, operator)
|
||||
|
||||
// regular operators
|
||||
switch operator {
|
||||
// regular operators
|
||||
|
||||
@@ -367,7 +367,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), __limit_cte AS (SELECT toString(multiIf(response_status_code <> '', response_status_code, NULL)) AS `__GROUP_BY_KEY_0_responseStatusCode`, quantile(0.90)(multiIf(duration_nano <> 0, accurateCastOrNull(duration_nano, 'Float64'), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_responseStatusCode` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(response_status_code <> '', response_status_code, NULL)) AS `__GROUP_BY_KEY_0_responseStatusCode`, quantile(0.90)(multiIf(duration_nano <> 0, accurateCastOrNull(duration_nano, 'Float64'), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_responseStatusCode`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_responseStatusCode` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_responseStatusCode`",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), __limit_cte AS (SELECT toString(multiIf(response_status_code <> '', response_status_code, NULL)) AS `__GROUP_BY_KEY_0_responseStatusCode`, quantile(0.90)(multiIf(duration_nano <> 0, duration_nano, NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_responseStatusCode` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(response_status_code <> '', response_status_code, NULL)) AS `__GROUP_BY_KEY_0_responseStatusCode`, quantile(0.90)(multiIf(duration_nano <> 0, duration_nano, NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_responseStatusCode`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_responseStatusCode` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_responseStatusCode`",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
|
||||
@@ -343,7 +343,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND toFloat64(response_status_code) < ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, avg(multiIf(duration_nano <> 0, accurateCastOrNull(duration_nano, 'Float64'), mapContains(attributes_number, 'duration_nano'), toFloat64(attributes_number['duration_nano']), NULL)) AS __result_0 FROM A_AND_B GROUP BY `service.name` ORDER BY __result_0 desc SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND toFloat64(response_status_code) < ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, avg(multiIf(duration_nano <> 0, duration_nano, mapContains(attributes_number, 'duration_nano'), toFloat64(attributes_number['duration_nano']), NULL)) AS __result_0 FROM A_AND_B GROUP BY `service.name` ORDER BY __result_0 desc SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), float64(400)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
|
||||
@@ -613,7 +613,7 @@ func (o OrderBy) Copy() OrderBy {
|
||||
type SecondaryAggregation struct {
|
||||
// stepInterval of the query
|
||||
// if not set, it will use the step interval of the primary aggregation
|
||||
StepInterval Step `json:"stepInterval,omitzero"`
|
||||
StepInterval Step `json:"stepInterval,omitempty"`
|
||||
// expression to aggregate. example: count(), sum(item_price), countIf(day > 10)
|
||||
Expression string `json:"expression"`
|
||||
// if any, it will be used as the alias of the aggregation in the result
|
||||
|
||||
@@ -16,7 +16,7 @@ type QueryBuilderQuery[T any] struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
// stepInterval of the query
|
||||
StepInterval Step `json:"stepInterval,omitzero"`
|
||||
StepInterval Step `json:"stepInterval,omitempty"`
|
||||
|
||||
// signal to query
|
||||
Signal telemetrytypes.Signal `json:"signal,omitempty"`
|
||||
|
||||
@@ -709,40 +709,3 @@ func TestQueryBuilderQuery_MetricAggregation_MarshalJSONEnumRoundTrip(t *testing
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// stepInterval is a struct-backed Step, so omitempty had no effect and an unset
|
||||
// value serialized as 0; ,omitzero omits it when unset while still echoing a set
|
||||
// value (as seconds), keeping the create -> GET round-trip stable.
|
||||
func TestQueryBuilderQuery_StepInterval_MarshalRoundTrip(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
query QueryBuilderQuery[LogAggregation]
|
||||
present []string
|
||||
absent []string
|
||||
}{
|
||||
{
|
||||
name: "UnsetStepIntervalIsOmitted",
|
||||
query: QueryBuilderQuery[LogAggregation]{Name: "A", Signal: telemetrytypes.SignalLogs},
|
||||
absent: []string{`"stepInterval"`},
|
||||
},
|
||||
{
|
||||
name: "SetStepIntervalIsSerializedAsSeconds",
|
||||
query: QueryBuilderQuery[LogAggregation]{Name: "A", Signal: telemetrytypes.SignalLogs, StepInterval: Step{60 * time.Second}},
|
||||
present: []string{`"stepInterval":60`},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
expected, err := json.Marshal(testCase.query)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, fragment := range testCase.present {
|
||||
assert.Contains(t, string(expected), fragment)
|
||||
}
|
||||
for _, fragment := range testCase.absent {
|
||||
assert.NotContains(t, string(expected), fragment)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func TestScalarData_MarshalJSON(t *testing.T) {
|
||||
{4.0, 5.0, 6.0},
|
||||
},
|
||||
},
|
||||
expected: `{"queryName":"test_query","columns":[{"name":"value","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[1,2,3],[4,5,6]]}`,
|
||||
expected: `{"queryName":"test_query","columns":[{"name":"value","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[1,2,3],[4,5,6]]}`,
|
||||
},
|
||||
{
|
||||
name: "scalar data with NaN",
|
||||
@@ -149,7 +149,7 @@ func TestScalarData_MarshalJSON(t *testing.T) {
|
||||
{math.Inf(1), 5.0, math.Inf(-1)},
|
||||
},
|
||||
},
|
||||
expected: `{"queryName":"test_query","columns":[{"name":"value","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[1,"NaN",3],["Inf",5,"-Inf"]]}`,
|
||||
expected: `{"queryName":"test_query","columns":[{"name":"value","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[1,"NaN",3],["Inf",5,"-Inf"]]}`,
|
||||
},
|
||||
{
|
||||
name: "scalar data with mixed types",
|
||||
@@ -168,7 +168,7 @@ func TestScalarData_MarshalJSON(t *testing.T) {
|
||||
{nil, math.Inf(1), 3.14, false},
|
||||
},
|
||||
},
|
||||
expected: `{"queryName":"test_query","columns":[{"name":"mixed","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[["string",42,"NaN",true],[null,"Inf",3.14,false]]}`,
|
||||
expected: `{"queryName":"test_query","columns":[{"name":"mixed","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[["string",42,"NaN",true],[null,"Inf",3.14,false]]}`,
|
||||
},
|
||||
{
|
||||
name: "scalar data with nested structures",
|
||||
@@ -189,7 +189,7 @@ func TestScalarData_MarshalJSON(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: `{"queryName":"test_query","columns":[{"name":"nested","signal":"","fieldContext":"","fieldDataType":"","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[{"count":10,"value":"NaN"},[1,"Inf",3]]]}`,
|
||||
expected: `{"queryName":"test_query","columns":[{"name":"nested","queryName":"test_query","aggregationIndex":0,"meta":{},"columnType":"aggregation"}],"data":[[{"count":10,"value":"NaN"},[1,"Inf",3]]]}`,
|
||||
},
|
||||
{
|
||||
name: "empty scalar data",
|
||||
|
||||
@@ -51,7 +51,7 @@ type QueryBuilderTraceOperator struct {
|
||||
Order []OrderBy `json:"order,omitzero"`
|
||||
|
||||
Aggregations []TraceAggregation `json:"aggregations,omitzero"`
|
||||
StepInterval Step `json:"stepInterval,omitzero"`
|
||||
StepInterval Step `json:"stepInterval,omitempty"`
|
||||
GroupBy []GroupByKey `json:"groupBy,omitzero"`
|
||||
|
||||
// having clause to apply to the aggregated query results
|
||||
|
||||
183
pkg/types/spantypes/spanmappersimulator.go
Normal file
183
pkg/types/spantypes/spanmappersimulator.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz-otel-collector/pkg/collectorsimulator"
|
||||
"github.com/SigNoz/signoz-otel-collector/processor/signozspanmapperprocessor"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"go.opentelemetry.io/collector/otelcol"
|
||||
"go.opentelemetry.io/collector/pdata/ptrace"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeProcessorFactoryMapFailed = errors.MustNewCode("processor_factory_map_failed")
|
||||
ErrCodeSpanMapperSimulationFailed = errors.MustNewCode("span_mapper_simulation_failed")
|
||||
)
|
||||
|
||||
const spanInputOrderAttr = "__signoz_input_idx__"
|
||||
|
||||
func SimulateSpanMappersProcessing(ctx context.Context, groups []*SpanMapperGroupWithMappers, spans []SpanMapperTestSpan) ([]SpanMapperTestSpan, []string, error) {
|
||||
enabled := filterEnabledGroupsWithMappers(groups)
|
||||
if len(enabled) < 1 {
|
||||
return spans, nil, nil
|
||||
}
|
||||
|
||||
// Tag each span with its request position so the output can be returned in
|
||||
// input order; work on copies so the caller's attribute maps stay untouched.
|
||||
inputSpans := make([]SpanMapperTestSpan, len(spans))
|
||||
for i, s := range spans {
|
||||
attrs := make(map[string]any, len(s.Attributes)+1)
|
||||
maps.Copy(attrs, s.Attributes)
|
||||
attrs[spanInputOrderAttr] = int64(i)
|
||||
inputSpans[i] = SpanMapperTestSpan{Attributes: attrs, Resource: s.Resource}
|
||||
}
|
||||
simulatorInput := SpansToPTraces(inputSpans)
|
||||
|
||||
processorFactories, err := otelcol.MakeFactoryMap(signozspanmapperprocessor.NewFactory())
|
||||
if err != nil {
|
||||
return nil, nil, errors.WrapInternalf(err, ErrCodeProcessorFactoryMapFailed, "could not construct processor factory map")
|
||||
}
|
||||
|
||||
configGenerator := func(baseConf []byte) ([]byte, error) {
|
||||
withProcessor, err := GenerateCollectorConfigWithSpanMapperProcessor(baseConf, enabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wireSpanMapperIntoTracesPipeline(withProcessor)
|
||||
}
|
||||
|
||||
// signozspanmapperprocessor does no batching; spans flow through immediately.
|
||||
timeout := 200 * time.Millisecond
|
||||
|
||||
outputTraces, collectorErrs, simErr := collectorsimulator.SimulateTracesProcessing(
|
||||
ctx,
|
||||
processorFactories,
|
||||
configGenerator,
|
||||
simulatorInput,
|
||||
timeout,
|
||||
)
|
||||
if simErr != nil {
|
||||
if errors.Is(simErr, collectorsimulator.ErrInvalidConfig) {
|
||||
return nil, nil, errors.WrapInvalidInputf(simErr, errors.CodeInvalidInput, "invalid config")
|
||||
}
|
||||
return nil, nil, errors.WrapInternalf(simErr, ErrCodeSpanMapperSimulationFailed, "could not simulate span mapper processing")
|
||||
}
|
||||
|
||||
outputSpans := PTracesToSpans(outputTraces)
|
||||
|
||||
// The simulator returns whatever has been exported when its timeout
|
||||
// elapses, without error; surface that instead of silently dropping spans.
|
||||
if len(outputSpans) != len(spans) {
|
||||
return nil, nil, errors.Newf(errors.TypeInternal, ErrCodeSpanMapperSimulationFailed, "simulation returned %d spans for %d input spans", len(outputSpans), len(spans))
|
||||
}
|
||||
|
||||
sort.Slice(outputSpans, func(i, j int) bool {
|
||||
iIdx, _ := outputSpans[i].Attributes[spanInputOrderAttr].(int64)
|
||||
jIdx, _ := outputSpans[j].Attributes[spanInputOrderAttr].(int64)
|
||||
return iIdx < jIdx
|
||||
})
|
||||
for _, s := range outputSpans {
|
||||
delete(s.Attributes, spanInputOrderAttr)
|
||||
}
|
||||
|
||||
return outputSpans, collectorErrs, nil
|
||||
}
|
||||
|
||||
func SpansToPTraces(spans []SpanMapperTestSpan) []ptrace.Traces {
|
||||
result := make([]ptrace.Traces, 0, len(spans))
|
||||
for _, s := range spans {
|
||||
td := ptrace.NewTraces()
|
||||
rs := td.ResourceSpans().AppendEmpty()
|
||||
if s.Resource != nil {
|
||||
_ = rs.Resource().Attributes().FromRaw(s.Resource)
|
||||
}
|
||||
sl := rs.ScopeSpans().AppendEmpty()
|
||||
span := sl.Spans().AppendEmpty()
|
||||
if s.Attributes != nil {
|
||||
_ = span.Attributes().FromRaw(s.Attributes)
|
||||
}
|
||||
result = append(result, td)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func PTracesToSpans(traces []ptrace.Traces) []SpanMapperTestSpan {
|
||||
result := []SpanMapperTestSpan{}
|
||||
for _, td := range traces {
|
||||
rss := td.ResourceSpans()
|
||||
for i := 0; i < rss.Len(); i++ {
|
||||
rs := rss.At(i)
|
||||
resourceAttrs := rs.Resource().Attributes().AsRaw()
|
||||
ilss := rs.ScopeSpans()
|
||||
for j := 0; j < ilss.Len(); j++ {
|
||||
spans := ilss.At(j).Spans()
|
||||
for k := 0; k < spans.Len(); k++ {
|
||||
result = append(result, SpanMapperTestSpan{
|
||||
Attributes: spans.At(k).Attributes().AsRaw(),
|
||||
Resource: resourceAttrs,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// wireSpanMapperIntoTracesPipeline appends "signozspanmapper" to
|
||||
// service.pipelines.traces.processors so the processor defined by
|
||||
// GenerateCollectorConfigWithSpanMapperProcessor actually runs against the
|
||||
// traces flowing through the simulator. Idempotent: skips appending if the
|
||||
// processor name is already present. Errors if the config has no traces
|
||||
// pipeline, since the simulation would otherwise silently pass spans through.
|
||||
func wireSpanMapperIntoTracesPipeline(confYaml []byte) ([]byte, error) {
|
||||
var conf map[string]any
|
||||
if err := yaml.Unmarshal(confYaml, &conf); err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeInvalidCollectorConfig, "failed to unmarshal collector config for pipeline wiring")
|
||||
}
|
||||
// Failed assertions yield nil, and indexing a nil map is safe,
|
||||
// so any missing/mistyped level surfaces as traces == nil below.
|
||||
service, _ := conf["service"].(map[string]any)
|
||||
pipelines, _ := service["pipelines"].(map[string]any)
|
||||
traces, _ := pipelines["traces"].(map[string]any)
|
||||
if traces == nil {
|
||||
return nil, errors.Newf(errors.TypeInternal, ErrCodeBuildMappingProcessorConfig, "collector config has no service.pipelines.traces pipeline to wire %q into", ProcessorName)
|
||||
}
|
||||
|
||||
procs, _ := traces["processors"].([]any)
|
||||
for _, p := range procs {
|
||||
if name, ok := p.(string); ok && name == ProcessorName {
|
||||
return confYaml, nil
|
||||
}
|
||||
}
|
||||
traces["processors"] = append(procs, ProcessorName)
|
||||
|
||||
out, err := yaml.Marshal(conf)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, ErrCodeBuildMappingProcessorConfig, "failed to marshal collector config after pipeline wiring")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func filterEnabledGroupsWithMappers(groups []*SpanMapperGroupWithMappers) []*SpanMapperGroupWithMappers {
|
||||
out := make([]*SpanMapperGroupWithMappers, 0, len(groups))
|
||||
for _, gm := range groups {
|
||||
if gm == nil || gm.Group == nil || !gm.Group.Enabled {
|
||||
continue
|
||||
}
|
||||
enabled := make([]*SpanMapper, 0, len(gm.Mappers))
|
||||
for _, m := range gm.Mappers {
|
||||
if m != nil && m.Enabled {
|
||||
enabled = append(enabled, m)
|
||||
}
|
||||
}
|
||||
if len(enabled) > 0 {
|
||||
out = append(out, &SpanMapperGroupWithMappers{Group: gm.Group, Mappers: enabled})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
43
pkg/types/spantypes/spanmappersimulator_test.go
Normal file
43
pkg/types/spantypes/spanmappersimulator_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSimulateSpanMappersProcessing_EndToEnd is an integration test: it spins
|
||||
// up an actual in-memory otel-collector pipeline with signozspanmapperprocessor
|
||||
// and verifies the produced span has the expected target attribute.
|
||||
func TestSimulateSpanMappersProcessing_EndToEnd(t *testing.T) {
|
||||
groups := []*SpanMapperGroupWithMappers{{
|
||||
Group: &SpanMapperGroup{
|
||||
Name: "llm",
|
||||
Condition: SpanMapperGroupCondition{Attributes: []string{"model"}},
|
||||
Enabled: true,
|
||||
},
|
||||
Mappers: []*SpanMapper{{
|
||||
Name: "gen_ai.request.model",
|
||||
FieldContext: FieldContextSpanAttribute,
|
||||
Config: SpanMapperConfig{Sources: []SpanMapperSource{
|
||||
{Key: "llm.model", Context: FieldContextSpanAttribute, Operation: SpanMapperOperationCopy, Priority: 1},
|
||||
}},
|
||||
Enabled: true,
|
||||
}},
|
||||
}}
|
||||
|
||||
spans := []SpanMapperTestSpan{{Attributes: map[string]any{"llm.model": "gpt-4"}}}
|
||||
|
||||
out, _, err := SimulateSpanMappersProcessing(context.Background(), groups, spans)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 1)
|
||||
assert.Equal(t, "gpt-4", out[0].Attributes["gen_ai.request.model"], "target attribute must be populated by the spanmapper processor")
|
||||
// Source attribute is preserved (copy, not move).
|
||||
assert.Equal(t, "gpt-4", out[0].Attributes["llm.model"])
|
||||
// Order-tracking attribute is stripped from the output.
|
||||
_, hasOrderAttr := out[0].Attributes[spanInputOrderAttr]
|
||||
assert.False(t, hasOrderAttr, "internal ordering attribute must be removed from the response")
|
||||
}
|
||||
54
pkg/types/spantypes/spanmappertest.go
Normal file
54
pkg/types/spantypes/spanmappertest.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type SpanMapperTestSpan struct {
|
||||
Attributes map[string]any `json:"attributes"`
|
||||
Resource map[string]any `json:"resource"`
|
||||
}
|
||||
|
||||
// Mappers is optional because the module can backfill from the store by Group.Name.
|
||||
type PostableSpanMapperTestGroup struct {
|
||||
PostableSpanMapperGroup
|
||||
Mappers []PostableSpanMapper `json:"mappers"`
|
||||
}
|
||||
|
||||
type PostableSpanMapperTest struct {
|
||||
Spans []SpanMapperTestSpan `json:"spans" required:"true"`
|
||||
Groups []PostableSpanMapperTestGroup `json:"groups" required:"true"`
|
||||
}
|
||||
|
||||
type GettableSpanMapperTest struct {
|
||||
Spans []SpanMapperTestSpan `json:"spans"`
|
||||
CollectorLogs []string `json:"collectorLogs"`
|
||||
}
|
||||
|
||||
func NewSpanMapperGroupsWithMappersFromPostable(orgID valuer.UUID, in []PostableSpanMapperTestGroup) []*SpanMapperGroupWithMappers {
|
||||
out := make([]*SpanMapperGroupWithMappers, 0, len(in))
|
||||
for _, pg := range in {
|
||||
var mappers []*SpanMapper
|
||||
if pg.Mappers != nil {
|
||||
mappers = make([]*SpanMapper, 0, len(pg.Mappers))
|
||||
for _, pm := range pg.Mappers {
|
||||
mappers = append(mappers, &SpanMapper{
|
||||
Name: pm.Name,
|
||||
FieldContext: pm.FieldContext,
|
||||
Config: pm.Config,
|
||||
Enabled: pm.Enabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
out = append(out, &SpanMapperGroupWithMappers{
|
||||
Group: &SpanMapperGroup{
|
||||
OrgID: orgID,
|
||||
Name: pg.Name,
|
||||
Condition: pg.Condition,
|
||||
Enabled: pg.Enabled,
|
||||
},
|
||||
Mappers: mappers,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz-otel-collector/exporter/jsontypeexporter"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -22,24 +21,23 @@ const (
|
||||
// BodyJSONStringSearchPrefix is the prefix used for body JSON search queries.
|
||||
// e.g., "body.status" where "body." is the prefix.
|
||||
BodyJSONStringSearchPrefix = "body."
|
||||
ArraySep = jsontypeexporter.ArraySeparator
|
||||
ArraySepSuffix = "[]"
|
||||
// ArraySep must match the array separator written by the collector's JSON
|
||||
// type exporter; the shared constant (jsontypeexporter.ArraySeparator) was
|
||||
// removed from signoz-otel-collector, so the value is duplicated here.
|
||||
ArraySep = "[]."
|
||||
ArraySepSuffix = "[]"
|
||||
// TODO(Piyush): Remove once we've migrated to the new array syntax.
|
||||
ArrayAnyIndex = "[*]."
|
||||
ArrayAnyIndexSuffix = "[*]"
|
||||
)
|
||||
|
||||
type TelemetryFieldKey struct {
|
||||
Name string `json:"name" validate:"required" required:"true"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
// signal/fieldContext/fieldDataType always serialize (empty included): the empty
|
||||
// value is a first-class "unspecified / any" selection a client can set, so it
|
||||
// must round-trip verbatim rather than be dropped. Their Enum()s include the
|
||||
// empty member so the "" is a valid schema value.
|
||||
Signal Signal `json:"signal"`
|
||||
FieldContext FieldContext `json:"fieldContext"`
|
||||
FieldDataType FieldDataType `json:"fieldDataType"`
|
||||
Name string `json:"name" validate:"required" required:"true"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Signal Signal `json:"signal,omitzero"`
|
||||
FieldContext FieldContext `json:"fieldContext,omitzero"`
|
||||
FieldDataType FieldDataType `json:"fieldDataType,omitzero"`
|
||||
|
||||
JSONPlan JSONAccessPlan `json:"-"`
|
||||
Indexes []TelemetryFieldKeySkipIndex `json:"-"`
|
||||
|
||||
@@ -185,6 +185,5 @@ func (FieldContext) Enum() []any {
|
||||
FieldContextAttribute,
|
||||
// FieldContextEvent,
|
||||
FieldContextBody,
|
||||
FieldContextUnspecified,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,6 @@ func (FieldDataType) Enum() []any {
|
||||
FieldDataTypeFloat64,
|
||||
FieldDataTypeInt64,
|
||||
FieldDataTypeNumber,
|
||||
FieldDataTypeUnspecified,
|
||||
// FieldDataTypeArrayString,
|
||||
// FieldDataTypeArrayFloat64,
|
||||
// FieldDataTypeArrayBool,
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz-otel-collector/exporter/jsontypeexporter"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -76,7 +75,7 @@ func (n *JSONAccessNode) Alias() string {
|
||||
parentAlias := strings.TrimLeft(n.Parent.Alias(), "`")
|
||||
parentAlias = strings.TrimRight(parentAlias, "`")
|
||||
|
||||
sep := jsontypeexporter.ArraySeparator
|
||||
sep := ArraySep
|
||||
if n.Parent.isRoot {
|
||||
sep = "."
|
||||
}
|
||||
|
||||
@@ -19,6 +19,5 @@ func (Signal) Enum() []any {
|
||||
SignalTraces,
|
||||
SignalLogs,
|
||||
SignalMetrics,
|
||||
SignalUnspecified,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
const EDITOR_CONTENT = '.query-where-clause-editor .cm-content';
|
||||
const SPANS = '.query-where-clause-editor .cm-line span';
|
||||
|
||||
export async function typeExpression(page: Page, expr: string): Promise<void> {
|
||||
const editor = page.locator(EDITOR_CONTENT).first();
|
||||
await editor.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
await editor.click();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await page.keyboard.press('Delete');
|
||||
await page.keyboard.type(expr);
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
export async function tokenColor(
|
||||
page: Page,
|
||||
text: string,
|
||||
): Promise<string | null> {
|
||||
return page.evaluate(
|
||||
({ selector, target }) => {
|
||||
const el = Array.from(
|
||||
document.querySelectorAll<HTMLSpanElement>(selector),
|
||||
).find((s) => s.textContent === target);
|
||||
return el ? window.getComputedStyle(el).color : null;
|
||||
},
|
||||
{ selector: SPANS, target: text },
|
||||
);
|
||||
}
|
||||
|
||||
export async function setupTheme(
|
||||
page: Page,
|
||||
theme: 'dark' | 'light',
|
||||
): Promise<void> {
|
||||
await page.addInitScript((t) => localStorage.setItem('THEME', t), theme);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { setupTheme, tokenColor, typeExpression } from '@helpers/query-builder';
|
||||
|
||||
const LOGS_EXPLORER = '/logs/logs-explorer';
|
||||
|
||||
type ThemeConfig = {
|
||||
name: string;
|
||||
theme: 'dark' | 'light';
|
||||
colors: {
|
||||
identifier: string;
|
||||
operator: string;
|
||||
property: string;
|
||||
string: string;
|
||||
};
|
||||
};
|
||||
|
||||
const THEMES: ThemeConfig[] = [
|
||||
{
|
||||
name: 'dark (copilot)',
|
||||
theme: 'dark',
|
||||
colors: {
|
||||
identifier: 'rgb(147, 157, 165)',
|
||||
operator: 'rgb(186, 142, 247)',
|
||||
property: 'rgb(255, 234, 107)',
|
||||
string: 'rgb(91, 236, 149)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'light (githubLight)',
|
||||
theme: 'light',
|
||||
colors: {
|
||||
identifier: 'rgb(0, 92, 197)',
|
||||
operator: 'rgb(0, 92, 197)',
|
||||
property: 'rgb(111, 66, 193)',
|
||||
string: 'rgb(3, 47, 98)',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, theme, colors } of THEMES) {
|
||||
test.describe(`QueryBuilder token highlighting — ${name}`, () => {
|
||||
test(`k8s.namespace.name — each segment gets its own color`, async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await setupTheme(page, theme);
|
||||
await page.goto(LOGS_EXPLORER);
|
||||
await typeExpression(page, 'k8s.namespace.name');
|
||||
|
||||
expect(await tokenColor(page, 'k8s')).toBe(colors.identifier);
|
||||
expect(await tokenColor(page, '.')).toBe(colors.operator);
|
||||
expect(await tokenColor(page, 'namespace')).toBe(colors.property);
|
||||
expect(await tokenColor(page, 'name')).toBe(colors.property);
|
||||
});
|
||||
|
||||
test(`['value', 'value2'] — string tokens have native theme color`, async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await setupTheme(page, theme);
|
||||
await page.goto(LOGS_EXPLORER);
|
||||
await typeExpression(page, "['value', 'value2']");
|
||||
|
||||
expect(await tokenColor(page, "'value'")).toBe(colors.string);
|
||||
expect(await tokenColor(page, "'value2'")).toBe(colors.string);
|
||||
});
|
||||
});
|
||||
}
|
||||
21
tests/fixtures/traces.py
vendored
21
tests/fixtures/traces.py
vendored
@@ -968,27 +968,12 @@ CORRUPT_RESOURCES: dict[str, Any] = {
|
||||
"http_method": "corrupt_data",
|
||||
}
|
||||
|
||||
# A TYPE-CONSISTENT collision, distinct from CORRUPT_* (whose wrong-type values are
|
||||
# dropped by field-key resolution): a numeric span attribute named `duration_nano`
|
||||
# shares both the name AND a compatible type with the intrinsic duration_nano (UInt64)
|
||||
# column, so resolution unions it with the column into a multiIf. This exercises the
|
||||
# collision path that regressed with ClickHouse NO_COMMON_TYPE (386) — the intrinsic
|
||||
# column must still win. Kept out of CORRUPT_ATTRIBUTES because a type-consistent
|
||||
# collision changes raw-select output (the multiIf stringifies the value), which the
|
||||
# list tests assert against; only aggregation/filter tests opt into this variant.
|
||||
COLLISION_ATTRIBUTES: dict[str, Any] = {
|
||||
"duration_nano": 1.0, # numeric attr vs the numeric intrinsic (type-consistent)
|
||||
}
|
||||
|
||||
|
||||
def trace_noise(variant: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""(extra_attributes, extra_resources) to merge into every span a traces test seeds, keyed by
|
||||
variant. "clean" adds nothing; "corrupt" injects wrong-type colliding intrinsic/calculated field
|
||||
names (dropped by resolution, so results are unchanged); "collision" injects a type-consistent
|
||||
numeric duration_nano attribute that unions into a multiIf, exercising collision resolution on
|
||||
aggregation/filter paths. Returns fresh dicts so callers can mutate them safely."""
|
||||
variant. "clean" adds nothing; "corrupt" injects colliding intrinsic/calculated field names and
|
||||
an attribute/resource collision so a test parametrized over both doubles as a
|
||||
collision-robustness check. Returns fresh dicts so callers can mutate them safely."""
|
||||
if variant == "clean":
|
||||
return {}, {}
|
||||
if variant == "collision":
|
||||
return dict(COLLISION_ATTRIBUTES), {}
|
||||
return dict(CORRUPT_ATTRIBUTES), dict(CORRUPT_RESOURCES)
|
||||
|
||||
@@ -200,7 +200,6 @@ def test_hosts_warnings(
|
||||
{"prod-linux-1", "dev-linux-1"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("host.namee = 'prod-linux-1'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_hosts_filter(
|
||||
@@ -258,6 +257,7 @@ def test_hosts_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param("host.namee = 'prod-linux-1'", "host.namee", id="bad_attr_name"),
|
||||
pytest.param("host.name =", None, id="trailing_op"),
|
||||
pytest.param("(host.name = 'prod-linux-1'", None, id="unclosed_paren"),
|
||||
# Cases dropped — parser is permissive and accepts these silently:
|
||||
@@ -274,8 +274,8 @@ def test_hosts_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
|
||||
@@ -290,7 +290,6 @@ def test_pods_warnings(
|
||||
{"web-prod-1", "web-dev-1"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("k8s.pod.namee = 'web-prod-1'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_pods_filter(
|
||||
@@ -349,6 +348,7 @@ def test_pods_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param("k8s.pod.namee = 'web-prod-1'", "k8s.pod.namee", id="bad_attr_name"),
|
||||
pytest.param("k8s.pod.name =", None, id="trailing_op"),
|
||||
pytest.param("(k8s.pod.name = 'web-prod-1'", None, id="unclosed_paren"),
|
||||
],
|
||||
@@ -361,8 +361,8 @@ def test_pods_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
_load_pods_metrics(
|
||||
|
||||
@@ -216,7 +216,6 @@ def test_nodes_warnings(
|
||||
{"web-a-us-1", "web-b-us-1"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("k8s.node.namee = 'web-a-us-1'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_nodes_filter(
|
||||
@@ -273,6 +272,7 @@ def test_nodes_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param("k8s.node.namee = 'web-a-us-1'", "k8s.node.namee", id="bad_attr_name"),
|
||||
pytest.param("k8s.node.name =", None, id="trailing_op"),
|
||||
pytest.param("(k8s.node.name = 'web-a-us-1'", None, id="unclosed_paren"),
|
||||
],
|
||||
@@ -285,8 +285,8 @@ def test_nodes_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
|
||||
@@ -215,7 +215,6 @@ def test_namespaces_warnings(
|
||||
{"web-a-prod", "web-b-prod"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("k8s.namespace.namee = 'web-a-prod'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_namespaces_filter(
|
||||
@@ -271,6 +270,7 @@ def test_namespaces_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param("k8s.namespace.namee = 'web-a-prod'", "k8s.namespace.namee", id="bad_attr_name"),
|
||||
pytest.param("k8s.namespace.name =", None, id="trailing_op"),
|
||||
pytest.param("(k8s.namespace.name = 'web-a-prod'", None, id="unclosed_paren"),
|
||||
],
|
||||
@@ -283,8 +283,8 @@ def test_namespaces_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
|
||||
@@ -233,7 +233,6 @@ def test_clusters_warnings(
|
||||
{"web-gcp-prod", "web-aws-prod"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("k8s.cluster.namee = 'web-gcp-prod'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_clusters_filter(
|
||||
@@ -291,6 +290,7 @@ def test_clusters_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param("k8s.cluster.namee = 'web-gcp-prod'", "k8s.cluster.namee", id="bad_attr_name"),
|
||||
pytest.param("k8s.cluster.name =", None, id="trailing_op"),
|
||||
pytest.param("(k8s.cluster.name = 'web-gcp-prod'", None, id="unclosed_paren"),
|
||||
],
|
||||
@@ -303,8 +303,8 @@ def test_clusters_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
|
||||
@@ -231,7 +231,6 @@ def test_volumes_warnings(
|
||||
{"data-ns-a-prod", "data-ns-b-prod"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("k8s.persistentvolumeclaim.namee = 'data-ns-a-prod'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_volumes_filter(
|
||||
@@ -290,6 +289,11 @@ def test_volumes_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param(
|
||||
"k8s.persistentvolumeclaim.namee = 'data-ns-a-prod'",
|
||||
"k8s.persistentvolumeclaim.namee",
|
||||
id="bad_attr_name",
|
||||
),
|
||||
pytest.param("k8s.persistentvolumeclaim.name =", None, id="trailing_op"),
|
||||
pytest.param("(k8s.persistentvolumeclaim.name = 'data-ns-a-prod'", None, id="unclosed_paren"),
|
||||
],
|
||||
@@ -302,8 +306,8 @@ def test_volumes_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
|
||||
@@ -242,7 +242,6 @@ def test_deployments_warnings(
|
||||
{"web-a-prod", "web-b-prod"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("k8s.deployment.namee = 'web-a-prod'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_deployments_filter(
|
||||
@@ -302,6 +301,7 @@ def test_deployments_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param("k8s.deployment.namee = 'web-a-prod'", "k8s.deployment.namee", id="bad_attr_name"),
|
||||
pytest.param("k8s.deployment.name =", None, id="trailing_op"),
|
||||
pytest.param("(k8s.deployment.name = 'web-a-prod'", None, id="unclosed_paren"),
|
||||
],
|
||||
@@ -314,8 +314,8 @@ def test_deployments_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
|
||||
@@ -156,7 +156,6 @@ def test_statefulsets_accuracy(
|
||||
{"web-a-prod", "web-b-prod"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("k8s.statefulset.namee = 'web-a-prod'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_statefulsets_filter(
|
||||
@@ -216,6 +215,7 @@ def test_statefulsets_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param("k8s.statefulset.namee = 'web-a-prod'", "k8s.statefulset.namee", id="bad_attr_name"),
|
||||
pytest.param("k8s.statefulset.name =", None, id="trailing_op"),
|
||||
pytest.param("(k8s.statefulset.name = 'web-a-prod'", None, id="unclosed_paren"),
|
||||
],
|
||||
@@ -228,8 +228,8 @@ def test_statefulsets_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
|
||||
@@ -246,7 +246,6 @@ def test_jobs_warnings(
|
||||
{"etl-a-prod", "etl-b-prod"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("k8s.job.namee = 'etl-a-prod'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_jobs_filter(
|
||||
@@ -305,6 +304,7 @@ def test_jobs_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param("k8s.job.namee = 'etl-a-prod'", "k8s.job.namee", id="bad_attr_name"),
|
||||
pytest.param("k8s.job.name =", None, id="trailing_op"),
|
||||
pytest.param("(k8s.job.name = 'etl-a-prod'", None, id="unclosed_paren"),
|
||||
],
|
||||
@@ -317,8 +317,8 @@ def test_jobs_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
|
||||
@@ -162,7 +162,6 @@ def test_daemonsets_accuracy(
|
||||
{"logs-a-prod", "logs-b-prod"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("k8s.daemonset.namee = 'logs-a-prod'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_daemonsets_filter(
|
||||
@@ -222,6 +221,7 @@ def test_daemonsets_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param("k8s.daemonset.namee = 'logs-a-prod'", "k8s.daemonset.namee", id="bad_attr_name"),
|
||||
pytest.param("k8s.daemonset.name =", None, id="trailing_op"),
|
||||
pytest.param("(k8s.daemonset.name = 'logs-a-prod'", None, id="unclosed_paren"),
|
||||
],
|
||||
@@ -234,8 +234,8 @@ def test_daemonsets_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
|
||||
@@ -390,9 +390,6 @@ def test_logs_time_series_count(
|
||||
{
|
||||
"key": {
|
||||
"name": "host.name",
|
||||
"signal": "",
|
||||
"fieldContext": "",
|
||||
"fieldDataType": "",
|
||||
},
|
||||
"value": "linux-001",
|
||||
}
|
||||
@@ -414,9 +411,6 @@ def test_logs_time_series_count(
|
||||
{
|
||||
"key": {
|
||||
"name": "host.name",
|
||||
"signal": "",
|
||||
"fieldContext": "",
|
||||
"fieldDataType": "",
|
||||
},
|
||||
"value": "linux-000",
|
||||
}
|
||||
|
||||
@@ -66,9 +66,9 @@ def test_metrics_filter_label_context(
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
"""Metrics has no per-context storage: every label lives in the `labels` JSON, so a label
|
||||
*filter* collapses every context to JSONExtractString(labels,'region') just like group-by does.
|
||||
bare `region`, `attribute.region`, and `resource.region` are all equivalent and select `us`."""
|
||||
"""Unlike group-by (which collapses every context to labels), a label *filter* resolves via
|
||||
metadata under the label's registered (attribute) context: bare `region` and `attribute.region`
|
||||
are equivalent, but an explicit mismatched context (`resource.region`) is not found (400)."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
insert_metrics(
|
||||
[
|
||||
@@ -106,8 +106,7 @@ def test_metrics_filter_label_context(
|
||||
data = {row[0]: row[-1] for row in querier.get_scalar_table_data(response.json())}
|
||||
assert data == {"us": 30.0}, f"{expr}: {data}"
|
||||
|
||||
# resource. is a context the label is not registered under; metrics collapses it to the
|
||||
# same labels lookup, so it resolves rather than erroring.
|
||||
# resource. is a context the label is not registered under -> hard "not found".
|
||||
response = querier.make_scalar_query_request(
|
||||
signoz,
|
||||
token,
|
||||
@@ -121,7 +120,7 @@ def test_metrics_filter_label_context(
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
|
||||
def test_metrics_group_by_unknown_label(
|
||||
@@ -209,50 +208,3 @@ def test_metrics_filter_unknown_label_matches_nothing(
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert querier.get_scalar_table_data(response.json()) == []
|
||||
assert querier.get_all_warnings(response.json()) == []
|
||||
|
||||
|
||||
def test_metrics_full_text_filter_does_not_error(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
"""A bare/quoted term has no key=value form, so the visitor routes it through the metrics
|
||||
full-text search column, which is never present in the metadata keys. The condition builder
|
||||
must resolve it (not hard-error) so the query runs. Regression: a partial filter like `abc`
|
||||
used to 400 with `key <full-text-column> not found` (broke the Metrics Explorer summary)."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=METRIC,
|
||||
labels={"region": "us"},
|
||||
timestamp=now - timedelta(seconds=1),
|
||||
temporality="Unspecified",
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
value=30.0,
|
||||
)
|
||||
]
|
||||
)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# bare word and quoted term are both full-text searches; neither may 400.
|
||||
for expr in ("abc", '"abc"'):
|
||||
response = querier.make_scalar_query_request(
|
||||
signoz,
|
||||
token,
|
||||
now,
|
||||
[
|
||||
querier.build_scalar_query(
|
||||
name="A",
|
||||
signal="metrics",
|
||||
aggregations=[querier.build_metrics_aggregation(METRIC, "latest", "sum", "unspecified")],
|
||||
filter_expression=expr,
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"{expr}: {response.text}"
|
||||
# the term matches no series, and metrics emits no key-not-found warning.
|
||||
assert querier.get_scalar_table_data(response.json()) == [], f"{expr}: {response.json()}"
|
||||
assert querier.get_all_warnings(response.json()) == [], f"{expr}: {querier.get_all_warnings(response.json())}"
|
||||
|
||||
@@ -26,7 +26,7 @@ from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCo
|
||||
# intrinsic column.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("noise", ["clean", "corrupt", "collision"])
|
||||
@pytest.mark.parametrize("noise", ["clean", "corrupt"])
|
||||
def test_traces_aggregate_percentiles(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
@@ -41,10 +41,6 @@ def test_traces_aggregate_percentiles(
|
||||
Tests:
|
||||
p25 / p50 / p90 / p95 / p99 over duration_nano match numpy's linear-interpolated
|
||||
percentiles (ClickHouse quantile() uses the same interpolation for small inputs).
|
||||
Under the "collision" variant a numeric span attribute named duration_nano unions
|
||||
with the intrinsic column into a multiIf; the aggregation must still resolve to the
|
||||
intrinsic column rather than error with ClickHouse NO_COMMON_TYPE (386) — the
|
||||
regression behind the span_percentile 500.
|
||||
"""
|
||||
extra_attrs, extra_resources = trace_noise(noise)
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
@@ -253,61 +249,3 @@ def test_traces_aggregate_having_breadth(
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
data = get_scalar_table_data(response.json())
|
||||
assert {row[0] for row in data} == expected_services
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"duration_filter",
|
||||
[
|
||||
pytest.param("duration_nano > '2s'", id="duration_string"),
|
||||
pytest.param("duration_nano > 2000000000", id="raw_nanoseconds"),
|
||||
],
|
||||
)
|
||||
def test_traces_duration_nano_qol_filter_with_collision(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
duration_filter: str,
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
5 spans (durations 1s..5s) that also carry a same-named `duration_nano` span
|
||||
attribute (numeric on some, string on others).
|
||||
|
||||
Tests the duration_nano QoL filter — as a duration string ('2s') and as raw
|
||||
nanoseconds — resolves to the intrinsic column despite the collision: it parses the
|
||||
duration, filters on the real durations (3 spans exceed 2s), and doesn't error with
|
||||
ClickHouse NO_COMMON_TYPE (386). The string attribute is cast; the intrinsic column
|
||||
stays a bare comparison.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
durations_s = [1, 2, 3, 4, 5]
|
||||
spans = [
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=i + 1),
|
||||
duration=timedelta(seconds=dur_s),
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name=f"dur-qol-{dur_s}",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": "dur-qol-svc"},
|
||||
attributes={"duration_nano": 42.0} if i % 2 == 0 else {"duration_nano": "boom"},
|
||||
)
|
||||
for i, dur_s in enumerate(durations_s)
|
||||
]
|
||||
insert_traces(spans)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
query = build_traces_scalar_query(
|
||||
aggregations=[build_aggregation("count()", "cnt")],
|
||||
filter_expression=duration_filter,
|
||||
)
|
||||
response = make_scalar_query_request(signoz, token, now, [query])
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
data = get_scalar_table_data(response.json())
|
||||
assert len(data) == 1
|
||||
# durations 3s/4s/5s exceed 2s -> 3 spans; the collision must not change this.
|
||||
assert data[0][0] == 3, f"expected 3 spans with duration > 2s, got {data[0]}"
|
||||
|
||||
174
tests/integration/tests/spanmapper/01_span_mapper.py
Normal file
174
tests/integration/tests/spanmapper/01_span_mapper.py
Normal file
@@ -0,0 +1,174 @@
|
||||
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
|
||||
|
||||
GROUPS_PATH = "/api/v1/span_mapper_groups"
|
||||
|
||||
|
||||
def _auth_headers(token: str) -> dict[str, str]:
|
||||
return {
|
||||
"authorization": f"Bearer {token}",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def test_create_groups_and_simulate_with_backfill(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Create a mapping group and a mapper through the CRUD API, then hit the
|
||||
simulate endpoint with two groups: the saved one referenced by name only
|
||||
(server backfills its mappers from the store) and a second, unsaved group
|
||||
sent with inline mappers.
|
||||
|
||||
Tests:
|
||||
1. Create a group (POST /span_mapper_groups) and read back its id
|
||||
2. Create a mapper in that group (POST .../{groupId}/span_mappers)
|
||||
3. List groups and mappers and verify what we created is persisted
|
||||
4. Simulate spans against both groups: a matching span gets the saved
|
||||
group's mappers (backfilled, copy) and the inline group's mappers
|
||||
(move) applied, while a span matching no condition passes through
|
||||
5. Delete the group and verify it (and its mappers) are gone
|
||||
"""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
create_group = requests.post(
|
||||
signoz.self.host_configs["8080"].get(GROUPS_PATH),
|
||||
timeout=10,
|
||||
headers=_auth_headers(token),
|
||||
json={
|
||||
"name": "llm-backfill",
|
||||
"condition": {"attributes": ["model"], "resource": []},
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert create_group.status_code == HTTPStatus.CREATED
|
||||
group = create_group.json()["data"]
|
||||
groupId = group["id"]
|
||||
assert group["name"] == "llm-backfill"
|
||||
assert group["enabled"] is True
|
||||
|
||||
create_mapper = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{groupId}/span_mappers"),
|
||||
timeout=10,
|
||||
headers=_auth_headers(token),
|
||||
json={
|
||||
"name": "gen_ai.request.model",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "llm.model",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 1,
|
||||
}
|
||||
]
|
||||
},
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert create_mapper.status_code == HTTPStatus.CREATED
|
||||
mapper = create_mapper.json()["data"]
|
||||
assert mapper["name"] == "gen_ai.request.model"
|
||||
assert mapper["groupId"] == groupId
|
||||
|
||||
list_groups = requests.get(
|
||||
signoz.self.host_configs["8080"].get(GROUPS_PATH),
|
||||
timeout=10,
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert list_groups.status_code == HTTPStatus.OK
|
||||
assert groupId in [g["id"] for g in list_groups.json()["data"]["items"]]
|
||||
|
||||
list_mappers = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{groupId}/span_mappers"),
|
||||
timeout=10,
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert list_mappers.status_code == HTTPStatus.OK
|
||||
assert "gen_ai.request.model" in [m["name"] for m in list_mappers.json()["data"]["items"]]
|
||||
|
||||
simulate = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/test"),
|
||||
timeout=10,
|
||||
headers=_auth_headers(token),
|
||||
json={
|
||||
"spans": [
|
||||
{
|
||||
"attributes": {"llm.model": "gpt-4", "db.system": "postgres"},
|
||||
"resource": {},
|
||||
},
|
||||
# Matches no group condition, so it must pass through untouched.
|
||||
{
|
||||
"attributes": {"http.method": "GET"},
|
||||
"resource": {},
|
||||
},
|
||||
],
|
||||
"groups": [
|
||||
# No "mappers" key: the server backfills them from the saved group.
|
||||
{
|
||||
"name": "llm-backfill",
|
||||
"condition": {"attributes": ["model"], "resource": []},
|
||||
"enabled": True,
|
||||
},
|
||||
# Unsaved group; mappers provided inline.
|
||||
{
|
||||
"name": "db-inline",
|
||||
"condition": {"attributes": ["db"], "resource": []},
|
||||
"enabled": True,
|
||||
"mappers": [
|
||||
{
|
||||
"name": "db.name",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "db.system",
|
||||
"context": "attribute",
|
||||
"operation": "move",
|
||||
"priority": 1,
|
||||
}
|
||||
]
|
||||
},
|
||||
"enabled": True,
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert simulate.status_code == HTTPStatus.OK
|
||||
out_spans = simulate.json()["data"]["spans"]
|
||||
assert len(out_spans) == 2
|
||||
attrs = out_spans[0]["attributes"]
|
||||
assert attrs["gen_ai.request.model"] == "gpt-4" # backfilled mapper applied
|
||||
assert attrs["llm.model"] == "gpt-4" # copy preserves source
|
||||
assert attrs["db.name"] == "postgres" # inline mapper applied
|
||||
assert "db.system" not in attrs # move removes source
|
||||
assert out_spans[1]["attributes"] == {"http.method": "GET"} # unchanged
|
||||
|
||||
delete_group = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{groupId}"),
|
||||
timeout=10,
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert delete_group.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
list_after_delete = requests.get(
|
||||
signoz.self.host_configs["8080"].get(GROUPS_PATH),
|
||||
timeout=10,
|
||||
headers=_auth_headers(token),
|
||||
)
|
||||
assert list_after_delete.status_code == HTTPStatus.OK
|
||||
assert groupId not in [g["id"] for g in list_after_delete.json()["data"]["items"]]
|
||||
0
tests/integration/tests/spanmapper/__init__.py
Normal file
0
tests/integration/tests/spanmapper/__init__.py
Normal file
Reference in New Issue
Block a user