Merge branch 'main' of github.com:SigNoz/signoz into fix/prom-aggr

This commit is contained in:
aniket 2025-06-16 17:24:14 +05:30
commit 7f19df18c3
33 changed files with 1807 additions and 73 deletions

7
.github/CODEOWNERS vendored
View File

@ -12,4 +12,9 @@
/pkg/factory/ @grandwizard28
/pkg/types/ @grandwizard28
.golangci.yml @grandwizard28
**/(zeus|licensing|sqlmigration)/ @vikrantgupta25
/pkg/zeus/ @vikrantgupta25
/pkg/licensing/ @vikrantgupta25
/pkg/sqlmigration/ @vikrantgupta25
/ee/zeus/ @vikrantgupta25
/ee/licensing/ @vikrantgupta25
/ee/sqlmigration/ @vikrantgupta25

View File

@ -100,12 +100,18 @@ services:
# - "9000:9000"
# - "8123:8123"
# - "9181:9181"
configs:
- source: clickhouse-config
target: /etc/clickhouse-server/config.xml
- source: clickhouse-users
target: /etc/clickhouse-server/users.xml
- source: clickhouse-custom-function
target: /etc/clickhouse-server/custom-function.xml
- source: clickhouse-cluster
target: /etc/clickhouse-server/config.d/cluster.xml
volumes:
- ../common/clickhouse/config.xml:/etc/clickhouse-server/config.xml
- ../common/clickhouse/users.xml:/etc/clickhouse-server/users.xml
- ../common/clickhouse/custom-function.xml:/etc/clickhouse-server/custom-function.xml
- ../common/clickhouse/user_scripts:/var/lib/clickhouse/user_scripts/
- ../common/clickhouse/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml
- clickhouse:/var/lib/clickhouse/
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
@ -117,9 +123,10 @@ services:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port
volumes:
- ../common/signoz/prometheus.yml:/root/config/prometheus.yml
- ../common/dashboards:/root/config/dashboards
- sqlite:/var/lib/signoz/
configs:
- source: signoz-prometheus-config
target: /root/config/prometheus.yml
environment:
- SIGNOZ_ALERTMANAGER_PROVIDER=signoz
- SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://clickhouse:9000
@ -147,9 +154,11 @@ services:
- --manager-config=/etc/manager-config.yaml
- --copy-path=/var/tmp/collector-config.yaml
- --feature-gates=-pkg.translator.prometheus.NormalizeName
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
- ../common/signoz/otel-collector-opamp-config.yaml:/etc/manager-config.yaml
configs:
- source: otel-collector-config
target: /etc/otel-collector-config.yaml
- source: otel-manager-config
target: /etc/manager-config.yaml
environment:
- OTEL_RESOURCE_ATTRIBUTES=host.name={{.Node.Hostname}},os.type={{.Node.Platform.OS}}
- LOW_CARDINAL_EXCEPTION_GROUPING=false
@ -186,3 +195,26 @@ volumes:
name: signoz-sqlite
zookeeper-1:
name: signoz-zookeeper-1
configs:
clickhouse-config:
file: ../common/clickhouse/config.xml
clickhouse-users:
file: ../common/clickhouse/users.xml
clickhouse-custom-function:
file: ../common/clickhouse/custom-function.xml
clickhouse-cluster:
file: ../common/clickhouse/cluster.xml
signoz-prometheus-config:
file: ../common/signoz/prometheus.yml
# If you have multiple dashboard files, you can list them individually:
# dashboard-foo:
# file: ../common/dashboards/foo.json
# dashboard-bar:
# file: ../common/dashboards/bar.json
otel-collector-config:
file: ./otel-collector-config.yaml
otel-manager-config:
file: ../common/signoz/otel-collector-opamp-config.yaml

View File

@ -6,11 +6,13 @@ import (
"time"
"github.com/SigNoz/signoz/ee/licensing/licensingstore/sqllicensingstore"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/analyticstypes"
"github.com/SigNoz/signoz/pkg/types/licensetypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/pkg/zeus"
@ -23,16 +25,17 @@ type provider struct {
config licensing.Config
settings factory.ScopedProviderSettings
orgGetter organization.Getter
analytics analytics.Analytics
stopChan chan struct{}
}
func NewProviderFactory(store sqlstore.SQLStore, zeus zeus.Zeus, orgGetter organization.Getter) factory.ProviderFactory[licensing.Licensing, licensing.Config] {
func NewProviderFactory(store sqlstore.SQLStore, zeus zeus.Zeus, orgGetter organization.Getter, analytics analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config] {
return factory.NewProviderFactory(factory.MustNewName("http"), func(ctx context.Context, providerSettings factory.ProviderSettings, config licensing.Config) (licensing.Licensing, error) {
return New(ctx, providerSettings, config, store, zeus, orgGetter)
return New(ctx, providerSettings, config, store, zeus, orgGetter, analytics)
})
}
func New(ctx context.Context, ps factory.ProviderSettings, config licensing.Config, sqlstore sqlstore.SQLStore, zeus zeus.Zeus, orgGetter organization.Getter) (licensing.Licensing, error) {
func New(ctx context.Context, ps factory.ProviderSettings, config licensing.Config, sqlstore sqlstore.SQLStore, zeus zeus.Zeus, orgGetter organization.Getter, analytics analytics.Analytics) (licensing.Licensing, error) {
settings := factory.NewScopedProviderSettings(ps, "github.com/SigNoz/signoz/ee/licensing/httplicensing")
licensestore := sqllicensingstore.New(sqlstore)
return &provider{
@ -42,6 +45,7 @@ func New(ctx context.Context, ps factory.ProviderSettings, config licensing.Conf
settings: settings,
orgGetter: orgGetter,
stopChan: make(chan struct{}),
analytics: analytics,
}, nil
}
@ -159,6 +163,25 @@ func (provider *provider) Refresh(ctx context.Context, organizationID valuer.UUI
return err
}
stats := licensetypes.NewStatsFromLicense(activeLicense)
provider.analytics.Send(ctx,
analyticstypes.Track{
UserId: "stats_" + organizationID.String(),
Event: "License Updated",
Properties: analyticstypes.NewPropertiesFromMap(stats),
Context: &analyticstypes.Context{
Extra: map[string]interface{}{
analyticstypes.KeyGroupID: organizationID.String(),
},
},
},
analyticstypes.Group{
UserId: "stats_" + organizationID.String(),
GroupId: organizationID.String(),
Traits: analyticstypes.NewTraitsFromMap(stats),
},
)
return nil
}

View File

@ -12,6 +12,7 @@ import (
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
"github.com/SigNoz/signoz/ee/zeus"
"github.com/SigNoz/signoz/ee/zeus/httpzeus"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/config"
"github.com/SigNoz/signoz/pkg/config/envprovider"
"github.com/SigNoz/signoz/pkg/config/fileprovider"
@ -134,8 +135,8 @@ func main() {
zeus.Config(),
httpzeus.NewProviderFactory(),
licensing.Config(24*time.Hour, 3),
func(sqlstore sqlstore.SQLStore, zeus pkgzeus.Zeus, orgGetter organization.Getter) factory.ProviderFactory[pkglicensing.Licensing, pkglicensing.Config] {
return httplicensing.NewProviderFactory(sqlstore, zeus, orgGetter)
func(sqlstore sqlstore.SQLStore, zeus pkgzeus.Zeus, orgGetter organization.Getter, analytics analytics.Analytics) factory.ProviderFactory[pkglicensing.Licensing, pkglicensing.Config] {
return httplicensing.NewProviderFactory(sqlstore, zeus, orgGetter, analytics)
},
signoz.NewEmailingProviderFactories(),
signoz.NewCacheProviderFactories(),

View File

@ -78,7 +78,7 @@
"fontfaceobserver": "2.3.0",
"history": "4.10.1",
"html-webpack-plugin": "5.5.0",
"http-proxy-middleware": "3.0.3",
"http-proxy-middleware": "3.0.5",
"http-status-codes": "2.3.0",
"i18next": "^21.6.12",
"i18next-browser-languagedetector": "^6.1.3",
@ -250,7 +250,7 @@
"xml2js": "0.5.0",
"phin": "^3.7.1",
"body-parser": "1.20.3",
"http-proxy-middleware": "3.0.3",
"http-proxy-middleware": "3.0.5",
"cross-spawn": "7.0.5",
"cookie": "^0.7.1",
"serialize-javascript": "6.0.2",

View File

@ -15,6 +15,7 @@ import { matchPath, useLocation } from 'react-router-dom';
import { SuccessResponseV2 } from 'types/api';
import APIError from 'types/api/error';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
import { OrgPreference } from 'types/api/preferences/preference';
import { Organization } from 'types/api/user/getOrganization';
import { UserResponse } from 'types/api/user/getUser';
import { USER_ROLES } from 'types/roles';
@ -96,8 +97,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
usersData.data
) {
const isOnboardingComplete = orgPreferences?.find(
(preference: Record<string, any>) =>
preference.key === ORG_PREFERENCES.ORG_ONBOARDING,
(preference: OrgPreference) =>
preference.name === ORG_PREFERENCES.ORG_ONBOARDING,
)?.value;
const isFirstUser = checkFirstTimeUser();

View File

@ -85,7 +85,13 @@ function LabelSelect({
}, [handleBlur]);
const handleLabelChange = (event: ChangeEvent<HTMLInputElement>): void => {
setCurrentVal(event.target?.value.replace(':', ''));
// Remove the colon if it's the last character.
// As the colon is used to separate the key and value in the query.
setCurrentVal(
event.target?.value.endsWith(':')
? event.target?.value.slice(0, -1)
: event.target?.value,
);
};
const handleClose = (key: string): void => {

View File

@ -18,6 +18,27 @@
background-color: yellow;
border-radius: 6px;
cursor: pointer;
.event-dot {
position: absolute;
top: 50%;
transform: translate(-50%, -50%) rotate(45deg);
width: 6px;
height: 6px;
background-color: var(--bg-robin-500);
border: 1px solid var(--bg-robin-600);
cursor: pointer;
z-index: 1;
&.error {
background-color: var(--bg-cherry-500);
border-color: var(--bg-cherry-600);
}
&:hover {
transform: translate(-50%, -50%) rotate(45deg) scale(1.5);
}
}
}
}

View File

@ -6,6 +6,7 @@ import { Tooltip } from 'antd';
import Color from 'color';
import TimelineV2 from 'components/TimelineV2/TimelineV2';
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import {
@ -20,6 +21,7 @@ import { useHistory, useLocation } from 'react-router-dom';
import { ListRange, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
interface ITraceMetadata {
startTime: number;
@ -91,7 +93,31 @@ function Success(props: ISuccessProps): JSX.Element {
searchParams.set('spanId', span.spanId);
history.replace({ search: searchParams.toString() });
}}
/>
>
{span.event?.map((event) => {
const eventTimeMs = event.timeUnixNano / 1e6;
const eventOffsetPercent =
((eventTimeMs - span.timestamp) / (span.durationNano / 1e6)) * 100;
const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99));
const { isError } = event;
const { time, timeUnitName } = convertTimeToRelevantUnit(
eventTimeMs - span.timestamp,
);
return (
<Tooltip
key={`${span.spanId}-event-${event.name}-${event.timeUnixNano}`}
title={`${event.name} @ ${toFixed(time, 2)} ${timeUnitName}`}
>
<div
className={`event-dot ${isError ? 'error' : ''}`}
style={{
left: `${clampedOffset}%`,
}}
/>
</Tooltip>
);
})}
</div>
</Tooltip>
);
})}

View File

@ -56,7 +56,7 @@
gap: 6px;
.diamond {
fill: var(--bg-cherry-500);
fill: var(--bg-robin-500);
}
}
}

