mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-29 19:40:31 +01:00
Compare commits
4 Commits
issue_5519
...
fix/notifi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a8a73a8dc | ||
|
|
cee707dac8 | ||
|
|
c5c1913f97 | ||
|
|
5ab6636863 |
@@ -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:
|
||||
|
||||
@@ -2673,6 +2673,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:
|
||||
@@ -5590,6 +5606,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:
|
||||
@@ -22763,6 +22789,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;
|
||||
};
|
||||
|
||||
@@ -3954,6 +3954,25 @@ export interface DashboardtypesDashboardDTO {
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDashboardPanelRefDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
dashboardId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
dashboardName: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
panelId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
panelName: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesDatasourcePluginVariantStructDTOKind {
|
||||
'signoz/Datasource' = 'signoz/Datasource',
|
||||
}
|
||||
@@ -7174,6 +7193,13 @@ export interface MetricsexplorertypesMetricDashboardDTO {
|
||||
widgetName: string;
|
||||
}
|
||||
|
||||
export interface MetricsexplorertypesMetricDashboardPanelsResponseDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
dashboards: DashboardtypesDashboardPanelRefDTO[] | null;
|
||||
}
|
||||
|
||||
export interface MetricsexplorertypesMetricDashboardsResponseDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -11455,6 +11481,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;
|
||||
};
|
||||
|
||||
@@ -26,7 +26,12 @@ jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
|
||||
|
||||
describe('Should check if the edit alert channel is properly displayed', () => {
|
||||
beforeEach(() => {
|
||||
render(<EditAlertChannels initialValue={editAlertChannelInitialValue} />);
|
||||
render(
|
||||
<EditAlertChannels
|
||||
channelId="3"
|
||||
initialValue={editAlertChannelInitialValue}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import EditAlertChannels from 'container/EditAlertChannels';
|
||||
import { editAlertChannelInitialValue } from 'mocks-server/__mockdata__/alerts';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
__esModule: true,
|
||||
useNotifications: jest.fn(() => ({
|
||||
notifications: { success: jest.fn(), error: jest.fn() },
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
|
||||
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
|
||||
}));
|
||||
|
||||
interface EditRequest {
|
||||
id: string;
|
||||
body: { name: string; slack_configs: { send_resolved: boolean }[] };
|
||||
}
|
||||
|
||||
// Captures the PUT /channels/:id request the edit form fires, so assertions can
|
||||
// run against the real HTTP payload instead of a hand-mocked api client.
|
||||
function mockEditChannel(): { calls: EditRequest[] } {
|
||||
const result: { calls: EditRequest[] } = { calls: [] };
|
||||
server.use(
|
||||
rest.put('http://localhost/api/v1/channels/:id', async (req, res, ctx) => {
|
||||
result.calls.push({
|
||||
id: req.params.id as string,
|
||||
body: await req.json(),
|
||||
});
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: 'channel updated' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('EditAlertChannels save', () => {
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('sends the channelId in the edit request (regression: empty id)', async () => {
|
||||
const edit = mockEditChannel();
|
||||
render(
|
||||
<EditAlertChannels
|
||||
channelId="3"
|
||||
initialValue={editAlertChannelInitialValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
|
||||
await waitFor(() => expect(edit.calls).toHaveLength(1));
|
||||
expect(edit.calls[0].id).toBe('3');
|
||||
});
|
||||
|
||||
it('persists send_resolved toggle in the edit request', async () => {
|
||||
const edit = mockEditChannel();
|
||||
render(
|
||||
<EditAlertChannels
|
||||
channelId="3"
|
||||
initialValue={editAlertChannelInitialValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const sendResolved = screen.getByTestId('field-send-resolved-checkbox');
|
||||
expect(sendResolved).toBeChecked();
|
||||
|
||||
await user.click(sendResolved);
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
|
||||
await waitFor(() => expect(edit.calls).toHaveLength(1));
|
||||
expect(edit.calls[0].id).toBe('3');
|
||||
expect(edit.calls[0].body.slack_configs[0].send_resolved).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import APIError from 'types/api/error';
|
||||
|
||||
function EditAlertChannels({
|
||||
initialValue,
|
||||
channelId: id,
|
||||
}: EditAlertChannelsProps): JSX.Element {
|
||||
// init namespace for translations
|
||||
const { t } = useTranslation('channels');
|
||||
@@ -53,11 +54,6 @@ function EditAlertChannels({
|
||||
const [testingState, setTestingState] = useState<boolean>(false);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
// Extract channelId from URL pathname since useParams doesn't work in nested routing
|
||||
const { pathname } = window.location;
|
||||
const channelIdMatch = pathname.match(/\/settings\/channels\/edit\/([^/]+)/);
|
||||
const id = channelIdMatch ? channelIdMatch[1] : '';
|
||||
|
||||
const [type, setType] = useState<ChannelType>(
|
||||
initialValue?.type ? (initialValue.type as ChannelType) : ChannelType.Slack,
|
||||
);
|
||||
@@ -520,6 +516,7 @@ interface EditAlertChannelsProps {
|
||||
initialValue: {
|
||||
[x: string]: unknown;
|
||||
};
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export default EditAlertChannels;
|
||||
|
||||
@@ -136,6 +136,7 @@ function FormAlertChannels({
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
data-testid="save-channel-button"
|
||||
disabled={savingState}
|
||||
loading={savingState}
|
||||
type="primary"
|
||||
@@ -144,6 +145,7 @@ function FormAlertChannels({
|
||||
{t('button_save_channel')}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="test-channel-button"
|
||||
disabled={testingState}
|
||||
loading={testingState}
|
||||
onClick={(): void => onTestHandler(type)}
|
||||
@@ -151,6 +153,7 @@ function FormAlertChannels({
|
||||
{t('button_test_channel')}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="return-button"
|
||||
onClick={(): void => {
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
}}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from 'react-query';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import get from 'api/channels/get';
|
||||
import AlertBreadcrumb from 'components/AlertBreadcrumb';
|
||||
@@ -24,10 +25,10 @@ import './ChannelsEdit.styles.scss';
|
||||
function ChannelsEdit(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Extract channelId from URL pathname
|
||||
const { pathname } = window.location;
|
||||
const channelIdMatch = pathname.match(/\/alerts\/channels\/edit\/([^/]+)/);
|
||||
const channelId = channelIdMatch ? channelIdMatch[1] : undefined;
|
||||
const { pathname } = useLocation();
|
||||
const channelId = matchPath<{ channelId: string }>(pathname, {
|
||||
path: ROUTES.CHANNELS_EDIT,
|
||||
})?.params?.channelId;
|
||||
|
||||
const { isFetching, isError, data, error } = useQuery<
|
||||
SuccessResponseV2<Channels>,
|
||||
@@ -147,6 +148,7 @@ function ChannelsEdit(): JSX.Element {
|
||||
<div className="edit-alert-channels-container">
|
||||
<EditAlertChannels
|
||||
{...{
|
||||
channelId: channelId || '',
|
||||
initialValue: {
|
||||
...target.channel,
|
||||
type: target.type,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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