mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-29 19:40:31 +01:00
Compare commits
26 Commits
issue_5519
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f0d5f8376 | ||
|
|
1261dbf670 | ||
|
|
78bf137a43 | ||
|
|
4e6ca583b6 | ||
|
|
32ff3dccf8 | ||
|
|
7c3ac5b221 | ||
|
|
c1d40d7359 | ||
|
|
c5c1913f97 | ||
|
|
3a992eabec | ||
|
|
5ab6636863 | ||
|
|
c348773e09 | ||
|
|
fb47df674c | ||
|
|
8269e76a3a | ||
|
|
8d7c10bd40 | ||
|
|
50fad6bb19 | ||
|
|
a1c9b86ff5 | ||
|
|
5a504fd6db | ||
|
|
dcde938423 | ||
|
|
a0b2256d46 | ||
|
|
6bbf5473dd | ||
|
|
38d056b9c0 | ||
|
|
8ad7d2dd20 | ||
|
|
7dc5b1fd0b | ||
|
|
0b6cc1d21f | ||
|
|
9889212225 | ||
|
|
ae463fa042 |
@@ -141,6 +141,10 @@ querier:
|
||||
flux_interval: 5m
|
||||
# The maximum number of concurrent queries for missing ranges.
|
||||
max_concurrent_queries: 4
|
||||
# When filtering logs by trace_id, clamp the query window to the trace time
|
||||
# range with padding to include slightly delayed log exports. Logs only; set
|
||||
# to 0 to disable.
|
||||
log_trace_id_window_padding: 5m
|
||||
|
||||
##################### TelemetryStore #####################
|
||||
telemetrystore:
|
||||
|
||||
@@ -2553,17 +2553,6 @@ components:
|
||||
url:
|
||||
type: string
|
||||
type: object
|
||||
DashboardTextVariableSpec:
|
||||
properties:
|
||||
constant:
|
||||
type: boolean
|
||||
display:
|
||||
$ref: '#/components/schemas/VariableDisplay'
|
||||
name:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesAxes:
|
||||
properties:
|
||||
isLogScale:
|
||||
@@ -2673,6 +2662,22 @@ components:
|
||||
updatedBy:
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesDashboardPanelRef:
|
||||
properties:
|
||||
dashboardId:
|
||||
type: string
|
||||
dashboardName:
|
||||
type: string
|
||||
panelId:
|
||||
type: string
|
||||
panelName:
|
||||
type: string
|
||||
required:
|
||||
- dashboardId
|
||||
- dashboardName
|
||||
- panelId
|
||||
- panelName
|
||||
type: object
|
||||
DashboardtypesDashboardSpec:
|
||||
properties:
|
||||
datasources:
|
||||
@@ -2745,24 +2750,23 @@ components:
|
||||
DashboardtypesDatasourcePlugin:
|
||||
discriminator:
|
||||
mapping:
|
||||
signoz/Datasource: '#/components/schemas/DashboardtypesDatasourcePluginVariantStruct'
|
||||
signoz/Datasource: '#/components/schemas/DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpec'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDatasourcePluginVariantStruct'
|
||||
- $ref: '#/components/schemas/DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpec'
|
||||
type: object
|
||||
DashboardtypesDatasourcePluginKind:
|
||||
enum:
|
||||
- signoz/Datasource
|
||||
type: string
|
||||
DashboardtypesDatasourcePluginVariantStruct:
|
||||
DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/Datasource
|
||||
type: string
|
||||
spec:
|
||||
nullable: true
|
||||
type: object
|
||||
$ref: '#/components/schemas/DashboardtypesSigNozDatasourceSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
@@ -2928,9 +2932,15 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
mode:
|
||||
$ref: '#/components/schemas/DashboardtypesLegendMode'
|
||||
position:
|
||||
$ref: '#/components/schemas/DashboardtypesLegendPosition'
|
||||
type: object
|
||||
DashboardtypesLegendMode:
|
||||
enum:
|
||||
- list
|
||||
type: string
|
||||
DashboardtypesLegendPosition:
|
||||
enum:
|
||||
- bottom
|
||||
@@ -2977,19 +2987,30 @@ components:
|
||||
customAllValue:
|
||||
type: string
|
||||
defaultValue:
|
||||
$ref: '#/components/schemas/VariableDefaultValue'
|
||||
$ref: '#/components/schemas/DashboardtypesVariableDefaultValue'
|
||||
display:
|
||||
$ref: '#/components/schemas/DashboardtypesDisplay'
|
||||
name:
|
||||
minLength: 1
|
||||
type: string
|
||||
plugin:
|
||||
$ref: '#/components/schemas/DashboardtypesVariablePlugin'
|
||||
sort:
|
||||
nullable: true
|
||||
type: string
|
||||
$ref: '#/components/schemas/DashboardtypesListVariableSpecSort'
|
||||
required:
|
||||
- display
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesListVariableSpecSort:
|
||||
enum:
|
||||
- none
|
||||
- alphabetical-asc
|
||||
- alphabetical-desc
|
||||
- numerical-asc
|
||||
- numerical-desc
|
||||
- alphabetical-ci-asc
|
||||
- alphabetical-ci-desc
|
||||
type: string
|
||||
DashboardtypesListableDashboardForUserV2:
|
||||
properties:
|
||||
dashboards:
|
||||
@@ -3287,7 +3308,6 @@ components:
|
||||
queries:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesQuery'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- display
|
||||
@@ -3490,6 +3510,8 @@ components:
|
||||
required:
|
||||
- queryValue
|
||||
type: object
|
||||
DashboardtypesSigNozDatasourceSpec:
|
||||
type: object
|
||||
DashboardtypesSource:
|
||||
enum:
|
||||
- user
|
||||
@@ -3499,8 +3521,13 @@ components:
|
||||
DashboardtypesSpanGaps:
|
||||
properties:
|
||||
fillLessThan:
|
||||
description: The maximum gap size to connect when fillOnlyBelow is true.
|
||||
Gaps larger than this duration are left disconnected.
|
||||
type: string
|
||||
fillOnlyBelow:
|
||||
description: Controls whether lines connect across null values. When false
|
||||
(default), all gaps are connected. When true, only gaps smaller than fillLessThan
|
||||
are connected.
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesStorableDashboardData:
|
||||
@@ -3548,6 +3575,22 @@ components:
|
||||
- color
|
||||
- columnName
|
||||
type: object
|
||||
DashboardtypesTextVariableSpec:
|
||||
properties:
|
||||
constant:
|
||||
type: boolean
|
||||
display:
|
||||
$ref: '#/components/schemas/DashboardtypesDisplay'
|
||||
name:
|
||||
minLength: 1
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
required:
|
||||
- display
|
||||
- value
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesThresholdFormat:
|
||||
enum:
|
||||
- text
|
||||
@@ -3567,7 +3610,6 @@ components:
|
||||
required:
|
||||
- value
|
||||
- color
|
||||
- label
|
||||
type: object
|
||||
DashboardtypesTimePreference:
|
||||
enum:
|
||||
@@ -3652,24 +3694,18 @@ components:
|
||||
discriminator:
|
||||
mapping:
|
||||
ListVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
|
||||
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
|
||||
TextVariable: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec'
|
||||
type: object
|
||||
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- TextVariable
|
||||
DashboardtypesVariableDefaultValue:
|
||||
oneOf:
|
||||
- type: string
|
||||
- items:
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardTextVariableSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
type: array
|
||||
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -3682,6 +3718,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- TextVariable
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesTextVariableSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesVariablePlugin:
|
||||
discriminator:
|
||||
mapping:
|
||||
@@ -3755,15 +3803,12 @@ components:
|
||||
type:
|
||||
type: string
|
||||
url:
|
||||
nullable: true
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- code
|
||||
- message
|
||||
- url
|
||||
- errors
|
||||
- retry
|
||||
- suggestions
|
||||
type: object
|
||||
ErrorsResponseerroradditional:
|
||||
@@ -3779,7 +3824,6 @@ components:
|
||||
- suggestions
|
||||
type: object
|
||||
ErrorsResponseretryjson:
|
||||
nullable: true
|
||||
properties:
|
||||
delay:
|
||||
$ref: '#/components/schemas/TimeDuration'
|
||||
@@ -5590,6 +5634,16 @@ components:
|
||||
- widgetId
|
||||
- widgetName
|
||||
type: object
|
||||
MetricsexplorertypesMetricDashboardPanelsResponse:
|
||||
properties:
|
||||
dashboards:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesDashboardPanelRef'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- dashboards
|
||||
type: object
|
||||
MetricsexplorertypesMetricDashboardsResponse:
|
||||
properties:
|
||||
dashboards:
|
||||
@@ -8118,17 +8172,6 @@ components:
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
VariableDefaultValue:
|
||||
type: object
|
||||
VariableDisplay:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
hidden:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
ZeustypesGettableHost:
|
||||
properties:
|
||||
hosts:
|
||||
@@ -22763,6 +22806,75 @@ paths:
|
||||
summary: Put profile in Zeus for a deployment.
|
||||
tags:
|
||||
- zeus
|
||||
/api/v3/metrics/dashboards:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns associated v2 dashboards for a specified
|
||||
metric
|
||||
operationId: GetMetricDashboardsV2
|
||||
parameters:
|
||||
- description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
in: query
|
||||
name: metricName
|
||||
required: true
|
||||
schema:
|
||||
description: The name of the metric. May contain slashes (e.g. cloud-provider
|
||||
metrics like run.googleapis.com/request_latencies).
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/MetricsexplorertypesMetricDashboardPanelsResponse'
|
||||
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: Get metric dashboards (v2)
|
||||
tags:
|
||||
- metrics
|
||||
/api/v3/traces/{traceID}/flamegraph:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -290,6 +290,10 @@ func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, m
|
||||
return module.pkgDashboardModule.GetByMetricNames(ctx, orgID, metricNames)
|
||||
}
|
||||
|
||||
func (module *module) GetByMetricNamesV2(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
|
||||
return module.pkgDashboardModule.GetByMetricNamesV2(ctx, orgID, metricNames)
|
||||
}
|
||||
|
||||
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
|
||||
return module.pkgDashboardModule.List(ctx, orgID)
|
||||
}
|
||||
|
||||
@@ -152,3 +152,7 @@ func (f *formatter) LowerExpression(expression string) []byte {
|
||||
sql = append(sql, ')')
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) EscapeLikePattern(value string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(value)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import type {
|
||||
GetMetricAttributesParams,
|
||||
GetMetricDashboards200,
|
||||
GetMetricDashboardsParams,
|
||||
GetMetricDashboardsV2200,
|
||||
GetMetricDashboardsV2Params,
|
||||
GetMetricHighlights200,
|
||||
GetMetricHighlightsParams,
|
||||
GetMetricMetadata200,
|
||||
@@ -1787,3 +1789,100 @@ export const useGetMetricsTreemap = <
|
||||
> => {
|
||||
return useMutation(getGetMetricsTreemapMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns associated v2 dashboards for a specified metric
|
||||
* @summary Get metric dashboards (v2)
|
||||
*/
|
||||
export const getMetricDashboardsV2 = (
|
||||
params: GetMetricDashboardsV2Params,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetMetricDashboardsV2200>({
|
||||
url: `/api/v3/metrics/dashboards`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetMetricDashboardsV2QueryKey = (
|
||||
params?: GetMetricDashboardsV2Params,
|
||||
) => {
|
||||
return [`/api/v3/metrics/dashboards`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetMetricDashboardsV2QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getMetricDashboardsV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricDashboardsV2Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboardsV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetMetricDashboardsV2QueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getMetricDashboardsV2>>
|
||||
> = ({ signal }) => getMetricDashboardsV2(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboardsV2>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetMetricDashboardsV2QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getMetricDashboardsV2>>
|
||||
>;
|
||||
export type GetMetricDashboardsV2QueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get metric dashboards (v2)
|
||||
*/
|
||||
|
||||
export function useGetMetricDashboardsV2<
|
||||
TData = Awaited<ReturnType<typeof getMetricDashboardsV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: GetMetricDashboardsV2Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getMetricDashboardsV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetMetricDashboardsV2QueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get metric dashboards (v2)
|
||||
*/
|
||||
export const invalidateGetMetricDashboardsV2 = async (
|
||||
queryClient: QueryClient,
|
||||
params: GetMetricDashboardsV2Params,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetMetricDashboardsV2QueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
@@ -2185,14 +2185,9 @@ export interface ErrorsResponseerroradditionalDTO {
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
export type ErrorsResponseretryjsonDTOAnyOf = {
|
||||
export interface ErrorsResponseretryjsonDTO {
|
||||
delay: TimeDurationDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type ErrorsResponseretryjsonDTO = ErrorsResponseretryjsonDTOAnyOf | null;
|
||||
}
|
||||
|
||||
export interface ErrorsJSONDTO {
|
||||
/**
|
||||
@@ -2207,7 +2202,7 @@ export interface ErrorsJSONDTO {
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
retry: ErrorsResponseretryjsonDTO | null;
|
||||
retry?: ErrorsResponseretryjsonDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -2217,9 +2212,9 @@ export interface ErrorsJSONDTO {
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* @type string,null
|
||||
* @type string
|
||||
*/
|
||||
url: string | null;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesOrgSessionContextDTO {
|
||||
@@ -3271,37 +3266,6 @@ export interface DashboardLinkDTO {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface VariableDisplayDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hidden?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface DashboardTextVariableSpecDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
constant?: boolean;
|
||||
display?: VariableDisplayDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAxesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -3333,6 +3297,9 @@ export interface DashboardtypesPanelFormattingDTO {
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesLegendModeDTO {
|
||||
list = 'list',
|
||||
}
|
||||
export enum DashboardtypesLegendPositionDTO {
|
||||
bottom = 'bottom',
|
||||
right = 'right',
|
||||
@@ -3352,6 +3319,7 @@ export interface DashboardtypesLegendDTO {
|
||||
* @type object,null
|
||||
*/
|
||||
customColors?: DashboardtypesLegendDTOCustomColors;
|
||||
mode?: DashboardtypesLegendModeDTO;
|
||||
position?: DashboardtypesLegendPositionDTO;
|
||||
}
|
||||
|
||||
@@ -3363,7 +3331,7 @@ export interface DashboardtypesThresholdWithLabelDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
label: string;
|
||||
label?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -3954,33 +3922,43 @@ export interface DashboardtypesDashboardDTO {
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesDatasourcePluginVariantStructDTOKind {
|
||||
export interface DashboardtypesDashboardPanelRefDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
dashboardId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
dashboardName: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
panelId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
panelName: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTOKind {
|
||||
'signoz/Datasource' = 'signoz/Datasource',
|
||||
}
|
||||
export type DashboardtypesDatasourcePluginVariantStructDTOSpecAnyOf = {
|
||||
export interface DashboardtypesSigNozDatasourceSpecDTO {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesDatasourcePluginVariantStructDTOSpec =
|
||||
DashboardtypesDatasourcePluginVariantStructDTOSpecAnyOf | null;
|
||||
|
||||
export interface DashboardtypesDatasourcePluginVariantStructDTO {
|
||||
export interface DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTO {
|
||||
/**
|
||||
* @enum signoz/Datasource
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesDatasourcePluginVariantStructDTOKind;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
spec: DashboardtypesDatasourcePluginVariantStructDTOSpec;
|
||||
kind: DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTOKind;
|
||||
spec: DashboardtypesSigNozDatasourceSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesDatasourcePluginDTO =
|
||||
DashboardtypesDatasourcePluginVariantStructDTO;
|
||||
DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpecDTO;
|
||||
|
||||
export interface DashboardtypesDatasourceSpecDTO {
|
||||
/**
|
||||
@@ -4030,10 +4008,12 @@ export enum DashboardtypesLineStyleDTO {
|
||||
export interface DashboardtypesSpanGapsDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
|
||||
*/
|
||||
fillLessThan?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
|
||||
*/
|
||||
fillOnlyBelow?: boolean;
|
||||
}
|
||||
@@ -4573,9 +4553,9 @@ export interface DashboardtypesPanelSpecDTO {
|
||||
links?: DashboardLinkDTO[];
|
||||
plugin: DashboardtypesPanelPluginDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
queries: DashboardtypesQueryDTO[] | null;
|
||||
queries: DashboardtypesQueryDTO[];
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelDTO {
|
||||
@@ -4605,9 +4585,7 @@ export type DashboardtypesLayoutDTO =
|
||||
export enum DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind {
|
||||
ListVariable = 'ListVariable',
|
||||
}
|
||||
export interface VariableDefaultValueDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type DashboardtypesVariableDefaultValueDTO = string | string[];
|
||||
|
||||
export enum DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind {
|
||||
'signoz/DynamicVariable' = 'signoz/DynamicVariable',
|
||||
@@ -4665,6 +4643,15 @@ export type DashboardtypesVariablePluginDTO =
|
||||
| DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTO
|
||||
| DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTO;
|
||||
|
||||
export enum DashboardtypesListVariableSpecSortDTO {
|
||||
none = 'none',
|
||||
'alphabetical-asc' = 'alphabetical-asc',
|
||||
'alphabetical-desc' = 'alphabetical-desc',
|
||||
'numerical-asc' = 'numerical-asc',
|
||||
'numerical-desc' = 'numerical-desc',
|
||||
'alphabetical-ci-asc' = 'alphabetical-ci-asc',
|
||||
'alphabetical-ci-desc' = 'alphabetical-ci-desc',
|
||||
}
|
||||
export interface DashboardtypesListVariableSpecDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -4682,17 +4669,15 @@ export interface DashboardtypesListVariableSpecDTO {
|
||||
* @type string
|
||||
*/
|
||||
customAllValue?: string;
|
||||
defaultValue?: VariableDefaultValueDTO;
|
||||
defaultValue?: DashboardtypesVariableDefaultValueDTO;
|
||||
display: DashboardtypesDisplayDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @minLength 1
|
||||
*/
|
||||
name?: string;
|
||||
name: string;
|
||||
plugin?: DashboardtypesVariablePluginDTO;
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
sort?: string | null;
|
||||
sort?: DashboardtypesListVariableSpecSortDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO {
|
||||
@@ -4704,21 +4689,38 @@ export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDash
|
||||
spec: DashboardtypesListVariableSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind {
|
||||
export enum DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind {
|
||||
TextVariable = 'TextVariable',
|
||||
}
|
||||
export interface DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO {
|
||||
export interface DashboardtypesTextVariableSpecDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
constant?: boolean;
|
||||
display: DashboardtypesDisplayDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @minLength 1
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO {
|
||||
/**
|
||||
* @enum TextVariable
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind;
|
||||
spec: DashboardTextVariableSpecDTO;
|
||||
kind: DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind;
|
||||
spec: DashboardtypesTextVariableSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesVariableDTO =
|
||||
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO
|
||||
| DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO;
|
||||
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTO;
|
||||
|
||||
export interface DashboardtypesDashboardSpecDTO {
|
||||
/**
|
||||
@@ -7174,6 +7176,13 @@ export interface MetricsexplorertypesMetricDashboardDTO {
|
||||
widgetName: string;
|
||||
}
|
||||
|
||||
export interface MetricsexplorertypesMetricDashboardPanelsResponseDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
dashboards: DashboardtypesDashboardPanelRefDTO[] | null;
|
||||
}
|
||||
|
||||
export interface MetricsexplorertypesMetricDashboardsResponseDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -11455,6 +11464,22 @@ export type GetHosts200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMetricDashboardsV2Params = {
|
||||
/**
|
||||
* @type string
|
||||
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
|
||||
*/
|
||||
metricName: string;
|
||||
};
|
||||
|
||||
export type GetMetricDashboardsV2200 = {
|
||||
data: MetricsexplorertypesMetricDashboardPanelsResponseDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetFlamegraphPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
|
||||
@@ -150,7 +150,7 @@ export function useVariableForm({
|
||||
const next: VariableFormModel = {
|
||||
...model,
|
||||
name: trimmedName,
|
||||
defaultValue: defaultValue ? { value: defaultValue } : undefined,
|
||||
defaultValue: defaultValue || undefined,
|
||||
};
|
||||
|
||||
const cycle = detectVariableCycle([...siblings, next]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind as TextEnvelopeKind,
|
||||
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpecDTOKind as TextEnvelopeKind,
|
||||
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind as ListEnvelopeKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
DashboardtypesVariableDTO,
|
||||
DashboardtypesVariablePluginDTO,
|
||||
DashboardTextVariableSpecDTO,
|
||||
DashboardtypesTextVariableSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
signalForApi,
|
||||
VARIABLE_SORT_DISABLED,
|
||||
type VariableFormModel,
|
||||
type VariableSort,
|
||||
} from './variableFormModel';
|
||||
|
||||
/** DTO envelope → flat form model (for display / editing). */
|
||||
@@ -37,7 +36,7 @@ export function dtoToFormModel(
|
||||
|
||||
// Text variable — a distinct envelope (no list plugin).
|
||||
if (dto.kind === TextEnvelopeKind.TextVariable) {
|
||||
const spec = dto.spec as DashboardTextVariableSpecDTO;
|
||||
const spec = dto.spec as DashboardtypesTextVariableSpecDTO;
|
||||
return {
|
||||
...common,
|
||||
type: 'TEXT',
|
||||
@@ -52,7 +51,7 @@ export function dtoToFormModel(
|
||||
...common,
|
||||
multiSelect: spec.allowMultiple ?? false,
|
||||
showAllOption: spec.allowAllValue ?? false,
|
||||
sort: (spec.sort as VariableSort) ?? VARIABLE_SORT_DISABLED,
|
||||
sort: spec.sort ?? VARIABLE_SORT_DISABLED,
|
||||
defaultValue: spec.defaultValue,
|
||||
};
|
||||
const plugin = spec.plugin;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DashboardtypesListVariableSpecSortDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardtypesVariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { sortBy } from 'lodash-es';
|
||||
|
||||
/**
|
||||
@@ -24,19 +27,19 @@ export const DYNAMIC_SIGNAL_ALL = 'all' as const;
|
||||
export type DynamicSignalOption = TelemetrySignal | typeof DYNAMIC_SIGNAL_ALL;
|
||||
|
||||
/**
|
||||
* Sort order for list-variable values. The wire (Perses) validates `sort`
|
||||
* against a fixed method set. There is no generated TS enum for it
|
||||
* (`DashboardtypesListOrderDTO` is the query-builder order, a different field),
|
||||
* so we mirror the Perses `Sort` tokens here.
|
||||
* Sort order for list-variable values, keyed by the generated wire enum so the
|
||||
* form model and the DTO `sort` field share one source of truth. The friendly
|
||||
* keys (`DISABLED` / `ASC` / …) are UI-facing; the values are the Perses `Sort`
|
||||
* tokens the wire validates against.
|
||||
*/
|
||||
export const VARIABLE_SORT = {
|
||||
DISABLED: 'none',
|
||||
ASC: 'alphabetical-asc',
|
||||
DESC: 'alphabetical-desc',
|
||||
NUMERICAL_ASC: 'numerical-asc',
|
||||
NUMERICAL_DESC: 'numerical-desc',
|
||||
CI_ASC: 'alphabetical-ci-asc',
|
||||
CI_DESC: 'alphabetical-ci-desc',
|
||||
DISABLED: DashboardtypesListVariableSpecSortDTO.none,
|
||||
ASC: DashboardtypesListVariableSpecSortDTO['alphabetical-asc'],
|
||||
DESC: DashboardtypesListVariableSpecSortDTO['alphabetical-desc'],
|
||||
NUMERICAL_ASC: DashboardtypesListVariableSpecSortDTO['numerical-asc'],
|
||||
NUMERICAL_DESC: DashboardtypesListVariableSpecSortDTO['numerical-desc'],
|
||||
CI_ASC: DashboardtypesListVariableSpecSortDTO['alphabetical-ci-asc'],
|
||||
CI_DESC: DashboardtypesListVariableSpecSortDTO['alphabetical-ci-desc'],
|
||||
} as const;
|
||||
|
||||
export type VariableSort = (typeof VARIABLE_SORT)[keyof typeof VARIABLE_SORT];
|
||||
@@ -133,7 +136,7 @@ export interface VariableFormModel {
|
||||
* Runtime-selected default, not editable in the management tab yet; carried
|
||||
* through edits so saving a definition doesn't clobber it.
|
||||
*/
|
||||
defaultValue?: VariableDefaultValueDTO;
|
||||
defaultValue?: DashboardtypesVariableDefaultValueDTO;
|
||||
}
|
||||
|
||||
export function emptyVariableFormModel(): VariableFormModel {
|
||||
|
||||
@@ -30,7 +30,6 @@ describe('panelStatusFromError', () => {
|
||||
{ message: 'missing aggregation', suggestions: [] },
|
||||
{ message: 'bad filter', suggestions: [] },
|
||||
],
|
||||
retry: null,
|
||||
suggestions: [],
|
||||
type: '',
|
||||
});
|
||||
@@ -59,7 +58,6 @@ describe('panelStatusFromError', () => {
|
||||
message: 'y',
|
||||
url: '',
|
||||
errors: [],
|
||||
retry: null,
|
||||
suggestions: [],
|
||||
type: '',
|
||||
},
|
||||
|
||||
@@ -148,7 +148,7 @@ function AnalyticsPanel({
|
||||
className="floating-panel__drag-handle"
|
||||
/>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div className={styles.body} data-testid="trace-analytics-panel">
|
||||
<TabsRoot defaultValue="exec-time" onValueChange={onTabChange}>
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="exec-time" variant="secondary">
|
||||
|
||||
@@ -60,7 +60,7 @@ function DockModeSwitcher({
|
||||
{DOCK_OPTIONS.map((option) => (
|
||||
<TooltipRoot key={option.value}>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<span data-testid={`dock-mode-${option.value}`}>
|
||||
<ToggleGroupItem value={option.value}>{option.icon}</ToggleGroupItem>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -64,7 +64,11 @@ export function SpanTooltipContent({
|
||||
{previewRows && previewRows.length > 0 && (
|
||||
<div className={styles.preview}>
|
||||
{previewRows.map((row) => (
|
||||
<div key={row.key} className={styles.row}>
|
||||
<div
|
||||
key={row.key}
|
||||
className={styles.row}
|
||||
data-testid={`span-hover-card-preview-${row.key}`}
|
||||
>
|
||||
<span className={styles.previewKey}>{row.key}:</span>{' '}
|
||||
<span className={styles.previewValue}>{row.value}</span>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useFlamegraphCrosshair } from './hooks/useFlamegraphCrosshair';
|
||||
import { useFlamegraphDrag } from './hooks/useFlamegraphDrag';
|
||||
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
|
||||
import { useFlamegraphHover } from './hooks/useFlamegraphHover';
|
||||
import { useFlamegraphTestHook } from './hooks/useFlamegraphTestHook';
|
||||
import { useFlamegraphZoom } from './hooks/useFlamegraphZoom';
|
||||
import { useScrollToSpan } from './hooks/useScrollToSpan';
|
||||
import { EventRect, FlamegraphCanvasProps, SpanRect } from './types';
|
||||
@@ -159,6 +160,14 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
|
||||
useCanvasSetup(canvasRef, containerRef, drawFlamegraph, overlayCanvasRef);
|
||||
|
||||
// E2E-only: expose the live span→rect map so specs can target canvas bars.
|
||||
// No-op unless window.__SIGNOZ_E2E__ is set (Playwright addInitScript).
|
||||
useFlamegraphTestHook({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
spanRectsRef,
|
||||
});
|
||||
|
||||
const {
|
||||
cursorXPercent,
|
||||
cursorX,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { MutableRefObject, useEffect } from 'react';
|
||||
|
||||
import { SpanRect } from '../types';
|
||||
|
||||
/**
|
||||
* E2E test hook for the canvas flamegraph. The flamegraph is `<canvas>`, so
|
||||
* individual bars have no DOM nodes to target — but `spanRectsRef` already
|
||||
* holds the live span→rectangle map (CSS pixels) used for hit-testing. This
|
||||
* exposes a thin, read-only view of it on `window.__sigTraceFlame__` so a
|
||||
* Playwright spec can resolve a span's on-screen point and drive real
|
||||
* hover/click events at it (see tests/e2e/helpers/trace-details.ts).
|
||||
*
|
||||
* Gated on `window.__SIGNOZ_E2E__` (set by Playwright via addInitScript), so
|
||||
* nothing is attached in normal runtime — the e2e build is a production build,
|
||||
* so this must be a RUNTIME flag, not a NODE_ENV/mode check.
|
||||
*/
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface FlamegraphTestApi {
|
||||
getSpanPoint: (spanId: string) => Point | null;
|
||||
isSpanInView: (spanId: string) => boolean;
|
||||
// Resting group color of a span's bar — changes when colour-by changes.
|
||||
getSpanColor: (spanId: string) => string | null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__SIGNOZ_E2E__?: boolean;
|
||||
__sigTraceFlame__?: FlamegraphTestApi;
|
||||
}
|
||||
}
|
||||
|
||||
// Inverse of `getCanvasPointer` in useFlamegraphHover: a CSS-space span rect
|
||||
// maps back to a viewport point at the bar's center.
|
||||
function rectToViewportCenter(canvas: HTMLCanvasElement, r: SpanRect): Point {
|
||||
const box = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssWidth = canvas.width / dpr;
|
||||
const cssHeight = canvas.height / dpr;
|
||||
const cssX = r.x + r.width / 2;
|
||||
const cssY = r.y + r.height / 2;
|
||||
return {
|
||||
x: box.left + cssX * (box.width / cssWidth),
|
||||
y: box.top + cssY * (box.height / cssHeight),
|
||||
};
|
||||
}
|
||||
|
||||
interface UseFlamegraphTestHookParams {
|
||||
canvasRef: MutableRefObject<HTMLCanvasElement | null>;
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>;
|
||||
spanRectsRef: MutableRefObject<SpanRect[]>;
|
||||
}
|
||||
|
||||
export function useFlamegraphTestHook({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
spanRectsRef,
|
||||
}: UseFlamegraphTestHookParams): void {
|
||||
useEffect(() => {
|
||||
if (!window.__SIGNOZ_E2E__) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Reads `.current` at call time, so it always reflects the latest draw.
|
||||
const findRect = (spanId: string): SpanRect | undefined =>
|
||||
spanRectsRef.current.find((r) => r.span.spanId === spanId);
|
||||
|
||||
window.__sigTraceFlame__ = {
|
||||
getSpanPoint: (spanId): Point | null => {
|
||||
const canvas = canvasRef.current;
|
||||
const rect = findRect(spanId);
|
||||
return canvas && rect ? rectToViewportCenter(canvas, rect) : null;
|
||||
},
|
||||
isSpanInView: (spanId): boolean => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
const rect = findRect(spanId);
|
||||
if (!canvas || !container || !rect) {
|
||||
return false;
|
||||
}
|
||||
const pt = rectToViewportCenter(canvas, rect);
|
||||
const box = container.getBoundingClientRect();
|
||||
return (
|
||||
pt.x >= box.left &&
|
||||
pt.x <= box.right &&
|
||||
pt.y >= box.top &&
|
||||
pt.y <= box.bottom
|
||||
);
|
||||
},
|
||||
getSpanColor: (spanId): string | null => findRect(spanId)?.color ?? null,
|
||||
};
|
||||
|
||||
return (): void => {
|
||||
delete window.__sigTraceFlame__;
|
||||
};
|
||||
}, [canvasRef, containerRef, spanRectsRef]);
|
||||
}
|
||||
@@ -28,6 +28,9 @@ export interface SpanRect {
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
// Resting fill color for the current colour-by grouping. Optional: only the
|
||||
// draw path sets it; consumers (e.g. the e2e colour-by hook) read it.
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface EventRect {
|
||||
|
||||
@@ -279,6 +279,9 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
|
||||
width,
|
||||
height: metrics.SPAN_BAR_HEIGHT,
|
||||
level: levelIndex,
|
||||
// Resting group color (selected/hovered bars override the fill, but this
|
||||
// still reflects the colour-by grouping — used by the e2e colour-by hook).
|
||||
color: isDarkMode ? color : colorDark,
|
||||
});
|
||||
|
||||
span.event?.forEach((event) => {
|
||||
|
||||
@@ -259,7 +259,10 @@ function Filters({
|
||||
);
|
||||
|
||||
const highlightErrorsToggle = (
|
||||
<div className={styles.highlightErrorsToggle}>
|
||||
<div
|
||||
className={styles.highlightErrorsToggle}
|
||||
data-testid="highlight-errors-toggle"
|
||||
>
|
||||
<Typography.Text>Highlight errors</Typography.Text>
|
||||
<Switch
|
||||
color="cherry"
|
||||
|
||||
@@ -246,6 +246,19 @@ const SpanOverview = memo(function SpanOverview({
|
||||
onAddSpanToFunnel(span);
|
||||
};
|
||||
|
||||
// e2e hook: expose the filter highlight/dim state as a stable attribute, since
|
||||
// the styles.* classes are hashed at build time and can't be asserted.
|
||||
let spanState = 'default';
|
||||
if (isHighlighted) {
|
||||
spanState = 'highlighted';
|
||||
} else if (isDimmed) {
|
||||
spanState = 'dimmed';
|
||||
} else if (isSelectedNonMatching) {
|
||||
spanState = 'selected-non-matching';
|
||||
} else if (isSelected) {
|
||||
spanState = 'selected';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.spanOverview, {
|
||||
@@ -254,6 +267,7 @@ const SpanOverview = memo(function SpanOverview({
|
||||
[styles.isSelectedNonMatching]: isSelectedNonMatching,
|
||||
[styles.isDimmed]: isDimmed,
|
||||
})}
|
||||
data-span-state={spanState}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
onMouseEnter={(): void => onHoverEnter(span.span_id)}
|
||||
onMouseLeave={(): void => onHoverLeave()}
|
||||
@@ -301,6 +315,7 @@ const SpanOverview = memo(function SpanOverview({
|
||||
{span.has_children && (
|
||||
<span
|
||||
className={styles.treeArrow}
|
||||
data-testid={`cell-collapse-${span.span_id}`}
|
||||
onClick={(event): void => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
@@ -187,6 +187,26 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v3/metrics/dashboards", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricDashboardsV2),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetMetricDashboardsV2",
|
||||
Tags: []string{"metrics"},
|
||||
Summary: "Get metric dashboards (v2)",
|
||||
Description: "This endpoint returns associated v2 dashboards for a specified metric",
|
||||
Request: nil,
|
||||
RequestQuery: new(metricsexplorertypes.MetricNameQuery),
|
||||
RequestContentType: "",
|
||||
Response: new(metricsexplorertypes.MetricDashboardPanelsResponse),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/metrics/inspect", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.InspectMetrics),
|
||||
handler.OpenAPIDef{
|
||||
|
||||
@@ -7,12 +7,12 @@ import (
|
||||
)
|
||||
|
||||
type JSON struct {
|
||||
Type string `json:"type" required:"true"`
|
||||
Code string `json:"code" required:"true"`
|
||||
Message string `json:"message" required:"true"`
|
||||
Url string `json:"url" required:"true" nullable:"true"`
|
||||
Type string `json:"type" required:"true" nullable:"false"`
|
||||
Code string `json:"code" required:"true" nullable:"false"`
|
||||
Message string `json:"message" required:"true" nullable:"false"`
|
||||
Url string `json:"url,omitempty" required:"false"`
|
||||
Errors []responseerroradditional `json:"errors" required:"true" nullable:"false"`
|
||||
Retry *responseretryjson `json:"retry" required:"true" nullable:"true"`
|
||||
Retry *responseretryjson `json:"retry,omitempty" required:"false"`
|
||||
Suggestions []string `json:"suggestions" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ type responseretryjson struct {
|
||||
}
|
||||
|
||||
type responseerroradditional struct {
|
||||
Message string `json:"message" required:"true"`
|
||||
Message string `json:"message" required:"true" nullable:"false"`
|
||||
Suggestions []string `json:"suggestions" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
|
||||
@@ -99,13 +99,13 @@ func TestError(t *testing.T) {
|
||||
name: "AlreadyExists",
|
||||
statusCode: http.StatusConflict,
|
||||
err: errors.New(errors.TypeAlreadyExists, errors.MustNewCode("already_exists"), "already exists").WithUrl("https://already_exists"),
|
||||
expected: []byte(`{"status":"error","error":{"type":"already-exists","code":"already_exists","message":"already exists","url":"https://already_exists","errors":[],"retry":null,"suggestions":[]}}`),
|
||||
expected: []byte(`{"status":"error","error":{"type":"already-exists","code":"already_exists","message":"already exists","url":"https://already_exists","errors":[],"suggestions":[]}}`),
|
||||
},
|
||||
"/unauthenticated": {
|
||||
name: "Unauthenticated",
|
||||
statusCode: http.StatusUnauthorized,
|
||||
err: errors.New(errors.TypeUnauthenticated, errors.MustNewCode("not_allowed"), "not allowed").WithUrl("https://unauthenticated").WithAdditional("a1", "a2"),
|
||||
expected: []byte(`{"status":"error","error":{"type":"unauthenticated","code":"not_allowed","message":"not allowed","url":"https://unauthenticated","errors":[{"message":"a1","suggestions":[]},{"message":"a2","suggestions":[]}],"retry":null,"suggestions":[]}}`),
|
||||
expected: []byte(`{"status":"error","error":{"type":"unauthenticated","code":"not_allowed","message":"not allowed","url":"https://unauthenticated","errors":[{"message":"a1","suggestions":[]},{"message":"a2","suggestions":[]}],"suggestions":[]}}`),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -177,8 +177,7 @@ func TestErrorRetryAfterHeader(t *testing.T) {
|
||||
name: "BareErrorNoHeaderNoRetryBlock",
|
||||
err: errors.New(errors.TypeInternal, errors.MustNewCode("boom"), "boom"),
|
||||
wantRetryAfter: "",
|
||||
wantBodyContains: `"retry":null`,
|
||||
wantBodyNotContains: `"delay"`,
|
||||
wantBodyNotContains: `"retry"`, // omitempty drops the nil retry block entirely
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,8 @@ type Module interface {
|
||||
UpdateView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updateable dashboardtypes.UpdatableDashboardView) (*dashboardtypes.DashboardView, error)
|
||||
|
||||
DeleteView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
|
||||
|
||||
GetByMetricNamesV2(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error)
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
|
||||
@@ -285,9 +285,9 @@ func (v *visitor) buildStringOperation(builder *sqlbuilder.SelectBuilder, ctx *g
|
||||
like = "NOT LIKE"
|
||||
}
|
||||
// Escape the user's % and _ so they match literally, then wrap in wildcards.
|
||||
// ESCAPE declares the backslash we just injected as the escape char — needed
|
||||
// on SQLite (no default) and a harmless restatement of the Postgres default.
|
||||
escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(val)
|
||||
// ESCAPE declares the backslash the escaper injected as the escape char —
|
||||
// needed on SQLite (no default) and a harmless restatement of the Postgres default.
|
||||
escaped := v.formatter.EscapeLikePattern(val)
|
||||
return fmt.Sprintf("%s %s %s ESCAPE '\\'", columnExpression, like, builder.Var("%"+escaped+"%"))
|
||||
case qbtypesv5.FilterOperatorRegexp, qbtypesv5.FilterOperatorNotRegexp:
|
||||
v.addError("REGEXP filtering on %q is not yet supported", keyForError)
|
||||
|
||||
@@ -213,6 +213,41 @@ func (store *store) sortExprForListV2(sort dashboardtypes.ListSort) (string, err
|
||||
"unsupported sort field %q", sort)
|
||||
}
|
||||
|
||||
func (store *store) ListByDataContainsAny(ctx context.Context, orgID valuer.UUID, searches []string) ([]*dashboardtypes.StorableDashboard, error) {
|
||||
storableDashboards := make([]*dashboardtypes.StorableDashboard, 0)
|
||||
if len(searches) == 0 {
|
||||
return storableDashboards, nil
|
||||
}
|
||||
|
||||
clause, args := buildContainsAnyClauseForDataColumn(store.sqlstore.Formatter(), searches)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&storableDashboards).
|
||||
Where("org_id = ?", orgID).
|
||||
Where(clause, args...).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't list dashboards by data")
|
||||
}
|
||||
|
||||
return storableDashboards, nil
|
||||
}
|
||||
|
||||
// buildContainsAnyClauseForDataColumn builds a parenthesised OR of `data LIKE` predicates, one
|
||||
// per search, matching the raw substring literally (LIKE wildcards escaped). It
|
||||
// returns the predicate and its bind args, ready for a single bun Where call.
|
||||
func buildContainsAnyClauseForDataColumn(formatter sqlstore.SQLFormatter, searches []string) (string, []any) {
|
||||
conditions := make([]string, 0, len(searches))
|
||||
args := make([]any, 0, len(searches))
|
||||
for _, search := range searches {
|
||||
conditions = append(conditions, "data LIKE ? ESCAPE '\\'")
|
||||
args = append(args, "%"+formatter.EscapeLikePattern(search)+"%")
|
||||
}
|
||||
return "(" + strings.Join(conditions, " OR ") + ")", args
|
||||
}
|
||||
|
||||
func (store *store) GetPublic(ctx context.Context, dashboardID string) (*dashboardtypes.StorablePublicDashboard, error) {
|
||||
storable := new(dashboardtypes.StorablePublicDashboard)
|
||||
err := store.
|
||||
|
||||
43
pkg/modules/dashboard/impldashboard/store_test.go
Normal file
43
pkg/modules/dashboard/impldashboard/store_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package impldashboard
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBuildContainsAnyClauseForDataColumn(t *testing.T) {
|
||||
cases := []struct {
|
||||
subtestName string
|
||||
searches []string
|
||||
expectedSQL string
|
||||
expectedArgs []any
|
||||
}{
|
||||
{
|
||||
subtestName: "single search",
|
||||
searches: []string{"http.server.duration"},
|
||||
expectedSQL: `(data LIKE ? ESCAPE '\')`,
|
||||
expectedArgs: []any{`%http.server.duration%`},
|
||||
},
|
||||
{
|
||||
subtestName: "multiple searches are OR-ed",
|
||||
searches: []string{"metric.a", "metric.b", "metric.c"},
|
||||
expectedSQL: `(data LIKE ? ESCAPE '\' OR data LIKE ? ESCAPE '\' OR data LIKE ? ESCAPE '\')`,
|
||||
expectedArgs: []any{`%metric.a%`, `%metric.b%`, `%metric.c%`},
|
||||
},
|
||||
{
|
||||
subtestName: "like wildcards in the search are escaped",
|
||||
searches: []string{`a%b_c\d`},
|
||||
expectedSQL: `(data LIKE ? ESCAPE '\')`,
|
||||
expectedArgs: []any{`%a\%b\_c\\d%`},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.subtestName, func(t *testing.T) {
|
||||
clause, args := buildContainsAnyClauseForDataColumn(formatter(t), c.searches)
|
||||
assert.Equal(t, c.expectedSQL, clause)
|
||||
assert.Equal(t, c.expectedArgs, args)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package impldashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"maps"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func (m *module) GetByMetricNamesV2(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
|
||||
metricNamesMap := make(map[string]bool, len(metricNames))
|
||||
for _, name := range metricNames {
|
||||
metricNamesMap[name] = true
|
||||
}
|
||||
|
||||
candidateDashboards, err := m.getCandidatesDashboardsForMetricNames(ctx, orgID, metricNames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m.selectDashboardsFromCandidates(ctx, metricNamesMap, candidateDashboards), nil
|
||||
}
|
||||
|
||||
func (m *module) getCandidatesDashboardsForMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) ([]*dashboardtypes.DashboardV2, error) {
|
||||
storables, err := m.store.ListByDataContainsAny(ctx, orgID, metricNames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
candidates := make([]*dashboardtypes.DashboardV2, 0, len(storables))
|
||||
for _, storable := range storables {
|
||||
if storable.Source == dashboardtypes.SourceSystem {
|
||||
continue
|
||||
}
|
||||
|
||||
// tags are not required for this process so sending a nil list here.
|
||||
dashboard, err := storable.ToDashboardV2(nil)
|
||||
if err != nil {
|
||||
m.settings.Logger().WarnContext(ctx, "skipping dashboard that couldn't be parsed as v2", slog.String("dashboard_id", storable.ID.StringValue()), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, dashboard)
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (m *module) selectDashboardsFromCandidates(ctx context.Context, metricNamesMap map[string]bool, candidateDashboards []*dashboardtypes.DashboardV2) map[string][]dashboardtypes.DashboardPanelRef {
|
||||
result := make(map[string][]dashboardtypes.DashboardPanelRef)
|
||||
for _, dashboard := range candidateDashboards {
|
||||
for panelID, panel := range dashboard.Spec.Panels {
|
||||
if panel == nil {
|
||||
continue
|
||||
}
|
||||
metricsInPanel := make(map[string]bool)
|
||||
for _, query := range panel.Spec.Queries {
|
||||
maps.Copy(metricsInPanel, m.extractMetricNamesFromQuerySpec(ctx, query.Spec.Plugin.Spec))
|
||||
}
|
||||
for metricName := range metricsInPanel {
|
||||
if !metricNamesMap[metricName] {
|
||||
continue
|
||||
}
|
||||
result[metricName] = append(result[metricName], dashboardtypes.DashboardPanelRef{
|
||||
DashboardID: dashboard.ID.StringValue(),
|
||||
DashboardName: dashboard.Spec.Display.Name,
|
||||
PanelID: panelID,
|
||||
PanelName: panel.Spec.Display.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *module) extractMetricNamesFromQuerySpec(ctx context.Context, spec any) map[string]bool {
|
||||
found := make(map[string]bool)
|
||||
switch s := spec.(type) {
|
||||
case *qbtypes.CompositeQuery:
|
||||
for _, envelope := range s.Queries {
|
||||
maps.Copy(found, m.extractMetricNamesFromQueryEnvelope(ctx, envelope))
|
||||
}
|
||||
case *dashboardtypes.BuilderQuerySpec:
|
||||
if builder, ok := s.Spec.(qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]); ok {
|
||||
for _, aggregation := range builder.Aggregations {
|
||||
if aggregation.MetricName != "" {
|
||||
found[aggregation.MetricName] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
case *qbtypes.PromQuery:
|
||||
maps.Copy(found, m.extractMetricNamesFromRawQuery(ctx, qbtypes.QueryTypePromQL, s.Query))
|
||||
case *qbtypes.ClickHouseQuery:
|
||||
maps.Copy(found, m.extractMetricNamesFromRawQuery(ctx, qbtypes.QueryTypeClickHouseSQL, s.Query))
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func (m *module) extractMetricNamesFromQueryEnvelope(ctx context.Context, envelope qbtypes.QueryEnvelope) map[string]bool {
|
||||
found := make(map[string]bool)
|
||||
switch s := envelope.Spec.(type) {
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
|
||||
for _, aggregation := range s.Aggregations {
|
||||
if aggregation.MetricName != "" {
|
||||
found[aggregation.MetricName] = true
|
||||
}
|
||||
}
|
||||
case qbtypes.PromQuery:
|
||||
maps.Copy(found, m.extractMetricNamesFromRawQuery(ctx, qbtypes.QueryTypePromQL, s.Query))
|
||||
case qbtypes.ClickHouseQuery:
|
||||
maps.Copy(found, m.extractMetricNamesFromRawQuery(ctx, qbtypes.QueryTypeClickHouseSQL, s.Query))
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func (m *module) extractMetricNamesFromRawQuery(ctx context.Context, queryType qbtypes.QueryType, query string) map[string]bool {
|
||||
found := make(map[string]bool)
|
||||
if query == "" {
|
||||
return found
|
||||
}
|
||||
result, err := m.queryParser.AnalyzeQueryFilter(ctx, queryType, query)
|
||||
if err != nil {
|
||||
m.settings.Logger().WarnContext(ctx, "failed to parse query for metric names", slog.String("query", query), errors.Attr(err))
|
||||
return found
|
||||
}
|
||||
for _, metricName := range result.MetricNames {
|
||||
found[metricName] = true
|
||||
}
|
||||
return found
|
||||
}
|
||||
@@ -228,6 +228,38 @@ func (h *handler) GetMetricDashboards(rw http.ResponseWriter, req *http.Request)
|
||||
render.Success(rw, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *handler) GetMetricDashboardsV2(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var in metricsexplorertypes.MetricNameQuery
|
||||
if err := binding.Query.BindQuery(req.URL.Query(), &in); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
if err := in.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
if err := h.checkMetricExists(req.Context(), orgID, in.MetricName); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.module.GetMetricDashboardsV2(req.Context(), orgID, in.MetricName)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
render.Success(rw, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *handler) GetMetricHighlights(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -373,22 +373,35 @@ func (m *module) GetMetricDashboards(ctx context.Context, orgID valuer.UUID, met
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to get dashboards for metric")
|
||||
}
|
||||
|
||||
dashboards := make([]metricsexplorertypes.MetricDashboard, 0)
|
||||
if dashboardList, ok := data[metricName]; ok {
|
||||
dashboards = make([]metricsexplorertypes.MetricDashboard, 0, len(dashboardList))
|
||||
for _, item := range dashboardList {
|
||||
dashboards = append(dashboards, metricsexplorertypes.MetricDashboard{
|
||||
DashboardName: item["dashboard_name"],
|
||||
DashboardID: item["dashboard_id"],
|
||||
WidgetID: item["widget_id"],
|
||||
WidgetName: item["widget_name"],
|
||||
})
|
||||
}
|
||||
return newMetricDashboardsResponse(data[metricName]), nil
|
||||
}
|
||||
|
||||
func (m *module) GetMetricDashboardsV2(ctx context.Context, orgID valuer.UUID, metricName string) (*metricsexplorertypes.MetricDashboardPanelsResponse, error) {
|
||||
if metricName == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required")
|
||||
}
|
||||
data, err := m.dashboardModule.GetByMetricNamesV2(ctx, orgID, []string{metricName})
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to get dashboards for metric")
|
||||
}
|
||||
|
||||
return metricsexplorertypes.NewMetricDashboardPanelsResponse(data[metricName]), nil
|
||||
}
|
||||
|
||||
func newMetricDashboardsResponse(dashboardList []map[string]string) *metricsexplorertypes.MetricDashboardsResponse {
|
||||
dashboards := make([]metricsexplorertypes.MetricDashboard, 0, len(dashboardList))
|
||||
for _, item := range dashboardList {
|
||||
dashboards = append(dashboards, metricsexplorertypes.MetricDashboard{
|
||||
DashboardName: item["dashboard_name"],
|
||||
DashboardID: item["dashboard_id"],
|
||||
WidgetID: item["widget_id"],
|
||||
WidgetName: item["widget_name"],
|
||||
})
|
||||
}
|
||||
|
||||
return &metricsexplorertypes.MetricDashboardsResponse{
|
||||
Dashboards: dashboards,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// GetMetricHighlights returns highlights for a metric including data points, last received, total time series, and active time series.
|
||||
|
||||
@@ -18,6 +18,7 @@ type Handler interface {
|
||||
UpdateMetricMetadata(http.ResponseWriter, *http.Request)
|
||||
GetMetricAlerts(http.ResponseWriter, *http.Request)
|
||||
GetMetricDashboards(http.ResponseWriter, *http.Request)
|
||||
GetMetricDashboardsV2(http.ResponseWriter, *http.Request)
|
||||
GetMetricHighlights(http.ResponseWriter, *http.Request)
|
||||
GetOnboardingStatus(http.ResponseWriter, *http.Request)
|
||||
InspectMetrics(http.ResponseWriter, *http.Request)
|
||||
@@ -33,6 +34,7 @@ type Module interface {
|
||||
UpdateMetricMetadata(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.UpdateMetricMetadataRequest) error
|
||||
GetMetricAlerts(ctx context.Context, orgID valuer.UUID, metricName string) (*metricsexplorertypes.MetricAlertsResponse, error)
|
||||
GetMetricDashboards(ctx context.Context, orgID valuer.UUID, metricName string) (*metricsexplorertypes.MetricDashboardsResponse, error)
|
||||
GetMetricDashboardsV2(ctx context.Context, orgID valuer.UUID, metricName string) (*metricsexplorertypes.MetricDashboardPanelsResponse, error)
|
||||
GetMetricHighlights(ctx context.Context, orgID valuer.UUID, metricName string) (*metricsexplorertypes.MetricHighlightsResponse, error)
|
||||
GetMetricAttributes(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.MetricAttributesRequest) (*metricsexplorertypes.MetricAttributesResponse, error)
|
||||
HasNonSigNozMetrics(ctx context.Context) (bool, error)
|
||||
|
||||
@@ -31,10 +31,16 @@ type builderQuery[T any] struct {
|
||||
fromMS uint64
|
||||
toMS uint64
|
||||
kind qbtypes.RequestType
|
||||
|
||||
builderConfig builderConfig
|
||||
}
|
||||
|
||||
var _ qbtypes.Query = (*builderQuery[any])(nil)
|
||||
|
||||
type builderConfig struct {
|
||||
logTraceIDWindowPaddingMS uint64
|
||||
}
|
||||
|
||||
func newBuilderQuery[T any](
|
||||
logger *slog.Logger,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
@@ -43,6 +49,7 @@ func newBuilderQuery[T any](
|
||||
tr qbtypes.TimeRange,
|
||||
kind qbtypes.RequestType,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
cfg builderConfig,
|
||||
) *builderQuery[T] {
|
||||
return &builderQuery[T]{
|
||||
logger: logger,
|
||||
@@ -53,6 +60,7 @@ func newBuilderQuery[T any](
|
||||
fromMS: tr.From,
|
||||
toMS: tr.To,
|
||||
kind: kind,
|
||||
builderConfig: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,9 +294,20 @@ func (q *builderQuery[T]) narrowWindowByTraceID(ctx context.Context, fromMS, toM
|
||||
return fromMS, toMS, true, ""
|
||||
}
|
||||
|
||||
// Logs can be flushed slightly after the span ends. The trace
|
||||
// time range comes from the spans table, so for logs we widen it by the
|
||||
// configured padding before clamping. Keep the actual recorded bounds for
|
||||
// the user-facing warning so it reports where the trace truly lies, not the
|
||||
// padded range.
|
||||
actualStartMS, actualEndMS := traceStartMS, traceEndMS
|
||||
if q.spec.Signal == telemetrytypes.SignalLogs {
|
||||
traceStartMS -= q.builderConfig.logTraceIDWindowPaddingMS
|
||||
traceEndMS += q.builderConfig.logTraceIDWindowPaddingMS
|
||||
}
|
||||
|
||||
if traceStartMS > toMS || traceEndMS < fromMS {
|
||||
traceStartUTC := time.UnixMilli(int64(traceStartMS)).UTC().Format(time.RFC3339)
|
||||
traceEndUTC := time.UnixMilli(int64(traceEndMS)).UTC().Format(time.RFC3339)
|
||||
traceStartUTC := time.UnixMilli(int64(actualStartMS)).UTC().Format(time.RFC3339)
|
||||
traceEndUTC := time.UnixMilli(int64(actualEndMS)).UTC().Format(time.RFC3339)
|
||||
return fromMS, toMS, false, fmt.Sprintf(traceOutsideRangeWarn, q.spec.Name, traceStartUTC, traceEndUTC)
|
||||
}
|
||||
if traceStartMS > fromMS {
|
||||
|
||||
@@ -23,6 +23,8 @@ type Config struct {
|
||||
MaxConcurrentQueries int `yaml:"max_concurrent_queries" mapstructure:"max_concurrent_queries"`
|
||||
// SkipResourceFingerprint configures when the resource fingerprint subquery is skipped in favor of main-table filtering.
|
||||
SkipResourceFingerprint SkipResourceFingerprint `yaml:"skip_resource_fingerprint" mapstructure:"skip_resource_fingerprint"`
|
||||
// LogTraceIDWindowPadding is the padding added to narrowed down timerange from trace summary to logs with trace_id filter.
|
||||
LogTraceIDWindowPadding time.Duration `yaml:"log_trace_id_window_padding" mapstructure:"log_trace_id_window_padding"`
|
||||
}
|
||||
|
||||
// NewConfigFactory creates a new config factory for querier.
|
||||
@@ -40,6 +42,7 @@ func newConfig() factory.Config {
|
||||
Enabled: false,
|
||||
Threshold: 100000,
|
||||
},
|
||||
LogTraceIDWindowPadding: 5 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +60,9 @@ func (c Config) Validate() error {
|
||||
if c.SkipResourceFingerprint.Enabled && c.SkipResourceFingerprint.Threshold == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "skip_resource_fingerprint.threshold must be > 0 when enabled")
|
||||
}
|
||||
if c.LogTraceIDWindowPadding < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "log_trace_id_window_padding must not be negative, got %v", c.LogTraceIDWindowPadding)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -35,19 +35,20 @@ var (
|
||||
)
|
||||
|
||||
type querier struct {
|
||||
logger *slog.Logger
|
||||
fl flagger.Flagger
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
promEngine prometheus.Prometheus
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
||||
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
||||
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder
|
||||
bucketCache BucketCache
|
||||
liveDataRefresh time.Duration
|
||||
logger *slog.Logger
|
||||
fl flagger.Flagger
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
promEngine prometheus.Prometheus
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
||||
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
||||
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder
|
||||
bucketCache BucketCache
|
||||
liveDataRefresh time.Duration
|
||||
builderConfig builderConfig
|
||||
}
|
||||
|
||||
var _ Querier = (*querier)(nil)
|
||||
@@ -65,22 +66,26 @@ func New(
|
||||
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder,
|
||||
bucketCache BucketCache,
|
||||
flagger flagger.Flagger,
|
||||
logTraceIDWindowPadding time.Duration,
|
||||
) *querier {
|
||||
querierSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/querier")
|
||||
return &querier{
|
||||
logger: querierSettings.Logger(),
|
||||
fl: flagger,
|
||||
telemetryStore: telemetryStore,
|
||||
metadataStore: metadataStore,
|
||||
promEngine: promEngine,
|
||||
traceStmtBuilder: traceStmtBuilder,
|
||||
logStmtBuilder: logStmtBuilder,
|
||||
auditStmtBuilder: auditStmtBuilder,
|
||||
metricStmtBuilder: metricStmtBuilder,
|
||||
meterStmtBuilder: meterStmtBuilder,
|
||||
traceOperatorStmtBuilder: traceOperatorStmtBuilder,
|
||||
bucketCache: bucketCache,
|
||||
liveDataRefresh: 5 * time.Second,
|
||||
logger: querierSettings.Logger(),
|
||||
fl: flagger,
|
||||
telemetryStore: telemetryStore,
|
||||
metadataStore: metadataStore,
|
||||
promEngine: promEngine,
|
||||
traceStmtBuilder: traceStmtBuilder,
|
||||
logStmtBuilder: logStmtBuilder,
|
||||
auditStmtBuilder: auditStmtBuilder,
|
||||
metricStmtBuilder: metricStmtBuilder,
|
||||
meterStmtBuilder: meterStmtBuilder,
|
||||
traceOperatorStmtBuilder: traceOperatorStmtBuilder,
|
||||
bucketCache: bucketCache,
|
||||
liveDataRefresh: 5 * time.Second,
|
||||
builderConfig: builderConfig{
|
||||
logTraceIDWindowPaddingMS: uint64(logTraceIDWindowPadding.Milliseconds()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +228,7 @@ func (q *querier) buildQueries(
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
|
||||
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, q.traceStmtBuilder, spec, timeRange, req.RequestType, tmplVars)
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, q.traceStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
queries[spec.Name] = bq
|
||||
steps[spec.Name] = spec.StepInterval
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
|
||||
@@ -233,7 +238,7 @@ func (q *querier) buildQueries(
|
||||
if spec.Source == telemetrytypes.SourceAudit {
|
||||
stmtBuilder = q.auditStmtBuilder
|
||||
}
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, stmtBuilder, spec, timeRange, req.RequestType, tmplVars)
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, stmtBuilder, spec, timeRange, req.RequestType, tmplVars, q.builderConfig)
|
||||
queries[spec.Name] = bq
|
||||
steps[spec.Name] = spec.StepInterval
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
|
||||
@@ -250,9 +255,9 @@ func (q *querier) buildQueries(
|
||||
|
||||
if spec.Source == telemetrytypes.SourceMeter {
|
||||
event.Source = telemetrytypes.SourceMeter.StringValue()
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, q.meterStmtBuilder, spec, timeRange, req.RequestType, tmplVars)
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, q.meterStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
} else {
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, q.metricStmtBuilder, spec, timeRange, req.RequestType, tmplVars)
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, q.metricStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
}
|
||||
|
||||
queries[spec.Name] = bq
|
||||
@@ -527,7 +532,7 @@ func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qb
|
||||
"id": {
|
||||
Value: updatedLogID,
|
||||
},
|
||||
})
|
||||
}, q.builderConfig)
|
||||
queries[spec.Name] = bq
|
||||
|
||||
qbResp, qbErr := q.run(ctx, orgID, queries, req, nil, event, nil)
|
||||
@@ -823,7 +828,7 @@ func (q *querier) createRangedQuery(originalQuery qbtypes.Query, timeRange qbtyp
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, q.traceStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables)
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, q.traceStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
|
||||
case *builderQuery[qbtypes.LogAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
@@ -833,16 +838,16 @@ func (q *querier) createRangedQuery(originalQuery qbtypes.Query, timeRange qbtyp
|
||||
if qt.spec.Source == telemetrytypes.SourceAudit {
|
||||
shiftStmtBuilder = q.auditStmtBuilder
|
||||
}
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, shiftStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables)
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, shiftStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, q.builderConfig)
|
||||
|
||||
case *builderQuery[qbtypes.MetricAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
if qt.spec.Source == telemetrytypes.SourceMeter {
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, q.meterStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables)
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, q.meterStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
}
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, q.metricStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables)
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, q.metricStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
case *traceOperatorQuery:
|
||||
specCopy := qt.spec.Copy()
|
||||
return &traceOperatorQuery{
|
||||
|
||||
@@ -54,6 +54,7 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
flaggertest.New(t), // flagger
|
||||
0,
|
||||
)
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
@@ -124,6 +125,7 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
flaggertest.New(t), // flagger
|
||||
0,
|
||||
)
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
|
||||
@@ -192,5 +192,6 @@ func newProvider(
|
||||
traceOperatorStmtBuilder,
|
||||
bucketCache,
|
||||
flagger,
|
||||
cfg.LogTraceIDWindowPadding,
|
||||
), nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package rules
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
@@ -54,6 +55,7 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
flagger,
|
||||
0,
|
||||
), metadataStore
|
||||
}
|
||||
|
||||
@@ -107,6 +109,7 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
fl,
|
||||
5*time.Minute, // logTraceIDWindowPadding
|
||||
)
|
||||
}
|
||||
|
||||
@@ -154,5 +157,6 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
fl,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package sqlitesqlstore
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun/schema"
|
||||
)
|
||||
@@ -105,3 +107,7 @@ func (f *formatter) LowerExpression(expression string) []byte {
|
||||
sql = append(sql, ')')
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) EscapeLikePattern(value string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(value)
|
||||
}
|
||||
|
||||
@@ -119,4 +119,8 @@ type SQLFormatter interface {
|
||||
|
||||
// LowerExpression wraps any SQL expression with lower() function for case-insensitive operations
|
||||
LowerExpression(expression string) []byte
|
||||
|
||||
// EscapeLikePattern escapes LIKE wildcards (`%`, `_`, and the escape char `\`)
|
||||
// in a value so it matches literally. Pair the pattern with `ESCAPE '\'`.
|
||||
EscapeLikePattern(value string) string
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package sqlstoretest
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun/schema"
|
||||
)
|
||||
@@ -105,3 +107,7 @@ func (f *formatter) LowerExpression(expression string) []byte {
|
||||
sql = append(sql, ')')
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) EscapeLikePattern(value string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(value)
|
||||
}
|
||||
|
||||
11
pkg/types/dashboardtypes/dashboards_metric_usage.go
Normal file
11
pkg/types/dashboardtypes/dashboards_metric_usage.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package dashboardtypes
|
||||
|
||||
// DashboardPanelRef identifies a single panel within a dashboard. The
|
||||
// "dashboards by metric name" lookup returns these to report each panel that
|
||||
// references a given metric.
|
||||
type DashboardPanelRef struct {
|
||||
DashboardID string `json:"dashboardId" required:"true"`
|
||||
DashboardName string `json:"dashboardName" required:"true"`
|
||||
PanelID string `json:"panelId" required:"true"`
|
||||
PanelName string `json:"panelName" required:"true"`
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func newTestDashboardV2(t *testing.T, orgID valuer.UUID, source Source) *Dashboa
|
||||
FillMode: FillModeSolid,
|
||||
SpanGaps: SpanGaps{FillLessThan: valuer.MustParseTextDuration("60s")},
|
||||
},
|
||||
Legend: Legend{Position: LegendPositionBottom},
|
||||
Legend: Legend{Position: LegendPositionBottom, Mode: LegendModeList},
|
||||
},
|
||||
},
|
||||
Queries: []Query{
|
||||
|
||||
@@ -48,7 +48,42 @@ func (d *DashboardSpec) UnmarshalJSON(data []byte) error {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func (d *DashboardSpec) Validate() error {
|
||||
if err := d.validateVariables(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.validatePanels(); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.validateLayouts()
|
||||
}
|
||||
|
||||
// validateVariables rejects two variables sharing the same name.
|
||||
func (d *DashboardSpec) validateVariables() error {
|
||||
seen := make(map[string]struct{}, len(d.Variables))
|
||||
for i, v := range d.Variables {
|
||||
var name string
|
||||
switch s := v.Spec.(type) {
|
||||
case *ListVariableSpec:
|
||||
name = s.Name
|
||||
case *TextVariableSpec:
|
||||
name = s.Name
|
||||
default:
|
||||
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
|
||||
return errors.NewInternalf(errors.CodeInternal, "spec.variables[%d].spec: unexpected variable spec type %T", i, v.Spec)
|
||||
}
|
||||
if _, dup := seen[name]; dup {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.variables[%d]: duplicate variable name %q", i, name)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardSpec) validatePanels() error {
|
||||
for key, panel := range d.Panels {
|
||||
if err := common.ValidateID(key); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "spec.panels: %s", err.Error())
|
||||
}
|
||||
if panel == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.panels.%s: panel must not be null", key)
|
||||
}
|
||||
@@ -69,6 +104,13 @@ func (d *DashboardSpec) Validate() error {
|
||||
}
|
||||
|
||||
func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind, panelKind PanelPluginKind, path string) error {
|
||||
compositeSubQueryTypeToPluginKind := map[qb.QueryType]QueryPluginKind{
|
||||
qb.QueryTypeBuilder: QueryKindBuilder,
|
||||
qb.QueryTypeFormula: QueryKindFormula,
|
||||
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
|
||||
qb.QueryTypePromQL: QueryKindPromQL,
|
||||
qb.QueryTypeClickHouseSQL: QueryKindClickHouseSQL,
|
||||
}
|
||||
if !slices.Contains(allowed, plugin.Kind) {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
|
||||
"%s: query kind %q is not supported by panel kind %q", path, plugin.Kind, panelKind)
|
||||
@@ -96,12 +138,35 @@ func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind,
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
compositeSubQueryTypeToPluginKind = map[qb.QueryType]QueryPluginKind{
|
||||
qb.QueryTypeBuilder: QueryKindBuilder,
|
||||
qb.QueryTypeFormula: QueryKindFormula,
|
||||
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
|
||||
qb.QueryTypePromQL: QueryKindPromQL,
|
||||
qb.QueryTypeClickHouseSQL: QueryKindClickHouseSQL,
|
||||
// validateLayouts rejects grid items referencing a panel that doesn't exist.
|
||||
func (d *DashboardSpec) validateLayouts() error {
|
||||
for li, layout := range d.Layouts {
|
||||
grid, ok := layout.Spec.(*dashboard.GridLayoutSpec)
|
||||
if !ok {
|
||||
// Unreachable via UnmarshalJSON; reaching here means a Go caller broke the Kind/Spec pairing.
|
||||
return errors.NewInternalf(errors.CodeInternal, "spec.layouts[%d].spec: unexpected layout spec type %T", li, layout.Spec)
|
||||
}
|
||||
for ii, item := range grid.Items {
|
||||
path := fmt.Sprintf("spec.layouts[%d].spec.items[%d].content", li, ii)
|
||||
if item.Content == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: content reference is required", path)
|
||||
}
|
||||
key, err := panelKeyFromRef(item.Content.Path, item.Content.Ref, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := d.Panels[key]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: references unknown panel %q", path, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// panelKeyFromRef extracts <key> from a "#/spec/panels/<key>" content ref.
|
||||
func panelKeyFromRef(refPath []string, ref string, path string) (string, error) {
|
||||
if len(refPath) != 3 || refPath[0] != "spec" || refPath[1] != "panels" {
|
||||
return "", errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: %q must reference a panel as \"#/spec/panels/<key>\"", path, ref)
|
||||
}
|
||||
return refPath[2], nil
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ func (p PatchableDashboardV2) Apply(existing *DashboardV2) (*UpdatableDashboardV
|
||||
}
|
||||
patched, err := p.patch.ApplyWithOptions(raw, &jsonpatch.ApplyOptions{AllowMissingPathOnRemove: true, EnsurePathExistsOnAdd: true})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "JSON Patch could not be applied to the target dashboard")
|
||||
return nil, errors.Wrap(err, errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "JSON Patch could not be applied to the target dashboard").WithAdditional(err.Error())
|
||||
}
|
||||
out := &UpdatableDashboardV2{}
|
||||
if err := json.Unmarshal(patched, out); err != nil {
|
||||
|
||||
@@ -405,6 +405,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
|
||||
out, err := decode(t, `[
|
||||
{"op": "replace", "path": "/spec/display/name", "value": "Multi-step"},
|
||||
{"op": "remove", "path": "/spec/panels/p2"},
|
||||
{"op": "remove", "path": "/spec/layouts/0/spec/items/1"},
|
||||
{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}
|
||||
]`).Apply(base)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -112,6 +112,174 @@ func TestValidateOnlyVariables(t *testing.T) {
|
||||
require.NoError(t, err, "expected valid")
|
||||
}
|
||||
|
||||
func TestInvalidateDuplicateVariableNames(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [
|
||||
{
|
||||
"kind": "TextVariable",
|
||||
"spec": {"name": "env", "value": "prod"}
|
||||
},
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "env",
|
||||
"allowAllValue": false,
|
||||
"allowMultiple": false,
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {"name": "service.name", "signal": "metrics"}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected error for duplicate variable name")
|
||||
require.Contains(t, err.Error(), `duplicate variable name "env"`)
|
||||
}
|
||||
|
||||
func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
|
||||
listVarWithName := func(name string) []byte {
|
||||
return []byte(`{
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "` + name + `",
|
||||
"allowAllValue": false,
|
||||
"allowMultiple": false,
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {"name": "service.name", "signal": "metrics"}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"layouts": []
|
||||
}`)
|
||||
}
|
||||
for _, name := range []string{"my var", "cost$", "bad!", "a/b"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVarWithName(name))
|
||||
require.Error(t, err, "expected error for invalid variable name %q", name)
|
||||
require.Contains(t, err.Error(), "is not a correct name")
|
||||
})
|
||||
}
|
||||
for _, name := range []string{"service", "my_var", "MY_VAR", "MixedCase9", "with-hyphen", "with.dot"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVarWithName(name))
|
||||
require.NoError(t, err, "expected valid variable name %q", name)
|
||||
})
|
||||
}
|
||||
t.Run("digits only", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVarWithName("123"))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cannot contain only digits")
|
||||
})
|
||||
}
|
||||
|
||||
func TestInvalidatePanelKey(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
"bad key!": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "time_series",
|
||||
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]
|
||||
}}}
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected error for invalid panel key")
|
||||
require.Contains(t, err.Error(), "is not a correct name")
|
||||
}
|
||||
|
||||
func TestInvalidateListVariableCrossFields(t *testing.T) {
|
||||
listVar := func(specFields string) []byte {
|
||||
return []byte(`{
|
||||
"variables": [
|
||||
{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "service",
|
||||
` + specFields + `
|
||||
"plugin": {
|
||||
"kind": "signoz/DynamicVariable",
|
||||
"spec": {"name": "service.name", "signal": "metrics"}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"layouts": []
|
||||
}`)
|
||||
}
|
||||
|
||||
t.Run("customAllValue without allowAllValue", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "customAllValue": "*",`))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "customAllValue cannot be set")
|
||||
})
|
||||
|
||||
t.Run("list defaultValue without allowMultiple", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["a", "b"],`))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "allowMultiple")
|
||||
})
|
||||
|
||||
t.Run("single-element list default without allowMultiple", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"allowAllValue": false, "allowMultiple": false, "defaultValue": ["only"],`))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "allowMultiple")
|
||||
})
|
||||
|
||||
t.Run("valid sort is accepted", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"sort": "alphabetical-asc",`))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("unknown sort is rejected", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(listVar(`"sort": "bogus",`))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unknown sort")
|
||||
})
|
||||
}
|
||||
|
||||
func TestInvalidateEmptyVariableName(t *testing.T) {
|
||||
cases := map[string][]byte{
|
||||
"text variable": []byte(`{
|
||||
"variables": [{"kind": "TextVariable", "spec": {"name": "", "value": "x"}}],
|
||||
"layouts": []
|
||||
}`),
|
||||
"list variable": []byte(`{
|
||||
"variables": [{
|
||||
"kind": "ListVariable",
|
||||
"spec": {
|
||||
"name": "",
|
||||
"allowAllValue": false,
|
||||
"allowMultiple": false,
|
||||
"plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}
|
||||
}
|
||||
}],
|
||||
"layouts": []
|
||||
}`),
|
||||
}
|
||||
for name, data := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected error for empty variable name")
|
||||
require.Contains(t, err.Error(), "name cannot be empty")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -270,6 +438,65 @@ func TestInvalidateOneInvalidPanel(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "FakePanel", "error should mention FakePanel")
|
||||
}
|
||||
|
||||
func TestInvalidateLayoutPanelReferences(t *testing.T) {
|
||||
validPanels := `"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
|
||||
"queries": [{
|
||||
"kind": "time_series",
|
||||
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
|
||||
"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]
|
||||
}}}
|
||||
}]
|
||||
}
|
||||
}
|
||||
}`
|
||||
layout := func(items string) []byte {
|
||||
return []byte(`{` + validPanels + `, "layouts": [{"kind": "Grid", "spec": {"items": [` + items + `]}}]}`)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
wantContain string
|
||||
}{
|
||||
{
|
||||
name: "reference to unknown panel",
|
||||
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/ghost"}}`),
|
||||
wantContain: `references unknown panel "ghost"`,
|
||||
},
|
||||
{
|
||||
name: "reference not pointing at a panel",
|
||||
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/variables/p1"}}`),
|
||||
wantContain: "must reference a panel",
|
||||
},
|
||||
{
|
||||
name: "reference missing spec prefix",
|
||||
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/panels/p1"}}`),
|
||||
wantContain: "must reference a panel",
|
||||
},
|
||||
{
|
||||
name: "valid reference",
|
||||
data: layout(`{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}`),
|
||||
wantContain: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(tt.data)
|
||||
if tt.wantContain == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.wantContain)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -569,6 +796,24 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
}`,
|
||||
wantContain: "legend position",
|
||||
},
|
||||
{
|
||||
name: "bad legend mode",
|
||||
data: `{
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BarChartPanel",
|
||||
"spec": {"legend": {"mode": "grid"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}`,
|
||||
wantContain: "legend mode",
|
||||
},
|
||||
{
|
||||
name: "bad threshold format",
|
||||
data: `{
|
||||
@@ -634,6 +879,39 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Label on ThresholdWithLabel is optional — the backend never reads it, so a
|
||||
// threshold with an omitted or empty label must validate cleanly.
|
||||
func TestThresholdLabelOptional(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
threshold string
|
||||
}{
|
||||
{name: "label omitted", threshold: `{"value": 100, "color": "Red"}`},
|
||||
{name: "label empty", threshold: `{"value": 100, "color": "Red", "label": ""}`},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {"thresholds": [` + tt.threshold + `]}},
|
||||
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"layouts": []
|
||||
}`)
|
||||
d, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "threshold without a label should validate")
|
||||
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
|
||||
require.Len(t, spec.Thresholds, 1)
|
||||
require.Empty(t, spec.Thresholds[0].Label, "label should remain empty")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidatePanelWithoutQueries(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"panels": {
|
||||
@@ -749,11 +1027,6 @@ func TestValidateRequiredFields(t *testing.T) {
|
||||
data: wrapPanel("signoz/TimeSeriesPanel", `{"thresholds": [{"value": 100, "label": "high", "color": ""}]}`),
|
||||
wantContain: "Color",
|
||||
},
|
||||
{
|
||||
name: "ThresholdWithLabel missing label",
|
||||
data: wrapPanel("signoz/TimeSeriesPanel", `{"thresholds": [{"value": 100, "color": "Red", "label": ""}]}`),
|
||||
wantContain: "Label",
|
||||
},
|
||||
{
|
||||
name: "ComparisonThreshold missing value",
|
||||
data: wrapPanel("signoz/NumberPanel", `{"thresholds": [{"operator": "above", "format": "text", "color": "Red"}]}`),
|
||||
@@ -811,10 +1084,11 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
|
||||
require.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
|
||||
require.Equal(t, "spline", spec.ChartAppearance.LineInterpolation.ValueOrDefault(), "expected LineInterpolation default spline")
|
||||
require.Equal(t, "solid", spec.ChartAppearance.LineStyle.ValueOrDefault(), "expected LineStyle default solid")
|
||||
require.Equal(t, "solid", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default solid")
|
||||
require.Equal(t, "none", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default none")
|
||||
require.False(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow, "expected SpanGaps.FillOnlyBelow default false")
|
||||
require.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
|
||||
require.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
|
||||
require.Equal(t, "list", spec.Legend.Mode.ValueOrDefault(), "expected LegendMode default list")
|
||||
|
||||
// Re-marshal the full dashboard (what we'd store in DB / return in API response)
|
||||
// and verify the output contains the default values.
|
||||
@@ -825,9 +1099,10 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
|
||||
"decimalPrecision": `"2"`,
|
||||
"lineInterpolation": `"spline"`,
|
||||
"lineStyle": `"solid"`,
|
||||
"fillMode": `"solid"`,
|
||||
"fillMode": `"none"`,
|
||||
"timePreference": `"global_time"`,
|
||||
"position": `"bottom"`,
|
||||
"mode": `"list"`,
|
||||
} {
|
||||
assert.Contains(t, outputStr, `"`+field+`":`+want, "expected stored/response JSON to contain %s:%s", field, want)
|
||||
}
|
||||
@@ -930,7 +1205,7 @@ func TestStorageRoundTrip(t *testing.T) {
|
||||
assert.Equal(t, "2", tsSpec.Formatting.DecimalPrecision.ValueOrDefault())
|
||||
assert.Equal(t, "spline", tsSpec.ChartAppearance.LineInterpolation.ValueOrDefault())
|
||||
assert.Equal(t, "solid", tsSpec.ChartAppearance.LineStyle.ValueOrDefault())
|
||||
assert.Equal(t, "solid", tsSpec.ChartAppearance.FillMode.ValueOrDefault())
|
||||
assert.Equal(t, "none", tsSpec.ChartAppearance.FillMode.ValueOrDefault())
|
||||
assert.Equal(t, "global_time", tsSpec.Visualization.TimePreference.ValueOrDefault())
|
||||
assert.Equal(t, "bottom", tsSpec.Legend.Position.ValueOrDefault())
|
||||
numSpec := d.Panels["p2"].Spec.Plugin.Spec.(*NumberPanelSpec)
|
||||
@@ -950,7 +1225,7 @@ func TestStorageRoundTrip(t *testing.T) {
|
||||
assert.Equal(t, "2", tsLoaded.Formatting.DecimalPrecision.ValueOrDefault(), "after load")
|
||||
assert.Equal(t, "spline", tsLoaded.ChartAppearance.LineInterpolation.ValueOrDefault(), "after load")
|
||||
assert.Equal(t, "solid", tsLoaded.ChartAppearance.LineStyle.ValueOrDefault(), "after load")
|
||||
assert.Equal(t, "solid", tsLoaded.ChartAppearance.FillMode.ValueOrDefault(), "after load")
|
||||
assert.Equal(t, "none", tsLoaded.ChartAppearance.FillMode.ValueOrDefault(), "after load")
|
||||
assert.Equal(t, "global_time", tsLoaded.Visualization.TimePreference.ValueOrDefault(), "after load")
|
||||
assert.Equal(t, "bottom", tsLoaded.Legend.Position.ValueOrDefault(), "after load")
|
||||
numLoaded := loaded.Panels["p2"].Spec.Plugin.Spec.(*NumberPanelSpec)
|
||||
@@ -966,7 +1241,7 @@ func TestStorageRoundTrip(t *testing.T) {
|
||||
"decimalPrecision": `"2"`,
|
||||
"lineInterpolation": `"spline"`,
|
||||
"lineStyle": `"solid"`,
|
||||
"fillMode": `"solid"`,
|
||||
"fillMode": `"none"`,
|
||||
"timePreference": `"global_time"`,
|
||||
"position": `"bottom"`,
|
||||
"format": `"text"`,
|
||||
|
||||
@@ -30,6 +30,7 @@ func TestDashboardSpecMatchesPerses(t *testing.T) {
|
||||
{"DatasourceSpec", typeOf[DatasourceSpec](), typeOf[datasource.Spec]()},
|
||||
{"Variable", typeOf[Variable](), typeOf[dashboard.Variable]()},
|
||||
{"ListVariableSpec", typeOf[ListVariableSpec](), typeOf[dashboard.ListVariableSpec]()},
|
||||
{"TextVariableSpec", typeOf[TextVariableSpec](), typeOf[dashboard.TextVariableSpec]()},
|
||||
{"Layout", typeOf[Layout](), typeOf[dashboard.Layout]()},
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ func (p *PanelPlugin) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
p.Kind = PanelPluginKind(kind)
|
||||
p.Spec = spec
|
||||
p.Spec = *spec
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ func (p *QueryPlugin) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
p.Kind = QueryPluginKind(kind)
|
||||
p.Spec = spec
|
||||
p.Spec = *spec
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func (p *VariablePlugin) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
p.Kind = VariablePluginKind(kind)
|
||||
p.Spec = spec
|
||||
p.Spec = *spec
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ type DatasourcePlugin struct {
|
||||
|
||||
func (DatasourcePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return markDiscriminator(s, "kind", map[string]string{
|
||||
string(DatasourceKindSigNoz): schemaRef("DashboardtypesDatasourcePluginVariantStruct"),
|
||||
string(DatasourceKindSigNoz): schemaRef("DashboardtypesDatasourcePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesSigNozDatasourceSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -215,13 +215,13 @@ func (p *DatasourcePlugin) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
p.Kind = DatasourcePluginKind(kind)
|
||||
p.Spec = spec
|
||||
p.Spec = *spec
|
||||
return nil
|
||||
}
|
||||
|
||||
func (DatasourcePlugin) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
DatasourcePluginVariant[struct{}]{Kind: string(DatasourceKindSigNoz)},
|
||||
DatasourcePluginVariant[SigNozDatasourceSpec]{Kind: string(DatasourceKindSigNoz)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ var (
|
||||
VariableKindCustom: func() any { return new(CustomVariableSpec) },
|
||||
}
|
||||
datasourcePluginSpecs = map[DatasourcePluginKind]func() any{
|
||||
DatasourceKindSigNoz: func() any { return new(struct{}) },
|
||||
DatasourceKindSigNoz: func() any { return new(SigNozDatasourceSpec) },
|
||||
}
|
||||
|
||||
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
|
||||
@@ -297,8 +297,7 @@ func extractKindAndSpec(data []byte) (string, []byte, error) {
|
||||
return head.Kind, head.Spec, nil
|
||||
}
|
||||
|
||||
// decodeSpec strict-decodes a spec JSON into target and runs struct-tag validation (go-playground/validator).
|
||||
func decodeSpec(specJSON []byte, target any, kind string) (any, error) {
|
||||
func decodeSpec[T any](specJSON []byte, target T, kind string) (*T, error) {
|
||||
if len(specJSON) == 0 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "kind %q: spec is required", kind)
|
||||
}
|
||||
@@ -310,7 +309,12 @@ func decodeSpec(specJSON []byte, target any, kind string) (any, error) {
|
||||
if err := validator.New().Struct(target); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "kind %q: spec failed validation", kind)
|
||||
}
|
||||
return target, nil
|
||||
if v, ok := any(target).(interface{ validate() error }); ok {
|
||||
if err := v.validate(); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "kind %q: %s", kind, err.Error())
|
||||
}
|
||||
}
|
||||
return &target, nil
|
||||
}
|
||||
|
||||
// signozDiscriminatorKey is the extension key that signoz.attachDiscriminators
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/perses/spec/go/common"
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
"github.com/perses/spec/go/dashboard/variable"
|
||||
@@ -61,7 +63,7 @@ func (k *PanelKind) UnmarshalJSON(data []byte) error {
|
||||
type PanelSpec struct {
|
||||
Display Display `json:"display" required:"true"`
|
||||
Plugin PanelPlugin `json:"plugin" required:"true"`
|
||||
Queries []Query `json:"queries" required:"true"`
|
||||
Queries []Query `json:"queries" required:"true" nullable:"false"`
|
||||
Links []dashboard.Link `json:"links,omitempty"`
|
||||
}
|
||||
|
||||
@@ -84,7 +86,7 @@ type QuerySpec struct {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// Variable is the list/text sum type. Spec is set to *ListVariableSpec or
|
||||
// *dashboard.TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
|
||||
// *TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
|
||||
// discriminated oneOf (see JSONSchemaOneOf).
|
||||
type Variable struct {
|
||||
Kind variable.Kind `json:"kind" required:"true"`
|
||||
@@ -94,7 +96,7 @@ type Variable struct {
|
||||
func (Variable) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return markDiscriminator(s, "kind", map[string]string{
|
||||
string(variable.KindList): schemaRef("DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec"),
|
||||
string(variable.KindText): schemaRef("DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec"),
|
||||
string(variable.KindText): schemaRef("DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesTextVariableSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -110,14 +112,14 @@ func (v *Variable) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
v.Kind = variable.KindList
|
||||
v.Spec = spec
|
||||
v.Spec = *spec
|
||||
case string(variable.KindText):
|
||||
spec, err := decodeSpec(specJSON, new(dashboard.TextVariableSpec), kind)
|
||||
spec, err := decodeSpec(specJSON, new(TextVariableSpec), kind)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v.Kind = variable.KindText
|
||||
v.Spec = spec
|
||||
v.Spec = *spec
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown variable kind %q; allowed values: %s", kind, allowedValuesForKind([]variable.Kind{variable.KindList, variable.KindText}))
|
||||
}
|
||||
@@ -127,7 +129,7 @@ func (v *Variable) UnmarshalJSON(data []byte) error {
|
||||
func (Variable) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
VariableEnvelope[ListVariableSpec]{Kind: string(variable.KindList)},
|
||||
VariableEnvelope[dashboard.TextVariableSpec]{Kind: string(variable.KindText)},
|
||||
VariableEnvelope[TextVariableSpec]{Kind: string(variable.KindText)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,15 +145,137 @@ func (v VariableEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
// ListVariableSpec mirrors dashboard.ListVariableSpec (variable.ListSpec
|
||||
// fields + Name) but with a typed VariablePlugin replacing common.Plugin.
|
||||
type ListVariableSpec struct {
|
||||
Display Display `json:"display" required:"true"`
|
||||
DefaultValue *variable.DefaultValue `json:"defaultValue,omitempty"`
|
||||
AllowAllValue bool `json:"allowAllValue"`
|
||||
AllowMultiple bool `json:"allowMultiple"`
|
||||
CustomAllValue string `json:"customAllValue,omitempty"`
|
||||
CapturingRegexp string `json:"capturingRegexp,omitempty"`
|
||||
Sort *variable.Sort `json:"sort,omitempty"`
|
||||
Plugin VariablePlugin `json:"plugin"`
|
||||
Name string `json:"name"`
|
||||
Display Display `json:"display" required:"true"`
|
||||
DefaultValue *VariableDefaultValue `json:"defaultValue,omitempty"`
|
||||
AllowAllValue bool `json:"allowAllValue"`
|
||||
AllowMultiple bool `json:"allowMultiple"`
|
||||
CustomAllValue string `json:"customAllValue,omitempty"`
|
||||
CapturingRegexp string `json:"capturingRegexp,omitempty"`
|
||||
Sort ListVariableSpecSort `json:"sort,omitzero"`
|
||||
Plugin VariablePlugin `json:"plugin"`
|
||||
Name string `json:"name" required:"true" minLength:"1"`
|
||||
}
|
||||
|
||||
// VariableDefaultValue is a list variable's defaultValue: the string | []string
|
||||
// union. It subclasses the perses variable.DefaultValue (which marshals as a
|
||||
// scalar-or-array) so SigNoz can attach the oneOf schema to it as a named
|
||||
// component.
|
||||
//
|
||||
// Emitting it as a named oneOf component (and having defaultValue $ref it),
|
||||
// instead of inlining the union onto the property, gives downstream codegen a
|
||||
// hook to canonicalize: oapi-codegen generates the union's Marshal/UnmarshalJSON
|
||||
// and skaff's scalar-union pre-pass flattens it to a string attribute. An inline
|
||||
// oneOf has no such named component to hook.
|
||||
type VariableDefaultValue struct {
|
||||
variable.DefaultValue
|
||||
}
|
||||
|
||||
// PrepareJSONSchema shapes the component as the string | []string oneOf; the
|
||||
// reflected struct shape (a bare object) is wrong because the value marshals as
|
||||
// a scalar-or-array, not an object.
|
||||
func (VariableDefaultValue) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
stringItem := jsonschema.String.ToSchemaOrBool()
|
||||
s.Type = nil
|
||||
s.Properties = nil
|
||||
s.WithOneOf(
|
||||
jsonschema.String.ToSchemaOrBool(),
|
||||
(&jsonschema.Schema{}).
|
||||
WithType(jsonschema.Array.Type()).
|
||||
WithItems(jsonschema.Items{SchemaOrBool: &stringItem}).
|
||||
ToSchemaOrBool(),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// validate mirrors perses ListVariableSpec validation (plus the digits-only name
|
||||
// check perses only applies to text variables); run by decodeSpec on unmarshal.
|
||||
func (s *ListVariableSpec) validate() error {
|
||||
if err := common.ValidateID(s.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := strconv.Atoi(s.Name); err == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "variable name cannot contain only digits")
|
||||
}
|
||||
if s.CustomAllValue != "" && !s.AllowAllValue {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "customAllValue cannot be set if allowAllValue is not set to true")
|
||||
}
|
||||
if s.DefaultValue != nil && len(s.DefaultValue.SliceValues) > 0 && !s.AllowMultiple {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "defaultValue cannot be a list if allowMultiple is not set to true")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListVariableSpecSort is the value-list sort method, mirrored from Perses as a
|
||||
// stable enum so the allowed values surface in the generated OpenAPI schema.
|
||||
type ListVariableSpecSort struct{ valuer.String }
|
||||
|
||||
var (
|
||||
SortNone = ListVariableSpecSort{valuer.NewString("none")}
|
||||
SortAlphabeticalAsc = ListVariableSpecSort{valuer.NewString("alphabetical-asc")}
|
||||
SortAlphabeticalDesc = ListVariableSpecSort{valuer.NewString("alphabetical-desc")}
|
||||
SortNumericalAsc = ListVariableSpecSort{valuer.NewString("numerical-asc")}
|
||||
SortNumericalDesc = ListVariableSpecSort{valuer.NewString("numerical-desc")}
|
||||
SortAlphabeticalCaseInsensitiveAsc = ListVariableSpecSort{valuer.NewString("alphabetical-ci-asc")}
|
||||
SortAlphabeticalCaseInsensitiveDesc = ListVariableSpecSort{valuer.NewString("alphabetical-ci-desc")}
|
||||
)
|
||||
|
||||
func (ListVariableSpecSort) Enum() []any {
|
||||
return []any{
|
||||
SortNone,
|
||||
SortAlphabeticalAsc,
|
||||
SortAlphabeticalDesc,
|
||||
SortNumericalAsc,
|
||||
SortNumericalDesc,
|
||||
SortAlphabeticalCaseInsensitiveAsc,
|
||||
SortAlphabeticalCaseInsensitiveDesc,
|
||||
}
|
||||
}
|
||||
|
||||
func (s ListVariableSpecSort) IsValid() bool {
|
||||
return slices.ContainsFunc(s.Enum(), func(v any) bool { return v == s })
|
||||
}
|
||||
|
||||
// UnmarshalJSON validates against the enum on decode (valuer.String alone
|
||||
// accepts any string). An empty value is allowed and means "no sort", matching
|
||||
// Perses.
|
||||
func (s *ListVariableSpecSort) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid sort: must be a string, one of `none`, `alphabetical-asc`, `alphabetical-desc`, `numerical-asc`, `numerical-desc`, `alphabetical-ci-asc`, or `alphabetical-ci-desc`")
|
||||
}
|
||||
if v == "" {
|
||||
*s = ListVariableSpecSort{}
|
||||
return nil
|
||||
}
|
||||
sort := ListVariableSpecSort{valuer.NewString(v)}
|
||||
if !sort.IsValid() {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "unknown sort %q: must be `none`, `alphabetical-asc`, `alphabetical-desc`, `numerical-asc`, `numerical-desc`, `alphabetical-ci-asc`, or `alphabetical-ci-desc`", v)
|
||||
}
|
||||
*s = sort
|
||||
return nil
|
||||
}
|
||||
|
||||
// TextVariableSpec replicates dashboard.TextVariableSpec so name can carry the
|
||||
// required/non-empty schema tags perses leaves off.
|
||||
type TextVariableSpec struct {
|
||||
Display Display `json:"display" required:"true"`
|
||||
Value string `json:"value" required:"true"`
|
||||
Constant bool `json:"constant,omitempty"`
|
||||
Name string `json:"name" required:"true" minLength:"1"`
|
||||
}
|
||||
|
||||
// validate mirrors perses TextVariableSpec validation; run by decodeSpec on unmarshal.
|
||||
func (s *TextVariableSpec) validate() error {
|
||||
if err := common.ValidateID(s.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := strconv.Atoi(s.Name); err == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "variable name cannot contain only digits")
|
||||
}
|
||||
if s.Value == "" && s.Constant {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "value for a constant text variable cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -194,7 +318,7 @@ func (l *Layout) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
l.Kind = dashboard.LayoutKind(kind)
|
||||
l.Spec = spec
|
||||
l.Spec = *spec
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,11 @@ func (DatasourcePluginKind) Enum() []any {
|
||||
return []any{DatasourceKindSigNoz}
|
||||
}
|
||||
|
||||
// SigNozDatasourceSpec is the (empty) signoz/Datasource plugin spec. Naming the
|
||||
// type gives the variant a concrete, non-nullable spec schema instead of an
|
||||
// inline free-form one.
|
||||
type SigNozDatasourceSpec struct{}
|
||||
|
||||
type TimeSeriesPanelSpec struct {
|
||||
Visualization TimeSeriesVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
@@ -241,6 +246,7 @@ type TableFormatting struct {
|
||||
|
||||
type Legend struct {
|
||||
Position LegendPosition `json:"position"`
|
||||
Mode LegendMode `json:"mode"`
|
||||
CustomColors map[string]string `json:"customColors"`
|
||||
}
|
||||
|
||||
@@ -248,7 +254,7 @@ type ThresholdWithLabel struct {
|
||||
Value float64 `json:"value" validate:"required" required:"true"`
|
||||
Unit string `json:"unit"`
|
||||
Color string `json:"color" validate:"required" required:"true"`
|
||||
Label string `json:"label" validate:"required" required:"true"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type ComparisonThreshold struct {
|
||||
@@ -358,6 +364,47 @@ func (l *LegendPosition) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
type LegendMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
LegendModeList = LegendMode{valuer.NewString("list")} // default
|
||||
LegendModeTable = LegendMode{valuer.NewString("table")}
|
||||
)
|
||||
|
||||
func (LegendMode) Enum() []any {
|
||||
return []any{LegendModeList} // others are not supported in UI yet
|
||||
}
|
||||
|
||||
func (m LegendMode) ValueOrDefault() string {
|
||||
if m.IsZero() {
|
||||
return LegendModeList.StringValue()
|
||||
}
|
||||
return m.StringValue()
|
||||
}
|
||||
|
||||
func (m LegendMode) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(m.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (m *LegendMode) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid legend mode: must be a string, one of `list` or `table`")
|
||||
}
|
||||
if v == "" {
|
||||
*m = LegendModeList
|
||||
return nil
|
||||
}
|
||||
lm := LegendMode{valuer.NewString(v)}
|
||||
switch lm {
|
||||
case LegendModeList, LegendModeTable:
|
||||
*m = lm
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid legend mode %q: must be `list` or `table`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type ThresholdFormat struct{ valuer.String }
|
||||
|
||||
var (
|
||||
@@ -534,9 +581,9 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
|
||||
type FillMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
FillModeSolid = FillMode{valuer.NewString("solid")} // default
|
||||
FillModeSolid = FillMode{valuer.NewString("solid")}
|
||||
FillModeGradient = FillMode{valuer.NewString("gradient")}
|
||||
FillModeNone = FillMode{valuer.NewString("none")}
|
||||
FillModeNone = FillMode{valuer.NewString("none")} // default
|
||||
)
|
||||
|
||||
func (FillMode) Enum() []any {
|
||||
@@ -545,7 +592,7 @@ func (FillMode) Enum() []any {
|
||||
|
||||
func (fm FillMode) ValueOrDefault() string {
|
||||
if fm.IsZero() {
|
||||
return FillModeSolid.StringValue()
|
||||
return FillModeNone.StringValue()
|
||||
}
|
||||
return fm.StringValue()
|
||||
}
|
||||
@@ -560,7 +607,7 @@ func (fm *FillMode) UnmarshalJSON(data []byte) error {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid fill mode: must be a string, one of `solid`, `gradient`, or `none`")
|
||||
}
|
||||
if v == "" {
|
||||
*fm = FillModeSolid
|
||||
*fm = FillModeNone
|
||||
return nil
|
||||
}
|
||||
val := FillMode{valuer.NewString(v)}
|
||||
@@ -573,12 +620,9 @@ func (fm *FillMode) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
// SpanGaps controls whether lines connect across null values.
|
||||
// When FillOnlyBelow is false (default), all gaps are connected.
|
||||
// When FillOnlyBelow is true, only gaps smaller than FillLessThan are connected.
|
||||
type SpanGaps struct {
|
||||
FillOnlyBelow bool `json:"fillOnlyBelow"`
|
||||
FillLessThan valuer.TextDuration `json:"fillLessThan"`
|
||||
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
|
||||
FillLessThan valuer.TextDuration `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`
|
||||
}
|
||||
|
||||
type PrecisionOption struct{ valuer.String }
|
||||
|
||||
@@ -43,6 +43,10 @@ type Store interface {
|
||||
|
||||
ListForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *ListDashboardsV2Params) ([]*StorableDashboardWithPinInfo, int64, error)
|
||||
|
||||
// ListByDataContainsAny returns the org's dashboards whose raw `data` JSON
|
||||
// contains any of the given substrings (matched literally; LIKE wildcards escaped).
|
||||
ListByDataContainsAny(ctx context.Context, orgID valuer.UUID, searches []string) ([]*StorableDashboard, error)
|
||||
|
||||
// Returns ErrCodePinnedDashboardLimitHit when the user is at MaxPinnedDashboardsPerUser.
|
||||
PinForUser(ctx context.Context, preference *UserDashboardPreference) error
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -252,6 +253,22 @@ type MetricDashboardsResponse struct {
|
||||
Dashboards []MetricDashboard `json:"dashboards" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
// MetricDashboardPanelsResponse is the response for the v2 metric dashboards
|
||||
// endpoint: the dashboard panels that reference the metric.
|
||||
type MetricDashboardPanelsResponse struct {
|
||||
Dashboards []dashboardtypes.DashboardPanelRef `json:"dashboards" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
// NewMetricDashboardPanelsResponse wraps the dashboard panels that reference a
|
||||
// metric into the v2 API response.
|
||||
func NewMetricDashboardPanelsResponse(refs []dashboardtypes.DashboardPanelRef) *MetricDashboardPanelsResponse {
|
||||
if refs == nil {
|
||||
refs = []dashboardtypes.DashboardPanelRef{}
|
||||
}
|
||||
|
||||
return &MetricDashboardPanelsResponse{Dashboards: refs}
|
||||
}
|
||||
|
||||
// MetricHighlightsResponse is the output structure for the metric highlights endpoint.
|
||||
type MetricHighlightsResponse struct {
|
||||
DataPoints uint64 `json:"dataPoints" required:"true"`
|
||||
|
||||
37
tests/e2e/helpers/common.ts
Normal file
37
tests/e2e/helpers/common.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
// Shared helpers used across feature-specific helper modules (dashboards,
|
||||
// trace-details, …). Keep this to genuinely cross-feature utilities.
|
||||
|
||||
// ─── Seeder ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Base URL of the HTTP seeder container the pytest harness brings up (exposes
|
||||
// POST/DELETE on /telemetry/{traces,logs,metrics}). Written to
|
||||
// `tests/e2e/.env.local` as `SIGNOZ_E2E_SEEDER_URL` and read here from the env.
|
||||
export function seederUrl(): string {
|
||||
const url = process.env.SIGNOZ_E2E_SEEDER_URL;
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
'SIGNOZ_E2E_SEEDER_URL not set — pytest test_setup must be running.',
|
||||
);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Read the app JWT from the context's stored auth state. No navigation needed:
|
||||
// the auth fixture loads the admin storageState (localStorage AUTH_TOKEN) into
|
||||
// the context at creation, so storageState() returns it regardless of the page's
|
||||
// current URL. Server-side APIs need this as a Bearer token (auth is
|
||||
// JWT-in-localStorage, not cookies, so request.* doesn't carry it automatically).
|
||||
export async function authToken(page: Page): Promise<string> {
|
||||
const state = await page.context().storageState();
|
||||
for (const origin of state.origins) {
|
||||
const entry = origin.localStorage.find((e) => e.name === 'AUTH_TOKEN');
|
||||
if (entry) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
throw new Error('AUTH_TOKEN not found in storage state — is the page authed?');
|
||||
}
|
||||
405
tests/e2e/helpers/trace-details.ts
Normal file
405
tests/e2e/helpers/trace-details.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import type { APIRequestContext, Page } from '@playwright/test';
|
||||
|
||||
import largeTraceRecords from '../testdata/traces/large-trace.json';
|
||||
import { authToken, seederUrl } from './common';
|
||||
|
||||
// ── Seeder: insert traces via POST /telemetry/traces ─────────────────────────
|
||||
|
||||
// Shape accepted by the seeder's POST /telemetry/traces endpoint
|
||||
// (mirrors `Traces.from_dict` in tests/fixtures/traces.py). One object per span;
|
||||
// spans sharing a `trace_id` form one trace, linked into a tree via
|
||||
// `parent_span_id`. NOTE: the endpoint does NOT ingest span events/links.
|
||||
export interface SeederSpan {
|
||||
timestamp: string; // ISO-8601, e.g. new Date().toISOString()
|
||||
trace_id: string; // 32 hex chars
|
||||
span_id: string; // 16 hex chars
|
||||
parent_span_id?: string; // empty/omitted = root span
|
||||
name?: string;
|
||||
kind?: number; // 1=internal 2=server 3=client 4=producer 5=consumer
|
||||
status_code?: number; // 0=unset 1=ok 2=error
|
||||
status_message?: string;
|
||||
duration?: string; // ISO-8601 duration, e.g. "PT0.12S" (default PT1S)
|
||||
resources?: Record<string, string>; // include 'service.name'
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// 16-byte trace id / 8-byte span id, matching tests/fixtures/traces.py.
|
||||
export const randomTraceId = (): string => randomBytes(16).toString('hex');
|
||||
export const randomSpanId = (): string => randomBytes(8).toString('hex');
|
||||
|
||||
// Insert spans into the backend via the seeder. No auth needed (direct seeder
|
||||
// call), so any APIRequestContext works — `page.request` or a standalone
|
||||
// `playwright.request.newContext()` (cheaper than a full browser page for a
|
||||
// pure API call).
|
||||
//
|
||||
// The seeder shares a single ClickHouse client, so concurrent POSTs from
|
||||
// parallel workers collide with a 500 "concurrent queries within the same
|
||||
// session". That's transient, so retry with backoff; any other error is real.
|
||||
export async function seedTracesViaSeeder(
|
||||
request: APIRequestContext,
|
||||
spans: SeederSpan[],
|
||||
): Promise<void> {
|
||||
const url = `${seederUrl()}/telemetry/traces`;
|
||||
const maxAttempts = 6;
|
||||
let lastStatus = 0;
|
||||
let lastText = '';
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const res = await request.post(url, {
|
||||
data: spans,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (res.ok()) {
|
||||
return;
|
||||
}
|
||||
lastStatus = res.status();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
lastText = await res.text();
|
||||
if (!(lastStatus === 500 && lastText.includes('concurrent'))) {
|
||||
break;
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 150 * (attempt + 1) + Math.floor(Math.random() * 100));
|
||||
});
|
||||
}
|
||||
throw new Error(`seeder POST /telemetry/traces ${lastStatus}: ${lastText}`);
|
||||
}
|
||||
|
||||
// ── Navigation ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Pages that already had the e2e test-hook init script registered, so
|
||||
// gotoTraceUntilLoaded adds it at most once per Page (addInitScript re-runs on
|
||||
// every navigation, and the script would otherwise stack up across calls).
|
||||
const e2eHookRegistered = new WeakSet<Page>();
|
||||
|
||||
// Open a seeded trace and wait until the waterfall has rendered. The trace page
|
||||
// fetches once on load, so if the seed isn't query-able yet (ClickHouse lag, worse
|
||||
// under parallel load) it lands on the NoData state and never refetches — this
|
||||
// reloads until the given row testid appears. Makes seeded-trace specs
|
||||
// deterministic in the full parallel run, not just when run alone.
|
||||
export async function gotoTraceUntilLoaded(
|
||||
page: Page,
|
||||
url: string,
|
||||
readyTestId: string,
|
||||
{ attempts = 5, perAttemptTimeoutMs = 8000 } = {},
|
||||
): Promise<void> {
|
||||
// Enable e2e-only test hooks (e.g. the flamegraph span→rect map in
|
||||
// useFlamegraphTestHook) before the first navigation. Registered here because
|
||||
// every trace-detail spec loads the page through this helper, so the flag is
|
||||
// set without a dedicated fixture. Guarded to once per Page — addInitScript
|
||||
// re-runs on every navigation, so re-registering would stack duplicates.
|
||||
if (!e2eHookRegistered.has(page)) {
|
||||
await page.addInitScript(() => {
|
||||
(window as unknown as { __SIGNOZ_E2E__?: boolean }).__SIGNOZ_E2E__ = true;
|
||||
});
|
||||
// Dock the left nav so it doesn't fly out on hover and overlay the trace
|
||||
// content's left strip (which otherwise makes left-edge hover/click targets
|
||||
// land on the sidebar). Once per Page, before the first navigation.
|
||||
await pinSidenav(page);
|
||||
e2eHookRegistered.add(page);
|
||||
}
|
||||
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await page.goto(url);
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await page
|
||||
.getByTestId(readyTestId)
|
||||
.waitFor({ state: 'visible', timeout: perAttemptTimeoutMs });
|
||||
return;
|
||||
} catch {
|
||||
// not loaded yet (NoData / seed lag) — reload and retry
|
||||
}
|
||||
}
|
||||
// final navigation so the test's own assertion surfaces a clear failure
|
||||
await page.goto(url);
|
||||
}
|
||||
|
||||
// ── Trace options menu ─────────────────────────────────────────────────────
|
||||
|
||||
// Change the colour-by field via the trace options menu (Trace options → Colour
|
||||
// by → field). colour-by is a per-user preference that persists, so tests should
|
||||
// set a known field explicitly rather than assume the default. `fieldName` is a
|
||||
// COLOR_BY_OPTIONS label (service.name | service.namespace | host.name |
|
||||
// k8s.node.name | k8s.container.name); exact match avoids service.name matching
|
||||
// service.namespace.
|
||||
export async function changeColourByViaMenu(
|
||||
page: Page,
|
||||
fieldName: string,
|
||||
): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Trace options' }).click();
|
||||
await page.getByRole('menuitem', { name: /colour by/i }).click();
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: fieldName, exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
// ── Large trace fixture (tests/e2e/testdata/traces/large-trace.json) ─────────
|
||||
// One deep, realistic trace: 100 spans across 18 services, nested ~34 levels,
|
||||
// 8 error spans, a wide duration spread, and db/http/llm/messaging attributes —
|
||||
// enough to drive the flamegraph, waterfall, filters and drawer off one seed.
|
||||
// Converted once from a real getWaterfallV4 capture. `loadLargeTrace()` stamps
|
||||
// fresh ids per run (parallel isolation), rebases the timeline to ~now, and
|
||||
// derives landmark span ids so specs target rows without hardcoding ids.
|
||||
|
||||
// Shape of each record in large-trace.json.
|
||||
interface LargeTraceRecord {
|
||||
span_id: string;
|
||||
parent_span_id: string; // empty = root
|
||||
name: string;
|
||||
kind: number;
|
||||
status_code: number;
|
||||
duration: string; // ISO-8601, e.g. "PT0.080000S"
|
||||
offset_ms: number; // start offset from the root span
|
||||
resources: Record<string, string>;
|
||||
attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const LARGE_TRACE_RECORDS = largeTraceRecords as LargeTraceRecord[];
|
||||
|
||||
export interface LargeTrace {
|
||||
traceId: string;
|
||||
spans: SeederSpan[];
|
||||
// landmark span ids — already stamped — for targeting rows / the drawer
|
||||
landmarks: {
|
||||
root: string;
|
||||
errors: string[];
|
||||
db: string;
|
||||
http: string;
|
||||
llm: string;
|
||||
messaging: string;
|
||||
deepLeaf: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Depth of a record via its parent chain (the JSON doesn't store level).
|
||||
function recordDepth(
|
||||
rec: LargeTraceRecord,
|
||||
byId: Map<string, LargeTraceRecord>,
|
||||
): number {
|
||||
let depth = 0;
|
||||
let cur: LargeTraceRecord | undefined = rec;
|
||||
while (cur && cur.parent_span_id) {
|
||||
cur = byId.get(cur.parent_span_id);
|
||||
depth += 1;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
// Build a seedable copy of the large trace with fresh, isolated ids.
|
||||
export function loadLargeTrace(): LargeTrace {
|
||||
const traceId = randomTraceId();
|
||||
// Stamp a fresh span id for every original id, preserving the tree links.
|
||||
const idMap = new Map<string, string>();
|
||||
LARGE_TRACE_RECORDS.forEach((r) => idMap.set(r.span_id, randomSpanId()));
|
||||
|
||||
// Sit the whole trace ~1 min in the past so all timestamps stay <= now.
|
||||
const baseStartMs = Date.now() - 60_000;
|
||||
|
||||
const spans: SeederSpan[] = LARGE_TRACE_RECORDS.map((r) => {
|
||||
const span: SeederSpan = {
|
||||
timestamp: new Date(baseStartMs + r.offset_ms).toISOString(),
|
||||
trace_id: traceId,
|
||||
span_id: idMap.get(r.span_id) as string,
|
||||
name: r.name,
|
||||
kind: r.kind,
|
||||
status_code: r.status_code,
|
||||
duration: r.duration,
|
||||
resources: r.resources,
|
||||
attributes: r.attributes,
|
||||
};
|
||||
if (r.parent_span_id) {
|
||||
span.parent_span_id = idMap.get(r.parent_span_id);
|
||||
}
|
||||
return span;
|
||||
});
|
||||
|
||||
const byId = new Map(LARGE_TRACE_RECORDS.map((r) => [r.span_id, r]));
|
||||
const stamp = (r: LargeTraceRecord | undefined): string =>
|
||||
r ? (idMap.get(r.span_id) as string) : '';
|
||||
const firstWithAttr = (key: string): LargeTraceRecord | undefined =>
|
||||
LARGE_TRACE_RECORDS.find((r) => key in r.attributes);
|
||||
|
||||
const deepest = LARGE_TRACE_RECORDS.reduce((a, b) =>
|
||||
recordDepth(b, byId) > recordDepth(a, byId) ? b : a,
|
||||
);
|
||||
|
||||
const landmarks = {
|
||||
root: stamp(LARGE_TRACE_RECORDS.find((r) => !r.parent_span_id)),
|
||||
errors: LARGE_TRACE_RECORDS.filter((r) => r.status_code === 2).map((r) =>
|
||||
stamp(r),
|
||||
),
|
||||
db: stamp(firstWithAttr('db.system')),
|
||||
http: stamp(firstWithAttr('http.method')),
|
||||
llm: stamp(firstWithAttr('gen_ai.request.model')),
|
||||
messaging: stamp(firstWithAttr('messaging.system')),
|
||||
deepLeaf: stamp(deepest),
|
||||
};
|
||||
|
||||
return { traceId, spans, landmarks };
|
||||
}
|
||||
|
||||
// ── Flamegraph canvas test hook ──────────────────────────────────────────────
|
||||
// The flamegraph is canvas-rendered, so individual bars have no DOM nodes. The
|
||||
// frontend exposes a read-only span→rect view on window.__sigTraceFlame__
|
||||
// (useFlamegraphTestHook), present only when __SIGNOZ_E2E__ is set — which
|
||||
// gotoTraceUntilLoaded injects via addInitScript.
|
||||
|
||||
// Mirror of the API exposed by useFlamegraphTestHook.
|
||||
interface FlamegraphTestApi {
|
||||
getSpanPoint: (spanId: string) => { x: number; y: number } | null;
|
||||
isSpanInView: (spanId: string) => boolean;
|
||||
getSpanColor: (spanId: string) => string | null;
|
||||
}
|
||||
|
||||
interface FlameWindow {
|
||||
__sigTraceFlame__?: FlamegraphTestApi;
|
||||
}
|
||||
|
||||
// Resolve a span's on-canvas viewport point, waiting through the first paint
|
||||
// (the hook + spanRects populate only after the flamegraph's draw rAF).
|
||||
async function spanPoint(
|
||||
page: Page,
|
||||
spanId: string,
|
||||
): Promise<{ x: number; y: number }> {
|
||||
const handle = await page.waitForFunction(
|
||||
(id) =>
|
||||
(window as unknown as FlameWindow).__sigTraceFlame__?.getSpanPoint(id) ??
|
||||
null,
|
||||
spanId,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
const point = await handle.jsonValue();
|
||||
if (!point) {
|
||||
throw new Error(`flamegraph span "${spanId}" is not drawn on the canvas`);
|
||||
}
|
||||
return point;
|
||||
}
|
||||
|
||||
// Hover the flamegraph bar for `spanId` (opens its SpanHoverCard).
|
||||
export async function hoverFlamegraphSpan(
|
||||
page: Page,
|
||||
spanId: string,
|
||||
): Promise<void> {
|
||||
const { x, y } = await spanPoint(page, spanId);
|
||||
await page.mouse.move(x, y);
|
||||
}
|
||||
|
||||
// Click the flamegraph bar for `spanId` (selects the span / opens the drawer).
|
||||
export async function clickFlamegraphSpan(
|
||||
page: Page,
|
||||
spanId: string,
|
||||
): Promise<void> {
|
||||
const { x, y } = await spanPoint(page, spanId);
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.click(x, y);
|
||||
}
|
||||
|
||||
// Whether `spanId`'s bar is currently drawn AND inside the viewport container.
|
||||
export async function isFlamegraphSpanInView(
|
||||
page: Page,
|
||||
spanId: string,
|
||||
): Promise<boolean> {
|
||||
return page.evaluate(
|
||||
(id) =>
|
||||
(window as unknown as FlameWindow).__sigTraceFlame__?.isSpanInView(id) ??
|
||||
false,
|
||||
spanId,
|
||||
);
|
||||
}
|
||||
|
||||
// Resting group color of a span's bar — used to assert colour-by recolor.
|
||||
export async function getFlamegraphSpanColor(
|
||||
page: Page,
|
||||
spanId: string,
|
||||
): Promise<string | null> {
|
||||
return page.evaluate(
|
||||
(id) =>
|
||||
(window as unknown as FlameWindow).__sigTraceFlame__?.getSpanColor(id) ??
|
||||
null,
|
||||
spanId,
|
||||
);
|
||||
}
|
||||
|
||||
// ── User preferences (server-side, per-user) ─────────────────────────────────
|
||||
|
||||
// Trace-detail user-preference keys (mirror frontend constants/userPreferences.ts).
|
||||
export const TRACE_PREFERENCE = {
|
||||
COLOR_BY: 'span_details_color_by_attribute',
|
||||
PREVIEW_FIELDS: 'span_details_preview_attributes',
|
||||
PINNED_ATTRIBUTES: 'span_details_pinned_attributes',
|
||||
} as const;
|
||||
|
||||
// Whether the left nav is docked/pinned (mirror USER_PREFERENCES.SIDENAV_PINNED).
|
||||
const SIDENAV_PINNED = 'sidenav_pinned';
|
||||
|
||||
// A telemetry field key as persisted in the preview-fields preference. Only
|
||||
// `name` is required by the store (derivePreviewFields), but fieldContext /
|
||||
// fieldDataType match how the UI persists them.
|
||||
export interface PreviewFieldKey {
|
||||
name: string;
|
||||
fieldContext?: string;
|
||||
fieldDataType?: string;
|
||||
}
|
||||
|
||||
// PUT a single user preference (server-side, per-user). Call BEFORE navigating
|
||||
// to the trace page so its on-mount preference fetch returns the seeded value.
|
||||
//
|
||||
// NOTE: user preferences are GLOBAL PER USER, not per-test — they persist on the
|
||||
// server for the admin user. Reset them (resetTracePreferences) in afterAll, and
|
||||
// be aware other specs run by the same user in parallel share this state.
|
||||
export async function setUserPreference(
|
||||
page: Page,
|
||||
name: string,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.put(`/api/v1/user/preferences/${name}`, {
|
||||
data: { value },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`PUT /api/v1/user/preferences/${name} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the flamegraph color-by field. `fieldName` must be one of
|
||||
// COLOR_BY_OPTIONS (service.name | service.namespace | host.name |
|
||||
// k8s.node.name | k8s.container.name); '' falls back to the default.
|
||||
export async function setColorByPreference(
|
||||
page: Page,
|
||||
fieldName: string,
|
||||
): Promise<void> {
|
||||
await setUserPreference(page, TRACE_PREFERENCE.COLOR_BY, fieldName);
|
||||
}
|
||||
|
||||
// Persist the span-details preview fields (shown as rows in the hover card).
|
||||
export async function setPreviewFieldsPreference(
|
||||
page: Page,
|
||||
fields: PreviewFieldKey[],
|
||||
): Promise<void> {
|
||||
await setUserPreference(page, TRACE_PREFERENCE.PREVIEW_FIELDS, fields);
|
||||
}
|
||||
|
||||
// Reset trace-detail prefs to defaults. Run in afterAll so a prefs spec doesn't
|
||||
// leak color-by / preview-field state into other specs for the same user.
|
||||
export async function resetTracePreferences(page: Page): Promise<void> {
|
||||
await setColorByPreference(page, '');
|
||||
await setPreviewFieldsPreference(page, []);
|
||||
}
|
||||
|
||||
// Pin (dock) the left nav. When unpinned it's a collapsed rail that flies out on
|
||||
// hover as an absolute OVERLAY, covering the trace content's left strip — so
|
||||
// hover/click on left-edge targets (the waterfall collapse arrow, flamegraph
|
||||
// bars) lands on the sidebar instead. Pinned, it's a flex child that reserves
|
||||
// layout space, so nothing is occluded. Set before navigating: the server pref
|
||||
// wins over localStorage once preferences load.
|
||||
export async function pinSidenav(page: Page): Promise<void> {
|
||||
await setUserPreference(page, SIDENAV_PINNED, true);
|
||||
}
|
||||
2482
tests/e2e/testdata/traces/large-trace.json
vendored
Normal file
2482
tests/e2e/testdata/traces/large-trace.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
87
tests/e2e/tests/trace-details/drawer.spec.ts
Normal file
87
tests/e2e/tests/trace-details/drawer.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
import {
|
||||
gotoTraceUntilLoaded,
|
||||
loadLargeTrace,
|
||||
seedTracesViaSeeder,
|
||||
} from '../../helpers/trace-details';
|
||||
|
||||
// One shared trace for the whole file, seeded once. Unique ids per run keep this
|
||||
// isolated from other parallel specs; the global teardown clears the traces signal.
|
||||
const trace = loadLargeTrace();
|
||||
|
||||
test.describe('Trace details — span details drawer', () => {
|
||||
test.beforeAll(async ({ playwright }) => {
|
||||
// Seed once via a disposable request context — no auth needed (direct
|
||||
// seeder call), and cheaper than spinning up a full browser page.
|
||||
const request = await playwright.request.newContext();
|
||||
await seedTracesViaSeeder(request, trace.spans);
|
||||
await request.dispose();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ authedPage: page }) => {
|
||||
// open the trace, reloading until the waterfall renders (seed→query lag)
|
||||
await gotoTraceUntilLoaded(
|
||||
page,
|
||||
`/trace/${trace.traceId}`,
|
||||
`cell-0-${trace.landmarks.root}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-03 dock-mode switching toggles the drawer between floating and docked', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.getByTestId(`cell-0-${trace.landmarks.root}`).click();
|
||||
await expect(page.getByRole('tab', { name: /overview/i })).toBeVisible();
|
||||
|
||||
// Default is docked-right → not a floating panel (no drag handle).
|
||||
await expect(page.locator('.floating-panel__drag-handle')).toHaveCount(0);
|
||||
|
||||
// Switch to floating (dialog) → the drag handle appears.
|
||||
await page.getByTestId('dock-mode-dialog').click();
|
||||
await expect(page.locator('.floating-panel__drag-handle')).toBeVisible();
|
||||
|
||||
// Switch to docked-bottom → floating handle gone again.
|
||||
await page.getByTestId('dock-mode-docked').click();
|
||||
await expect(page.locator('.floating-panel__drag-handle')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-04 the floating drawer can be dragged', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.getByTestId(`cell-0-${trace.landmarks.root}`).click();
|
||||
await page.getByTestId('dock-mode-dialog').click();
|
||||
|
||||
const handle = page.locator('.floating-panel__drag-handle');
|
||||
await expect(handle).toBeVisible();
|
||||
|
||||
const zero = { x: 0, y: 0, width: 0, height: 0 };
|
||||
const before = (await handle.boundingBox()) ?? zero;
|
||||
// Drag from the left of the header (title area) to avoid the action buttons.
|
||||
const startX = before.x + 30;
|
||||
const startY = before.y + before.height / 2;
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX - 120, startY + 80, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect
|
||||
.poll(async () => Math.round(((await handle.boundingBox()) ?? before).x))
|
||||
.toBeLessThan(Math.round(before.x));
|
||||
});
|
||||
|
||||
test('TC-05 a dock-mode change persists and is restored on reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// §0 prefs-boot, UI-first: switch to floating via the dock-mode UI (which
|
||||
// persists the variant), then reload and confirm it's restored — the drawer
|
||||
// boots floating, not the docked-right default.
|
||||
await page.getByTestId(`cell-0-${trace.landmarks.root}`).click();
|
||||
await page.getByTestId('dock-mode-dialog').click();
|
||||
await expect(page.locator('.floating-panel__drag-handle')).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await page.getByTestId(`cell-0-${trace.landmarks.root}`).click();
|
||||
|
||||
await expect(page.locator('.floating-panel__drag-handle')).toBeVisible();
|
||||
});
|
||||
});
|
||||
122
tests/e2e/tests/trace-details/flamegraph.spec.ts
Normal file
122
tests/e2e/tests/trace-details/flamegraph.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import {
|
||||
changeColourByViaMenu,
|
||||
clickFlamegraphSpan,
|
||||
getFlamegraphSpanColor,
|
||||
gotoTraceUntilLoaded,
|
||||
hoverFlamegraphSpan,
|
||||
isFlamegraphSpanInView,
|
||||
loadLargeTrace,
|
||||
seedTracesViaSeeder,
|
||||
setColorByPreference,
|
||||
} from '../../helpers/trace-details';
|
||||
|
||||
// The flamegraph is canvas-rendered, so individual bars have no DOM nodes. These
|
||||
// specs drive it through the window.__sigTraceFlame__ test hook (enabled by
|
||||
// gotoTraceUntilLoaded) — see helpers/trace-details.ts — which resolves a span's
|
||||
// on-canvas point from the live span→rect map and dispatches real mouse events.
|
||||
//
|
||||
// One shared trace for the file, seeded once. Random ids per run isolate it from
|
||||
// other parallel specs; the global teardown clears the traces signal.
|
||||
//
|
||||
// Colour-by recolor is asserted via the hook's getSpanColor (the resting group
|
||||
// color per bar), since canvas pixels aren't directly assertable.
|
||||
//
|
||||
// Deferred: sampled large trace — sampling needs >100k spans
|
||||
// (FLAMEGRAPH_SPAN_LIMIT), which is the deferred large-trace work.
|
||||
const trace = loadLargeTrace();
|
||||
|
||||
test.describe('Trace details — flamegraph', () => {
|
||||
test.beforeAll(async ({ playwright }) => {
|
||||
const request = await playwright.request.newContext();
|
||||
await seedTracesViaSeeder(request, trace.spans);
|
||||
await request.dispose();
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
// TC-05 changes colour-by — a per-user pref. Reset it so it doesn't leak to
|
||||
// other specs (afterAll can't use the test-scoped authedPage fixture).
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
await setColorByPreference(page, '');
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ authedPage: page }) => {
|
||||
await gotoTraceUntilLoaded(
|
||||
page,
|
||||
`/trace/${trace.traceId}`,
|
||||
`cell-0-${trace.landmarks.root}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-01 hovering an error bar opens its hover card with status/start/duration', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await hoverFlamegraphSpan(page, trace.landmarks.errors[0]);
|
||||
|
||||
// "status: error" only renders in the hover card (not in waterfall rows),
|
||||
// so it proves both that the card opened and that we hovered the right
|
||||
// (error) span — the bar was targeted by id via the span→rect map.
|
||||
await expect(page.getByText('status: error')).toBeVisible();
|
||||
await expect(page.getByText(/start: [\d.]+ ms/)).toBeVisible();
|
||||
await expect(page.getByText(/duration: [\d.]+/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 hovering a non-error bar shows status: ok', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await hoverFlamegraphSpan(page, trace.landmarks.db);
|
||||
|
||||
await expect(page.getByText('status: ok')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 clicking a bar selects the span, opens the drawer, and syncs the waterfall row', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await clickFlamegraphSpan(page, trace.landmarks.db);
|
||||
|
||||
// selection is reflected in the shared URL state...
|
||||
await expect(page).toHaveURL(new RegExp(`spanId=${trace.landmarks.db}`));
|
||||
// ...the drawer opens (Overview tab is drawer-only)...
|
||||
await expect(page.getByRole('tab', { name: /overview/i })).toBeVisible();
|
||||
// ...and the same span's waterfall row is present (views share selection).
|
||||
await expect(page.getByTestId(`cell-0-${trace.landmarks.db}`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 deep-linking a deeply-nested span scrolls it into view on the flamegraph', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Open pre-pointed at a deep (level ~34) span; useScrollToSpan should
|
||||
// center it, so its bar becomes drawn and inside the viewport container.
|
||||
await gotoTraceUntilLoaded(
|
||||
page,
|
||||
`/trace/${trace.traceId}?spanId=${trace.landmarks.deepLeaf}`,
|
||||
`cell-0-${trace.landmarks.deepLeaf}`,
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(() => isFlamegraphSpanInView(page, trace.landmarks.deepLeaf))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('TC-05 changing colour-by recolors the flamegraph bars', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// colour-by persists per-user, so set an explicit baseline rather than
|
||||
// assuming the default. Root's color under service.name:
|
||||
await changeColourByViaMenu(page, 'service.name');
|
||||
const colorByService = await getFlamegraphSpanColor(
|
||||
page,
|
||||
trace.landmarks.root,
|
||||
);
|
||||
expect(colorByService).not.toBeNull();
|
||||
|
||||
// Switch to host.name → root groups by a different value → new color.
|
||||
await changeColourByViaMenu(page, 'host.name');
|
||||
await expect
|
||||
.poll(() => getFlamegraphSpanColor(page, trace.landmarks.root))
|
||||
.not.toBe(colorByService);
|
||||
});
|
||||
});
|
||||
57
tests/e2e/tests/trace-details/header.spec.ts
Normal file
57
tests/e2e/tests/trace-details/header.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
import {
|
||||
gotoTraceUntilLoaded,
|
||||
loadLargeTrace,
|
||||
seedTracesViaSeeder,
|
||||
} from '../../helpers/trace-details';
|
||||
|
||||
// §1 header — the Analytics FloatingPanel. The action cluster (Analytics button
|
||||
// + options menu) only renders once trace data is loaded, which gotoTraceUntilLoaded
|
||||
// guarantees by waiting for the root waterfall row.
|
||||
//
|
||||
// Not covered here: subheader summary (presentational → unit test), colour-by /
|
||||
// options menu / trace-id copy (unit), Noz button (feature-flagged, lives in the
|
||||
// filter bar). Resize is deferred — react-rnd's resize handles have no stable hook.
|
||||
const trace = loadLargeTrace();
|
||||
|
||||
test.describe('Trace details — header analytics panel', () => {
|
||||
test.beforeAll(async ({ playwright }) => {
|
||||
const request = await playwright.request.newContext();
|
||||
await seedTracesViaSeeder(request, trace.spans);
|
||||
await request.dispose();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ authedPage: page }) => {
|
||||
await gotoTraceUntilLoaded(
|
||||
page,
|
||||
`/trace/${trace.traceId}`,
|
||||
`cell-0-${trace.landmarks.root}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-02 the analytics panel can be dragged by its header', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: 'Analytics' }).click();
|
||||
const panel = page.getByTestId('trace-analytics-panel');
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const zero = { x: 0, y: 0, width: 0, height: 0 };
|
||||
const before = (await panel.boundingBox()) ?? zero;
|
||||
const hb =
|
||||
(await page.locator('.floating-panel__drag-handle').boundingBox()) ?? zero;
|
||||
|
||||
// Drag the header left + down.
|
||||
await page.mouse.move(hb.x + hb.width / 2, hb.y + hb.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(hb.x + hb.width / 2 - 120, hb.y + hb.height / 2 + 60, {
|
||||
steps: 8,
|
||||
});
|
||||
await page.mouse.up();
|
||||
|
||||
// Panel shifted left.
|
||||
await expect
|
||||
.poll(async () => Math.round(((await panel.boundingBox()) ?? before).x))
|
||||
.toBeLessThan(Math.round(before.x));
|
||||
});
|
||||
});
|
||||
83
tests/e2e/tests/trace-details/preview-fields.spec.ts
Normal file
83
tests/e2e/tests/trace-details/preview-fields.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import {
|
||||
gotoTraceUntilLoaded,
|
||||
hoverFlamegraphSpan,
|
||||
loadLargeTrace,
|
||||
resetTracePreferences,
|
||||
seedTracesViaSeeder,
|
||||
setPreviewFieldsPreference,
|
||||
} from '../../helpers/trace-details';
|
||||
|
||||
// §6 — preview fields. A configured preview field appears as a row in the span
|
||||
// hover card (SpanTooltipContent, testid span-hover-card-preview-<key>). The
|
||||
// waterfall variant is covered at the unit/integration level; this spec keeps
|
||||
// the flamegraph (canvas) case, which can't run in jsdom.
|
||||
//
|
||||
// Preview fields are a server-side, per-user preference, so each test seeds them
|
||||
// via the API before navigating; afterAll resets them so the state doesn't leak
|
||||
// into other specs run by the same admin user.
|
||||
const trace = loadLargeTrace();
|
||||
|
||||
// The db landmark span carries db.system="redis"; seed db.system as a preview
|
||||
// field so its value renders in the hover card.
|
||||
const PREVIEW_FIELD = 'db.system';
|
||||
const PREVIEW_VALUE = 'redis';
|
||||
const PREVIEW_TESTID = `span-hover-card-preview-${PREVIEW_FIELD}`;
|
||||
|
||||
test.describe('Trace details — preview fields in the hover card', () => {
|
||||
// Run serially in one worker: preview fields are a per-user preference, so
|
||||
// the afterAll reset must not race a sibling test still using them on another
|
||||
// worker (which intermittently wiped the preview row mid-test).
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async ({ playwright }) => {
|
||||
const request = await playwright.request.newContext();
|
||||
await seedTracesViaSeeder(request, trace.spans);
|
||||
await request.dispose();
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
// Reset prefs to defaults (afterAll can't use the authedPage fixture).
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
await resetTracePreferences(page);
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ authedPage: page }) => {
|
||||
// Seed the preview field BEFORE navigating so the on-mount prefs fetch
|
||||
// returns it and the hover card renders the row.
|
||||
// db.system is a span ATTRIBUTE (fieldContext 'attribute', not 'span') —
|
||||
// the flamegraph fetches fields selectively, so the wrong context means
|
||||
// the bar's span wouldn't carry the value and the hover row wouldn't render.
|
||||
await setPreviewFieldsPreference(page, [
|
||||
{ name: PREVIEW_FIELD, fieldContext: 'attribute', fieldDataType: 'string' },
|
||||
]);
|
||||
await gotoTraceUntilLoaded(
|
||||
page,
|
||||
`/trace/${trace.traceId}`,
|
||||
`cell-0-${trace.landmarks.root}`,
|
||||
);
|
||||
});
|
||||
|
||||
// FIXME: blocked by a frontend bug — the flamegraph fires its span fetch
|
||||
// (POST /flamegraph) with selectFields = color-by only, before previewFields
|
||||
// syncs into the store, and does NOT refetch when the preference lands. So the
|
||||
// flamegraph span never carries the preview attribute (e.g. db.system) and its
|
||||
// hover card can't render the row. Intermittent (passes only when prefs are
|
||||
// cache-warm before the first fetch). Re-enable once the flamegraph
|
||||
// gates/refetches on previewFields. See sprint task.
|
||||
test.fixme('TC-01 flamegraph hover card shows the configured preview field', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const previewRow = page.getByTestId(PREVIEW_TESTID).first();
|
||||
await expect(async () => {
|
||||
await page.mouse.move(0, 0);
|
||||
await hoverFlamegraphSpan(page, trace.landmarks.db);
|
||||
await expect(previewRow).toBeVisible({ timeout: 1500 });
|
||||
}).toPass({ timeout: 15_000 });
|
||||
|
||||
await expect(previewRow).toContainText(PREVIEW_VALUE);
|
||||
});
|
||||
});
|
||||
50
tests/e2e/tests/trace-details/waterfall.spec.ts
Normal file
50
tests/e2e/tests/trace-details/waterfall.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
import {
|
||||
gotoTraceUntilLoaded,
|
||||
loadLargeTrace,
|
||||
seedTracesViaSeeder,
|
||||
} from '../../helpers/trace-details';
|
||||
|
||||
const trace = loadLargeTrace();
|
||||
|
||||
test.describe('Trace details — waterfall', () => {
|
||||
test.beforeAll(async ({ playwright }) => {
|
||||
const request = await playwright.request.newContext();
|
||||
await seedTracesViaSeeder(request, trace.spans);
|
||||
await request.dispose();
|
||||
});
|
||||
|
||||
test('TC-01 deep-link ?spanId auto-selects the span and opens the drawer', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Open the trace pre-pointed at a specific span via the URL, reloading
|
||||
// until the waterfall renders (seed→query lag).
|
||||
const errorSpan = trace.landmarks.errors[0];
|
||||
await gotoTraceUntilLoaded(
|
||||
page,
|
||||
`/trace/${trace.traceId}?spanId=${errorSpan}`,
|
||||
`cell-0-${errorSpan}`,
|
||||
);
|
||||
|
||||
// the deep-linked span's row renders...
|
||||
await expect(page.getByTestId(`cell-0-${errorSpan}`)).toBeVisible();
|
||||
// ...and it auto-selects → the drawer is open (Overview tab is drawer-only)
|
||||
await expect(page.getByRole('tab', { name: /overview/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 deep-linking a deeply-nested span auto-expands ancestors and scrolls it into view', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// deepLeaf sits ~34 levels down; rendering its row at all proves every
|
||||
// ancestor auto-expanded and the waterfall scrolled it into view.
|
||||
const deep = trace.landmarks.deepLeaf;
|
||||
await gotoTraceUntilLoaded(
|
||||
page,
|
||||
`/trace/${trace.traceId}?spanId=${deep}`,
|
||||
`cell-0-${deep}`,
|
||||
);
|
||||
|
||||
await expect(page.getByTestId(`cell-0-${deep}`)).toBeVisible();
|
||||
await expect(page).toHaveURL(new RegExp(`spanId=${deep}`));
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import pytest
|
||||
import requests
|
||||
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.types import Operation, SigNoz
|
||||
|
||||
BASE_URL = "/api/v2/dashboards"
|
||||
@@ -934,3 +935,306 @@ def test_dashboard_v2_like_escaping(
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == expected, query
|
||||
|
||||
|
||||
# ─── get dashboards by metric name (v3) ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_dashboard_v2_get_by_metric_name(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
"""The v3 endpoint shortlists dashboards via a coarse data prefilter, then
|
||||
confirms matches by parsing the typed v2 panels. It must find the metric in
|
||||
builder, promql, and clickhouse queries, and must NOT report a dashboard where
|
||||
the metric appears only in panel names (the prefilter matches but the parse
|
||||
rejects it)."""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
_wipe_all_dashboards(signoz, token)
|
||||
|
||||
target_metric = "system.network.dropped"
|
||||
decoy_metric = "system.network.io"
|
||||
|
||||
# The endpoint gates on metric existence (checkMetricExists reads
|
||||
# signoz_metrics.distributed_metadata), so seed the target metric there. A
|
||||
# label is required for a metadata row to be written.
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=target_metric,
|
||||
labels={"host.name": "test-host"},
|
||||
temporality="Cumulative",
|
||||
value=1.0,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# D1: a single builder query referencing the target metric.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "by-metric-builder",
|
||||
"spec": {
|
||||
"display": {"name": "by-metric-builder"},
|
||||
"panels": {
|
||||
"p-builder": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": "D1 builder target"},
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": target_metric,
|
||||
"timeAggregation": "rate",
|
||||
"spaceAggregation": "sum",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
d1_id = response.json()["data"]["id"]
|
||||
|
||||
# D2: one clickhouse panel and one promql panel, both referencing the target
|
||||
# metric (one query per panel is enforced by validation). Two matching panels
|
||||
# in one dashboard also guards against a dashboard/widget being returned twice.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "by-metric-ch-promql",
|
||||
"spec": {
|
||||
"display": {"name": "by-metric-ch-promql"},
|
||||
"panels": {
|
||||
"p-ch": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": "D2 clickhouse target"},
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/ClickHouseSQL",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": f"select * from signoz_metrics.distributed_samples_v4 where metric_name IN ['{target_metric}']",
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
"p-promql": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": "D2 promql target"},
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/PromQLQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": f'sum(rate({{"{target_metric}"}}[5m]))',
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
d2_id = response.json()["data"]["id"]
|
||||
|
||||
# D3: a promql-only dashboard referencing the target metric, so a promql
|
||||
# extraction regression is caught independently of the clickhouse path above.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "by-metric-promql",
|
||||
"spec": {
|
||||
"display": {"name": "by-metric-promql"},
|
||||
"panels": {
|
||||
"p-promql": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": "D3 promql target"},
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/PromQLQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": f'sum(rate({{"{target_metric}"}}[5m]))',
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
d3_id = response.json()["data"]["id"]
|
||||
|
||||
# D4: all three query types, but the target name appears only in the panel
|
||||
# names; the queries reference a decoy metric. The data prefilter matches
|
||||
# (panel names contain the target), but parsing the queries must not associate
|
||||
# the target metric, so this dashboard must be excluded from the result.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": "by-metric-false-positive",
|
||||
"spec": {
|
||||
"display": {"name": "by-metric-false-positive"},
|
||||
"panels": {
|
||||
"p-builder": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": f"{target_metric} builder"},
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": decoy_metric,
|
||||
"timeAggregation": "rate",
|
||||
"spaceAggregation": "sum",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
"p-ch": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": f"{target_metric} clickhouse"},
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/ClickHouseSQL",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": f"select * from signoz_metrics.distributed_samples_v4 where metric_name IN ['{decoy_metric}']",
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
"p-promql": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {"name": f"{target_metric} promql"},
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [
|
||||
{
|
||||
"kind": "time_series",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/PromQLQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": f'sum(rate({{"{decoy_metric}"}}[5m]))',
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"tags": [],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
d4_id = response.json()["data"]["id"]
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v3/metrics/dashboards"),
|
||||
params={"metricName": target_metric},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
dashboards = response.json()["data"]["dashboards"]
|
||||
|
||||
# No dashboard/panel should be returned more than once.
|
||||
pairs = [(d["dashboardId"], d["panelId"]) for d in dashboards]
|
||||
assert len(pairs) == len(set(pairs))
|
||||
|
||||
# D1 (1 panel) + D2 (2 panels) + D3 (1 promql panel) match; D4 (target only in
|
||||
# panel names) does not.
|
||||
assert {d["dashboardId"] for d in dashboards} == {d1_id, d2_id, d3_id}
|
||||
assert d4_id not in {d["dashboardId"] for d in dashboards}
|
||||
|
||||
by_dashboard: dict[str, list[str]] = {}
|
||||
for d in dashboards:
|
||||
by_dashboard.setdefault(d["dashboardId"], []).append(d["panelName"])
|
||||
assert sorted(by_dashboard[d1_id]) == ["D1 builder target"]
|
||||
assert sorted(by_dashboard[d2_id]) == ["D2 clickhouse target", "D2 promql target"]
|
||||
assert sorted(by_dashboard[d3_id]) == ["D3 promql target"]
|
||||
|
||||
@@ -2306,9 +2306,11 @@ def test_logs_list_filter_by_trace_id(
|
||||
"""
|
||||
Tests that filtering logs by trace_id uses the trace_summary lookup to
|
||||
narrow the query window before scanning the logs table:
|
||||
1. Returns the matching log (narrow window, single bucket).
|
||||
1. Returns the matching logs (narrow window, single bucket), including a log
|
||||
flushed shortly after the span ends — kept by the configured padding.
|
||||
2. Does not return duplicate logs when the query window should span multiple
|
||||
exponential buckets (>1 h). But is clamped to the timerange of trace.
|
||||
exponential buckets (>1 h). The window is clamped to the trace's recorded
|
||||
range widened by the padding, so the post-span log survives the clamp.
|
||||
3. Returns no results when the query window does not contain the trace.
|
||||
4. Logs carrying a trace_id whose trace is NOT in trace_summary (e.g.
|
||||
traces disabled) are still returned — the lookup miss must not
|
||||
@@ -2366,6 +2368,9 @@ def test_logs_list_filter_by_trace_id(
|
||||
# Insert logs:
|
||||
# - one with the target trace_id, at a timestamp within the trace's
|
||||
# recorded window (now-10s..now-5s, padded ±1s).
|
||||
# - one with the target trace_id flushed ~3s AFTER the span's recorded end
|
||||
# (now-2s). This is outside the ±1s base pad but inside the multi-minute
|
||||
# log_trace_id_window_padding, so it must still be returned.
|
||||
# - one with an orphan trace_id whose trace was never ingested — used to
|
||||
# verify the lookup miss does NOT short-circuit logs queries.
|
||||
insert_logs(
|
||||
@@ -2379,6 +2384,15 @@ def test_logs_list_filter_by_trace_id(
|
||||
trace_id=target_trace_id,
|
||||
span_id=target_root_span_id,
|
||||
),
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=2),
|
||||
resources=common_resources,
|
||||
attributes={"http.method": "POST"},
|
||||
body="log flushed after the span ends, within padding window",
|
||||
severity_text="INFO",
|
||||
trace_id=target_trace_id,
|
||||
span_id=target_root_span_id,
|
||||
),
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=2),
|
||||
resources=common_resources,
|
||||
@@ -2429,23 +2443,31 @@ def test_logs_list_filter_by_trace_id(
|
||||
|
||||
now_ms = int(now.timestamp() * 1000)
|
||||
|
||||
inside_window_body = "log inside the target trace window"
|
||||
post_span_body = "log flushed after the span ends, within padding window"
|
||||
|
||||
# --- Test 1: narrow window (single bucket, <1 h) ---
|
||||
narrow_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
|
||||
narrow_rows, narrow_warnings = _query(narrow_start_ms, now_ms, target_trace_id)
|
||||
|
||||
assert len(narrow_rows) == 1, f"Expected 1 log for trace_id filter (narrow window), got {len(narrow_rows)}"
|
||||
assert narrow_rows[0]["data"]["trace_id"] == target_trace_id
|
||||
assert narrow_rows[0]["data"]["span_id"] == target_root_span_id
|
||||
assert len(narrow_rows) == 2, f"Expected 2 logs for trace_id filter (narrow window), got {len(narrow_rows)}"
|
||||
assert {r["data"]["trace_id"] for r in narrow_rows} == {target_trace_id}
|
||||
narrow_bodies = {r["data"]["body"] for r in narrow_rows}
|
||||
assert inside_window_body in narrow_bodies
|
||||
assert post_span_body in narrow_bodies, "post-span log should be returned within the padding window"
|
||||
assert not any(outside_range_msg in m for m in narrow_warnings), f"Did not expect outside-range warning, got {narrow_warnings}"
|
||||
|
||||
# --- Test 2: wide window (>1 h, clamp to the timerange from trace_summary) ---
|
||||
# Should still return exactly one log — no duplicates from multi-bucket scan.
|
||||
# --- Test 2: wide window (>1 h, clamp to the padded timerange from trace_summary) ---
|
||||
# Should return exactly the two target logs — no duplicates from multi-bucket
|
||||
# scan, and the post-span log survives the clamp only because of the padding.
|
||||
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
|
||||
wide_rows, wide_warnings = _query(wide_start_ms, now_ms, target_trace_id)
|
||||
|
||||
assert len(wide_rows) == 1, f"Expected 1 log for trace_id filter (wide window, multi-bucket), got {len(wide_rows)} — possible duplicate-log regression"
|
||||
assert wide_rows[0]["data"]["trace_id"] == target_trace_id
|
||||
assert wide_rows[0]["data"]["span_id"] == target_root_span_id
|
||||
assert len(wide_rows) == 2, f"Expected 2 logs for trace_id filter (wide window, multi-bucket), got {len(wide_rows)} — possible duplicate-log regression or padding not applied"
|
||||
assert {r["data"]["trace_id"] for r in wide_rows} == {target_trace_id}
|
||||
wide_bodies = {r["data"]["body"] for r in wide_rows}
|
||||
assert inside_window_body in wide_bodies
|
||||
assert post_span_body in wide_bodies, "post-span log should survive the clamp because of the padding"
|
||||
assert not any(outside_range_msg in m for m in wide_warnings), f"Did not expect outside-range warning, got {wide_warnings}"
|
||||
|
||||
# --- Test 3: window that does not contain the trace returns no results + warning ---
|
||||
|
||||
@@ -15,7 +15,6 @@ from fixtures.querier import (
|
||||
build_builder_query,
|
||||
find_named_result,
|
||||
get_all_warnings,
|
||||
get_error_message,
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user