Compare commits

..

12 Commits

Author SHA1 Message Date
manika-signoz
1525255250 Merge branch 'chore/migrate-tag' of github.com:SigNoz/signoz into chore/migrate-tag 2026-05-27 02:15:16 +05:30
manika-signoz
920965cd4d Merge branch 'main' into chore/migrate-tag 2026-05-26 17:02:55 +05:30
Nikhil Soni
1e326159b0 feat(tracedetail): add waterfall api with memory optimisations (#11450)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: add store methods for minimal trace fetch

* feat: break down waterfall module to handle large spans

Handling large traces in two steps to avoid high
memory allocation

* refactor: keep the waterfall changes in new api version

This is to avoid the contract change in existing v3

* chore: avoid unnecessary diffs

* refactor: move conversion logic to types

* chore: update openapi specs

* refactor: use sqlbuider for queries

* chore: fix comment

* chore: avoid passing request type to module

* refactor: avoid passing whole summary object around

* chore: remove trace_id from querying since its already known

* chore: remove unused reference column from query

* chore: update openapi specs
2026-05-26 10:11:16 +00:00
Nityananda Gohain
ceb1b4871b feat: trace based filters for logs, supporting aggregations as well (#11394)
* feat: trace based filters for logs, supporting aggregations as well

* fix: update comments

* fix: cleanup query from tests

* fix: address comments

* fix: address comments

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-05-26 09:57:18 +00:00
Abhi kumar
d48a238e15 chore: broke down drilldown navigate into a saperate hook (#11070)
* chore: broke down drilldown navigate into a saperate hook

* chore: fmt fix
2026-05-26 06:16:37 +00:00
Abhi kumar
2ca6ff7719 test: added test for crosshair series highlight changes (#11015)
* chore: added changes for crosshair sync for tooltip

* chore: minor cleanup

* chore: updated the core structure

* chore: updated the types

* chore: minor cleanup

* feat: added changes for sereis highlighting on crosshair sync

* test: added test for crosshair series highlight changes

* chore: pr review fixes

* chore: handled other cases of groupby

* chore: updated tests
2026-05-26 06:09:52 +00:00
Manika Malhotra
e100749d9a Merge branch 'main' into chore/migrate-tag 2026-05-23 10:04:19 +05:30
manika-signoz
0e0bfc6384 fix: remove accidently added files 2026-05-22 22:05:30 +05:30
manika-signoz
06045ee8c7 fix: test snapshot 2026-05-22 22:02:13 +05:30
Manika Malhotra
1aca69bf3d Merge branch 'main' into chore/migrate-tag 2026-05-22 21:54:46 +05:30
manika-signoz
db59109e03 Merge branch 'main' into chore/migrate-tag 2026-05-22 20:24:58 +05:30
manika-signoz
630442f391 chore: migrate antd Tag to badge 2026-05-22 20:21:40 +05:30
94 changed files with 2882 additions and 588 deletions

View File

@@ -18948,6 +18948,77 @@ paths:
summary: Get waterfall view for a trace
tags:
- tracedetail
/api/v4/traces/{traceID}/waterfall:
post:
deprecated: false
description: Returns the waterfall view of spans including all spans if total
spans are under a limit, a max count otherwise. Aggregations are dropped compared
to v3
operationId: GetWaterfallV4
parameters:
- in: path
name: traceID
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SpantypesPostableWaterfall'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableWaterfallTrace'
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 waterfall view for a trace
tags:
- tracedetail
/api/v5/query_range:
post:
deprecated: false

View File

@@ -19,6 +19,7 @@ const BANNED_COMPONENTS = {
Switch: 'Use @signozhq/ui/switch Switch instead of antd Switch.',
Badge: 'Use @signozhq/ui/badge instead of antd Badge.',
Progress: 'Use @signozhq/ui/progress instead of antd Progress.',
Tag: 'Use @signozhq/ui/badge Bagde instead of antd Tag.',
};
export default {

View File

@@ -9232,6 +9232,17 @@ export type GetWaterfall200 = {
status: string;
};
export type GetWaterfallV4PathParameters = {
traceID: string;
};
export type GetWaterfallV4200 = {
data: SpantypesGettableWaterfallTraceDTO;
/**
* @type string
*/
status: string;
};
export type QueryRangeV5200 = {
data: Querybuildertypesv5QueryRangeResponseDTO;
/**

View File

@@ -14,6 +14,8 @@ import type {
import type {
GetWaterfall200,
GetWaterfallPathParameters,
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
SpantypesPostableWaterfallDTO,
} from '../sigNoz.schemas';
@@ -120,3 +122,102 @@ export const useGetWaterfall = <
> => {
return useMutation(getGetWaterfallMutationOptions(options));
};
/**
* Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3
* @summary Get waterfall view for a trace
*/
export const getWaterfallV4 = (
{ traceID }: GetWaterfallV4PathParameters,
spantypesPostableWaterfallDTO?: BodyType<SpantypesPostableWaterfallDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetWaterfallV4200>({
url: `/api/v4/traces/${traceID}/waterfall`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: spantypesPostableWaterfallDTO,
signal,
});
};
export const getGetWaterfallV4MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getWaterfallV4>>,
TError,
{
pathParams: GetWaterfallV4PathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof getWaterfallV4>>,
TError,
{
pathParams: GetWaterfallV4PathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
},
TContext
> => {
const mutationKey = ['getWaterfallV4'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof getWaterfallV4>>,
{
pathParams: GetWaterfallV4PathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return getWaterfallV4(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type GetWaterfallV4MutationResult = NonNullable<
Awaited<ReturnType<typeof getWaterfallV4>>
>;
export type GetWaterfallV4MutationBody =
| BodyType<SpantypesPostableWaterfallDTO>
| undefined;
export type GetWaterfallV4MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get waterfall view for a trace
*/
export const useGetWaterfallV4 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getWaterfallV4>>,
TError,
{
pathParams: GetWaterfallV4PathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof getWaterfallV4>>,
TError,
{
pathParams: GetWaterfallV4PathParameters;
data?: BodyType<SpantypesPostableWaterfallDTO>;
},
TContext
> => {
return useMutation(getGetWaterfallV4MutationOptions(options));
};

View File

@@ -9,7 +9,7 @@ import {
useState,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Select, Tag, Tooltip } from 'antd';
import { Select, Tooltip } from 'antd';
import {
OPERATORS,
QUERY_BUILDER_OPERATORS_BY_TYPES,
@@ -37,7 +37,7 @@ import { validationMapper } from 'hooks/queryBuilder/useIsValidTag';
import { operatorTypeMapper } from 'hooks/queryBuilder/useOperatorType';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { isArray, isEmpty, isEqual, isObject } from 'lodash-es';
import { ChevronDown, ChevronUp } from '@signozhq/icons';
import { ChevronDown, ChevronUp, X } from '@signozhq/icons';
import type { BaseSelectRef } from 'rc-select';
import {
BaseAutocompleteData,
@@ -51,6 +51,7 @@ import { popupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import './ClientSideQBSearch.styles.scss';
import { Badge } from '@signozhq/ui/badge';
export interface AttributeKey {
key: string;
@@ -547,11 +548,7 @@ function ClientSideQBSearch(
return (
<span className="qb-search-bar-tokenised-tags">
<Tag
closable={!searchValue && closable}
onClose={onCloseHandler}
className={tagDetails?.key?.type || ''}
>
<Badge color="vanilla" className={tagDetails?.key?.type || ''}>
<Tooltip title={chipValue}>
<TypographyText
$isInNin={isInNin}
@@ -566,7 +563,15 @@ function ClientSideQBSearch(
{chipValue}
</TypographyText>
</Tooltip>
</Tag>
{!searchValue && closable && (
<X
size={12}
className="close-icon"
style={{ cursor: 'pointer' }}
onClick={onCloseHandler}
/>
)}
</Badge>
</span>
);
};

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Color } from '@signozhq/design-tokens';
import { Button, Modal, Tag } from 'antd';
import { Button, Modal } from 'antd';
import { CircleAlert, X } from '@signozhq/icons';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { useAppContext } from 'providers/App/App';
@@ -9,6 +9,7 @@ import APIError from 'types/api/error';
import ErrorContent from './components/ErrorContent';
import './ErrorModal.styles.scss';
import { Badge } from '@signozhq/ui/badge';
type Props = {
error: APIError;
@@ -45,14 +46,17 @@ function ErrorModal({
return (
<>
{!triggerComponent ? (
<Tag
<span
className="error-modal__trigger"
icon={<CircleAlert size={14} color={Color.BG_CHERRY_500} />}
color="error"
role="button"
tabIndex={0}
onClick={(): void => setVisible(true)}
onKeyDown={undefined}
>
error
</Tag>
<Badge color="error">
<CircleAlert size={14} color={Color.BG_CHERRY_500} /> error
</Badge>
</span>
) : (
React.cloneElement(triggerComponent, {
onClick: () => setVisible(true),

View File

@@ -14,7 +14,8 @@ import { Color } from '@signozhq/design-tokens';
import { copilot } from '@uiw/codemirror-theme-copilot';
import { githubLight } from '@uiw/codemirror-theme-github';
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
import { Button, Card, Collapse, Popover, Tag, Tooltip } from 'antd';
import { Button, Card, Collapse, Popover, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import cx from 'classnames';
@@ -664,26 +665,26 @@ function QuerySearch({
// Helper function to render a badge for the current context mode
const renderContextBadge = (): JSX.Element => {
if (!editingMode) {
return <Tag>Unknown</Tag>;
return <Badge color="vanilla">Unknown</Badge>;
}
switch (editingMode) {
case 'key':
return <Tag color="blue">Key</Tag>;
return <Badge color="robin">Key</Badge>;
case 'operator':
return <Tag color="purple">Operator</Tag>;
return <Badge color="sakura">Operator</Badge>;
case 'value':
return <Tag color="green">Value</Tag>;
return <Badge color="forest">Value</Badge>;
case 'conjunction':
return <Tag color="orange">Conjunction</Tag>;
return <Badge color="amber">Conjunction</Badge>;
case 'function':
return <Tag color="cyan">Function</Tag>;
return <Badge color="aqua">Function</Badge>;
case 'parenthesis':
return <Tag color="magenta">Parenthesis</Tag>;
return <Badge color="sakura">Parenthesis</Badge>;
case 'bracketList':
return <Tag color="red">Bracket List</Tag>;
return <Badge color="cherry">Bracket List</Badge>;
default:
return <Tag>Unknown</Tag>;
return <Badge color="vanilla">Unknown</Badge>;
}
};
@@ -1304,34 +1305,37 @@ function QuerySearch({
Currently editing: {renderContextBadge()}
{queryContext?.keyToken && (
<span className="triplet-info">
Key: <Tag>{queryContext.keyToken}</Tag>
Key: <Badge color="vanilla">{queryContext.keyToken}</Badge>
</span>
)}
{queryContext?.operatorToken && (
<span className="triplet-info">
Operator: <Tag>{queryContext.operatorToken}</Tag>
Operator: <Badge color="vanilla">{queryContext.operatorToken}</Badge>
</span>
)}
{queryContext?.valueToken && (
<span className="triplet-info">
Value: <Tag>{queryContext.valueToken}</Tag>
Value: <Badge color="vanilla">{queryContext.valueToken}</Badge>
</span>
)}
{queryContext?.currentPair && (
<span className="triplet-info query-pair-info">
Current pair: <Tag color="blue">{queryContext.currentPair.key}</Tag>
<Tag color="purple">{queryContext.currentPair.operator}</Tag>
Current pair: <Badge color="robin">{queryContext.currentPair.key}</Badge>
<Badge color="sakura">{queryContext.currentPair.operator}</Badge>
{queryContext.currentPair.value && (
<Tag color="green">{queryContext.currentPair.value}</Tag>
<Badge color="forest">{queryContext.currentPair.value}</Badge>
)}
<Tag color={queryContext.currentPair.isComplete ? 'success' : 'warning'}>
<Badge
color={queryContext.currentPair.isComplete ? 'success' : 'warning'}
>
{queryContext.currentPair.isComplete ? 'Complete' : 'Incomplete'}
</Tag>
</Badge>
</span>
)}
{queryContext?.queryPairs && queryContext.queryPairs.length > 0 && (
<span className="triplet-info">
Total pairs: <Tag color="blue">{queryContext.queryPairs.length}</Tag>
Total pairs:{' '}
<Badge color="robin">{queryContext.queryPairs.length}</Badge>
</span>
)}
</div>

View File

@@ -1,4 +1,5 @@
import { Popover, Tag } from 'antd';
import { Popover } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { LabelColumnProps } from './TableRenderer.types';
import TagWithToolTip from './TagWithToolTip';
@@ -6,7 +7,7 @@ import { getLabelAndValueContent } from './utils';
import './LabelColumn.styles.scss';
function LabelColumn({ labels, value, color }: LabelColumnProps): JSX.Element {
function LabelColumn({ labels, value }: LabelColumnProps): JSX.Element {
const newLabels = labels.length > 3 ? labels.slice(0, 3) : labels;
const remainingLabels = labels.length > 3 ? labels.slice(3) : [];
@@ -14,7 +15,7 @@ function LabelColumn({ labels, value, color }: LabelColumnProps): JSX.Element {
<div className="label-column">
{newLabels.map(
(label: string): JSX.Element => (
<TagWithToolTip key={label} label={label} color={color} value={value} />
<TagWithToolTip key={label} label={label} value={value} />
),
)}
{remainingLabels.length > 0 && (
@@ -26,9 +27,9 @@ function LabelColumn({ labels, value, color }: LabelColumnProps): JSX.Element {
{labels.map(
(label: string): JSX.Element => (
<div key={label}>
<Tag className="label-column--tag" color={color}>
<Badge className="label-column--tag" color="vanilla">
{getLabelAndValueContent(label, value && value[label])}
</Tag>
</Badge>
</div>
),
)}
@@ -36,9 +37,9 @@ function LabelColumn({ labels, value, color }: LabelColumnProps): JSX.Element {
}
trigger="hover"
>
<Tag className="label-column--tag" color={color}>
<Badge className="label-column--tag" color="vanilla">
+{remainingLabels.length}
</Tag>
</Badge>
</Popover>
)}
</div>

View File

@@ -1,20 +1,17 @@
import { Tag, Tooltip } from 'antd';
import { Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { getLabelRenderingValue } from './utils';
function TagWithToolTip({
label,
value,
color,
}: TagWithToolTipProps): JSX.Element {
function TagWithToolTip({ label, value }: TagWithToolTipProps): JSX.Element {
const tooltipTitle =
value && value[label] ? `${label}: ${value[label]}` : label;
return (
<div key={label}>
<Tooltip title={tooltipTitle}>
<Tag className="label-column--tag" color={color}>
<Badge className="label-column--tag" color="vanilla">
{getLabelRenderingValue(label, value && value[label])}
</Tag>
</Badge>
</Tooltip>
</div>
);
@@ -22,7 +19,6 @@ function TagWithToolTip({
type TagWithToolTipProps = {
label: string;
color?: string;
value?: {
[key: string]: string;
};
@@ -30,7 +26,6 @@ type TagWithToolTipProps = {
TagWithToolTip.defaultProps = {
value: undefined,
color: undefined,
};
export default TagWithToolTip;

View File

@@ -14,11 +14,6 @@
.ant-form-item {
margin-bottom: 0;
}
.ant-tag {
margin-right: 0;
background: var(--card);
}
}
.add-tag-container {

View File

@@ -1,6 +1,7 @@
import React, { Dispatch, SetStateAction, useState } from 'react';
import { Check, Plus, X } from '@signozhq/icons';
import { Button, Flex, Tag } from 'antd';
import { Button, Flex } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import Input from 'components/Input';
import './Tags.styles.scss';
@@ -46,14 +47,14 @@ function Tags({ tags, setTags }: AddTagsProps): JSX.Element {
return (
<div className="tags-container">
{tags.map<React.ReactNode>((tag) => (
<Tag
key={tag}
closable
style={{ userSelect: 'none' }}
onClose={(): void => handleClose(tag)}
>
<Badge key={tag} color="vanilla" style={{ userSelect: 'none' }}>
<span>{tag}</span>
</Tag>
<X
size={12}
style={{ cursor: 'pointer', marginInlineStart: 4 }}
onClick={(): void => handleClose(tag)}
/>
</Badge>
))}
{inputVisible && (

View File

@@ -1,7 +1,8 @@
import { ReactNode } from 'react';
import { Color } from '@signozhq/design-tokens';
import { TableColumnType as ColumnType, Tag, Tooltip } from 'antd';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Progress } from '@signozhq/ui/progress';
import { Badge } from '@signozhq/ui/badge';
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
import {
FiltersType,
@@ -972,13 +973,9 @@ export const getEndPointsColumnsConfig = (
})()}
{isGroupedByAttribute
? text.split(',').map((value) => (
<Tag
key={value}
color={Color.BG_SLATE_100}
className="endpoint-group-tag-item"
>
<Badge key={value} color="vanilla" className="endpoint-group-tag-item">
{value === '' ? '<no-value>' : value}
</Tag>
</Badge>
))
: endPointName}
</div>

View File

@@ -14,8 +14,8 @@ import {
Skeleton,
Table,
TableColumnsType as ColumnsType,
Tag,
} from 'antd';
import { Badge } from '@signozhq/ui/badge';
import getUsage, { UsageResponsePayloadProps } from 'api/billing/getUsage';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
@@ -434,7 +434,7 @@ export default function BillingContainer(): JSX.Element {
<Flex vertical>
<Typography.Title level={5} style={{ marginTop: 2, fontWeight: 500 }}>
{isCloudUserVal ? t('teams_cloud') : t('teams')}{' '}
{isFreeTrial ? <Tag color="success"> Free Trial </Tag> : ''}
{isFreeTrial ? <Badge color="success"> Free Trial </Badge> : ''}
</Typography.Title>
{!isLoading && !isFetchingBillingData && !showGracePeriodMessage ? (

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Row, Tag } from 'antd';
import { Row } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
@@ -66,13 +67,7 @@ function SelectAlertType({ onSelect }: SelectAlertTypeProps): JSX.Element {
<AlertTypeCard
key={option.selection}
title={option.title}
extra={
option.isBeta ? (
<Tag bordered={false} color="geekblue">
Beta
</Tag>
) : undefined
}
extra={option.isBeta ? <Badge color="robin">Beta</Badge> : undefined}
onClick={(e): void => {
onSelect(option.selection, isModifierKeyPressed(e));
}}

View File

@@ -16,7 +16,8 @@ import {
Plus,
X,
} from '@signozhq/icons';
import { Button, Card, Input, Modal, Popover, Tag, Tooltip } from 'antd';
import { Button, Card, Input, Modal, Popover, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
@@ -506,9 +507,9 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
{(tags?.length || 0) > 0 && (
<div className="dashboard-tags">
{tags?.map((tag) => (
<Tag key={tag} className="tag">
<Badge key={tag} className="tag" color="vanilla">
{tag}
</Tag>
</Badge>
))}
</div>
)}

View File

@@ -359,7 +359,7 @@
flex-flow: wrap;
gap: 8px;
.ant-tag {
[data-slot='badge'] {
height: 30px;
color: var(--l1-foreground);
font-family: 'Space Mono';

View File

@@ -5,7 +5,8 @@ import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { orange } from '@ant-design/colors';
import { Color } from '@signozhq/design-tokens';
import { Button, Collapse, Input, Select, Tag } from 'antd';
import { Button, Collapse, Input, Select } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
@@ -542,9 +543,9 @@ function VariableItem({
}}
>
Dynamic
<Tag bordered={false} className="sidenav-beta-tag" color="geekblue">
<Badge color="robin" className="sidenav-beta-tag">
Beta
</Tag>
</Badge>
</Button>
<Button
type="text"
@@ -599,9 +600,9 @@ function VariableItem({
}}
>
Query
<Tag bordered={false} className="sidenav-beta-tag" color="warning">
<Badge color="amber" className="sidenav-beta-tag">
Not Recommended
</Tag>
</Badge>
<div onClick={(e): void => e.stopPropagation()}>
<TextToolTip
text="Learn why we don't recommend"
@@ -733,7 +734,9 @@ function VariableItem({
<Typography style={{ color: orange[5] }}>{errorPreview}</Typography>
) : (
map(previewValues, (value, idx) => (
<Tag key={`${value}${idx}`}>{value.toString()}</Tag>
<Badge key={`${value}${idx}`} color="vanilla">
{value.toString()}
</Badge>
))
)}
</div>

View File

@@ -1,4 +1,5 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { X } from '@signozhq/icons';
import { Col, Tooltip } from 'antd';
import Input from 'components/Input';
@@ -60,12 +61,7 @@ function AddTags({ tags, setTags }: AddTagsProps): JSX.Element {
const isLongTag = tag.length > 20;
const tagElem = (
<NewTagContainer
closable
key={tag}
onClose={(): void => handleClose(tag)}
className="tag-container"
>
<NewTagContainer key={tag} color="vanilla" className="tag-container">
<span
onDoubleClick={(e): void => {
setEditInputIndex(index);
@@ -75,6 +71,11 @@ function AddTags({ tags, setTags }: AddTagsProps): JSX.Element {
>
{isLongTag ? `${tag.slice(0, 20)}...` : tag}
</span>
<X
size={12}
style={{ cursor: 'pointer', marginInlineStart: 4 }}
onClick={(): void => handleClose(tag)}
/>
</NewTagContainer>
);

View File

@@ -1,4 +1,5 @@
import { Col, Tag } from 'antd';
import { Col } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import styled from 'styled-components';
export const TagsContainer = styled.div`
@@ -8,7 +9,7 @@ export const TagsContainer = styled.div`
gap: 6px;
`;
export const NewTagContainer = styled(Tag)`
export const NewTagContainer = styled(Badge)`
&&& {
display: flex;
justify-content: space-between;

View File

@@ -0,0 +1,282 @@
import { Widgets } from 'types/api/dashboard/getAll';
import {
MetricRangePayloadProps,
MetricRangePayloadV3,
} from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { PanelMode } from '../../types';
import { prepareBarPanelConfig } from '../utils';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
jest.mock(
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
() => ({
getStoredSeriesVisibility: jest.fn(),
}),
);
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
__esModule: true,
default: jest.fn().mockReturnValue({ name: 'onClickPlugin' }),
}));
jest.mock('lib/dashboard/getQueryResults', () => ({
getLegend: jest.fn(
(_queryData: unknown, _query: unknown, labelName: string) =>
`legend-${labelName}`,
),
}));
jest.mock('lib/getLabelName', () => ({
__esModule: true,
default: jest.fn(
(_metric: unknown, _queryName: string, _legend: string) => 'baseLabel',
),
}));
jest.mock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
() => ({
getInitialStackedBands: jest.fn().mockReturnValue([]),
}),
);
const getLegendMock = jest.requireMock('lib/dashboard/getQueryResults')
.getLegend as jest.Mock;
const getLabelNameMock = jest.requireMock('lib/getLabelName')
.default as jest.Mock;
const getInitialStackedBandsMock = jest.requireMock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
).getInitialStackedBands as jest.Mock;
const createApiResponse = (
result: MetricRangePayloadProps['data']['result'] = [],
): MetricRangePayloadProps => ({
data: {
result,
resultType: 'matrix',
newResult: null as unknown as MetricRangePayloadV3,
},
});
const createWidget = (overrides: Partial<Widgets> = {}): Widgets =>
({
id: 'widget-1',
yAxisUnit: 'ms',
isLogScale: false,
thresholds: [],
customLegendColors: {},
...overrides,
}) as Widgets;
const defaultTimezone = {
name: 'UTC',
value: 'UTC',
offset: 'UTC',
searchIndex: 'UTC',
};
describe('BarPanel utils', () => {
beforeEach(() => {
jest.clearAllMocks();
getLabelNameMock.mockReturnValue('baseLabel');
getLegendMock.mockImplementation(
(_queryData: unknown, _query: unknown, labelName: string) =>
`legend-${labelName}`,
);
});
describe('prepareBarPanelData', () => {
it('returns aligned data with timestamps and empty series when result is empty', () => {
const data = prepareChartData(createApiResponse([]));
expect(data).toHaveLength(1);
expect(data[0]).toStrictEqual([]);
});
it('returns timestamps and one series of y values for single series', () => {
const data = prepareChartData(
createApiResponse([
{
metric: {},
queryName: 'Q',
legend: 'Series A',
values: [
[1000, '10'],
[2000, '20'],
],
} as MetricRangePayloadProps['data']['result'][0],
]),
);
expect(data).toHaveLength(2);
expect(data[0]).toStrictEqual([1000, 2000]);
expect(data[1]).toStrictEqual([10, 20]);
});
it('merges timestamps and fills missing values with null for multiple series', () => {
const data = prepareChartData(
createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [
[1000, '1'],
[3000, '3'],
],
} as MetricRangePayloadProps['data']['result'][0],
{
metric: {},
queryName: 'Q2',
values: [
[1000, '10'],
[2000, '20'],
],
} as MetricRangePayloadProps['data']['result'][0],
]),
);
expect(data[0]).toStrictEqual([1000, 2000, 3000]);
expect(data[1]).toStrictEqual([1, null, 3]);
expect(data[2]).toStrictEqual([10, 20, null]);
});
});
describe('prepareBarPanelConfig', () => {
const baseParams = {
widget: createWidget(),
isDarkMode: true,
currentQuery: {} as Query,
onClick: jest.fn(),
onDragSelect: jest.fn(),
apiResponse: createApiResponse(),
timezone: defaultTimezone,
panelMode: PanelMode.DASHBOARD_VIEW,
};
it('adds no series when apiResponse has empty result', () => {
const config = prepareBarPanelConfig(baseParams).getConfig();
expect(config.series).toHaveLength(1);
});
it('adds one series per result item', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
{
metric: {},
queryName: 'Q2',
values: [[1000, '2']],
} as MetricRangePayloadProps['data']['result'][0],
]);
const config = prepareBarPanelConfig({
...baseParams,
apiResponse,
}).getConfig();
expect(config.series).toHaveLength(3);
});
it('uses getLegend for label when currentQuery is provided', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
legend: 'L1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
const config = prepareBarPanelConfig({
...baseParams,
apiResponse,
currentQuery: {} as Query,
}).getConfig();
expect(getLegendMock).toHaveBeenCalled();
expect(config.series?.[1]).toMatchObject({ label: 'legend-baseLabel' });
});
it('uses getLabelName for label when currentQuery is null', () => {
getLegendMock.mockReset();
const apiResponse = createApiResponse([
{
metric: { __name__: 'requests' },
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
prepareBarPanelConfig({
...baseParams,
apiResponse,
currentQuery: null as unknown as Query,
});
expect(getLabelNameMock).toHaveBeenCalled();
expect(getLegendMock).not.toHaveBeenCalled();
});
it('passes result metric to each series for cross-panel sync', () => {
const metric = { host: 'server1', __name__: 'http_requests' };
const apiResponse = createApiResponse([
{
metric,
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
const config = prepareBarPanelConfig({
...baseParams,
apiResponse,
}).getConfig();
expect(config.series?.[1]).toMatchObject({ metric });
});
it('uses widget customLegendColors for series stroke', () => {
const widget = createWidget({
customLegendColors: { 'legend-baseLabel': '#ff0000' },
});
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
const config = prepareBarPanelConfig({
...baseParams,
widget,
apiResponse,
}).getConfig();
expect(config.series?.[1]).toMatchObject({ stroke: '#ff0000' });
});
it('calls getInitialStackedBands when widget is stackedBarChart', () => {
const widget = createWidget({ stackedBarChart: true });
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
{
metric: {},
queryName: 'Q2',
values: [[1000, '2']],
} as MetricRangePayloadProps['data']['result'][0],
]);
prepareBarPanelConfig({ ...baseParams, widget, apiResponse });
// seriesCount = result.length + 1 = 3
expect(getInitialStackedBandsMock).toHaveBeenCalledWith(3);
});
it('does not call getInitialStackedBands for non-stacked chart', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
prepareBarPanelConfig({ ...baseParams, apiResponse });
expect(getInitialStackedBandsMock).not.toHaveBeenCalled();
});
});
});

View File

@@ -303,6 +303,27 @@ describe('TimeSeriesPanel utils', () => {
expect(seriesConfig!.stroke).toBe('#ff0000');
});
it('passes result metric to each series for cross-panel sync', () => {
const metric = { host: 'server1', __name__: 'cpu' };
const apiResponse = createApiResponse([
{
metric,
queryName: 'Q',
values: [
[1000, '1'],
[2000, '2'],
],
} as MetricRangePayloadProps['data']['result'][0],
]);
const config = prepareUPlotConfig({
...baseParams,
apiResponse,
}).getConfig();
expect(config.series?.[1]).toMatchObject({ metric });
});
it('adds multiple series when result has multiple items', () => {
const apiResponse = createApiResponse([
{

View File

@@ -1,3 +1,4 @@
import { X } from '@signozhq/icons';
import { QueryChipContainer, QueryChipItem } from './styles';
import { ILabelRecord } from './types';
@@ -13,11 +14,15 @@ export default function QueryChip({
const { key, value } = queryData;
return (
<QueryChipContainer>
<QueryChipItem
closable={key !== 'severity' && key !== 'description'}
onClose={(): void => onRemove(key)}
>
<QueryChipItem color="vanilla">
{key}: {value}
{key !== 'severity' && key !== 'description' && (
<X
size={12}
style={{ cursor: 'pointer', marginInlineStart: 4 }}
onClick={(): void => onRemove(key)}
/>
)}
</QueryChipItem>
</QueryChipContainer>
);

View File

@@ -1,5 +1,5 @@
import { grey } from '@ant-design/colors';
import { Tag } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import styled from 'styled-components';
interface SearchContainerProps {
@@ -29,6 +29,6 @@ export const QueryChipContainer = styled.span`
}
`;
export const QueryChipItem = styled(Tag)`
export const QueryChipItem = styled(Badge)`
margin-right: 0.1rem;
`;

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Button, Skeleton, Tag } from 'antd';
import { Button, Skeleton } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import logEvent from 'api/common/logEvent';
import { useListRules } from 'api/generated/services/rules';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
@@ -177,12 +178,14 @@ export default function AlertRules({
</div>
<div className="alert-rule-item-description home-data-item-tag">
<Tag color={rule?.labels?.severity}>{rule?.labels?.severity}</Tag>
<Badge color="sienna" variant="outline">
{rule?.labels?.severity}
</Badge>
{rule.state === 'firing' && (
<Tag color="red" className="firing-tag">
<Badge color="cherry" variant="outline" className="firing-tag">
{rule.state}
</Tag>
</Badge>
)}
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button, Skeleton, Tag } from 'antd';
import { Button, Skeleton } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
@@ -148,9 +149,9 @@ export default function Dashboards({
<div className="alert-rule-item-description home-data-item-tag">
{dashboard.data.tags?.map((tag) => (
<Tag color={tag} key={tag}>
<Badge color="sienna" variant="outline" key={tag}>
{tag}
</Tag>
</Badge>
))}
</div>
</div>

View File

@@ -574,30 +574,7 @@
.home-data-item-tag {
display: flex;
.ant-tag {
display: flex;
padding: 2px 12px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
color: var(--bg-sienna-400);
text-align: center;
font-family: Inter;
font-size: 12px;
font-style: normal;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.firing-tag {
color: var(--bg-sakura-500);
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
}
gap: 6px;
}
&.services-list-container {

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button, Skeleton, Tag } from 'antd';
import { Button, Skeleton } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import logEvent from 'api/common/logEvent';
import { getViewDetailsUsingViewKey } from 'components/ExplorerCard/utils';
import ROUTES from 'constants/routes';
@@ -249,9 +250,9 @@ export default function SavedViews({
}
return (
<Tag color={tag} key={tag}>
<Badge color="sienna" key={tag}>
{tag}
</Tag>
</Badge>
);
})}
</div>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Color } from '@signozhq/design-tokens';
import { Tag } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Progress } from '@signozhq/ui/progress';
import { Typography } from '@signozhq/ui/typography';
import {
@@ -52,14 +52,14 @@ export const hostDetailsMetadataConfig: K8sDetailsMetadataConfig<HostData>[] = [
label: 'STATUS',
getValue: (h): string => (h.active ? 'ACTIVE' : 'INACTIVE'),
render: (value, h): React.ReactNode => (
<Tag
<Badge
variant="outline"
className={`${infraHostsStyles.infraMonitoringTags} ${
h.active ? infraHostsStyles.tagsActive : infraHostsStyles.tagsInactive
}`}
bordered
>
{value}
</Tag>
</Badge>
),
},
{
@@ -67,9 +67,9 @@ export const hostDetailsMetadataConfig: K8sDetailsMetadataConfig<HostData>[] = [
getValue: (h): string => h.os || '-',
render: (value): React.ReactNode =>
value !== '-' ? (
<Tag className={infraHostsStyles.infraMonitoringTags} bordered>
<Badge variant="outline" className={infraHostsStyles.infraMonitoringTags}>
{value}
</Tag>
</Badge>
) : (
<Typography.Text>-</Typography.Text>
),

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Tag, Tooltip } from 'antd';
import { Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { HostData } from 'api/infraMonitoring/getHostLists';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { getGroupByEl } from 'container/InfraMonitoringK8s/Base/utils';
@@ -92,14 +93,13 @@ export const hostColumnsConfig: TableColumnDef<HostData>[] = [
cell: ({ value }): React.ReactNode => {
const active = value as boolean;
return (
<Tag
bordered
<Badge
className={`${styles.statusTag} ${
active ? styles.statusTagActive : styles.statusTagInactive
}`}
>
{active ? 'ACTIVE' : 'INACTIVE'}
</Tag>
</Badge>
);
},
},

View File

@@ -14,7 +14,7 @@
font-size: 12px;
}
:global(.ant-tag .ant-typography) {
:global([data-slot='badge'] .ant-typography) {
font-size: 12px;
}
}

View File

@@ -19,7 +19,7 @@
font-size: 12px;
}
:global(.ant-tag .ant-typography) {
:global([data-slot='badge'] .ant-typography) {
font-size: 12px;
}
}

View File

@@ -19,7 +19,7 @@
font-size: 12px;
}
:global(.ant-tag .ant-typography) {
:global([data-slot='badge'] .ant-typography) {
font-size: 12px;
}
}

View File

@@ -19,7 +19,7 @@
font-size: 12px;
}
:global(.ant-tag .ant-typography) {
:global([data-slot='badge'] .ant-typography) {
font-size: 12px;
}
}

View File

@@ -1,4 +1,5 @@
import { TableColumnsType as ColumnsType, Tag } from 'antd';
import { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
@@ -93,9 +94,9 @@ export const getTraceListColumns = (
if (primaryKey === 'httpMethod' || primaryKey === 'responseStatusCode') {
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Tag data-testid={key} color="magenta">
<Badge data-testid={key} color="sakura">
{getValueForKey(itemData, key)}
</Tag>
</Badge>
</BlockLink>
);
}

View File

@@ -213,7 +213,7 @@
font-size: 12px;
}
.ant-tag .ant-typography {
[data-slot='badge'] .ant-typography {
font-size: 12px;
}
}
@@ -349,7 +349,7 @@
font-size: 12px;
}
.ant-tag .ant-typography {
[data-slot='badge'] .ant-typography {
font-size: 12px;
}
}

View File

@@ -18,7 +18,6 @@ import {
Table,
TablePaginationConfig,
TableProps as AntDTableProps,
Tag,
Tooltip,
} from 'antd';
import { Switch } from '@signozhq/ui/switch';
@@ -1055,7 +1054,10 @@ function MultiIngestionSettings(): JSX.Element {
<div className="ingestion-key-tags">
{APIKey.tags.map((tag, index) => (
// eslint-disable-next-line react/no-array-index-key
<Tag key={`${tag}-${index}`}> {tag} </Tag>
<Badge key={`${tag}-${index}`} color="vanilla">
{' '}
{tag}{' '}
</Badge>
))}
</div>
</div>

View File

@@ -310,9 +310,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
return <Typography>-</Typography>;
}
return (
<LabelColumn labels={withOutSeverityKeys} value={value} color="magenta" />
);
return <LabelColumn labels={withOutSeverityKeys} value={value} />;
},
},
];

View File

@@ -1,26 +1,46 @@
import { Tag } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
function Status({ status }: StatusProps): JSX.Element {
switch (status) {
case 'inactive': {
return <Tag color="green">OK</Tag>;
return (
<Badge color="forest" variant="outline">
OK
</Badge>
);
}
case 'pending': {
return <Tag color="orange">Pending</Tag>;
return (
<Badge color="amber" variant="outline">
Pending
</Badge>
);
}
case 'firing': {
return <Tag color="red">Firing</Tag>;
return (
<Badge color="cherry" variant="outline">
Firing
</Badge>
);
}
case 'disabled': {
return <Tag>Disabled</Tag>;
return (
<Badge color="vanilla" variant="outline">
Disabled
</Badge>
);
}
default: {
return <Tag color="default">Unknown</Tag>;
return (
<Badge color="vanilla" variant="outline">
Unknown
</Badge>
);
}
}
}

View File

@@ -22,9 +22,9 @@ import {
Popover,
Skeleton,
Table,
Tag,
Tooltip,
} from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import type { TableProps } from 'antd/lib';
@@ -420,15 +420,15 @@ function DashboardsList(): JSX.Element {
{dashboard?.tags && dashboard.tags.length > 0 && (
<div className="dashboard-tags">
{dashboard.tags.slice(0, 3).map((tag) => (
<Tag className="tag" key={tag}>
<Badge className="tag" color="vanilla" key={tag}>
{tag}
</Tag>
</Badge>
))}
{dashboard.tags.length > 3 && (
<Tag className="tag" key={dashboard.tags[3]}>
<Badge className="tag" color="vanilla" key={dashboard.tags[3]}>
+ <span> {dashboard.tags.length - 3} </span>
</Tag>
</Badge>
)}
</div>
)}

View File

@@ -1,7 +1,7 @@
import { Tag } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import styled from 'styled-components';
export const TagContainer = styled(Tag)`
export const TagContainer = styled(Badge)`
&&& {
border-color: var(--bg-slate-400);
border-radius: 0.25rem;

View File

@@ -84,7 +84,7 @@
padding-top: 12px;
}
.ant-tag-borderless {
[data-slot='badge'] {
border-radius: 2px;
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
}

View File

@@ -3,7 +3,8 @@ import MEditor, { EditorProps, Monaco } from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
import { Button } from '@signozhq/ui/button';
import { Switch } from '@signozhq/ui/switch';
import { Collapse, Divider, Input, Tag } from 'antd';
import { Collapse, Divider, Input } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
@@ -104,11 +105,11 @@ function Overview({
{
key: '1',
label: (
<Tag bordered={false}>
<Badge color="vanilla">
<Typography.Text style={{ color: Color.BG_ROBIN_400 }}>
body
</Typography.Text>
</Tag>
</Badge>
),
children: (
<div className="logs-body-content">
@@ -142,7 +143,7 @@ function Overview({
</div>
</div>
),
// extra: <Tag className="tag">JSON</Tag>,
// extra: <Badge className="tag" color="vanilla">JSON</Badge>,
className: 'collapse-content',
},
]}
@@ -163,11 +164,11 @@ function Overview({
className="attribute-tab-header"
onClick={toogleAttributePanelOpenState}
>
<Tag bordered={false}>
<Badge color="vanilla">
<Typography.Text style={{ color: Color.BG_ROBIN_400 }}>
Attributes
</Typography.Text>
</Tag>
</Badge>
{isAttributesExpanded && (
<Button

View File

@@ -11,7 +11,7 @@
font-size: 12px;
}
.ant-tag .ant-typography {
[data-slot='badge'] .ant-typography {
font-size: 12px;
}
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useMutation } from 'react-query';
import { Radio, RadioChangeEvent, Tag } from 'antd';
import { Radio, RadioChangeEvent } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Switch } from '@signozhq/ui/switch';
import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
@@ -64,9 +65,7 @@ function MySettings(): JSX.Element {
label: (
<div className="theme-option">
<Sun size={12} data-testid="light-theme-icon" /> Light{' '}
<Tag bordered={false} color="geekblue">
Beta
</Tag>
<Badge color="robin">Beta</Badge>
</div>
),
value: 'light',

View File

@@ -1,4 +1,4 @@
import { Tag as AntDTag } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import styled from 'styled-components';
export const Container = styled.div`
@@ -20,6 +20,6 @@ export const PanelContainer = styled.div`
overflow-y: auto;
`;
export const Tag = styled(AntDTag)`
export const Tag = styled(Badge)`
margin: 0;
`;

View File

@@ -1,12 +1,12 @@
import { Tag } from 'antd';
import { Badge } from '@signozhq/ui/badge';
function Tags({ tags }: TagsProps): JSX.Element {
return (
<span>
{tags?.map((tag) => (
<Tag color="magenta" key={tag}>
<Badge color="sakura" key={tag}>
{tag}
</Tag>
</Badge>
))}
</span>
);

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CircleAlert, CircleX } from '@signozhq/icons';
import { Button, Input, InputRef, message, Modal, Tag, Tooltip } from 'antd';
import { CircleAlert, CircleX, X } from '@signozhq/icons';
import { Button, Input, InputRef, message, Modal, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { tagInputStyle } from '../PipelineListsView/config';
import { TagInputWrapper } from './styles';
@@ -90,12 +91,7 @@ function TagInput({
}
const isLongTag = tag.length > 20;
const tagElem = (
<Tag
key={tag}
closable
style={{ userSelect: 'none' }}
onClose={handleClose(tag)}
>
<Badge key={tag} color="vanilla" style={{ userSelect: 'none' }}>
<span
onDoubleClick={(e): void => {
setEditInputIndex(index);
@@ -105,7 +101,12 @@ function TagInput({
>
{isLongTag ? `${tag.slice(0, 20)}...` : tag}
</span>
</Tag>
<X
size={12}
style={{ cursor: 'pointer', marginInlineStart: 4 }}
onClick={handleClose(tag)}
/>
</Badge>
);
return isLongTag ? (
<Tooltip title={tag} key={tag}>

View File

@@ -4,12 +4,20 @@ exports[`PipelinePage container test should render Tags section 1`] = `
<DocumentFragment>
<span>
<span
class="ant-tag ant-tag-magenta css-dev-only-do-not-override-2i2tap"
class="_badge_1jqif_1"
data-capitalize="false"
data-color="sakura"
data-slot="badge"
data-variant="default"
>
server
</span>
<span
class="ant-tag ant-tag-magenta css-dev-only-do-not-override-2i2tap"
class="_badge_1jqif_1"
data-capitalize="false"
data-color="sakura"
data-slot="badge"
data-variant="default"
>
app
</span>

View File

@@ -53,12 +53,6 @@
margin-bottom: 8px;
overflow: auto;
max-height: 100px;
.ant-tag {
user-select: none;
height: 28px;
display: inline-flex;
align-items: center;
}
}
.ant-select {
@@ -109,61 +103,8 @@
}
.alert-rule-tags {
.ant-tag {
display: flex;
align-items: center;
border: 1px solid color-mix(in srgb, var(--bg-robin-400) 20%, transparent);
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
padding-right: 0px;
border-right: 0;
color: var(--bg-robin-400);
.ant-tag-close-icon {
height: 28px;
width: 20px !important;
justify-content: center;
border-left: 1px solid
color-mix(in srgb, var(--bg-robin-400) 20%, transparent);
border-radius: 0px 2px 2px 0px;
background: color-mix(in srgb, var(--bg-robin-400) 30%, transparent);
border-right: 1px solid
color-mix(in srgb, var(--bg-robin-400) 20%, transparent);
margin-right: 0px;
svg {
fill: var(--primary-background);
}
}
}
.non-closable-tag {
padding-right: 7px;
border-right: 1px solid
color-mix(in srgb, var(--bg-robin-400) 20%, transparent);
}
.red-tag.non-closable-tag {
border-right: 1px solid
color-mix(in srgb, var(--bg-sakura-500) 20%, transparent) !important;
}
.red-tag {
border: 1px solid color-mix(in srgb, var(--bg-sakura-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sakura-500) 10%, transparent);
border-right: 0;
color: var(--bg-sakura-400);
.ant-tag-close-icon {
background: color-mix(in srgb, var(--bg-sakura-500) 30%, transparent);
border-left: 1px solid
color-mix(in srgb, var(--bg-sakura-500) 20%, transparent);
border-right: 1px solid
color-mix(in srgb, var(--bg-sakura-500) 20%, transparent);
svg {
fill: var(--bg-sakura-500);
}
}
}
}
@@ -271,9 +212,6 @@
line-height: 18px;
letter-spacing: -0.07px;
}
.ant-tag {
border-radius: 20px;
}
.action-btn {
display: flex;
@@ -328,12 +266,6 @@
width: 540px;
max-height: 100px;
overflow: auto;
.ant-tag {
height: 28px;
display: flex;
align-items: center;
}
}
.ant-collapse-content-active {
border-top: 0;

View File

@@ -1,7 +1,8 @@
import React, { ReactNode, useEffect } from 'react';
import { UseQueryResult } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Collapse, Flex, Space, Table, TableProps, Tag, Tooltip } from 'antd';
import { Collapse, Flex, Space, Table, TableProps, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import type { DefaultOptionType } from 'antd/es/select';
import type {
@@ -15,7 +16,7 @@ import cx from 'classnames';
import dayjs from 'dayjs';
import { useNotifications } from 'hooks/useNotifications';
import { defaultTo } from 'lodash-es';
import { CalendarClock, PenLine, Trash2 } from '@signozhq/icons';
import { CalendarClock, PenLine, Trash2, X } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
@@ -48,10 +49,10 @@ export function AlertRuleTags(props: AlertRuleTagsProps): JSX.Element {
{selectedTags?.map((tag: DefaultOptionType, index: number) => {
const isLongTag = (tag?.label as string)?.length > 20;
const tagElem = (
<Tag
<Badge
key={tag.value}
onClose={(): void => handleClose?.(tag?.value)}
closable={closable}
color={index % 2 ? 'sakura' : 'robin'}
variant="outline"
className={cx(
{ 'red-tag': index % 2 },
{ 'non-closable-tag': !closable },
@@ -62,7 +63,14 @@ export function AlertRuleTags(props: AlertRuleTagsProps): JSX.Element {
? `${(tag?.label as string | null)?.slice(0, 20)}...`
: tag?.label}
</span>
</Tag>
{closable && (
<X
size={12}
style={{ cursor: 'pointer', marginInlineStart: 4 }}
onClick={(): void => handleClose?.(tag?.value)}
/>
)}
</Badge>
);
return isLongTag ? (
<Tooltip title={tag?.label} key={tag?.value}>
@@ -93,7 +101,7 @@ function HeaderComponent({
<Flex className="header-content" justify="space-between">
<Flex gap={8}>
<Typography>{name}</Typography>
<Tag>{duration}</Tag>
<Badge color="vanilla">{duration}</Badge>
</Flex>
{isCrudEnabled && (
@@ -155,9 +163,7 @@ export function CollapseListContent({
created_by_name ? (
<Flex gap={8}>
<Typography>{created_by_name}</Typography>
{created_by_email && (
<Tag style={{ borderRadius: 20 }}>{created_by_email}</Tag>
)}
{created_by_email && <Badge color="vanilla">{created_by_email}</Badge>}
</Flex>
) : (
'-'

View File

@@ -1,11 +1,11 @@
import { Tag } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import styled from 'styled-components';
export const StyledText = styled.span`
cursor: pointer;
`;
export const StyledTag = styled(Tag)`
export const StyledTag = styled(Badge)`
margin-top: 0.125rem;
margin-bottom: 0.125rem;
padding-left: 0.5rem;

View File

@@ -1,3 +1,5 @@
import { X } from '@signozhq/icons';
import { HavingFilterTagProps } from './HavingFilterTag.interfaces';
import { StyledTag, StyledText } from './HavingFilterTag.styled';
@@ -12,10 +14,17 @@ export function HavingFilterTag({
};
return (
<StyledTag closable={closable} onClose={onClose}>
<StyledTag color="vanilla">
<span role="button" tabIndex={0} onClick={handleClick}>
<StyledText>{value}</StyledText>
</span>
{closable && (
<X
size={12}
style={{ cursor: 'pointer', marginInlineStart: 4 }}
onClick={onClose}
/>
)}
</StyledTag>
);
}

View File

@@ -9,7 +9,8 @@ import {
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
import { Button, Select, Spin, Tag, Tooltip } from 'antd';
import { Button, Select, Spin, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import {
@@ -37,6 +38,7 @@ import {
CornerDownLeft,
Filter,
Slash,
X,
} from '@signozhq/icons';
import type { BaseSelectRef } from 'rc-select';
import {
@@ -199,7 +201,7 @@ function QueryBuilderSearch({
const isDisabled = !!searchValue;
return (
<Tag closable={!searchValue && closable} onClose={onCloseHandler}>
<Badge color="vanilla">
<Tooltip title={chipValue}>
<TypographyText
$isInNin={isInNin}
@@ -213,7 +215,14 @@ function QueryBuilderSearch({
{chipValue}
</TypographyText>
</Tooltip>
</Tag>
{!searchValue && closable && (
<X
size={12}
style={{ cursor: 'pointer', marginInlineStart: 4 }}
onClick={onCloseHandler}
/>
)}
</Badge>
);
};

View File

@@ -1,5 +1,5 @@
import { Check } from '@signozhq/icons';
import { Tag } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import styled from 'styled-components';
export const TypographyText = styled.span<{
@@ -22,7 +22,7 @@ export const StyledCheckOutlined = styled(Check)`
float: right;
`;
export const TagContainer = styled(Tag)`
export const TagContainer = styled(Badge)`
&&& {
display: inline-block;
border-radius: 3px;

View File

@@ -225,7 +225,7 @@
}
.qb-search-bar-tokenised-tags {
.ant-tag {
[data-slot='badge'] {
display: flex;
align-items: center;
border-radius: 2px 0px 0px 2px;
@@ -244,7 +244,7 @@
padding: 2px 6px;
}
.ant-tag-close-icon {
.close-icon {
display: flex;
align-items: center;
justify-content: center;
@@ -265,7 +265,7 @@
font-size: 14px;
}
.ant-tag-close-icon {
.close-icon {
background: color-mix(in srgb, var(--bg-aqua-400) 6%, transparent);
}
}
@@ -278,7 +278,7 @@
font-size: 14px;
}
.ant-tag-close-icon {
.close-icon {
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
}
}
@@ -292,7 +292,7 @@
font-size: 14px;
}
.ant-tag-close-icon {
.close-icon {
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
}
}

View File

@@ -8,7 +8,8 @@ import {
useRef,
useState,
} from 'react';
import { Select, Spin, Tag, Tooltip } from 'antd';
import { Select, Spin, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import cx from 'classnames';
import {
DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY,
@@ -38,7 +39,7 @@ import {
isUndefined,
unset,
} from 'lodash-es';
import { ChevronDown, ChevronUp } from '@signozhq/icons';
import { ChevronDown, ChevronUp, X } from '@signozhq/icons';
import type { BaseSelectRef } from 'rc-select';
import {
BaseAutocompleteData,
@@ -954,11 +955,7 @@ function QueryBuilderSearchV2(
return (
<span className="qb-search-bar-tokenised-tags">
<Tag
closable={!searchValue && closable}
onClose={onCloseHandler}
className={tagDetails?.key?.type || ''}
>
<Badge color="vanilla" className={tagDetails?.key?.type || ''}>
<Tooltip title={chipValue}>
<TypographyText
$isInNin={isInNin}
@@ -972,7 +969,15 @@ function QueryBuilderSearchV2(
{chipValue}
</TypographyText>
</Tooltip>
</Tag>
{!searchValue && closable && (
<X
size={12}
className="close-icon"
style={{ cursor: 'pointer' }}
onClick={onCloseHandler}
/>
)}
</Badge>
</span>
);
};

View File

@@ -0,0 +1,296 @@
import { renderHook } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getViewQuery } from '../drilldownUtils';
import { AggregateData } from '../useAggregateDrilldown';
import useBaseDrilldownNavigate, {
buildDrilldownUrl,
getRoute,
} from '../useBaseDrilldownNavigate';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('../drilldownUtils', () => ({
...jest.requireActual('../drilldownUtils'),
getViewQuery: jest.fn(),
}));
const mockGetViewQuery = getViewQuery as jest.Mock;
// ─── Fixtures ────────────────────────────────────────────────────────────────
const MOCK_QUERY: Query = {
id: 'q1',
queryType: 'builder' as any,
builder: {
queryData: [
{
queryName: 'A',
dataSource: 'metrics' as any,
groupBy: [],
expression: '',
disabled: false,
functions: [],
legend: '',
having: [],
limit: null,
stepInterval: undefined,
orderBy: [],
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
};
const MOCK_VIEW_QUERY: Query = {
...MOCK_QUERY,
builder: {
...MOCK_QUERY.builder,
queryData: [
{
...MOCK_QUERY.builder.queryData[0],
filters: { items: [], op: 'AND' },
},
],
},
};
const MOCK_AGGREGATE_DATA: AggregateData = {
queryName: 'A',
filters: [{ filterKey: 'service_name', filterValue: 'auth', operator: '=' }],
timeRange: { startTime: 1000000, endTime: 2000000 },
};
// ─── getRoute ─────────────────────────────────────────────────────────────────
describe('getRoute', () => {
it.each([
['view_logs', ROUTES.LOGS_EXPLORER],
['view_metrics', ROUTES.METRICS_EXPLORER],
['view_traces', ROUTES.TRACES_EXPLORER],
])('maps %s to the correct explorer route', (key, expected) => {
expect(getRoute(key)).toBe(expected);
});
it('returns empty string for an unknown key', () => {
expect(getRoute('view_dashboard')).toBe('');
});
});
// ─── buildDrilldownUrl ────────────────────────────────────────────────────────
describe('buildDrilldownUrl', () => {
beforeEach(() => {
mockGetViewQuery.mockReturnValue(MOCK_VIEW_QUERY);
});
afterEach(() => {
jest.clearAllMocks();
});
it('returns null for an unknown drilldown key', () => {
const url = buildDrilldownUrl(
MOCK_QUERY,
MOCK_AGGREGATE_DATA,
'view_dashboard',
);
expect(url).toBeNull();
});
it('returns null when getViewQuery returns null', () => {
mockGetViewQuery.mockReturnValue(null);
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).toBeNull();
});
it('returns a URL starting with the logs explorer route for view_logs', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).not.toBeNull();
expect(url).toContain(ROUTES.LOGS_EXPLORER);
});
it('returns a URL starting with the traces explorer route for view_traces', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_traces');
expect(url).toContain(ROUTES.TRACES_EXPLORER);
});
it('includes compositeQuery param in the URL', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).toContain('compositeQuery=');
});
it('includes startTime and endTime when aggregateData has a timeRange', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).toContain('startTime=1000000');
expect(url).toContain('endTime=2000000');
});
it('omits startTime and endTime when aggregateData has no timeRange', () => {
const { timeRange: _, ...withoutTimeRange } = MOCK_AGGREGATE_DATA;
const url = buildDrilldownUrl(MOCK_QUERY, withoutTimeRange, 'view_logs');
expect(url).not.toContain('startTime=');
expect(url).not.toContain('endTime=');
});
it('includes summaryFilters param for view_metrics', () => {
const url = buildDrilldownUrl(
MOCK_QUERY,
MOCK_AGGREGATE_DATA,
'view_metrics',
);
expect(url).toContain(ROUTES.METRICS_EXPLORER);
expect(url).toContain('summaryFilters=');
});
it('does not include summaryFilters param for non-metrics routes', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).not.toContain('summaryFilters=');
});
it('handles null aggregateData by passing empty filters and empty queryName', () => {
const url = buildDrilldownUrl(MOCK_QUERY, null, 'view_logs');
expect(url).not.toBeNull();
expect(mockGetViewQuery).toHaveBeenCalledWith(
MOCK_QUERY,
[],
'view_logs',
'',
);
});
it('passes aggregateData filters and queryName to getViewQuery', () => {
buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(mockGetViewQuery).toHaveBeenCalledWith(
MOCK_QUERY,
MOCK_AGGREGATE_DATA.filters,
'view_logs',
MOCK_AGGREGATE_DATA.queryName,
);
});
});
// ─── useBaseDrilldownNavigate ─────────────────────────────────────────────────
describe('useBaseDrilldownNavigate', () => {
beforeEach(() => {
mockGetViewQuery.mockReturnValue(MOCK_VIEW_QUERY);
});
afterEach(() => {
jest.clearAllMocks();
});
it('calls safeNavigate with the built URL on a valid key', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
}),
);
result.current('view_logs');
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain(ROUTES.LOGS_EXPLORER);
expect(url).toContain('compositeQuery=');
});
it('opens the explorer in a new tab', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
}),
);
result.current('view_traces');
expect(mockSafeNavigate).toHaveBeenCalledWith(expect.any(String), {
newTab: true,
});
});
it('calls callback after successful navigation', () => {
const callback = jest.fn();
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
callback,
}),
);
result.current('view_logs');
expect(callback).toHaveBeenCalledTimes(1);
});
it('does not call safeNavigate for an unknown key', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
}),
);
result.current('view_dashboard');
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('still calls callback when the key is unknown', () => {
const callback = jest.fn();
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
callback,
}),
);
result.current('view_dashboard');
expect(callback).toHaveBeenCalledTimes(1);
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('still calls callback when getViewQuery returns null', () => {
mockGetViewQuery.mockReturnValue(null);
const callback = jest.fn();
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
callback,
}),
);
result.current('view_logs');
expect(callback).toHaveBeenCalledTimes(1);
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('handles null aggregateData without throwing', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: null,
}),
);
expect(() => result.current('view_logs')).not.toThrow();
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
});
});

View File

@@ -168,7 +168,7 @@ export const getAggregateColumnHeader = (
};
};
const getFiltersFromMetric = (metric: any): FilterData[] =>
export const getFiltersFromMetric = (metric: any): FilterData[] =>
Object.keys(metric).map((key) => ({
filterKey: key,
filterValue: metric[key],

View File

@@ -2,14 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Link, Loader } from '@signozhq/icons';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
import useContextVariables from 'hooks/dashboard/useContextVariables';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ContextLinksData } from 'types/api/dashboard/getAll';
@@ -18,9 +14,10 @@ import { openInNewTab } from 'utils/navigation';
import { ContextMenuItem } from './contextConfig';
import { getDataLinks } from './dataLinksUtils';
import { getAggregateColumnHeader, getViewQuery } from './drilldownUtils';
import { getAggregateColumnHeader } from './drilldownUtils';
import { getBaseContextConfig } from './menuOptions';
import { AggregateData } from './useAggregateDrilldown';
import useBaseDrilldownNavigate from './useBaseDrilldownNavigate';
interface UseBaseAggregateOptionsProps {
query: Query;
@@ -38,19 +35,6 @@ interface BaseAggregateOptionsConfig {
items?: ContextMenuItem;
}
const getRoute = (key: string): string => {
switch (key) {
case 'view_logs':
return ROUTES.LOGS_EXPLORER;
case 'view_metrics':
return ROUTES.METRICS_EXPLORER;
case 'view_traces':
return ROUTES.TRACES_EXPLORER;
default:
return '';
}
};
const useBaseAggregateOptions = ({
query,
onClose,
@@ -86,8 +70,6 @@ const useBaseAggregateOptions = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, aggregateData, panelType]);
const { safeNavigate } = useSafeNavigate();
// Use the new useContextVariables hook
const { processedVariables } = useContextVariables({
maxValues: 2,
@@ -121,50 +103,16 @@ const useBaseAggregateOptions = ({
{label}
</ContextMenu.Item>
));
} catch (error) {
} catch {
return [];
}
}, [contextLinks, processedVariables, onClose, aggregateData, query]);
const handleBaseDrilldown = useCallback(
(key: string): void => {
const route = getRoute(key);
const timeRange = aggregateData?.timeRange;
const filtersToAdd = aggregateData?.filters || [];
const viewQuery = getViewQuery(
resolvedQuery,
filtersToAdd,
key,
aggregateData?.queryName || '',
);
let queryParams = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange?.startTime.toString(),
[QueryParams.endTime]: timeRange?.endTime.toString(),
}),
} as Record<string, string>;
if (route === ROUTES.METRICS_EXPLORER) {
queryParams = {
...queryParams,
[QueryParams.summaryFilters]: JSON.stringify(
viewQuery?.builder.queryData[0].filters,
),
};
}
if (route) {
safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
newTab: true,
});
}
onClose();
},
[resolvedQuery, safeNavigate, onClose, aggregateData],
);
const handleBaseDrilldown = useBaseDrilldownNavigate({
resolvedQuery,
aggregateData,
callback: onClose,
});
const { pathname } = useLocation();

View File

@@ -0,0 +1,117 @@
import { useCallback } from 'react';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getViewQuery } from './drilldownUtils';
import { AggregateData } from './useAggregateDrilldown';
type DrilldownKey = 'view_logs' | 'view_metrics' | 'view_traces';
const DRILLDOWN_ROUTE_MAP: Record<DrilldownKey, string> = {
view_logs: ROUTES.LOGS_EXPLORER,
view_metrics: ROUTES.METRICS_EXPLORER,
view_traces: ROUTES.TRACES_EXPLORER,
};
const getRoute = (key: string): string =>
DRILLDOWN_ROUTE_MAP[key as DrilldownKey] ?? '';
interface UseBaseDrilldownNavigateProps {
resolvedQuery: Query;
aggregateData: AggregateData | null;
callback?: () => void;
}
const useBaseDrilldownNavigate = ({
resolvedQuery,
aggregateData,
callback,
}: UseBaseDrilldownNavigateProps): ((key: string) => void) => {
const { safeNavigate } = useSafeNavigate();
return useCallback(
(key: string): void => {
const route = getRoute(key);
const viewQuery = getViewQuery(
resolvedQuery,
aggregateData?.filters ?? [],
key,
aggregateData?.queryName ?? '',
);
if (!viewQuery || !route) {
callback?.();
return;
}
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
}),
};
if (route === ROUTES.METRICS_EXPLORER) {
queryParams = {
...queryParams,
[QueryParams.summaryFilters]: JSON.stringify(
viewQuery.builder.queryData[0].filters,
),
};
}
safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
newTab: true,
});
callback?.();
},
[resolvedQuery, safeNavigate, callback, aggregateData],
);
};
export function buildDrilldownUrl(
resolvedQuery: Query,
aggregateData: AggregateData | null,
key: string,
): string | null {
const route = getRoute(key);
const viewQuery = getViewQuery(
resolvedQuery,
aggregateData?.filters ?? [],
key,
aggregateData?.queryName ?? '',
);
if (!viewQuery || !route) {
return null;
}
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
}),
};
if (route === ROUTES.METRICS_EXPLORER) {
queryParams = {
...queryParams,
[QueryParams.summaryFilters]: JSON.stringify(
viewQuery.builder.queryData[0].filters,
),
};
}
return `${route}?${createQueryParams(queryParams)}`;
}
export { getRoute };
export default useBaseDrilldownNavigate;

View File

@@ -10,7 +10,7 @@
font-size: 12px;
}
.ant-tag .ant-typography {
[data-slot='badge'] .ant-typography {
font-size: 12px;
}
}

View File

@@ -1,3 +1,4 @@
import { X } from '@signozhq/icons';
import {
convertMetricKeyToTrace,
getResourceDeploymentKeys,
@@ -20,13 +21,19 @@ function QueryChip({ queryData, onClose }: IQueryChipProps): JSX.Element {
return (
<QueryChipContainer>
<QueryChipItem>{convertMetricKeyToTrace(queryData.tagKey)}</QueryChipItem>
<QueryChipItem>{queryData.operator}</QueryChipItem>
<QueryChipItem
closable={queryData.tagKey !== getResourceDeploymentKeys(dotMetricsEnabled)}
onClose={onCloseHandler}
>
<QueryChipItem color="vanilla">
{convertMetricKeyToTrace(queryData.tagKey)}
</QueryChipItem>
<QueryChipItem color="vanilla">{queryData.operator}</QueryChipItem>
<QueryChipItem color="vanilla">
{queryData.tagValue.join(', ')}
{queryData.tagKey !== getResourceDeploymentKeys(dotMetricsEnabled) && (
<X
size={12}
style={{ cursor: 'pointer', marginInlineStart: 4 }}
onClick={onCloseHandler}
/>
)}
</QueryChipItem>
</QueryChipContainer>
);

View File

@@ -1,5 +1,5 @@
import { grey } from '@ant-design/colors';
import { Tag } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import styled from 'styled-components';
export const SearchContainer = styled.div`
@@ -23,6 +23,6 @@ export const QueryChipContainer = styled.span`
}
`;
export const QueryChipItem = styled(Tag)`
export const QueryChipItem = styled(Badge)`
margin-right: 0.1rem;
`;

View File

@@ -1,5 +1,6 @@
import { Color } from '@signozhq/design-tokens';
import { Button, Collapse, Flex, Tag } from 'antd';
import { Button, Collapse, Flex } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { PenLine, Trash2 } from '@signozhq/icons';
@@ -118,7 +119,9 @@ function PolicyListItemContent({
<Typography>Channels</Typography>
<div>
{routingPolicy.channels.map((channel) => (
<Tag key={channel}>{channel}</Tag>
<Badge key={channel} color="vanilla">
{channel}
</Badge>
))}
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { Tag, Tooltip } from 'antd';
import { Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import cx from 'classnames';
import { Pin, PinOff } from '@signozhq/icons';
@@ -58,17 +59,17 @@ export default function NavItem({
{isBeta && (
<div className="nav-item-beta">
<Tag bordered={false} className="sidenav-beta-tag">
<Badge color="robin" className="sidenav-beta-tag">
Beta
</Tag>
</Badge>
</div>
)}
{isNew && (
<div className="nav-item-new">
<Tag bordered={false} className="sidenav-new-tag">
<Badge color="robin" className="sidenav-new-tag">
New
</Tag>
</Badge>
</div>
)}

View File

@@ -1,7 +1,8 @@
import { HTMLAttributes } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { TableColumnsType as ColumnsType, TableProps, Tag } from 'antd';
import { TableColumnsType as ColumnsType, TableProps } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { ResizeTable } from 'components/ResizeTable';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
@@ -70,7 +71,7 @@ function TraceTable(): JSX.Element {
if (value.length === 0) {
return <Typography>-</Typography>;
}
return <Tag color="magenta">{value}</Tag>;
return <Badge color="sakura">{value}</Badge>;
};
const columns: ColumnsType<TableType> = [

View File

@@ -1,6 +1,6 @@
import { Link } from 'react-router-dom';
import type { TableColumnsType as ColumnsType } from 'antd';
import { Tag } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
@@ -107,9 +107,9 @@ export const getListColumns = (
) {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Tag data-testid={name} color="magenta">
<Badge data-testid={name} color="sakura" variant="outline">
{value}
</Tag>
</Badge>
</BlockLink>
);
}

View File

@@ -1,6 +1,8 @@
import { useCallback, useMemo, useRef } from 'react';
import { X } from '@signozhq/icons';
import type { SelectProps } from 'antd';
import { Tag, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Tooltip } from 'antd';
import type { BaseOptionType } from 'antd/es/select';
import { Alerts } from 'types/api/alerts/getTriggered';
@@ -83,14 +85,16 @@ function Filter({
const { closable, onClose, label } = props;
return (
<Tag
color="magenta"
closable={closable}
onClose={onClose}
style={{ marginRight: 3 }}
>
<Badge color="sakura" style={{ marginRight: 3 }}>
{label}
</Tag>
{closable && (
<X
size={12}
style={{ cursor: 'pointer', marginInlineStart: 4 }}
onClick={(): void => onClose()}
/>
)}
</Badge>
);
};

View File

@@ -1,4 +1,4 @@
import { Tag } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { useTimezone } from 'providers/Timezone';
@@ -51,7 +51,7 @@ function ExapandableRow({ allAlerts }: ExapandableRowProps): JSX.Element {
<TableCell minWidth="90px" overflowX="scroll">
<div>
{tags.map((e) => (
<Tag key={e}>{`${e}:${labels[e]}`}</Tag>
<Badge color="vanilla" key={e}>{`${e}:${labels[e]}`}</Badge>
))}
</div>
</TableCell>

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { SquareMinus, SquarePlus } from '@signozhq/icons';
import { Tag } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Alerts } from 'types/api/alerts/getTriggered';
import ExapandableRow from './ExapandableRow';
@@ -26,9 +26,9 @@ function TableRowComponent({
</IconContainer>
<>
{tags.map((tag) => (
<Tag color="magenta" key={tag}>
<Badge color="sakura" key={tag}>
{tag}
</Tag>
</Badge>
))}
</>
</StatusContainer>

View File

@@ -59,9 +59,7 @@ function NoFilterTable({
return <Typography>-</Typography>;
}
return (
<LabelColumn labels={withOutSeverityKeys} value={labels} color="magenta" />
);
return <LabelColumn labels={withOutSeverityKeys} value={labels} />;
},
},
{

View File

@@ -1,21 +1,21 @@
import { Tag } from 'antd';
import { Badge } from '@signozhq/ui/badge';
function Severity({ severity }: SeverityProps): JSX.Element {
switch (severity) {
case 'unprocessed': {
return <Tag color="green">UnProcessed</Tag>;
return <Badge color="forest">UnProcessed</Badge>;
}
case 'active': {
return <Tag color="red">Firing</Tag>;
return <Badge color="cherry">Firing</Badge>;
}
case 'suppressed': {
return <Tag color="red">Suppressed</Tag>;
return <Badge color="cherry">Suppressed</Badge>;
}
default: {
return <Tag color="default">Unknown Status</Tag>;
return <Badge color="vanilla">Unknown Status</Badge>;
}
}
}

View File

@@ -0,0 +1,29 @@
import { syncCursorRegistry } from '../syncCursorRegistry';
describe('syncCursorRegistry', () => {
describe('metadata', () => {
it('returns undefined for unknown key', () => {
expect(syncCursorRegistry.getMetadata('unknown-meta')).toBeUndefined();
});
it('stores and retrieves metadata by syncKey', () => {
const metadata = { yAxisUnit: 'ms', groupBy: [] };
syncCursorRegistry.setMetadata('meta-key', metadata);
expect(syncCursorRegistry.getMetadata('meta-key')).toBe(metadata);
});
});
describe('activeSeriesMetric', () => {
it('returns null (not undefined) for unknown key', () => {
expect(
syncCursorRegistry.getActiveSeriesMetric('unknown-metric'),
).toBeNull();
});
it('stores and retrieves metric by syncKey', () => {
const metric = { host: 'server1', __name__: 'cpu' };
syncCursorRegistry.setActiveSeriesMetric('metric-key', metric);
expect(syncCursorRegistry.getActiveSeriesMetric('metric-key')).toBe(metric);
});
});
});

View File

@@ -0,0 +1,627 @@
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import uPlot from 'uplot';
import { syncCursorRegistry } from '../syncCursorRegistry';
import { createSyncDisplayHook } from '../syncDisplayHook';
import {
SyncTooltipFilterMode,
type TooltipControllerState,
type TooltipSyncMetadata,
} from '../types';
jest.mock('../syncCursorRegistry', () => ({
syncCursorRegistry: {
setMetadata: jest.fn(),
getMetadata: jest.fn(),
setActiveSeriesMetric: jest.fn(),
getActiveSeriesMetric: jest.fn(),
},
}));
const mockRegistry = syncCursorRegistry as {
setMetadata: jest.Mock;
getMetadata: jest.Mock;
setActiveSeriesMetric: jest.Mock;
getActiveSeriesMetric: jest.Mock;
};
const SYNC_KEY = 'test-sync-key';
// Builds a single-query groupByPerQuery from a list of dimension keys.
const makeGroupByPerQuery = (
...keys: string[]
): Record<string, BaseAutocompleteData[]> => ({
A: keys.map((key) => ({ key, type: 'tag' as const })),
});
function makeUPlotRoot(includeCrosshair = true): HTMLElement {
const root = document.createElement('div');
if (includeCrosshair) {
const el = document.createElement('div');
el.className = 'u-cursor-y';
root.append(el);
}
return root;
}
type FakeSeries = { metric?: Record<string, string>; show?: boolean };
function makeFakeUPlot(opts: {
cursorEvent?: MouseEvent | null;
cursorLeft?: number;
series?: FakeSeries[];
includeCrosshair?: boolean;
}): uPlot {
return {
root: makeUPlotRoot(opts.includeCrosshair ?? true),
cursor: {
event: opts.cursorEvent !== undefined ? opts.cursorEvent : null,
left: opts.cursorLeft ?? 50,
},
series: opts.series ?? [
{},
{ metric: { host: 'server1' } },
{ metric: { host: 'server2' } },
],
setSeries: jest.fn(),
} as unknown as uPlot;
}
function makeController(
focusedSeriesIndex: number | null = null,
): TooltipControllerState {
return {
focusedSeriesIndex,
syncedSeriesIndexes: null,
} as TooltipControllerState;
}
// Convenience cast used throughout assertions.
function mockSetSeries(u: uPlot): jest.Mock {
return (u as unknown as { setSeries: jest.Mock }).setSeries;
}
function getCrosshair(u: uPlot): HTMLElement {
const el = u.root.querySelector<HTMLElement>('.u-cursor-y');
if (!el) {
throw new Error('crosshair element missing');
}
return el;
}
// ─────────────────────────────────────────────────────────────────────────────
describe('createSyncDisplayHook', () => {
beforeEach(() => {
jest.clearAllMocks();
});
// ── guard ────────────────────────────────────────────────────────────────
describe('no crosshair element', () => {
it('returns early without calling registry when .u-cursor-y absent', () => {
const hook = createSyncDisplayHook(SYNC_KEY, undefined, makeController());
const u = makeFakeUPlot({ includeCrosshair: false });
hook(u);
expect(mockRegistry.setMetadata).not.toHaveBeenCalled();
expect(mockRegistry.getMetadata).not.toHaveBeenCalled();
expect(mockSetSeries(u)).not.toHaveBeenCalled();
});
});
// ── source panel ─────────────────────────────────────────────────────────
describe('source behavior (cursor.event != null)', () => {
it('writes syncMetadata to registry', () => {
const syncMetadata: TooltipSyncMetadata = { yAxisUnit: 'ms' };
const hook = createSyncDisplayHook(SYNC_KEY, syncMetadata, makeController());
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
hook(u);
expect(mockRegistry.setMetadata).toHaveBeenCalledWith(
SYNC_KEY,
syncMetadata,
);
});
it('writes focused series metric when focusedSeriesIndex is set', () => {
const series: FakeSeries[] = [
{},
{ metric: { host: 'server1' } },
{ metric: { host: 'server2' } },
];
const hook = createSyncDisplayHook(SYNC_KEY, undefined, makeController(1));
const u = makeFakeUPlot({
cursorEvent: new MouseEvent('mousemove'),
series,
});
hook(u);
expect(mockRegistry.setActiveSeriesMetric).toHaveBeenCalledWith(SYNC_KEY, {
host: 'server1',
});
});
it('writes null metric when focusedSeriesIndex is null', () => {
const hook = createSyncDisplayHook(
SYNC_KEY,
undefined,
makeController(null),
);
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
hook(u);
expect(mockRegistry.setActiveSeriesMetric).toHaveBeenCalledWith(
SYNC_KEY,
null,
);
});
it('clears controller.syncedSeriesIndexes', () => {
const controller = makeController();
controller.syncedSeriesIndexes = [1, 2];
const hook = createSyncDisplayHook(SYNC_KEY, undefined, controller);
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
hook(u);
expect(controller.syncedSeriesIndexes).toBeNull();
});
it('shows crosshair and does not read from registry', () => {
const hook = createSyncDisplayHook(SYNC_KEY, undefined, makeController());
const u = makeFakeUPlot({ cursorEvent: new MouseEvent('mousemove') });
hook(u);
expect(getCrosshair(u).style.display).toBe('');
expect(mockRegistry.getMetadata).not.toHaveBeenCalled();
});
});
// ── receiver panel ───────────────────────────────────────────────────────
describe('receiver behavior (cursor.event is null)', () => {
describe('crosshair visibility', () => {
it('shows crosshair when yAxisUnit matches source', () => {
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'ms' });
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms' },
makeController(),
);
const u = makeFakeUPlot({ cursorEvent: null });
hook(u);
expect(getCrosshair(u).style.display).toBe('');
});
it('hides crosshair when yAxisUnit differs from source', () => {
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'bytes' });
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms' },
makeController(),
);
const u = makeFakeUPlot({ cursorEvent: null });
hook(u);
expect(getCrosshair(u).style.display).toBe('none');
});
});
// ── exact groupBy match ───────────────────────────────────────────────
describe('exact groupBy match', () => {
const groupByPerQuery = makeGroupByPerQuery('host');
const series: FakeSeries[] = [
{},
{ metric: { host: 'server1' } },
{ metric: { host: 'server2' } },
];
it('focuses the matching series and records it on the controller', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server2' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(2, { focus: true });
expect(controller.syncedSeriesIndexes).toStrictEqual([2]);
});
it('unfocuses all and emits empty matches (Filtered) when active metric is null', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
});
it('unfocuses all when metric matches no series', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({
host: 'unknown-server',
});
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
});
it('clears syncedSeriesIndexes when cursor is off-plot (left < 0)', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: -1, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
expect(controller.syncedSeriesIndexes).toBeNull();
expect(mockRegistry.getActiveSeriesMetric).not.toHaveBeenCalled();
});
it('never focuses series at index 0 (x-axis)', () => {
const sameMetric = { host: 'server1' };
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue(sameMetric);
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery },
controller,
);
const u = makeFakeUPlot({
cursorEvent: null,
cursorLeft: 50,
// Index 0 has the same metric — it must always be skipped.
series: [{ metric: sameMetric }, { metric: { host: 'other' } }],
});
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(null, { focus: false });
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
});
it('skips hidden series (show === false)', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery },
controller,
);
const u = makeFakeUPlot({
cursorEvent: null,
cursorLeft: 50,
series: [
{},
{ metric: { host: 'server1' }, show: false },
{ metric: { host: 'server1' } },
],
});
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(2, { focus: true });
expect(controller.syncedSeriesIndexes).toStrictEqual([2]);
});
});
// ── partial groupBy overlap ───────────────────────────────────────────
describe('partial groupBy overlap', () => {
it('subset — records every receiver series matching on the common key', () => {
// Source groupBy=[host], receiver groupBy=[host, service].
// Hook focuses the first match; the rest are surfaced via controller.syncedSeriesIndexes.
const sourceGroupBy = makeGroupByPerQuery('host');
const receiverGroupBy = makeGroupByPerQuery('host', 'service');
const series: FakeSeries[] = [
{},
{ metric: { host: 'server1', service: 'api' } },
{ metric: { host: 'server1', service: 'frontend' } },
{ metric: { host: 'server2', service: 'api' } },
];
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: sourceGroupBy,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
expect(controller.syncedSeriesIndexes).toStrictEqual([1, 2]);
});
it('superset — records the one receiver series matching on the common key', () => {
// Source groupBy=[host, service], receiver groupBy=[host].
const sourceGroupBy = makeGroupByPerQuery('host', 'service');
const receiverGroupBy = makeGroupByPerQuery('host');
const series: FakeSeries[] = [
{},
{ metric: { host: 'server1' } },
{ metric: { host: 'server2' } },
];
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: sourceGroupBy,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({
host: 'server1',
service: 'api',
});
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
expect(controller.syncedSeriesIndexes).toStrictEqual([1]);
});
it('partial — matches on the intersecting key only', () => {
// Source groupBy=[host, service], receiver groupBy=[service, region].
// Common key is [service]. Both receiver series with service=api match.
const sourceGroupBy = makeGroupByPerQuery('host', 'service');
const receiverGroupBy = makeGroupByPerQuery('service', 'region');
const series: FakeSeries[] = [
{},
{ metric: { service: 'api', region: 'us-east' } },
{ metric: { service: 'api', region: 'eu-west' } },
{ metric: { service: 'frontend', region: 'us-east' } },
];
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: sourceGroupBy,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({
host: 'server1',
service: 'api',
});
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
expect(controller.syncedSeriesIndexes).toStrictEqual([1, 2]);
});
});
// ── union across queries in groupByPerQuery ───────────────────────────
describe('union across queries', () => {
it("treats the panel's effective groupBy as the union across its queries", () => {
// Source has query A=[host]; receiver has A=[host], B=[service].
// The shared key is `host` — receiver matches on that.
const sourceGroupBy: Record<string, BaseAutocompleteData[]> = {
A: [{ key: 'host', type: 'tag' }],
};
const receiverGroupBy: Record<string, BaseAutocompleteData[]> = {
A: [{ key: 'host', type: 'tag' }],
B: [{ key: 'service', type: 'tag' }],
};
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: sourceGroupBy,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: receiverGroupBy },
controller,
);
const u = makeFakeUPlot({
cursorEvent: null,
cursorLeft: 50,
series: [
{},
{ metric: { host: 'server1' } },
{ metric: { host: 'server2' } },
],
});
hook(u);
expect(controller.syncedSeriesIndexes).toStrictEqual([1]);
});
});
// ── no overlap (Filtered mode default) ────────────────────────────────
describe('no overlap → Filtered mode emits []', () => {
it('emits [] when groupBy keys are completely different', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: makeGroupByPerQuery('host'),
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: makeGroupByPerQuery('service') },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null });
hook(u);
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
});
it('emits [] when receiver groupBy is empty', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: makeGroupByPerQuery('host'),
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: {} },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null });
hook(u);
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
});
it('emits [] when source groupBy is absent', () => {
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'ms' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms', groupByPerQuery: makeGroupByPerQuery('host') },
controller,
);
const u = makeFakeUPlot({ cursorEvent: null });
hook(u);
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
});
});
// ── filterMode: All ──────────────────────────────────────────────────
describe('filterMode All', () => {
it('emits null (no filter) when there is no overlap in groupBy', () => {
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery: makeGroupByPerQuery('host'),
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{
yAxisUnit: 'ms',
groupByPerQuery: makeGroupByPerQuery('service'),
filterMode: SyncTooltipFilterMode.All,
},
controller,
);
const u = makeFakeUPlot({ cursorEvent: null });
hook(u);
expect(controller.syncedSeriesIndexes).toBeNull();
});
it('emits null when metric matches no series', () => {
const groupByPerQuery = makeGroupByPerQuery('host');
mockRegistry.getMetadata.mockReturnValue({
yAxisUnit: 'ms',
groupByPerQuery,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'unknown' });
const controller = makeController();
const hook = createSyncDisplayHook(
SYNC_KEY,
{
yAxisUnit: 'ms',
groupByPerQuery,
filterMode: SyncTooltipFilterMode.All,
},
controller,
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50 });
hook(u);
expect(controller.syncedSeriesIndexes).toBeNull();
});
});
// ── caching ───────────────────────────────────────────────────────────
describe('caching optimizations', () => {
it('reuses the crosshair element across multiple invocations', () => {
mockRegistry.getMetadata.mockReturnValue({ yAxisUnit: 'ms' });
mockRegistry.getActiveSeriesMetric.mockReturnValue(null);
const hook = createSyncDisplayHook(
SYNC_KEY,
{ yAxisUnit: 'ms' },
makeController(),
);
const u = makeFakeUPlot({ cursorEvent: null });
const spy = jest.spyOn(u.root, 'querySelector');
hook(u);
hook(u);
hook(u);
// querySelector should only be called once regardless of invocation count.
expect(spy).toHaveBeenCalledTimes(1);
});
it('recomputes common keys when source groupByPerQuery reference changes', () => {
const hostGroupBy = makeGroupByPerQuery('host');
const serviceGroupBy = makeGroupByPerQuery('service');
const series: FakeSeries[] = [
{},
{ metric: { host: 'server1', service: 'api' } },
{ metric: { host: 'server2', service: 'frontend' } },
];
const hook = createSyncDisplayHook(
SYNC_KEY,
{ groupByPerQuery: makeGroupByPerQuery('host', 'service') },
makeController(),
);
const u = makeFakeUPlot({ cursorEvent: null, cursorLeft: 50, series });
// First call: source groups by host → matches series 1.
mockRegistry.getMetadata.mockReturnValue({
groupByPerQuery: hostGroupBy,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({ host: 'server1' });
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(1, { focus: true });
jest.clearAllMocks();
// Second call: source now groups by service → matches series 2.
mockRegistry.getMetadata.mockReturnValue({
groupByPerQuery: serviceGroupBy,
});
mockRegistry.getActiveSeriesMetric.mockReturnValue({
service: 'frontend',
});
hook(u);
expect(mockSetSeries(u)).toHaveBeenCalledWith(2, { focus: true });
});
});
});
});

View File

@@ -1,9 +1,5 @@
import { Tag } from 'antd';
import { Badge } from '@signozhq/ui/badge';
export default function BetaTag(): JSX.Element {
return (
<Tag bordered={false} color="geekblue">
Beta
</Tag>
);
return <Badge color="robin">Beta</Badge>;
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Button, Tag } from 'antd';
import { Button } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { TimelineFilter } from 'container/AlertHistory/types';
import { Undo } from '@signozhq/icons';
@@ -65,11 +66,7 @@ function Tabs2({
>
{tab.label}
{tab.isBeta && (
<Tag bordered={false} color="geekblue">
Beta
</Tag>
)}
{tab.isBeta && <Badge color="robin">Beta</Badge>}
</Button>
))}
</Button.Group>

View File

@@ -29,5 +29,24 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v4/traces/{traceID}/waterfall", handler.New(
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetWaterfallV4),
handler.OpenAPIDef{
ID: "GetWaterfallV4",
Tags: []string{"tracedetail"},
Summary: "Get waterfall view for a trace",
Description: "Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3",
Request: new(spantypes.PostableWaterfall),
RequestContentType: "application/json",
Response: new(spantypes.GettableWaterfallTrace),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -38,3 +38,24 @@ func (h *handler) GetWaterfall(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, result)
}
func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
req := new(spantypes.PostableWaterfall)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
if err := req.Validate(); err != nil {
render.Error(rw, err)
return
}
result, err := h.module.GetWaterfallV4(r.Context(), mux.Vars(r)["traceID"], req.SelectedSpanID, req.UncollapsedSpans, req.Limit)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

View File

@@ -2,6 +2,7 @@ package impltracedetail
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
@@ -45,7 +46,7 @@ func (m *module) GetWaterfall(ctx context.Context, traceID string, req *spantype
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, uncollapsedSpans, selectedAllSpans, aggregationResults), nil
}
// getTraceData returns the waterfall cache for the given traceID with fallback on DB.
// getTraceData fetches all spans for a trace and builds the WaterfallTrace.
func (m *module) getTraceData(ctx context.Context, traceID string) (*spantypes.WaterfallTrace, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
@@ -61,6 +62,86 @@ func (m *module) getTraceData(ctx context.Context, traceID string) (*spantypes.W
return nil, spantypes.ErrTraceNotFound
}
traceData := spantypes.NewWaterfallTraceFromSpans(spanItems)
return traceData, nil
nodes := make([]*spantypes.WaterfallSpan, len(spanItems))
for i := range spanItems {
nodes[i] = spanItems[i].ToWaterfallSpan(traceID)
}
return spantypes.NewWaterfallTraceFromSpans(nodes), nil
}
// GetWaterfallV4 is the OOM-safe V4 waterfall.
// For large traces (NumSpans > effectiveLimit) it uses a two-step fetch:
// minimal fields for all spans to build the tree, then full fields for the
// visible window only. Aggregations are not returned.
func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string, selectAllLimit uint) (*spantypes.GettableWaterfallTrace, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
return nil, err
}
effectiveLimit := min(selectAllLimit, m.config.Waterfall.MaxLimitToSelectAllSpans)
if summary.NumSpans > uint64(effectiveLimit) {
return m.getWindowedWaterfall(ctx, traceID, selectedSpanID, uncollapsedSpans, summary.Start, summary.End)
}
return m.getFullWaterfall(ctx, traceID, summary)
}
func (m *module) getFullWaterfall(ctx context.Context, traceID string, summary *spantypes.TraceSummary) (*spantypes.GettableWaterfallTrace, error) {
spanItems, err := m.store.GetTraceSpans(ctx, traceID, summary)
if err != nil {
return nil, err
}
if len(spanItems) == 0 {
return nil, spantypes.ErrTraceNotFound
}
nodes := make([]*spantypes.WaterfallSpan, len(spanItems))
for i := range spanItems {
nodes[i] = spanItems[i].ToWaterfallSpan(traceID)
}
waterfallTrace := spantypes.NewWaterfallTraceFromSpans(nodes)
selectedSpans := waterfallTrace.GetAllSpans()
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, nil, true, nil), nil
}
// getWindowedWaterfall builds the waterfall tree with minimal data and then returns only a window of full spans.
func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpanID string, uncollapsedSpans []string, start, end time.Time) (*spantypes.GettableWaterfallTrace, error) {
// Step 1: minimal fetch → build full tree → select visible window
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, start, end)
if err != nil {
return nil, err
}
if len(minimalSpans) == 0 {
return nil, spantypes.ErrTraceNotFound
}
nodes := make([]*spantypes.WaterfallSpan, len(minimalSpans))
for i := range minimalSpans {
nodes[i] = minimalSpans[i].ToWaterfallSpan(traceID)
}
waterfallTrace := spantypes.NewWaterfallTraceFromSpans(nodes)
selectedSpans, uncollapsedSpans := waterfallTrace.GetSelectedSpans(
uncollapsedSpans,
selectedSpanID,
m.config.Waterfall.SpanPageSize,
m.config.Waterfall.MaxDepthToAutoExpand,
)
// Step 2: full fetch for the selected window only
spanIDs := make([]string, len(selectedSpans))
for i, s := range selectedSpans {
spanIDs[i] = s.SpanID
}
fullSpans, err := m.store.GetTraceSpansByIDs(ctx, traceID, start, end, spanIDs)
if err != nil {
return nil, err
}
spantypes.EnrichSelectedSpans(selectedSpans, fullSpans)
return spantypes.NewGettableWaterfallTrace(
waterfallTrace, selectedSpans, uncollapsedSpans, false, nil,
), nil
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"time"
sqlbuilder "github.com/huandu/go-sqlbuilder"
@@ -12,6 +13,8 @@ import (
"github.com/SigNoz/signoz/pkg/types/spantypes"
)
const colServiceName = `resource_string_service$$$$name` // $ gets escaped so $$$$ converts to $$.
type traceStore struct {
telemetryStore telemetrystore.TelemetryStore
}
@@ -45,8 +48,8 @@ func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary
// DISTINCT ON (span_id) is ClickHouse-specific syntax not supported by sqlbuilder
query := fmt.Sprintf(`
SELECT DISTINCT ON (span_id)
timestamp, duration_nano, span_id, trace_id, has_error, kind,
resource_string_service$$name, name, links as references,
timestamp, duration_nano, span_id, has_error, kind,
resource_string_service$$name, name,
attributes_string, attributes_number, attributes_bool, resources_string,
events, status_message, status_code_string, kind_string, parent_span_id,
flags, is_remote, trace_state, status_code,
@@ -69,3 +72,64 @@ func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary
}
return spanItems, nil
}
func (s *traceStore) GetMinimalSpans(ctx context.Context, traceID string, start, end time.Time) ([]spantypes.MinimalSpan, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"DISTINCT ON (span_id) span_id",
"parent_span_id", "timestamp", "duration_nano", "has_error",
colServiceName,
)
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
sb.Where(
sb.E("trace_id", traceID),
sb.GE("ts_bucket_start", start.Unix()-1800),
sb.LE("ts_bucket_start", end.Unix()),
)
sb.OrderByAsc("timestamp")
sb.OrderByAsc("name")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var spans []spantypes.MinimalSpan
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying minimal spans")
}
return spans, nil
}
func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
if len(spanIDs) == 0 {
return []spantypes.StorableSpan{}, nil
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"DISTINCT ON (span_id) timestamp",
"duration_nano", "span_id", "has_error", "kind",
colServiceName, "name",
"attributes_string", "attributes_number", "attributes_bool", "resources_string",
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
"flags", "is_remote", "trace_state", "status_code",
"db_name", "db_operation", "http_method", "http_url", "http_host",
"external_http_method", "external_http_url", "response_status_code",
)
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
ids := make([]any, len(spanIDs))
for i, id := range spanIDs {
ids[i] = id
}
sb.Where(
sb.E("trace_id", traceID),
sb.In("span_id", ids...),
sb.GE("ts_bucket_start", start.Unix()-1800),
sb.LE("ts_bucket_start", end.Unix()),
)
sb.OrderByAsc("timestamp")
sb.OrderByAsc("name")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var spans []spantypes.StorableSpan
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace spans by IDs")
}
return spans, nil
}

View File

@@ -10,9 +10,11 @@ import (
// Handler exposes HTTP handlers for trace detail APIs.
type Handler interface {
GetWaterfall(http.ResponseWriter, *http.Request)
GetWaterfallV4(http.ResponseWriter, *http.Request)
}
// Module defines the business logic for trace detail operations.
type Module interface {
GetWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error)
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string, selectAllLimit uint) (*spantypes.GettableWaterfallTrace, error)
}

View File

@@ -19,6 +19,8 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const traceOutsideRangeWarn = "Query %s references a trace_id that exists between %s and %s (UTC) but lies outside the selected time range; adjust the time range to see results"
type builderQuery[T any] struct {
logger *slog.Logger
telemetryStore telemetrystore.TelemetryStore
@@ -199,7 +201,21 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
return q.executeWindowList(ctx)
}
stmt, err := q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.variables)
fromMS, toMS := q.fromMS, q.toMS
if q.spec.Signal == telemetrytypes.SignalTraces || q.spec.Signal == telemetrytypes.SignalLogs {
var overlap bool
var warning string
fromMS, toMS, overlap, warning = q.narrowWindowByTraceID(ctx, fromMS, toMS)
if !overlap {
res := emptyResultFor(q.kind, q.spec.Name)
if warning != "" {
res.Warnings = []string{warning}
}
return res, nil
}
}
stmt, err := q.stmtBuilder.Build(ctx, fromMS, toMS, q.kind, q.spec, q.variables)
if err != nil {
return nil, err
}
@@ -215,6 +231,88 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
return result, nil
}
// narrowWindowByTraceID inspects the filter for trace_id predicates and clamps
// [fromMS,toMS] to the time range stored in signoz_traces.distributed_trace_summary.
// Returns the (possibly narrowed) window, overlap=false when the trace lies
// completely outside the query window (callers should short-circuit), and a
// warning string the caller should attach to the empty result when the trace
// exists but is outside the selected window.
//
// When the trace_id is not present in trace_summary the behaviour differs by
// signal:
// - traces: trace_summary is derived from the spans table, so a missing row
// means no spans exist for that trace_id; we short-circuit to empty.
// - logs: logs can carry a trace_id even when traces are not ingested at all
// (e.g. traces disabled). We must not short-circuit; instead leave the
// window untouched and let the query run.
func (q *builderQuery[T]) narrowWindowByTraceID(ctx context.Context, fromMS, toMS uint64) (uint64, uint64, bool, string) {
if q.spec.Filter == nil || q.spec.Filter.Expression == "" {
return fromMS, toMS, true, ""
}
traceIDs, found := telemetrytraces.ExtractTraceIDsFromFilter(q.spec.Filter.Expression)
if !found || len(traceIDs) == 0 {
return fromMS, toMS, true, ""
}
finder := telemetrytraces.NewTraceTimeRangeFinder(q.telemetryStore)
traceStart, traceEnd, exists, err := finder.GetTraceTimeRangeMulti(ctx, traceIDs)
if err != nil {
return fromMS, toMS, true, ""
}
if !exists {
if q.spec.Signal == telemetrytypes.SignalTraces {
q.logger.DebugContext(ctx, "trace_id not found in trace_summary; short-circuiting traces query to empty",
slog.Any("trace_ids", traceIDs))
return fromMS, toMS, false, ""
}
q.logger.DebugContext(ctx, "trace_id not found in trace_summary; leaving time range untouched for logs",
slog.Any("trace_ids", traceIDs))
return fromMS, toMS, true, ""
}
traceStartMS := uint64(traceStart) / 1_000_000
traceEndMS := uint64(traceEnd) / 1_000_000
if traceStartMS == 0 || traceEndMS == 0 {
return fromMS, toMS, true, ""
}
if traceStartMS > toMS || traceEndMS < fromMS {
traceStartUTC := time.UnixMilli(int64(traceStartMS)).UTC().Format(time.RFC3339)
traceEndUTC := time.UnixMilli(int64(traceEndMS)).UTC().Format(time.RFC3339)
return fromMS, toMS, false, fmt.Sprintf(traceOutsideRangeWarn, q.spec.Name, traceStartUTC, traceEndUTC)
}
if traceStartMS > fromMS {
fromMS = traceStartMS
}
if traceEndMS < toMS {
toMS = traceEndMS
}
q.logger.DebugContext(ctx, "optimized time range using trace_id lookup",
slog.String("signal", q.spec.Signal.StringValue()),
slog.Any("trace_ids", traceIDs),
slog.Uint64("start", fromMS),
slog.Uint64("end", toMS))
return fromMS, toMS, true, ""
}
// emptyResultFor returns an empty result payload appropriate for the given kind.
func emptyResultFor(kind qbtypes.RequestType, queryName string) *qbtypes.Result {
var value any
switch kind {
case qbtypes.RequestTypeTimeSeries:
value = &qbtypes.TimeSeriesData{QueryName: queryName}
case qbtypes.RequestTypeScalar:
value = &qbtypes.ScalarData{QueryName: queryName}
default:
value = &qbtypes.RawData{QueryName: queryName}
}
return &qbtypes.Result{
Type: kind,
Value: value,
}
}
// executeWithContext executes the query with query window and step context for partial value detection.
func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string, args []any) (*qbtypes.Result, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
@@ -310,42 +408,27 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
totalBytes := uint64(0)
start := time.Now()
// Check if filter contains trace_id(s) and optimize time range if needed
if q.spec.Signal == telemetrytypes.SignalTraces &&
q.spec.Filter != nil && q.spec.Filter.Expression != "" {
traceIDs, found := telemetrytraces.ExtractTraceIDsFromFilter(q.spec.Filter.Expression)
if found && len(traceIDs) > 0 {
finder := telemetrytraces.NewTraceTimeRangeFinder(q.telemetryStore)
traceStart, traceEnd, ok := finder.GetTraceTimeRangeMulti(ctx, traceIDs)
traceStartMS := uint64(traceStart) / 1_000_000
traceEndMS := uint64(traceEnd) / 1_000_000
if !ok {
q.logger.DebugContext(ctx, "failed to get trace time range", slog.Any("trace_ids", traceIDs))
} else if traceStartMS > 0 && traceEndMS > 0 {
// no overlap — nothing to return
if uint64(traceStartMS) > toMS || uint64(traceEndMS) < fromMS {
return &qbtypes.Result{
Type: qbtypes.RequestTypeRaw,
Value: &qbtypes.RawData{
QueryName: q.spec.Name,
},
Stats: qbtypes.ExecStats{
DurationMS: uint64(time.Since(start).Milliseconds()),
},
}, nil
}
// clamp window to trace time range before bucketing
if uint64(traceStartMS) > fromMS {
fromMS = uint64(traceStartMS)
}
if uint64(traceEndMS) < toMS {
toMS = uint64(traceEndMS)
}
q.logger.DebugContext(ctx, "optimized time range for traces", slog.Any("trace_ids", traceIDs), slog.Uint64("start", fromMS), slog.Uint64("end", toMS))
// Check if filter contains trace_id(s) and optimize time range if needed.
// Applies to both traces (the listing this branch was built for) and logs
// (which carry trace_id and benefit from the same clamp before bucketing).
if q.spec.Signal == telemetrytypes.SignalTraces || q.spec.Signal == telemetrytypes.SignalLogs {
var overlap bool
var warning string
fromMS, toMS, overlap, warning = q.narrowWindowByTraceID(ctx, fromMS, toMS)
if !overlap {
res := &qbtypes.Result{
Type: qbtypes.RequestTypeRaw,
Value: &qbtypes.RawData{
QueryName: q.spec.Name,
},
Stats: qbtypes.ExecStats{
DurationMS: uint64(time.Since(start).Milliseconds()),
},
}
if warning != "" {
res.Warnings = []string{warning}
}
return res, nil
}
}

View File

@@ -10,7 +10,6 @@ import (
"time"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz-otel-collector/utils"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
@@ -77,7 +76,7 @@ func NewFieldMapper(fl flagger.Flagger) qbtypes.FieldMapper {
return &fieldMapper{fl: fl}
}
func (m *fieldMapper) getColumns(ctx context.Context, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
func (m *fieldMapper) getColumn(ctx context.Context, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
switch key.FieldContext {
case telemetrytypes.FieldContextResource:
columns := []*schema.Column{logsV2Columns["resources_string"], logsV2Columns["resource"]}
@@ -246,7 +245,7 @@ func selectEvolutionsForColumns(columns []*schema.Column, evolutions []*telemetr
}
func (m *fieldMapper) FieldFor(ctx context.Context, tsStart, tsEnd uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
columns, err := m.getColumns(ctx, key)
columns, err := m.getColumn(ctx, key)
if err != nil {
return "", err
}
@@ -288,14 +287,12 @@ func (m *fieldMapper) FieldFor(ctx context.Context, tsStart, tsEnd uint64, key *
return "", qbtypes.ErrColumnNotFound
}
for _, column := range columns {
expr, err := m.buildFieldForJSON(key, column)
if err != nil {
return "", err
}
exprs = append(exprs, expr)
expr, err := m.buildFieldForJSON(key)
if err != nil {
return "", err
}
exprs = append(exprs, expr)
default:
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource/body context fields are supported for json columns, got %s", key.FieldContext.String)
}
@@ -351,7 +348,7 @@ func (m *fieldMapper) FieldFor(ctx context.Context, tsStart, tsEnd uint64, key *
}
func (m *fieldMapper) ColumnFor(ctx context.Context, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
return m.getColumns(ctx, key)
return m.getColumn(ctx, key)
}
func (m *fieldMapper) ColumnExpressionFor(
@@ -406,13 +403,16 @@ func (m *fieldMapper) ColumnExpressionFor(
}
// buildFieldForJSON builds the field expression for body JSON fields using arrayConcat pattern.
func (m *fieldMapper) buildFieldForJSON(key *telemetrytypes.TelemetryFieldKey, column *schemamigrator.Column) (string, error) {
node, err := key.PlanBuilder.Build(*column)
if err != nil {
return "", err
func (m *fieldMapper) buildFieldForJSON(key *telemetrytypes.TelemetryFieldKey) (string, error) {
plan := key.JSONPlan
if len(plan) == 0 {
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput,
"Could not find any valid paths for: %s", key.Name)
}
if node.IsTerminal {
if plan[0].IsTerminal {
node := plan[0]
expr := fmt.Sprintf("dynamicElement(%s, '%s')", node.FieldPath(), node.TerminalConfig.ElemType.StringValue())
// TODO(Piyush): Promoted path logic commented out. Materialized now means type hint
// promotion will be extracted from key field evolution
@@ -450,7 +450,7 @@ func (m *fieldMapper) buildFieldForJSON(key *telemetrytypes.TelemetryFieldKey, c
}
// Build arrayConcat pattern directly from the tree structure
arrayConcatExpr, err := m.buildArrayConcat(node)
arrayConcatExpr, err := m.buildArrayConcat(plan)
if err != nil {
return "", err
}
@@ -459,18 +459,23 @@ func (m *fieldMapper) buildFieldForJSON(key *telemetrytypes.TelemetryFieldKey, c
}
// buildArrayConcat builds the arrayConcat pattern directly from the tree structure.
func (m *fieldMapper) buildArrayConcat(node *telemetrytypes.JSONAccessNode) (string, error) {
func (m *fieldMapper) buildArrayConcat(plan telemetrytypes.JSONAccessPlan) (string, error) {
if len(plan) == 0 {
return "", errors.Newf(errors.TypeInternal, CodeGroupByPlanEmpty, "group by plan is empty while building arrayConcat")
}
// Build arrayMap expressions for ALL available branches at the root level.
// Iterate branches in deterministic order (JSON then Dynamic)
var arrayMapExpressions []string
for _, branchType := range node.BranchesInOrder() {
expr, err := m.buildArrayMap(node, branchType)
if err != nil {
return "", err
for _, node := range plan {
for _, branchType := range node.BranchesInOrder() {
expr, err := m.buildArrayMap(node, branchType)
if err != nil {
return "", err
}
arrayMapExpressions = append(arrayMapExpressions, expr)
}
arrayMapExpressions = append(arrayMapExpressions, expr)
}
if len(arrayMapExpressions) == 0 {
return "", errors.Newf(errors.TypeInternal, CodeArrayMapExpressionsEmpty, "array map expressions are empty while building arrayConcat")
}

View File

@@ -33,42 +33,32 @@ func NewJSONConditionBuilder(key *telemetrytypes.TelemetryFieldKey, valueType te
}
// BuildCondition builds the full WHERE condition for body_v2 JSON paths.
func (c *jsonConditionBuilder) buildJSONCondition(columns []schemamigrator.Column, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
if len(columns) == 1 {
baseCond, err := c.emitPlannedCondition(columns[0], operator, value, sb)
func (c *jsonConditionBuilder) buildJSONCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
baseCond, err := c.emitPlannedCondition(operator, value, sb)
if err != nil {
return "", err
}
// path index
if operator.AddDefaultExistsFilter() {
pathIndex := fmt.Sprintf(`has(%s, '%s')`, schemamigrator.JSONPathsIndexExpr(LogsV2BodyV2Column), c.key.ArrayParentPaths()[0])
return sb.And(baseCond, pathIndex), nil
}
return baseCond, nil
}
func (c *jsonConditionBuilder) emitPlannedCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
// Build traversal + terminal recursively per-hop
conditions := []string{}
for _, node := range c.key.JSONPlan {
condition, err := c.recurseArrayHops(node, operator, value, sb)
if err != nil {
return "", err
}
// path index
if operator.AddDefaultExistsFilter() {
pathIndex := fmt.Sprintf(`has(%s, '%s')`, schemamigrator.JSONPathsIndexExpr(LogsV2BodyV2Column), c.key.ArrayParentPaths()[0])
return sb.And(baseCond, pathIndex), nil
}
return baseCond, nil
conditions = append(conditions, condition)
}
}
func (c *jsonConditionBuilder) emitPlannedCondition(column schemamigrator.Column, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
if c.key.PlanBuilder == nil {
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "no plan builder for key %s", c.key.Name)
}
var conditions []string
node, err := c.key.PlanBuilder.Build(column)
if err != nil {
return "", err
}
cond, err := c.recurseArrayHops(node, operator, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
return sb.Or(conditions...), nil
}

View File

@@ -85,16 +85,27 @@ func (t *telemetryMetaStore) enrichJSONKeys(ctx context.Context, selectors []*te
key.Indexes = indexes[key.Name]
}
// attach a lazy PlanBuilder so field_mapper can build plans on demand
// build JSON access plans using the pre-fetched parent type cache
return t.buildJSONPlans(filteredKeys, parentTypeCache)
}
// buildJSONPlans builds JSON access plans for the given keys
// using the provided parent type cache (pre-fetched in the main UNION query).
func (t *telemetryMetaStore) buildJSONPlans(keys []*telemetrytypes.TelemetryFieldKey, typeCache map[string][]telemetrytypes.FieldDataType) error {
if len(keys) == 0 {
return nil
}
columnMeta := t.jsonColumnMetadata[telemetrytypes.SignalLogs][telemetrytypes.FieldContextBody]
for _, key := range filteredKeys {
key.PlanBuilder = telemetrytypes.NewFieldPlanBuilder(key, columnMeta, parentTypeCache)
for _, key := range keys {
if err := key.SetJSONAccessPlan(columnMeta, typeCache); err != nil {
return err
}
}
return nil
}
func (t *telemetryMetaStore) getJSONPathIndexes(ctx context.Context, paths ...string) (map[string][]telemetrytypes.TelemetryFieldKeySkipIndex, error) {
filteredPaths := []string{}
for _, path := range paths {

View File

@@ -21,19 +21,19 @@ func NewTraceTimeRangeFinder(telemetryStore telemetrystore.TelemetryStore) *Trac
}
}
func (f *TraceTimeRangeFinder) GetTraceTimeRange(ctx context.Context, traceID string) (startNano, endNano int64, ok bool) {
func (f *TraceTimeRangeFinder) GetTraceTimeRange(ctx context.Context, traceID string) (startNano, endNano int64, exists bool, error error) {
traceIDs := []string{traceID}
return f.GetTraceTimeRangeMulti(ctx, traceIDs)
}
func (f *TraceTimeRangeFinder) GetTraceTimeRangeMulti(ctx context.Context, traceIDs []string) (startNano, endNano int64, ok bool) {
func (f *TraceTimeRangeFinder) GetTraceTimeRangeMulti(ctx context.Context, traceIDs []string) (startNano, endNano int64, exists bool, error error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
instrumentationtypes.CodeNamespace: "trace-time-range",
instrumentationtypes.CodeFunctionName: "GetTraceTimeRangeMulti",
})
if len(traceIDs) == 0 {
return 0, 0, false
return 0, 0, false, nil
}
cleanedIDs := make([]string, len(traceIDs))
@@ -49,7 +49,8 @@ func (f *TraceTimeRangeFinder) GetTraceTimeRangeMulti(ctx context.Context, trace
}
query := fmt.Sprintf(`
SELECT
SELECT
count(),
toUnixTimestamp64Nano(min(start)),
toUnixTimestamp64Nano(max(end))
FROM %s.%s
@@ -58,9 +59,14 @@ func (f *TraceTimeRangeFinder) GetTraceTimeRangeMulti(ctx context.Context, trace
row := f.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...)
err := row.Scan(&startNano, &endNano)
var rowCount uint64
err := row.Scan(&rowCount, &startNano, &endNano)
if err != nil {
return 0, 0, false
return 0, 0, false, err
}
if rowCount == 0 {
return 0, 0, false, nil
}
if startNano > 1_000_000_000 {
@@ -68,5 +74,5 @@ func (f *TraceTimeRangeFinder) GetTraceTimeRangeMulti(ctx context.Context, trace
}
endNano += 1_000_000_000
return startNano, endNano, true
return startNano, endNano, true, nil
}

View File

@@ -43,7 +43,7 @@ func TestGetTraceTimeRangeMulti(t *testing.T) {
finder := &TraceTimeRangeFinder{telemetryStore: nil}
if !tt.expectOK {
_, _, ok := finder.GetTraceTimeRangeMulti(ctx, tt.traceIDs)
_, _, ok, _ := finder.GetTraceTimeRangeMulti(ctx, tt.traceIDs)
assert.False(t, ok)
}
})

View File

@@ -2,6 +2,7 @@ package spantypes
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -26,4 +27,6 @@ type SpanMapperStore interface {
type TraceStore interface {
GetTraceSummary(ctx context.Context, traceID string) (*TraceSummary, error)
GetTraceSpans(ctx context.Context, traceID string, summary *TraceSummary) ([]StorableSpan, error)
GetMinimalSpans(ctx context.Context, traceID string, start, end time.Time) ([]MinimalSpan, error)
GetTraceSpansByIDs(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
}

View File

@@ -103,12 +103,10 @@ type StorableSpan struct {
StartTime time.Time `ch:"timestamp"`
DurationNano uint64 `ch:"duration_nano"`
SpanID string `ch:"span_id"`
TraceID string `ch:"trace_id"`
HasError bool `ch:"has_error"`
Kind int8 `ch:"kind"`
ServiceName string `ch:"resource_string_service$$name"`
Name string `ch:"name"`
References string `ch:"references"`
AttributesString map[string]string `ch:"attributes_string"`
AttributesNumber map[string]float64 `ch:"attributes_number"`
AttributesBool map[string]bool `ch:"attributes_bool"`
@@ -132,6 +130,32 @@ type StorableSpan struct {
ResponseStatusCode string `ch:"response_status_code"`
}
// MinimalSpan with only the fields needed to build the parent-child tree.
type MinimalSpan struct {
SpanID string `ch:"span_id"`
ParentSpanID string `ch:"parent_span_id"`
StartTime time.Time `ch:"timestamp"`
DurationNano uint64 `ch:"duration_nano"`
HasError bool `ch:"has_error"`
ServiceName string `ch:"resource_string_service$$name"`
}
func (item *MinimalSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
return &WaterfallSpan{
SpanID: item.SpanID,
TraceID: traceID,
ParentSpanID: item.ParentSpanID,
TimeUnix: uint64(item.StartTime.UnixNano()),
DurationNano: item.DurationNano,
HasError: item.HasError,
ServiceName: item.ServiceName,
Resource: map[string]string{"service.name": item.ServiceName},
Children: make([]*WaterfallSpan, 0),
Attributes: make(map[string]any),
Events: make([]Event, 0),
}
}
// NewMissingWaterfallSpan creates a synthetic placeholder span for a parent that has no recorded data.
func NewMissingWaterfallSpan(spanID, traceID string, timeUnixNano, durationNano uint64) *WaterfallSpan {
return &WaterfallSpan{
@@ -261,7 +285,7 @@ func (item *StorableSpan) UnmarshalledEvents() []Event {
return events
}
func (item *StorableSpan) ToWaterfallSpan() *WaterfallSpan {
func (item *StorableSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
resources := make(map[string]string)
maps.Copy(resources, item.ResourcesString)
@@ -289,7 +313,7 @@ func (item *StorableSpan) ToWaterfallSpan() *WaterfallSpan {
StatusCode: item.StatusCode,
StatusCodeString: item.StatusCodeString,
StatusMessage: item.StatusMessage,
TraceID: item.TraceID,
TraceID: traceID,
TraceState: item.TraceState,
Children: make([]*WaterfallSpan, 0),
TimeUnix: uint64(item.StartTime.UnixNano()),
@@ -297,6 +321,24 @@ func (item *StorableSpan) ToWaterfallSpan() *WaterfallSpan {
}
}
func EnrichSelectedSpans(window []*WaterfallSpan, fullSpans []StorableSpan) {
fullByID := make(map[string]*StorableSpan, len(fullSpans))
for i := range fullSpans {
fullByID[fullSpans[i].SpanID] = &fullSpans[i]
}
for i, ws := range window {
full, ok := fullByID[ws.SpanID]
if !ok {
continue // synthesized MissingSpan — keep empty shell
}
newWS := full.ToWaterfallSpan(ws.TraceID)
newWS.Level = ws.Level
newWS.HasChildren = ws.HasChildren
newWS.SubTreeNodeCount = ws.SubTreeNodeCount
window[i] = newWS
}
}
// getSpanIndex returns the index of matched span and -1 for no match.
func getSpanIndex(spans []*WaterfallSpan, targetSpanID string) int {
for i, s := range spans {

View File

@@ -62,26 +62,24 @@ func NewWaterfallTrace(
}
}
func NewWaterfallTraceFromSpans(spans []StorableSpan) *WaterfallTrace {
// NewWaterfallTraceFromSpans requires WaterfallSpan nodes with only below fields:
// SpanID, ParentSpanID, TimeUnix, DurationNano, HasError, and ServiceName.
func NewWaterfallTraceFromSpans(nodes []*WaterfallSpan) *WaterfallTrace {
var (
startTime, endTime, totalErrorSpans uint64
spanIDToSpanNodeMap = make(map[string]*WaterfallSpan, len(spans))
spanIDToSpanNodeMap = make(map[string]*WaterfallSpan, len(nodes))
traceRoots []*WaterfallSpan
hasMissingSpans bool
)
for _, item := range spans {
span := item.ToWaterfallSpan()
startTimeUnixNano := uint64(item.StartTime.UnixNano())
if startTime == 0 || startTimeUnixNano < startTime {
startTime = startTimeUnixNano
for _, span := range nodes {
if startTime == 0 || span.TimeUnix < startTime {
startTime = span.TimeUnix
}
endTime = max(endTime, startTimeUnixNano+span.DurationNano)
endTime = max(endTime, span.TimeUnix+span.DurationNano)
if span.HasError {
totalErrorSpans++
}
spanIDToSpanNodeMap[span.SpanID] = span
}
@@ -116,7 +114,7 @@ func NewWaterfallTraceFromSpans(spans []StorableSpan) *WaterfallTrace {
return NewWaterfallTrace(
startTime,
endTime,
uint64(len(spans)),
uint64(len(nodes)),
totalErrorSpans,
spanIDToSpanNodeMap,
traceRoots,

View File

@@ -37,7 +37,7 @@ type TelemetryFieldKey struct {
FieldContext FieldContext `json:"fieldContext,omitzero"`
FieldDataType FieldDataType `json:"fieldDataType,omitzero"`
PlanBuilder *FieldPlanBuilder `json:"-"`
JSONPlan JSONAccessPlan `json:"-"`
Indexes []TelemetryFieldKeySkipIndex `json:"-"`
Materialized bool `json:"-"` // refers to promoted in case of body.... fields
@@ -120,7 +120,7 @@ func (f *TelemetryFieldKey) OverrideMetadataFrom(src *TelemetryFieldKey) {
f.FieldDataType = src.FieldDataType
f.Indexes = src.Indexes
f.Materialized = src.Materialized
f.PlanBuilder = src.PlanBuilder
f.JSONPlan = src.JSONPlan
f.Evolutions = src.Evolutions
}

View File

@@ -6,7 +6,6 @@ import (
"slices"
"strings"
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz-otel-collector/exporter/jsontypeexporter"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -29,6 +28,8 @@ type JSONColumnMetadata struct {
PromotedColumn string
}
type JSONAccessPlan = []*JSONAccessNode
type TerminalConfig struct {
Key *TelemetryFieldKey
ElemType JSONDataType
@@ -98,48 +99,23 @@ func (n *JSONAccessNode) BranchesInOrder() []JSONAccessBranchType {
})
}
// FieldPlanBuilder builds JSON access nodes on demand for a specific field key.
// It holds all necessary metadata (key, column info, type cache) and produces
// the correct root node per column, dispatching between base and promoted plans
// by column name — without any external "is promoted?" flag.
type FieldPlanBuilder struct {
type planBuilder struct {
key *TelemetryFieldKey
columnInfo JSONColumnMetadata
typeCache map[string][]FieldDataType
paths []string // cumulative paths for type cache lookups
segments []string // individual path segments for node names
}
func NewFieldPlanBuilder(key *TelemetryFieldKey, info JSONColumnMetadata, typeCache map[string][]FieldDataType) *FieldPlanBuilder {
return &FieldPlanBuilder{key: key, columnInfo: info, typeCache: typeCache}
}
// Build dispatches by column name — called by JSONConditionBuilder in field_mapper.
func (b *FieldPlanBuilder) Build(column schemamigrator.Column) (*JSONAccessNode, error) {
if column.Name == b.columnInfo.BaseColumn {
return b.build(NewRootJSONAccessNode(b.columnInfo.BaseColumn, 32, 0))
}
return b.build(NewRootJSONAccessNode(b.columnInfo.PromotedColumn, 32, 1024))
}
func (b *FieldPlanBuilder) build(root *JSONAccessNode) (*JSONAccessNode, error) {
if b.key.Name == "" {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "path is empty")
}
b.paths = b.key.ArrayParentPaths()
b.segments = b.key.ArrayPathSegments()
return b.buildPlan(0, root, false)
isPromoted bool
typeCache map[string][]FieldDataType
}
// buildPlan recursively builds the path plan tree.
func (b *FieldPlanBuilder) buildPlan(index int, parent *JSONAccessNode, isDynArrChild bool) (*JSONAccessNode, error) {
if index >= len(b.paths) {
func (pb *planBuilder) buildPlan(index int, parent *JSONAccessNode, isDynArrChild bool) (*JSONAccessNode, error) {
if index >= len(pb.paths) {
return nil, errors.NewInvalidInputf(CodePlanIndexOutOfBounds, "index is out of bounds")
}
pathSoFar := b.paths[index] // cumulative path for type cache lookup
segmentName := b.segments[index] // segment name for node
isTerminal := index == len(b.paths)-1
pathSoFar := pb.paths[index] // cumulative path for type cache lookup
segmentName := pb.segments[index] // segment name for node
isTerminal := index == len(pb.paths)-1
// Calculate progression parameters based on parent's values
var maxTypes, maxPaths int
@@ -173,18 +149,18 @@ func (b *FieldPlanBuilder) buildPlan(index int, parent *JSONAccessNode, isDynArr
// Configure terminal if this is the last part
if isTerminal {
// fielddatatype must not be unspecified else expression can not be generated
if b.key.FieldDataType == FieldDataTypeUnspecified {
if pb.key.FieldDataType == FieldDataTypeUnspecified {
return nil, errors.NewInternalf(CodePlanFieldDataTypeMissing, "field data type is missing for path %s", pathSoFar)
}
node.TerminalConfig = &TerminalConfig{
Key: b.key,
ElemType: b.key.GetJSONDataType(),
Key: pb.key,
ElemType: pb.key.GetJSONDataType(),
}
} else {
var err error
// Use cached types from the batched metadata query
types, ok := b.typeCache[pathSoFar]
types, ok := pb.typeCache[pathSoFar]
if !ok {
return nil, errors.NewInternalf(errors.CodeInvalidInput, "types missing for path %s", pathSoFar)
}
@@ -196,13 +172,13 @@ func (b *FieldPlanBuilder) buildPlan(index int, parent *JSONAccessNode, isDynArr
}
if hasJSON {
node.Branches[BranchJSON], err = b.buildPlan(index+1, node, false)
node.Branches[BranchJSON], err = pb.buildPlan(index+1, node, false)
if err != nil {
return nil, err
}
}
if hasDynamic {
node.Branches[BranchDynamic], err = b.buildPlan(index+1, node, true)
node.Branches[BranchDynamic], err = pb.buildPlan(index+1, node, true)
if err != nil {
return nil, err
}
@@ -211,3 +187,45 @@ func (b *FieldPlanBuilder) buildPlan(index int, parent *JSONAccessNode, isDynArr
return node, nil
}
// buildJSONAccessPlan builds a tree structure representing the complete JSON path traversal
// that precomputes all possible branches and their types.
func (key *TelemetryFieldKey) SetJSONAccessPlan(columnInfo JSONColumnMetadata, typeCache map[string][]FieldDataType,
) error {
// if path is empty, return nil
if key.Name == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "path is empty")
}
pb := &planBuilder{
key: key,
paths: key.ArrayParentPaths(),
segments: key.ArrayPathSegments(),
isPromoted: key.Materialized,
typeCache: typeCache,
}
node, err := pb.buildPlan(0,
NewRootJSONAccessNode(columnInfo.BaseColumn,
32, 0),
false,
)
if err != nil {
return err
}
key.JSONPlan = append(key.JSONPlan, node)
if pb.isPromoted {
node, err := pb.buildPlan(0,
NewRootJSONAccessNode(columnInfo.PromotedColumn,
32, 1024),
true,
)
if err != nil {
return err
}
key.JSONPlan = append(key.JSONPlan, node)
}
return nil
}

View File

@@ -20,6 +20,7 @@ from fixtures.querier import (
index_series_by_label,
make_query_request,
)
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
def test_logs_list(
@@ -2293,3 +2294,334 @@ def test_logs_formula_orderby_and_limit(
assert len(f3_services) == 3, f"F3: expected 3 rows after limit, got {len(f3_services)}"
assert f3_values == f4_values[:3], f"F3 values {f3_values} do not match F4[:3] values {f4_values[:3]}"
assert set(f3_services) == set(f4_services[:3]), f"F3 services {f3_services} do not match F4[:3] services {f4_services[:3]}"
def test_logs_list_filter_by_trace_id(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
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).
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.
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
short-circuit logs queries.
"""
target_trace_id = TraceIdGenerator.trace_id()
orphan_trace_id = TraceIdGenerator.trace_id()
target_root_span_id = TraceIdGenerator.span_id()
target_child_span_id = TraceIdGenerator.span_id()
orphan_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
common_resources = {
"deployment.environment": "production",
"service.name": "logs-trace-filter-service",
"cloud.provider": "integration",
}
# Populate signoz_traces.distributed_trace_summary by inserting spans for
# the target trace_id. trace_summary records min/max of span timestamps
# (it ignores span duration), so two spans are inserted to give the trace
# a non-trivial recorded window of [now-10s, now-5s].
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_root_span_id,
parent_span_id="",
name="root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
Traces(
timestamp=now - timedelta(seconds=5),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_child_span_id,
parent_span_id=target_root_span_id,
name="child-span",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
]
)
# 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 an orphan trace_id whose trace was never ingested — used to
# verify the lookup miss does NOT short-circuit logs queries.
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=7),
resources=common_resources,
attributes={"http.method": "GET"},
body="log inside the target trace window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=2),
resources=common_resources,
attributes={"http.method": "PUT"},
body="log with a trace_id absent from trace_summary",
severity_text="INFO",
trace_id=orphan_trace_id,
span_id=orphan_span_id,
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def _query(start_ms: int, end_ms: int, trace_id: str) -> tuple[list, list[str]]:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="raw",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"disabled": False,
"limit": 100,
"offset": 0,
"filter": {"expression": f"trace_id = '{trace_id}'"},
"order": [
{"key": {"name": "timestamp"}, "direction": "desc"},
{"key": {"name": "id"}, "direction": "desc"},
],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
rows = response.json()["data"]["data"]["results"][0]["rows"] or []
warning = (response.json().get("data") or {}).get("warning") or {}
messages = [w.get("message", "") for w in (warning.get("warnings") or [])]
return rows, messages
outside_range_msg = "lies outside the selected time range"
now_ms = int(now.timestamp() * 1000)
# --- 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 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.
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 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 ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_rows, past_warnings = _query(past_start_ms, past_end_ms, target_trace_id)
assert len(past_rows) == 0, f"Expected 0 logs for trace_id filter outside time window, got {len(past_rows)}"
assert any(outside_range_msg in m for m in past_warnings), f"Expected outside-range warning, got warnings={past_warnings}"
# --- Test 4: trace_id not present in trace_summary still returns logs (no warning) ---
orphan_rows, orphan_warnings = _query(narrow_start_ms, now_ms, orphan_trace_id)
assert len(orphan_rows) == 1, f"Expected 1 log for orphan trace_id (no trace_summary entry), got {len(orphan_rows)} — logs query may have been incorrectly short-circuited"
assert orphan_rows[0]["data"]["trace_id"] == orphan_trace_id
assert not any(outside_range_msg in m for m in orphan_warnings), f"Did not expect outside-range warning for orphan trace_id, got {orphan_warnings}"
def test_logs_aggregation_filter_by_trace_id(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Tests that the trace_id time-range optimization also applies to
non-window-list (time_series / aggregation) logs queries:
1. Wide query window containing the trace returns the correct count.
2. Query window outside the trace's time range short-circuits to an
empty result.
3. A trace_id with no row in trace_summary (e.g. traces disabled) still
returns the matching logs — the lookup miss must not short-circuit
logs aggregation queries.
"""
target_trace_id = TraceIdGenerator.trace_id()
orphan_trace_id = TraceIdGenerator.trace_id()
target_root_span_id = TraceIdGenerator.span_id()
target_child_span_id = TraceIdGenerator.span_id()
orphan_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
common_resources = {
"deployment.environment": "production",
"service.name": "logs-trace-agg-service",
"cloud.provider": "integration",
}
# trace_summary records min/max of span timestamps (it ignores duration),
# so insert two spans to give the trace a recorded window wide enough to
# comfortably contain the log timestamps below.
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_root_span_id,
parent_span_id="",
name="root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
Traces(
timestamp=now - timedelta(seconds=5),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_child_span_id,
parent_span_id=target_root_span_id,
name="child-span",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
]
)
# Two logs for the target trace_id, both inside the recorded trace window.
# One additional log carries an orphan trace_id with no row in
# trace_summary — used to verify that the lookup miss does not
# short-circuit logs aggregations.
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=7),
resources=common_resources,
attributes={},
body="log A inside trace window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=6),
resources=common_resources,
attributes={},
body="log B inside trace window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=2),
resources=common_resources,
attributes={},
body="log with a trace_id absent from trace_summary",
severity_text="INFO",
trace_id=orphan_trace_id,
span_id=orphan_span_id,
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def _count(start_ms: int, end_ms: int, trace_id: str) -> tuple[float, list[str]]:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="time_series",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": f"trace_id = '{trace_id}'"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
warning = (response.json().get("data") or {}).get("warning") or {}
messages = [w.get("message", "") for w in (warning.get("warnings") or [])]
aggregations = results[0].get("aggregations") or []
if not aggregations:
return 0, messages
series = aggregations[0].get("series") or []
if not series:
return 0, messages
return sum(v["value"] for v in series[0]["values"]), messages
outside_range_msg = "lies outside the selected time range"
now_ms = int(now.timestamp() * 1000)
narrow_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
# --- Test 1: wide window (>1 h) containing the trace returns 2 logs ---
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_count, wide_warnings = _count(wide_start_ms, now_ms, target_trace_id)
assert wide_count == 2, f"Expected count=2 for trace_id aggregation (wide window), got {wide_count}"
assert not any(outside_range_msg in m for m in wide_warnings), f"Did not expect outside-range warning, got {wide_warnings}"
# --- Test 2: window outside the trace short-circuits to empty + warning ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_count, past_warnings = _count(past_start_ms, past_end_ms, target_trace_id)
assert past_count == 0, f"Expected count=0 for trace_id aggregation outside time window, got {past_count}"
assert any(outside_range_msg in m for m in past_warnings), f"Expected outside-range warning, got warnings={past_warnings}"
# --- Test 3: trace_id not present in trace_summary still returns logs (no warning) ---
orphan_count, orphan_warnings = _count(narrow_start_ms, now_ms, orphan_trace_id)
assert orphan_count == 1, f"Expected count=1 for orphan trace_id aggregation, got {orphan_count} — query may have been incorrectly short-circuited"
assert not any(outside_range_msg in m for m in orphan_warnings), f"Did not expect outside-range warning for orphan trace_id, got {orphan_warnings}"

View File

@@ -2062,7 +2062,7 @@ def test_traces_list_filter_by_trace_id(
trace_filter = f"trace_id = '{target_trace_id}'"
def _query(start_ms: int, end_ms: int) -> list:
def _query(start_ms: int, end_ms: int) -> tuple[list, list[str]]:
response = make_query_request(
signoz,
token,
@@ -2096,30 +2096,157 @@ def test_traces_list_filter_by_trace_id(
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
return response.json()["data"]["data"]["results"][0]["rows"] or []
rows = response.json()["data"]["data"]["results"][0]["rows"] or []
warning = (response.json().get("data") or {}).get("warning") or {}
messages = [w.get("message", "") for w in (warning.get("warnings") or [])]
return rows, messages
outside_range_msg = "lies outside the selected time range"
now_ms = int(now.timestamp() * 1000)
# --- Test 1: narrow window (single bucket, <1 h) ---
narrow_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
narrow_rows = _query(narrow_start_ms, now_ms)
narrow_rows, narrow_warnings = _query(narrow_start_ms, now_ms)
assert len(narrow_rows) == 1, f"Expected 1 span for trace_id filter (narrow window), got {len(narrow_rows)}"
assert narrow_rows[0]["data"]["span_id"] == span_id_root
assert narrow_rows[0]["data"]["trace_id"] == target_trace_id
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, triggers multiple exponential buckets) ---
# should just return 1 span, not duplicate
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_rows = _query(wide_start_ms, now_ms)
wide_rows, wide_warnings = _query(wide_start_ms, now_ms)
assert len(wide_rows) == 1, f"Expected 1 span for trace_id filter (wide window, multi-bucket), got {len(wide_rows)} — possible duplicate-span regression"
assert wide_rows[0]["data"]["span_id"] == span_id_root
assert wide_rows[0]["data"]["trace_id"] == target_trace_id
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 ---
# --- Test 3: window that does not contain the trace returns no results + warning ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_rows = _query(past_start_ms, past_end_ms)
past_rows, past_warnings = _query(past_start_ms, past_end_ms)
assert len(past_rows) == 0, f"Expected 0 spans for trace_id filter outside time window, got {len(past_rows)}"
assert any(outside_range_msg in m for m in past_warnings), f"Expected outside-range warning, got warnings={past_warnings}"
def test_traces_aggregation_filter_by_trace_id(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Tests that the trace_id time-range optimization also applies to
non-window-list (time_series / aggregation) traces queries:
1. Wide query window containing the trace returns the correct count.
2. Query window outside the trace's time range short-circuits to empty.
3. Filter referencing a trace_id with no row in trace_summary
short-circuits to empty (trace_summary is authoritative for traces).
"""
target_trace_id = TraceIdGenerator.trace_id()
target_root_span_id = TraceIdGenerator.span_id()
target_child_span_id = TraceIdGenerator.span_id()
missing_trace_id = TraceIdGenerator.trace_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
common_resources = {
"deployment.environment": "production",
"service.name": "traces-agg-filter-service",
"cloud.provider": "integration",
}
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=5),
trace_id=target_trace_id,
span_id=target_root_span_id,
parent_span_id="",
name="root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={"http.request.method": "GET"},
),
Traces(
timestamp=now - timedelta(seconds=9),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_child_span_id,
parent_span_id=target_root_span_id,
name="child-span",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def _count(start_ms: int, end_ms: int, trace_id: str) -> tuple[float, list[str]]:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="time_series",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": f"trace_id = '{trace_id}'"},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
warning = (response.json().get("data") or {}).get("warning") or {}
messages = [w.get("message", "") for w in (warning.get("warnings") or [])]
aggregations = results[0].get("aggregations") or []
if not aggregations:
return 0, messages
series = aggregations[0].get("series") or []
if not series:
return 0, messages
return sum(v["value"] for v in series[0]["values"]), messages
outside_range_msg = "lies outside the selected time range"
now_ms = int(now.timestamp() * 1000)
# --- Test 1: wide window (>1 h) containing the trace returns both spans ---
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_count, wide_warnings = _count(wide_start_ms, now_ms, target_trace_id)
assert wide_count == 2, f"Expected count=2 for trace_id aggregation (wide window), got {wide_count}"
assert not any(outside_range_msg in m for m in wide_warnings), f"Did not expect outside-range warning, got {wide_warnings}"
# --- Test 2: window outside the trace short-circuits to empty + warning ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_count, past_warnings = _count(past_start_ms, past_end_ms, target_trace_id)
assert past_count == 0, f"Expected count=0 for trace_id aggregation outside time window, got {past_count}"
assert any(outside_range_msg in m for m in past_warnings), f"Expected outside-range warning, got warnings={past_warnings}"
# --- Test 3: trace_id with no entry in trace_summary short-circuits (no warning) ---
missing_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
missing_count, missing_warnings = _count(missing_start_ms, now_ms, missing_trace_id)
assert missing_count == 0, f"Expected count=0 for trace_id absent from trace_summary, got {missing_count}"
assert not any(outside_range_msg in m for m in missing_warnings), f"Did not expect outside-range warning for missing trace_id, got {missing_warnings}"