View File

@ -3,8 +3,8 @@ import './Events.styles.scss';
import { Collapse, Input, Tooltip, Typography } from 'antd';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { Diamond } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Event, Span } from 'types/api/trace/getTraceV2';
import { useState } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import NoData from '../NoData/NoData';
@ -17,14 +17,7 @@ interface IEventsTableProps {
function EventsTable(props: IEventsTableProps): JSX.Element {
const { span, startTime, isSearchVisible } = props;
const [fieldSearchInput, setFieldSearchInput] = useState<string>('');
const events: Event[] = useMemo(() => {
const tempEvents = [];
for (let i = 0; i < span.event?.length; i++) {
const parsedEvent = JSON.parse(span.event[i]);
tempEvents.push(parsedEvent);
}
return tempEvents;
}, [span.event]);
const events = span.event;
return (
<div className="events-table">
@ -81,7 +74,18 @@ function EventsTable(props: IEventsTableProps): JSX.Element {
)}
</Typography.Text>
<Typography.Text className="timestamp-text">
after the start
since trace start
</Typography.Text>
</div>
<div className="timestamp-container">
<Typography.Text className="attribute-value">
{getYAxisFormattedValue(
`${(event.timeUnixNano || 0) / 1e6 - span.timestamp}`,
'ms',
)}
</Typography.Text>
<Typography.Text className="timestamp-text">
since span start
</Typography.Text>
</div>
</div>

View File

@ -0,0 +1,71 @@
.no-linked-spans {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
}
.linked-spans-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
.item {
display: flex;
flex-direction: column;
gap: 8px;
justify-content: flex-start;
.item-key {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.value-wrapper {
display: flex;
padding: 2px 8px;
align-items: center;
width: fit-content;
max-width: 100%;
gap: 8px;
border-radius: 50px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-slate-500);
.item-value {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: 0.56px;
}
}
}
}
.lightMode {
.linked-spans-container {
.item {
.item-key {
color: var(--bg-ink-100);
}
.value-wrapper {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
.item-value {
color: var(--bg-ink-400);
}
}
}
}
}

View File

@ -0,0 +1,77 @@
import './LinkedSpans.styles.scss';
import { Button, Tooltip, Typography } from 'antd';
import ROUTES from 'constants/routes';
import { formUrlParams } from 'container/TraceDetail/utils';
import { useCallback } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import NoData from '../NoData/NoData';
interface LinkedSpansProps {
span: Span;
}
interface SpanReference {
traceId: string;
spanId: string;
refType: string;
}
function LinkedSpans(props: LinkedSpansProps): JSX.Element {
const { span } = props;
const getLink = useCallback((item: SpanReference): string | null => {
if (!item.traceId || !item.spanId) {
return null;
}
return `${ROUTES.TRACE}/${item.traceId}${formUrlParams({
spanId: item.spanId,
levelUp: 0,
levelDown: 0,
})}`;
}, []);
// Filter out CHILD_OF references as they are parent-child relationships
const linkedSpans =
span.references?.filter((ref: SpanReference) => ref.refType !== 'CHILD_OF') ||
[];
if (linkedSpans.length === 0) {
return (
<div className="no-linked-spans">
<NoData name="linked spans" />
</div>
);
}
return (
<div className="linked-spans-container">
{linkedSpans.map((item: SpanReference) => {
const link = getLink(item);
return (
<div className="item" key={item.spanId}>
<Typography.Text className="item-key" ellipsis>
Linked Span ID
</Typography.Text>
<div className="value-wrapper">
<Tooltip title={item.spanId}>
{link ? (
<Typography.Link href={link} className="item-value" ellipsis>
{item.spanId}
</Typography.Link>
) : (
<Button type="link" className="item-value" disabled>
{item.spanId}
</Button>
)}
</Tooltip>
</div>
</div>
);
})}
</div>
);
}
export default LinkedSpans;

View File

@ -158,20 +158,29 @@
border-bottom: 1px solid var(--bg-slate-400) !important;
}
.attributes-tab-btn {
.ant-tabs-tab {
margin: 0 !important;
padding: 0 2px !important;
min-width: 36px;
height: 36px;
display: flex;
align-items: center;
}
.attributes-tab-btn:hover {
background: unset;
justify-content: center;
}
.events-tab-btn {
.attributes-tab-btn,
.events-tab-btn,
.linked-spans-tab-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 4px 8px;
}
.events-tab-btn:hover {
.attributes-tab-btn:hover,
.events-tab-btn:hover,
.linked-spans-tab-btn:hover {
background: unset;
}
}
@ -261,3 +270,9 @@
}
}
}
.linked-spans-tab-btn {
display: flex;
align-items: center;
gap: 0.5rem;
}

View File

@ -10,13 +10,14 @@ import { getTraceToLogsQuery } from 'container/TraceDetail/SelectedSpanDetails/c
import createQueryParams from 'lib/createQueryParams';
import history from 'lib/history';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { Anvil, Bookmark, PanelRight, Search } from 'lucide-react';
import { Anvil, Bookmark, Link2, PanelRight, Search } from 'lucide-react';
import { Dispatch, SetStateAction, useState } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import { formatEpochTimestamp } from 'utils/timeUtils';
import Attributes from './Attributes/Attributes';
import Events from './Events/Events';
import LinkedSpans from './LinkedSpans/LinkedSpans';
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
interface ISpanDetailsDrawerProps {
@ -74,6 +75,19 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
/>
),
},
{
label: (
<Button
type="text"
icon={<Link2 size="14" />}
className="linked-spans-tab-btn"
>
Links
</Button>
),
key: 'linked-spans',
children: <LinkedSpans span={span} />,
},
];
}
const onLogsHandler = (): void => {

View File

@ -273,6 +273,27 @@
border-radius: 6px;
}
.event-dot {
position: absolute;
top: 50%;
transform: translate(-50%, -50%) rotate(45deg);
width: 6px;
height: 6px;
background-color: var(--bg-robin-500);
border: 1px solid var(--bg-robin-600);
cursor: pointer;
z-index: 1;
&.error {
background-color: var(--bg-cherry-500);
border-color: var(--bg-cherry-600);
}
&:hover {
transform: translate(-50%, -50%) rotate(45deg) scale(1.5);
}
}
.span-line-text {
position: relative;
top: 40%;

View File

@ -240,8 +240,33 @@ export function SpanDuration({
left: `${leftOffset}%`,
width: `${width}%`,
backgroundColor: color,
position: 'relative',
}}
/>
>
{span.event?.map((event) => {
const eventTimeMs = event.timeUnixNano / 1e6;
const eventOffsetPercent =
((eventTimeMs - span.timestamp) / (span.durationNano / 1e6)) * 100;
const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99));
const { isError } = event;
const { time, timeUnitName } = convertTimeToRelevantUnit(
eventTimeMs - span.timestamp,
);
return (
<Tooltip
key={`${span.spanId}-event-${event.name}-${event.timeUnixNano}`}
title={`${event.name} @ ${toFixed(time, 2)} ${timeUnitName}`}
>
<div
className={`event-dot ${isError ? 'error' : ''}`}
style={{
left: `${clampedOffset}%`,
}}
/>
</Tooltip>
);
})}
</div>
{hasActionButtons && <SpanLineActionButtons span={span} />}
<Tooltip title={`${toFixed(time, 2)} ${timeUnitName}`}>
<Typography.Text

View File

@ -7,6 +7,13 @@ export interface GetTraceFlamegraphPayloadProps {
selectedSpanId: string;
}
export interface Event {
name: string;
timeUnixNano: number;
attributeMap: Record<string, string>;
isError: boolean;
}
export interface FlamegraphSpan {
timestamp: number;
durationNano: number;
@ -17,6 +24,7 @@ export interface FlamegraphSpan {
serviceName: string;
name: string;
level: number;
event: Event[];
}
export interface GetTraceFlamegraphSuccessResponse {

View File

@ -13,6 +13,7 @@ export interface Event {
name: string;
timeUnixNano: number;
attributeMap: Record<string, string>;
isError: boolean;
}
export interface Span {
timestamp: number;
@ -27,7 +28,7 @@ export interface Span {
name: string;
references: any;
tagMap: Record<string, string>;
event: string[];
event: Event[];
rootName: string;
statusMessage: string;
statusCodeString: string;

View File

@ -9966,10 +9966,10 @@ http-proxy-agent@^4.0.1:
agent-base "6"
debug "4"
http-proxy-middleware@3.0.3, http-proxy-middleware@^2.0.7:
version "3.0.3"
resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-3.0.3.tgz#dc1313c75bd00d81e103823802551ee30130ebd1"
integrity sha512-usY0HG5nyDUwtqpiZdETNbmKtw3QQ1jwYFZ9wi5iHzX2BcILwQKtYDJPo7XHTsu5Z0B2Hj3W9NNnbd+AjFWjqg==
http-proxy-middleware@3.0.5, http-proxy-middleware@^2.0.7:
version "3.0.5"
resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz#9dcde663edc44079bc5a9c63e03fe5e5d6037fab"
integrity sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==
dependencies:
"@types/http-proxy" "^1.17.15"
debug "^4.3.6"

View File

@ -867,7 +867,7 @@ func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadataCache(ctx contex
return cachedTraceData, nil
}
func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Context, orgID valuer.UUID, traceID string, req *model.GetWaterfallSpansForTraceWithMetadataParams) (*model.GetWaterfallSpansForTraceWithMetadataResponse, *model.ApiError) {
func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Context, orgID valuer.UUID, traceID string, req *model.GetWaterfallSpansForTraceWithMetadataParams) (*model.GetWaterfallSpansForTraceWithMetadataResponse, error) {
response := new(model.GetWaterfallSpansForTraceWithMetadataResponse)
var startTime, endTime, durationNano, totalErrorSpans, totalSpans uint64
var spanIdToSpanNodeMap = map[string]*model.Span{}
@ -916,7 +916,7 @@ func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Con
err := json.Unmarshal([]byte(item.References), &ref)
if err != nil {
zap.L().Error("getWaterfallSpansForTraceWithMetadata: error unmarshalling references", zap.Error(err), zap.String("traceID", traceID))
return nil, model.BadRequest(fmt.Errorf("getWaterfallSpansForTraceWithMetadata: error unmarshalling references %w", err))
return nil, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, "getWaterfallSpansForTraceWithMetadata: error unmarshalling references %s", err.Error())
}
// merge attributes_number and attributes_bool to attributes_string
@ -930,6 +930,17 @@ func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Con
item.Attributes_string[k] = v
}
events := make([]model.Event, 0)
for _, event := range item.Events {
var eventMap model.Event
err = json.Unmarshal([]byte(event), &eventMap)
if err != nil {
zap.L().Error("Error unmarshalling events", zap.Error(err))
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "getWaterfallSpansForTraceWithMetadata: error in unmarshalling events %s", err.Error())
}
events = append(events, eventMap)
}
jsonItem := model.Span{
SpanID: item.SpanID,
TraceID: item.TraceID,
@ -942,7 +953,7 @@ func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Con
StatusCodeString: item.StatusCodeString,
SpanKind: item.SpanKind,
References: ref,
Events: item.Events,
Events: events,
TagMap: item.Attributes_string,
Children: make([]*model.Span, 0),
}
@ -998,6 +1009,7 @@ func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Con
StatusMessage: "",
StatusCodeString: "",
SpanKind: "",
Events: make([]model.Event, 0),
Children: make([]*model.Span, 0),
}
missingSpan.Children = append(missingSpan.Children, spanNode)
@ -1075,7 +1087,7 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTraceCache(ctx context.Context,
return cachedTraceData, nil
}
func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID valuer.UUID, traceID string, req *model.GetFlamegraphSpansForTraceParams) (*model.GetFlamegraphSpansForTraceResponse, *model.ApiError) {
func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID valuer.UUID, traceID string, req *model.GetFlamegraphSpansForTraceParams) (*model.GetFlamegraphSpansForTraceResponse, error) {
trace := new(model.GetFlamegraphSpansForTraceResponse)
var startTime, endTime, durationNano uint64
var spanIdToSpanNodeMap = map[string]*model.FlamegraphSpan{}
@ -1097,7 +1109,7 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID
if err != nil {
zap.L().Info("cache miss for getFlamegraphSpansForTrace", zap.String("traceID", traceID))
searchScanResponses, err := r.GetSpansForTrace(ctx, traceID, fmt.Sprintf("SELECT timestamp, duration_nano, span_id, trace_id, has_error,references, resource_string_service$$name, name FROM %s.%s WHERE trace_id=$1 and ts_bucket_start>=$2 and ts_bucket_start<=$3 ORDER BY timestamp ASC, name ASC", r.TraceDB, r.traceTableName))
searchScanResponses, err := r.GetSpansForTrace(ctx, traceID, fmt.Sprintf("SELECT timestamp, duration_nano, span_id, trace_id, has_error,references, resource_string_service$$name, name, events FROM %s.%s WHERE trace_id=$1 and ts_bucket_start>=$2 and ts_bucket_start<=$3 ORDER BY timestamp ASC, name ASC", r.TraceDB, r.traceTableName))
if err != nil {
return nil, err
}
@ -1111,7 +1123,18 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID
err := json.Unmarshal([]byte(item.References), &ref)
if err != nil {
zap.L().Error("Error unmarshalling references", zap.Error(err))
return nil, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error in unmarshalling references: %w", err)}
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "getFlamegraphSpansForTrace: error in unmarshalling references %s", err.Error())
}
events := make([]model.Event, 0)
for _, event := range item.Events {
var eventMap model.Event
err = json.Unmarshal([]byte(event), &eventMap)
if err != nil {
zap.L().Error("Error unmarshalling events", zap.Error(err))
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "getFlamegraphSpansForTrace: error in unmarshalling events %s", err.Error())
}
events = append(events, eventMap)
}
jsonItem := model.FlamegraphSpan{
@ -1122,6 +1145,7 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID
DurationNano: item.DurationNano,
HasError: item.HasError,
References: ref,
Events: events,
Children: make([]*model.FlamegraphSpan, 0),
}
@ -1160,6 +1184,7 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID
TimeUnixNano: spanNode.TimeUnixNano,
DurationNano: spanNode.DurationNano,
HasError: false,
Events: make([]model.Event, 0),
Children: make([]*model.FlamegraphSpan, 0),
}
missingSpan.Children = append(missingSpan.Children, spanNode)

View File

@ -1787,7 +1787,7 @@ func (aH *APIHandler) GetWaterfallSpansForTraceWithMetadata(w http.ResponseWrite
result, apiErr := aH.reader.GetWaterfallSpansForTraceWithMetadata(r.Context(), orgID, traceID, req)
if apiErr != nil {
RespondError(w, apiErr, nil)
render.Error(w, apiErr)
return
}
@ -1821,7 +1821,7 @@ func (aH *APIHandler) GetFlamegraphSpansForTrace(w http.ResponseWriter, r *http.
result, apiErr := aH.reader.GetFlamegraphSpansForTrace(r.Context(), orgID, traceID, req)
if apiErr != nil {
RespondError(w, apiErr, nil)
render.Error(w, apiErr)
return
}

View File

@ -42,8 +42,8 @@ type Reader interface {
// Search Interfaces
SearchTraces(ctx context.Context, params *model.SearchTracesParams) (*[]model.SearchSpansResult, error)
GetWaterfallSpansForTraceWithMetadata(ctx context.Context, orgID valuer.UUID, traceID string, req *model.GetWaterfallSpansForTraceWithMetadataParams) (*model.GetWaterfallSpansForTraceWithMetadataResponse, *model.ApiError)
GetFlamegraphSpansForTrace(ctx context.Context, orgID valuer.UUID, traceID string, req *model.GetFlamegraphSpansForTraceParams) (*model.GetFlamegraphSpansForTraceResponse, *model.ApiError)
GetWaterfallSpansForTraceWithMetadata(ctx context.Context, orgID valuer.UUID, traceID string, req *model.GetWaterfallSpansForTraceWithMetadataParams) (*model.GetWaterfallSpansForTraceWithMetadataResponse, error)
GetFlamegraphSpansForTrace(ctx context.Context, orgID valuer.UUID, traceID string, req *model.GetFlamegraphSpansForTraceParams) (*model.GetFlamegraphSpansForTraceResponse, error)
// Setter Interfaces
SetTTL(ctx context.Context, orgID string, ttlParams *model.TTLParams) (*model.SetTTLResponseItem, *model.ApiError)

View File

@ -6,6 +6,7 @@ import (
"os"
"time"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/config"
"github.com/SigNoz/signoz/pkg/config/envprovider"
"github.com/SigNoz/signoz/pkg/config/fileprovider"
@ -122,7 +123,7 @@ func main() {
zeus.Config{},
noopzeus.NewProviderFactory(),
licensing.Config{},
func(_ sqlstore.SQLStore, _ zeus.Zeus, _ organization.Getter) factory.ProviderFactory[licensing.Licensing, licensing.Config] {
func(_ sqlstore.SQLStore, _ zeus.Zeus, _ organization.Getter, _ analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config] {
return nooplicensing.NewFactory()
},
signoz.NewEmailingProviderFactories(),

View File

@ -288,7 +288,7 @@ type Span struct {
Name string `json:"name"`
References []OtelSpanRef `json:"references,omitempty"`
TagMap map[string]string `json:"tagMap"`
Events []string `json:"event"`
Events []Event `json:"event"`
RootName string `json:"rootName"`
StatusMessage string `json:"statusMessage"`
StatusCodeString string `json:"statusCodeString"`
@ -311,6 +311,7 @@ type FlamegraphSpan struct {
ServiceName string `json:"serviceName"`
Name string `json:"name"`
Level int64 `json:"level"`
Events []Event `json:"event"`
References []OtelSpanRef `json:"references,omitempty"`
Children []*FlamegraphSpan `json:"children"`
}

View File

@ -4,6 +4,7 @@ import (
"context"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/factory"
@ -54,7 +55,7 @@ func New(
zeusConfig zeus.Config,
zeusProviderFactory factory.ProviderFactory[zeus.Zeus, zeus.Config],
licenseConfig licensing.Config,
licenseProviderFactory func(sqlstore.SQLStore, zeus.Zeus, organization.Getter) factory.ProviderFactory[licensing.Licensing, licensing.Config],
licenseProviderFactory func(sqlstore.SQLStore, zeus.Zeus, organization.Getter, analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config],
emailingProviderFactories factory.NamedMap[factory.ProviderFactory[emailing.Emailing, emailing.Config]],
cacheProviderFactories factory.NamedMap[factory.ProviderFactory[cache.Cache, cache.Config]],
webProviderFactories factory.NamedMap[factory.ProviderFactory[web.Web, web.Config]],
@ -235,7 +236,7 @@ func New(
return nil, err
}
licensingProviderFactory := licenseProviderFactory(sqlstore, zeus, orgGetter)
licensingProviderFactory := licenseProviderFactory(sqlstore, zeus, orgGetter, analytics)
licensing, err := licensingProviderFactory.New(
ctx,
providerSettings,

View File

@ -216,5 +216,20 @@ func (provider *provider) collectOrg(ctx context.Context, orgID valuer.UUID) map
stats["telemetry.metrics.count"] = metrics
}
var tracesLastSeenAt time.Time
if err := provider.telemetryStore.ClickhouseDB().QueryRow(ctx, "SELECT max(timestamp) FROM signoz_traces.distributed_signoz_index_v3").Scan(&tracesLastSeenAt); err == nil {
stats["telemetry.traces.last_observed.time"] = tracesLastSeenAt.UTC()
}
var logsLastSeenAt time.Time
if err := provider.telemetryStore.ClickhouseDB().QueryRow(ctx, "SELECT fromUnixTimestamp64Nano(max(timestamp)) FROM signoz_logs.distributed_logs_v2").Scan(&logsLastSeenAt); err == nil {
stats["telemetry.logs.last_observed.time"] = logsLastSeenAt.UTC()
}
var metricsLastSeenAt time.Time
if err := provider.telemetryStore.ClickhouseDB().QueryRow(ctx, "SELECT toDateTime(max(unix_milli) / 1000) FROM signoz_metrics.distributed_samples_v4").Scan(&metricsLastSeenAt); err == nil {
stats["telemetry.metrics.last_observed.time"] = metricsLastSeenAt.UTC()
}
return stats
}

View File

@ -32,6 +32,8 @@ type License struct {
PlanName valuer.String
Features []*Feature
Status valuer.String
State string
FreeUntil time.Time
ValidFrom int64
ValidUntil int64
CreatedAt time.Time
@ -165,6 +167,21 @@ func NewLicense(data []byte, organizationID valuer.UUID) (*License, error) {
planName = PlanNameBasic
}
state, err := extractKeyFromMapStringInterface[string](licenseData, "state")
if err != nil {
state = ""
}
freeUntilStr, err := extractKeyFromMapStringInterface[string](licenseData, "free_until")
if err != nil {
freeUntilStr = ""
}
freeUntil, err := time.Parse(time.RFC3339, freeUntilStr)
if err != nil {
freeUntil = time.Time{}
}
featuresFromZeus := make([]*Feature, 0)
if _features, ok := licenseData["features"]; ok {
featuresData, err := json.Marshal(_features)
@ -224,6 +241,8 @@ func NewLicense(data []byte, organizationID valuer.UUID) (*License, error) {
ValidFrom: validFrom,
ValidUntil: validUntil,
Status: status,
State: state,
FreeUntil: freeUntil,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
LastValidatedAt: time.Now(),
@ -306,6 +325,21 @@ func NewLicenseFromStorableLicense(storableLicense *StorableLicense) (*License,
}
validUntil := int64(_validUntil)
state, err := extractKeyFromMapStringInterface[string](storableLicense.Data, "state")
if err != nil {
state = ""
}
freeUntilStr, err := extractKeyFromMapStringInterface[string](storableLicense.Data, "free_until")
if err != nil {
freeUntilStr = ""
}
freeUntil, err := time.Parse(time.RFC3339, freeUntilStr)
if err != nil {
freeUntil = time.Time{}
}
return &License{
ID: storableLicense.ID,
Key: storableLicense.Key,
@ -315,6 +349,8 @@ func NewLicenseFromStorableLicense(storableLicense *StorableLicense) (*License,
ValidFrom: validFrom,
ValidUntil: validUntil,
Status: status,
State: state,
FreeUntil: freeUntil,
CreatedAt: storableLicense.CreatedAt,
UpdatedAt: storableLicense.UpdatedAt,
LastValidatedAt: storableLicense.LastValidatedAt,
@ -325,8 +361,10 @@ func NewLicenseFromStorableLicense(storableLicense *StorableLicense) (*License,
func NewStatsFromLicense(license *License) map[string]any {
return map[string]any{
"license.plan": license.PlanName.StringValue(),
"license.id": license.ID.StringValue(),
"license.id": license.ID.StringValue(),
"license.plan.name": license.PlanName.StringValue(),
"license.state.name": license.State,
"license.free_until.time": license.FreeUntil.UTC(),
}
}

View File

@ -74,7 +74,7 @@ func TestNewLicenseV3(t *testing.T) {
},
{
name: "Parse the entire license properly",
data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{"name":"ENTERPRISE"},"valid_from": 1730899309,"valid_until": -1}`),
data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{"name":"ENTERPRISE"},"valid_from": 1730899309,"valid_until": -1,"state":"test","free_until":"2025-05-16T11:17:48.124202Z"}`),
pass: true,
expected: &License{
ID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"),
@ -87,11 +87,15 @@ func TestNewLicenseV3(t *testing.T) {
"status": "ACTIVE",
"valid_from": float64(1730899309),
"valid_until": float64(-1),
"state": "test",
"free_until": "2025-05-16T11:17:48.124202Z",
},
PlanName: PlanNameEnterprise,
ValidFrom: 1730899309,
ValidUntil: -1,
Status: valuer.NewString("ACTIVE"),
State: "test",
FreeUntil: time.Date(2025, 5, 16, 11, 17, 48, 124202000, time.UTC),
Features: make([]*Feature, 0),
OrganizationID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"),
},

View File

@ -12,6 +12,7 @@ var (
QueryTypeFormula = QueryType{valuer.NewString("builder_formula")}
QueryTypeSubQuery = QueryType{valuer.NewString("builder_sub_query")}
QueryTypeJoin = QueryType{valuer.NewString("builder_join")}
QueryTypeTraceOperator = QueryType{valuer.NewString("builder_trace_operator")}
QueryTypeClickHouseSQL = QueryType{valuer.NewString("clickhouse_sql")}
QueryTypePromQL = QueryType{valuer.NewString("promql")}
)

View File

@ -74,6 +74,13 @@ func (q *QueryEnvelope) UnmarshalJSON(data []byte) error {
}
q.Spec = spec
case QueryTypeTraceOperator:
var spec QueryBuilderTraceOperator
if err := json.Unmarshal(shadow.Spec, &spec); err != nil {
return errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "invalid trace operator spec")
}
q.Spec = spec
case QueryTypePromQL:
var spec PromQuery
if err := json.Unmarshal(shadow.Spec, &spec); err != nil {

View File

@ -102,6 +102,341 @@ func TestQueryRangeRequest_UnmarshalJSON(t *testing.T) {
},
wantErr: false,
},
{
name: "valid trace operator query with simple expression",
jsonData: `{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"filter": {
"expression": "service.name = 'checkoutservice'"
}
}
},
{
"type": "builder_trace_operator",
"spec": {
"name": "trace_flow_analysis",
"expression": "A => B",
"filter": {
"expression": "trace_duration > 200ms AND span_count >= 5"
},
"orderBy": [{
"key": {
"name": "trace_duration"
},
"direction": "desc"
}],
"limit": 100,
"cursor": "eyJsYXN0X3RyYWNlX2lkIjoiYWJjZGVmIn0="
}
}
]
},
"variables": {
"service": "frontend"
}
}`,
expected: QueryRangeRequest{
SchemaVersion: "v1",
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Filter: &Filter{
Expression: "service.name = 'checkoutservice'",
},
},
},
{
Type: QueryTypeTraceOperator,
Spec: QueryBuilderTraceOperator{
Name: "trace_flow_analysis",
Expression: "A => B",
Filter: &Filter{
Expression: "trace_duration > 200ms AND span_count >= 5",
},
Order: []OrderBy{{
Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "trace_duration"}},
Direction: OrderDirectionDesc,
}},
Limit: 100,
Cursor: "eyJsYXN0X3RyYWNlX2lkIjoiYWJjZGVmIn0=",
},
},
},
},
Variables: map[string]any{
"service": "frontend",
},
},
wantErr: false,
},
{
name: "valid trace operator with complex expression and span_count ordering",
jsonData: `{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"filter": { "expression": "service.name = 'frontend'" }
}
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "traces",
"filter": { "expression": "hasError = true" }
}
},
{
"type": "builder_query",
"spec": {
"name": "C",
"signal": "traces",
"filter": { "expression": "response_status_code = '200'" }
}
},
{
"type": "builder_trace_operator",
"spec": {
"name": "complex_trace_analysis",
"expression": "A => (B && NOT C)",
"filter": { "expression": "trace_duration BETWEEN 100ms AND 5s AND span_count IN (5, 10, 15)" },
"orderBy": [{
"key": { "name": "span_count" },
"direction": "asc"
}],
"limit": 50,
"functions": [{ "name": "absolute", "args": [] }]
}
}
]
}
}`,
expected: QueryRangeRequest{
SchemaVersion: "v1",
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Filter: &Filter{Expression: "service.name = 'frontend'"},
},
},
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "B",
Signal: telemetrytypes.SignalTraces,
Filter: &Filter{Expression: "hasError = true"},
},
},
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "C",
Signal: telemetrytypes.SignalTraces,
Filter: &Filter{Expression: "response_status_code = '200'"},
},
},
{
Type: QueryTypeTraceOperator,
Spec: QueryBuilderTraceOperator{
Name: "complex_trace_analysis",
Expression: "A => (B && NOT C)",
Filter: &Filter{Expression: "trace_duration BETWEEN 100ms AND 5s AND span_count IN (5, 10, 15)"},
Order: []OrderBy{{
Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: OrderBySpanCount.StringValue()}},
Direction: OrderDirectionAsc,
}},
Limit: 50,
Functions: []Function{{Name: FunctionNameAbsolute, Args: []FunctionArg{}}},
},
},
}},
},
wantErr: false,
},
{
name: "valid trace operator with NOT expression",
jsonData: `{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"filter": {
"expression": "service.name = 'frontend'"
}
}
},
{
"type": "builder_trace_operator",
"spec": {
"name": "not_trace_analysis",
"expression": "NOT A",
"filter": {
"expression": "trace_duration < 1s"
},
"disabled": false
}
}
]
}
}`,
expected: QueryRangeRequest{
SchemaVersion: "v1",
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Filter: &Filter{
Expression: "service.name = 'frontend'",
},
},
},
{
Type: QueryTypeTraceOperator,
Spec: QueryBuilderTraceOperator{
Name: "not_trace_analysis",
Expression: "NOT A",
Filter: &Filter{
Expression: "trace_duration < 1s",
},
Disabled: false,
},
},
},
},
},
wantErr: false,
},
{
name: "trace operator with binary NOT (exclusion)",
jsonData: `{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"filter": {
"expression": "service.name = 'frontend'"
}
}
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "traces",
"filter": {
"expression": "hasError = true"
}
}
},
{
"type": "builder_trace_operator",
"spec": {
"name": "exclusion_analysis",
"expression": "A NOT B",
"filter": {
"expression": "span_count > 3"
},
"limit": 75
}
}
]
}
}`,
expected: QueryRangeRequest{
SchemaVersion: "v1",
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Filter: &Filter{
Expression: "service.name = 'frontend'",
},
},
},
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "B",
Signal: telemetrytypes.SignalTraces,
Filter: &Filter{
Expression: "hasError = true",
},
},
},
{
Type: QueryTypeTraceOperator,
Spec: QueryBuilderTraceOperator{
Name: "exclusion_analysis",
Expression: "A NOT B",
Filter: &Filter{
Expression: "span_count > 3",
},
Limit: 75,
},
},
},
},
},
wantErr: false,
},
{
name: "valid log builder query",
jsonData: `{
@ -120,8 +455,8 @@ func TestQueryRangeRequest_UnmarshalJSON(t *testing.T) {
"expression": "severity_text = 'ERROR'"
},
"selectFields": [{
"key": "body",
"type": "log"
"name": "body",
"fieldContext": "log"
}],
"limit": 50,
"offset": 10
@ -177,8 +512,8 @@ func TestQueryRangeRequest_UnmarshalJSON(t *testing.T) {
}],
"stepInterval": 120,
"groupBy": [{
"key": "method",
"type": "tag"
"name": "method",
"fieldContext": "attribute"
}]
}
}]
@ -270,7 +605,7 @@ func TestQueryRangeRequest_UnmarshalJSON(t *testing.T) {
"name": "error_rate",
"expression": "A / B * 100",
"functions": [{
"name": "cut_off_min",
"name": "cutOffMin",
"args": [{
"value": "0.3"
}]
@ -436,7 +771,6 @@ func TestQueryRangeRequest_UnmarshalJSON(t *testing.T) {
}
},
{
"name": "B",
"type": "builder_formula",
"spec": {
"name": "rate",
@ -526,7 +860,6 @@ func TestQueryRangeRequest_UnmarshalJSON(t *testing.T) {
"requestType": "time_series",
"compositeQuery": {
"queries": [{
"name": "A",
"type": "unknown_type",
"spec": {}
}]
@ -543,9 +876,9 @@ func TestQueryRangeRequest_UnmarshalJSON(t *testing.T) {
"requestType": "time_series",
"compositeQuery": {
"queries": [{
"name": "A",
"type": "builder_query",
"spec": {
"name": "A",
"signal": "unknown_signal",
"aggregations": []
}
@ -563,9 +896,9 @@ func TestQueryRangeRequest_UnmarshalJSON(t *testing.T) {
"requestType": "time_series",
"compositeQuery": {
"queries": [{
"name": "A",
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"aggregations": [],
"stepInterval": "invalid_duration"
@ -650,6 +983,21 @@ func TestQueryRangeRequest_UnmarshalJSON(t *testing.T) {
assert.Equal(t, expectedSpec.Right.Name, actualSpec.Right.Name)
assert.Equal(t, expectedSpec.Type, actualSpec.Type)
assert.Equal(t, expectedSpec.On, actualSpec.On)
case QueryTypeTraceOperator:
expectedSpec := expectedQuery.Spec.(QueryBuilderTraceOperator)
actualSpec, ok := actualQuery.Spec.(QueryBuilderTraceOperator)
require.True(t, ok, "Expected QueryBuilderTraceOperator but got %T", actualQuery.Spec)
assert.Equal(t, expectedSpec.Name, actualSpec.Name)
assert.Equal(t, expectedSpec.Expression, actualSpec.Expression)
assert.Equal(t, expectedSpec.Limit, actualSpec.Limit)
assert.Equal(t, expectedSpec.Cursor, actualSpec.Cursor)
assert.Equal(t, len(expectedSpec.Order), len(actualSpec.Order))
for i, expectedOrder := range expectedSpec.Order {
if i < len(actualSpec.Order) {
assert.Equal(t, expectedOrder.Key.Name, actualSpec.Order[i].Key.Name)
assert.Equal(t, expectedOrder.Direction, actualSpec.Order[i].Direction)
}
}
case QueryTypePromQL:
expectedSpec := expectedQuery.Spec.(PromQuery)
actualSpec, ok := actualQuery.Spec.(PromQuery)
@ -673,3 +1021,507 @@ func TestQueryRangeRequest_UnmarshalJSON(t *testing.T) {
})
}
}
func TestParseTraceExpression(t *testing.T) {
tests := []struct {
name string
expression string
expectError bool
checkResult func(t *testing.T, result *TraceOperand)
}{
{
name: "simple query reference",
expression: "A",
expectError: false,
checkResult: func(t *testing.T, result *TraceOperand) {
assert.NotNil(t, result.QueryRef)
assert.Equal(t, "A", result.QueryRef.Name)
assert.Nil(t, result.Operator)
},
},
{
name: "simple implication",
expression: "A => B",
expectError: false,
checkResult: func(t *testing.T, result *TraceOperand) {
assert.NotNil(t, result.Operator)
assert.Equal(t, TraceOperatorDirectDescendant, *result.Operator)
assert.NotNil(t, result.Left)
assert.NotNil(t, result.Right)
assert.Equal(t, "A", result.Left.QueryRef.Name)
assert.Equal(t, "B", result.Right.QueryRef.Name)
},
},
{
name: "and operation",
expression: "A && B",
expectError: false,
checkResult: func(t *testing.T, result *TraceOperand) {
assert.NotNil(t, result.Operator)
assert.Equal(t, TraceOperatorAnd, *result.Operator)
assert.Equal(t, "A", result.Left.QueryRef.Name)
assert.Equal(t, "B", result.Right.QueryRef.Name)
},
},
{
name: "or operation",
expression: "A || B",
expectError: false,
checkResult: func(t *testing.T, result *TraceOperand) {
assert.NotNil(t, result.Operator)
assert.Equal(t, TraceOperatorOr, *result.Operator)
assert.Equal(t, "A", result.Left.QueryRef.Name)
assert.Equal(t, "B", result.Right.QueryRef.Name)
},
},
{
name: "unary NOT operation",
expression: "NOT A",
expectError: false,
checkResult: func(t *testing.T, result *TraceOperand) {
assert.NotNil(t, result.Operator)
assert.Equal(t, TraceOperatorNot, *result.Operator)
assert.NotNil(t, result.Left)
assert.Nil(t, result.Right)
assert.Equal(t, "A", result.Left.QueryRef.Name)
},
},
{
name: "binary NOT operation",
expression: "A NOT B",
expectError: false,
checkResult: func(t *testing.T, result *TraceOperand) {
assert.NotNil(t, result.Operator)
assert.Equal(t, TraceOperatorExclude, *result.Operator)
assert.NotNil(t, result.Left)
assert.NotNil(t, result.Right)
assert.Equal(t, "A", result.Left.QueryRef.Name)
assert.Equal(t, "B", result.Right.QueryRef.Name)
},
},
{
name: "complex expression with precedence",
expression: "A => B && C || D",
expectError: false,
checkResult: func(t *testing.T, result *TraceOperand) {
// Should parse as: A => (B && (C || D)) due to precedence: NOT > || > && > =>
// The parsing finds operators from lowest precedence first
assert.NotNil(t, result.Operator)
assert.Equal(t, TraceOperatorDirectDescendant, *result.Operator)
assert.Equal(t, "A", result.Left.QueryRef.Name)
// Right side should be an AND operation (next lowest precedence after =>)
assert.NotNil(t, result.Right.Operator)
assert.Equal(t, TraceOperatorAnd, *result.Right.Operator)
},
},
{
name: "simple parentheses",
expression: "(A)",
expectError: false,
checkResult: func(t *testing.T, result *TraceOperand) {
assert.NotNil(t, result.QueryRef)
assert.Equal(t, "A", result.QueryRef.Name)
assert.Nil(t, result.Operator)
},
},
{
name: "parentheses expression",
expression: "A => (B || C)",
expectError: false,
checkResult: func(t *testing.T, result *TraceOperand) {
assert.NotNil(t, result.Operator)
assert.Equal(t, TraceOperatorDirectDescendant, *result.Operator)
assert.Equal(t, "A", result.Left.QueryRef.Name)
// Right side should be an OR operation
assert.NotNil(t, result.Right.Operator)
assert.Equal(t, TraceOperatorOr, *result.Right.Operator)
assert.Equal(t, "B", result.Right.Left.QueryRef.Name)
assert.Equal(t, "C", result.Right.Right.QueryRef.Name)
},
},
{
name: "nested NOT with parentheses",
expression: "NOT (A && B)",
expectError: false,
checkResult: func(t *testing.T, result *TraceOperand) {
assert.NotNil(t, result.Operator)
assert.Equal(t, TraceOperatorNot, *result.Operator)
assert.Nil(t, result.Right) // Unary operator
// Left side should be an AND operation
assert.NotNil(t, result.Left.Operator)
assert.Equal(t, TraceOperatorAnd, *result.Left.Operator)
},
},
{
name: "invalid query reference with numbers",
expression: "123",
expectError: true,
},
{
name: "invalid query reference with special chars",
expression: "A-B",
expectError: true,
},
{
name: "empty expression",
expression: "",
expectError: true,
},
{
name: "expression with extra whitespace",
expression: " A => B ",
expectError: false,
checkResult: func(t *testing.T, result *TraceOperand) {
assert.NotNil(t, result.Operator)
assert.Equal(t, TraceOperatorDirectDescendant, *result.Operator)
assert.Equal(t, "A", result.Left.QueryRef.Name)
assert.Equal(t, "B", result.Right.QueryRef.Name)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseTraceExpression(tt.expression)
if tt.expectError {
assert.Error(t, err)
assert.Nil(t, result)
return
}
require.NoError(t, err)
require.NotNil(t, result)
if tt.checkResult != nil {
tt.checkResult(t, result)
}
})
}
}
func TestQueryBuilderTraceOperator_ValidateTraceOperator(t *testing.T) {
tests := []struct {
name string
traceOperator QueryBuilderTraceOperator
queries []QueryEnvelope
expectError bool
errorContains string
}{
{
name: "valid trace operator with trace queries",
traceOperator: QueryBuilderTraceOperator{
Name: "test_operator",
Expression: "A => B",
Filter: &Filter{
Expression: "trace_duration > 200ms",
},
Order: []OrderBy{{
Key: OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: OrderByTraceDuration.StringValue(),
FieldContext: telemetrytypes.FieldContextSpan,
},
},
Direction: OrderDirectionDesc,
}},
Limit: 100,
},
queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
},
},
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "B",
Signal: telemetrytypes.SignalTraces,
},
},
},
expectError: false,
},
{
name: "empty expression",
traceOperator: QueryBuilderTraceOperator{
Name: "test_operator",
Expression: "",
},
queries: []QueryEnvelope{},
expectError: true,
errorContains: "expression cannot be empty",
},
{
name: "referenced query does not exist",
traceOperator: QueryBuilderTraceOperator{
Name: "test_operator",
Expression: "A => B",
},
queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
},
},
},
expectError: true,
errorContains: "query 'B' referenced in trace operator expression does not exist or is not a trace query",
},
{
name: "referenced query is not trace signal",
traceOperator: QueryBuilderTraceOperator{
Name: "test_operator",
Expression: "A => B",
},
queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
},
},
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[LogAggregation]{
Name: "B",
Signal: telemetrytypes.SignalLogs,
},
},
},
expectError: true,
errorContains: "query 'B' referenced in trace operator expression does not exist or is not a trace query",
},
{
name: "invalid orderBy field",
traceOperator: QueryBuilderTraceOperator{
Name: "test_operator",
Expression: "A",
Order: []OrderBy{{
Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "invalid_string"}},
Direction: OrderDirectionDesc,
}},
},
queries: []QueryEnvelope{{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{Name: "A", Signal: telemetrytypes.SignalTraces},
}},
expectError: true,
errorContains: "orderBy[0] field must be either 'span_count' or 'trace_duration'",
},
{
name: "invalid pagination limit",
traceOperator: QueryBuilderTraceOperator{
Name: "test_operator",
Expression: "A",
Limit: -1,
},
queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
},
},
},
expectError: true,
errorContains: "limit must be non-negative",
},
{
name: "limit exceeds maximum",
traceOperator: QueryBuilderTraceOperator{
Name: "test_operator",
Expression: "A",
Limit: 15000,
},
queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
},
},
},
expectError: true,
errorContains: "limit cannot exceed 10000",
},
{
name: "valid returnSpansFrom",
traceOperator: QueryBuilderTraceOperator{
Name: "test_operator",
Expression: "A => B",
ReturnSpansFrom: "A",
},
queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
},
},
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "B",
Signal: telemetrytypes.SignalTraces,
},
},
},
expectError: false,
},
{
name: "returnSpansFrom references non-existent query",
traceOperator: QueryBuilderTraceOperator{
Name: "test_operator",
Expression: "A => B",
ReturnSpansFrom: "C",
},
queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
},
},
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "B",
Signal: telemetrytypes.SignalTraces,
},
},
},
expectError: true,
errorContains: "returnSpansFrom references query 'C' which does not exist or is not a trace query",
},
{
name: "returnSpansFrom references query not in expression",
traceOperator: QueryBuilderTraceOperator{
Name: "test_operator",
Expression: "A => B",
ReturnSpansFrom: "C",
},
queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
},
},
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "B",
Signal: telemetrytypes.SignalTraces,
},
},
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "C",
Signal: telemetrytypes.SignalTraces,
},
},
},
expectError: true,
errorContains: "returnSpansFrom references query 'C' which is not used in the expression",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.traceOperator.ValidateTraceOperator(tt.queries)
if tt.expectError {
assert.Error(t, err)
if tt.errorContains != "" {
assert.Contains(t, err.Error(), tt.errorContains)
}
} else {
assert.NoError(t, err)
}
})
}
}
func TestValidateUniqueTraceOperator(t *testing.T) {
tests := []struct {
name string
queries []QueryEnvelope
expectError bool
errorContains string
}{
{
name: "no trace operators",
queries: []QueryEnvelope{
{Type: QueryTypeBuilder},
{Type: QueryTypeFormula},
},
expectError: false,
},
{
name: "single trace operator",
queries: []QueryEnvelope{
{Type: QueryTypeBuilder},
{
Type: QueryTypeTraceOperator,
Spec: QueryBuilderTraceOperator{
Name: "T1",
},
},
{Type: QueryTypeFormula},
},
expectError: false,
},
{
name: "multiple trace operators",
queries: []QueryEnvelope{
{Type: QueryTypeBuilder},
{
Type: QueryTypeTraceOperator,
Spec: QueryBuilderTraceOperator{
Name: "T1",
},
},
{
Type: QueryTypeTraceOperator,
Spec: QueryBuilderTraceOperator{
Name: "T2",
},
},
{Type: QueryTypeFormula},
},
expectError: true,
errorContains: "only one trace operator is allowed per request, found 2 trace operators: [T1 T2]",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateUniqueTraceOperator(tt.queries)
if tt.expectError {
assert.Error(t, err)
if tt.errorContains != "" {
assert.Contains(t, err.Error(), tt.errorContains)
}
} else {
assert.NoError(t, err)
}
})
}
}

View File

@ -0,0 +1,438 @@
package querybuildertypesv5
import (
"regexp"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type TraceOperatorType struct{ valuer.String }
var (
TraceOperatorDirectDescendant = TraceOperatorType{valuer.NewString("=>")}
TraceOperatorIndirectDescendant = TraceOperatorType{valuer.NewString("->")}
TraceOperatorAnd = TraceOperatorType{valuer.NewString("&&")}
TraceOperatorOr = TraceOperatorType{valuer.NewString("||")}
TraceOperatorNot = TraceOperatorType{valuer.NewString("NOT")}
TraceOperatorExclude = TraceOperatorType{valuer.NewString("NOT")}
)
type TraceOrderBy struct {
valuer.String
}
var (
OrderBySpanCount = TraceOrderBy{valuer.NewString("span_count")}
OrderByTraceDuration = TraceOrderBy{valuer.NewString("trace_duration")}
)
type QueryBuilderTraceOperator struct {
Name string `json:"name"`
Disabled bool `json:"disabled,omitempty"`
Expression string `json:"expression"`
Filter *Filter `json:"filter,omitempty"`
// User-configurable span return strategy - which query's spans to return
ReturnSpansFrom string `json:"returnSpansFrom,omitempty"`
// Trace-specific ordering (only span_count and trace_duration allowed)
Order []OrderBy `json:"orderBy,omitempty"`
Aggregations []TraceAggregation `json:"aggregations,omitempty"`
StepInterval Step `json:"stepInterval,omitempty"`
GroupBy []GroupByKey `json:"groupBy,omitempty"`
Limit int `json:"limit,omitempty"`
Cursor string `json:"cursor,omitempty"`
// Other post-processing options
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitempty"`
Functions []Function `json:"functions,omitempty"`
// Internal parsed representation (not exposed in JSON)
ParsedExpression *TraceOperand `json:"-"`
}
// TraceOperand represents the internal parsed tree structure
type TraceOperand struct {
// For leaf nodes - reference to a query
QueryRef *TraceOperatorQueryRef `json:"-"`
// For nested operations
Operator *TraceOperatorType `json:"-"`
Left *TraceOperand `json:"-"`
Right *TraceOperand `json:"-"`
}
// TraceOperatorQueryRef represents a reference to another query
type TraceOperatorQueryRef struct {
Name string `json:"name"`
}
// ParseExpression parses the expression string into a tree structure
func (q *QueryBuilderTraceOperator) ParseExpression() error {
if q.Expression == "" {
return errors.WrapInvalidInputf(
nil,
errors.CodeInvalidInput,
"expression cannot be empty",
)
}
parsed, err := parseTraceExpression(q.Expression)
if err != nil {
return errors.WrapInvalidInputf(
err,
errors.CodeInvalidInput,
"failed to parse expression '%s'",
q.Expression,
)
}
q.ParsedExpression = parsed
return nil
}
// ValidateTraceOperator validates that all referenced queries exist and are trace queries
func (q *QueryBuilderTraceOperator) ValidateTraceOperator(queries []QueryEnvelope) error {
// Parse the expression
if err := q.ParseExpression(); err != nil {
return err
}
// Validate orderBy field if present
if err := q.ValidateOrderBy(); err != nil {
return err
}
// Validate pagination parameters
if err := q.ValidatePagination(); err != nil {
return err
}
// Create a map of query names to track if they exist and their signal type
availableQueries := make(map[string]telemetrytypes.Signal)
// Only collect trace queries
for _, query := range queries {
if query.Type == QueryTypeBuilder {
switch spec := query.Spec.(type) {
case QueryBuilderQuery[TraceAggregation]:
if spec.Signal == telemetrytypes.SignalTraces {
availableQueries[spec.Name] = spec.Signal
}
}
}
}
// Get all query names referenced in the expression
referencedQueries := q.collectReferencedQueries(q.ParsedExpression)
// Validate that all referenced queries exist and are trace queries
for _, queryName := range referencedQueries {
signal, exists := availableQueries[queryName]
if !exists {
return errors.WrapInvalidInputf(
nil,
errors.CodeInvalidInput,
"query '%s' referenced in trace operator expression does not exist or is not a trace query",
queryName,
)
}
// This check is redundant since we only add trace queries to availableQueries, but keeping for clarity
if signal != telemetrytypes.SignalTraces {
return errors.WrapInvalidInputf(
nil,
errors.CodeInvalidInput,
"query '%s' must be a trace query, but found signal '%s'",
queryName,
signal,
)
}
}
// Validate ReturnSpansFrom if specified
if q.ReturnSpansFrom != "" {
if _, exists := availableQueries[q.ReturnSpansFrom]; !exists {
return errors.WrapInvalidInputf(
nil,
errors.CodeInvalidInput,
"returnSpansFrom references query '%s' which does not exist or is not a trace query",
q.ReturnSpansFrom,
)
}
// Ensure the query is referenced in the expression
found := false
for _, queryName := range referencedQueries {
if queryName == q.ReturnSpansFrom {
found = true
break
}
}
if !found {
return errors.WrapInvalidInputf(
nil,
errors.CodeInvalidInput,
"returnSpansFrom references query '%s' which is not used in the expression '%s'",
q.ReturnSpansFrom,
q.Expression,
)
}
}
return nil
}
// ValidateOrderBy validates the orderBy field
func (q *QueryBuilderTraceOperator) ValidateOrderBy() error {
if len(q.Order) == 0 {
return nil
}
for i, orderBy := range q.Order {
// Validate field is one of the allowed values
fieldName := orderBy.Key.Name
if fieldName != OrderBySpanCount.StringValue() && fieldName != OrderByTraceDuration.StringValue() {
return errors.WrapInvalidInputf(
nil,
errors.CodeInvalidInput,
"orderBy[%d] field must be either '%s' or '%s', got '%s'",
i, OrderBySpanCount.StringValue(), OrderByTraceDuration.StringValue(), fieldName,
)
}
// Validate direction
if orderBy.Direction != OrderDirectionAsc && orderBy.Direction != OrderDirectionDesc {
return errors.WrapInvalidInputf(
nil,
errors.CodeInvalidInput,
"orderBy[%d] direction must be either 'asc' or 'desc', got '%s'",
i, orderBy.Direction,
)
}
}
return nil
}
// ValidatePagination validates pagination parameters (AIP-158 compliance)
func (q *QueryBuilderTraceOperator) ValidatePagination() error {
if q.Limit < 0 {
return errors.WrapInvalidInputf(
nil,
errors.CodeInvalidInput,
"limit must be non-negative, got %d",
q.Limit,
)
}
// For production use, you might want to enforce maximum limits
if q.Limit > 10000 {
return errors.WrapInvalidInputf(
nil,
errors.CodeInvalidInput,
"limit cannot exceed 10000, got %d",
q.Limit,
)
}
return nil
}
// collectReferencedQueries collects all query names referenced in the expression tree
func (q *QueryBuilderTraceOperator) collectReferencedQueries(operand *TraceOperand) []string {
if operand == nil {
return nil
}
var queries []string
if operand.QueryRef != nil {
queries = append(queries, operand.QueryRef.Name)
}
// Recursively collect from children
queries = append(queries, q.collectReferencedQueries(operand.Left)...)
queries = append(queries, q.collectReferencedQueries(operand.Right)...)
// Remove duplicates
seen := make(map[string]bool)
unique := []string{}
for _, q := range queries {
if !seen[q] {
seen[q] = true
unique = append(unique, q)
}
}
return unique
}
// ValidateUniqueTraceOperator ensures only one trace operator exists in queries
func ValidateUniqueTraceOperator(queries []QueryEnvelope) error {
traceOperatorCount := 0
var traceOperatorNames []string
for _, query := range queries {
if query.Type == QueryTypeTraceOperator {
// Extract the name from the trace operator spec
if spec, ok := query.Spec.(QueryBuilderTraceOperator); ok {
traceOperatorCount++
traceOperatorNames = append(traceOperatorNames, spec.Name)
}
}
}
if traceOperatorCount > 1 {
return errors.WrapInvalidInputf(
nil,
errors.CodeInvalidInput,
"only one trace operator is allowed per request, found %d trace operators: %v",
traceOperatorCount,
traceOperatorNames,
)
}
return nil
}
// parseTraceExpression parses an expression string into a tree structure
// Handles precedence: NOT (highest) > || > && > => (lowest)
func parseTraceExpression(expr string) (*TraceOperand, error) {
expr = strings.TrimSpace(expr)
// Handle parentheses
if strings.HasPrefix(expr, "(") && strings.HasSuffix(expr, ")") {
// Check if parentheses are balanced
if isBalancedParentheses(expr[1 : len(expr)-1]) {
return parseTraceExpression(expr[1 : len(expr)-1])
}
}
// Handle unary NOT operator (prefix)
if strings.HasPrefix(expr, "NOT ") {
operand, err := parseTraceExpression(expr[4:])
if err != nil {
return nil, err
}
notOp := TraceOperatorNot
return &TraceOperand{
Operator: &notOp,
Left: operand,
}, nil
}
// Find binary operators with lowest precedence first (=> has lowest precedence)
// Order: => (lowest) < && < || < NOT (highest)
operators := []string{"=>", "&&", "||", " NOT "}
for _, op := range operators {
if pos := findOperatorPosition(expr, op); pos != -1 {
leftExpr := strings.TrimSpace(expr[:pos])
rightExpr := strings.TrimSpace(expr[pos+len(op):])
left, err := parseTraceExpression(leftExpr)
if err != nil {
return nil, err
}
right, err := parseTraceExpression(rightExpr)
if err != nil {
return nil, err
}
var opType TraceOperatorType
switch strings.TrimSpace(op) {
case "=>":
opType = TraceOperatorDirectDescendant
case "&&":
opType = TraceOperatorAnd
case "||":
opType = TraceOperatorOr
case "NOT":
opType = TraceOperatorExclude // Binary NOT (A NOT B)
}
return &TraceOperand{
Operator: &opType,
Left: left,
Right: right,
}, nil
}
}
// If no operators found, this should be a query reference
if matched, _ := regexp.MatchString(`^[A-Za-z][A-Za-z0-9_]*$`, expr); !matched {
return nil, errors.WrapInvalidInputf(
nil,
errors.CodeInvalidInput,
"invalid query reference '%s'",
expr,
)
}
return &TraceOperand{
QueryRef: &TraceOperatorQueryRef{Name: expr},
}, nil
}
// isBalancedParentheses checks if parentheses are balanced in the expression
func isBalancedParentheses(expr string) bool {
depth := 0
for _, char := range expr {
if char == '(' {
depth++
} else if char == ')' {
depth--
if depth < 0 {
return false
}
}
}
return depth == 0
}
// findOperatorPosition finds the position of an operator, respecting parentheses
func findOperatorPosition(expr, op string) int {
depth := 0
opLen := len(op)
// Scan from right to left to find the rightmost operator at depth 0
for i := len(expr) - 1; i >= 0; i-- {
char := expr[i]
// Update depth based on parentheses (scanning right to left)
if char == ')' {
depth++
} else if char == '(' {
depth--
}
// Only check for operators when we're at depth 0 (outside parentheses)
// and make sure we have enough characters for the operator
if depth == 0 && i+opLen <= len(expr) {
// Check if the substring matches our operator
if expr[i:i+opLen] == op {
// For " NOT " (binary), ensure proper spacing
if op == " NOT " {
// Make sure it's properly space-padded
if i > 0 && i+opLen < len(expr) {
return i
}
} else {
// For other operators (=>, &&, ||), return immediately
return i
}
}
}
}
return -1
}