Compare commits

..

10 Commits

Author SHA1 Message Date
nityanandagohain
8afb78b17b fix: add adjustkeys in trace operator cte builder 2026-05-19 01:05:26 +05:30
Vikrant Gupta
885b41356a chore(authz): add authz feature flag (#11341) 2026-05-18 15:29:05 +00:00
Ashwin Bhatkal
b653c69e29 fix(frontend): unblock pnpm generate:api after orval v8 upgrade (#11346)
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
Orval v8's pre-generation validator (@scalar/openapi-parser) treats every
`$ref` key as a JSON Reference. Our spec embeds Perses' `common.JSONRef`
struct, which has a property literally named `$ref`, so validation aborts
with `INVALID_REFERENCE`. Set `input.unsafeDisableValidation: true` to
bypass — codegen itself handles the spec correctly, and the spec is
backend-generated and CI-gated.

Closes SigNoz/engineering-pod#4963
2026-05-18 14:33:23 +00:00
Abhi kumar
7d2f8b291e chore: added changes for sorting tooltip content (#11320) 2026-05-18 13:53:19 +00:00
Aditya Singh
3bea4484f9 Enable new trace details page (#11296)
* feat: span details floating drawer added

* feat: span details folder rename

* feat: replace draggable package

* feat: fix pinning. fix drag on top

* feat: add bound to drags while floating

* feat: add collapsible sections in trace details

* feat: use resizable for waterfall table as well

* feat: copy link change and url clear on span close

* feat: fix span details headr

* feat: key value label style fixes

* feat: linked spans

* feat: style fixes

* feat: setup types and interface for waterfall v3

v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

* fix: update span.attributes to map of string to any

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

* chore: add same test cases as for old waterfall api

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* feat: api integration

* feat: add limit

* feat: minor change

* feat: supress click

* chore: generate openapi spec for v3 waterfall

* feat: fix test

* feat: fix test

* feat: lint fix

* feat: span details ux

* feat: analytics

* feat: add icons

* feat: added loading to flamegraph and timeout to webworker

* feat: sync error and loading state for flamegraph for n/w and computation logic

* feat: auto scroll horizontally to span

* feat: show total span count

* feat: disable anaytics span tab for now

* feat: add span details loader

* feat: prevent api call on closing span detail

* fix: remove timeout since waterfall take longer

* fix: use int16 for status code as per db schema

* fix: update openapi specs

* feat: make filter and search work with flamegraph

* feat: filter ui fix

* feat: remove trace header

* feat: new filter ui

* feat: setup types and interface for waterfall v3

v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

* fix: update span.attributes to map of string to any

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

* chore: add same test cases as for old waterfall api

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* chore: generate openapi spec for v3 waterfall

* fix: remove timeout since waterfall take longer

* fix: use int16 for status code as per db schema

* fix: update openapi specs

* feat: api integration

* feat: automatically scroll left on vertical scroll

* feat: reduce time

* feat: set limit to 100k for flamegraph

* feat: show child count in waterfall

* fix: align timeline and span length in flamegraph and waterfall

* feat: fix flamegraph and waterfall bg color

* feat: show caution on sampled flamegraph

* feat: api integration v3

* feat: disable scroll to view for collapse and uncollapse

* feat: setup types and interface for waterfall v3

v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

* fix: update span.attributes to map of string to any

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

* chore: add same test cases as for old waterfall api

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* chore: generate openapi spec for v3 waterfall

* fix: remove timeout since waterfall take longer

* fix: use int16 for status code as per db schema

* fix: update openapi specs

* refactor: break down GetWaterfall method for readability

* chore: avoid returning nil, nil

* refactor: move type creation and constants to types package

- Move DB/table/cache/windowing constants to tracedetailtypes package
- Add NewWaterfallTrace and NewWaterfallResponse constructors in types
- Use constructors in module.go instead of inline struct literals
- Reorder waterfall.go so public functions precede private ones

* refactor: extract ClickHouse queries into a store abstraction

Move GetTraceSummary and GetTraceSpans out of module.go into a
traceStore interface backed by clickhouseTraceStore in store.go.
The module struct now holds a traceStore instead of a raw
telemetrystore.TelemetryStore, keeping DB access separate from
business logic.

* refactor: move error to types as well

* refactor: separate out store calls and computations

* refactor: breakdown GetSelectedSpans for readability

* refactor: return 404 on missing trace and other cleanup

* refactor: use same method for cache key creation

* chore: remove unused duration nano field

* chore: use sqlbuilder in clickhouse store where possible

* feat: dropdown added to span details

* feat: fix color duplications

* feat: no data screen

* feat: old trace btn added

* feat: minor fix

* feat: rename copy to copy value

* feat: delete unused file

* feat: use semantic tokens

* feat: use semantic tokens

* feat: add crosshair

* feat: fix test

* feat: disable crosshair in waterfall

* feat: fix colors

* feat: minor fix

* feat: add status codes

* feat: load all spans in waterfall under limit

* feat: uncollapse spans on select from flamegraph

* feat: style fix

* feat: add service name

* feat: open in new tab

* feat: add trace details header

* feat: add trace details header styles

* feat: add trace details header styles

* feat: minor changes

* feat: floating fields set

* feat: filters init

* feat: filter toggle added

* feat: fix color

* fix: scroll to span in frontend mode

* feat: delete waterfall go

* feat: minor change

* feat: minor change

* feat: lint fix

* feat: analytics spans

* feat: color by field

* feat: save color by pref in user pref

* feat: migrate v2 pinned attr

* feat: preview fields

* feat: minor refactors

* feat: minor refactors

* feat: v3 behind feature flag

* feat: minor refactors

* feat: packages remove

* feat: packages remove

* feat: remove common component

* feat: remove antd component usage

* feat: leaf node indent fix

* feat: fix mouse wheel in json view

* feat: update signoz ui

* feat: remove feature flag

* feat: fixed the waterfall span hover card

* feat: fix hidden filters

* feat: trace details always visible

* feat: correct status code

* fix: pagination calls in waterfall

* feat: fix failing test

* feat: show error count

* feat: fix waterfall child sibling indent

* feat: change how we show span hover data in waterfall

* feat: fix logs in span details styles

* feat: minor fixes

* feat: make trace id copyable

* feat: add status message to highlight section

* feat: persist user choosing old view

* feat: add more fields in color by

* feat: add llm as fast filter

* feat: show api error correctly

* feat: update test cases

* feat: revert route change

* feat: revert route change

* feat: replace antd btns

* feat: allow removing all fields in preview

* feat: send selected span when flamegraph is sampled

* feat: only scroll when span is not in view

* feat: auto expand on highlight errors

* feat: move analytics panel

* feat: additional check

* feat: minor fix

* feat: minor fix

* feat: dont use antd button and tooltip

* feat: dont use antd button and tooltip

* feat: update icons

* feat: minor change

* feat: minor change

* feat: move to zustand

* feat: update test cases

* feat: update border color

* feat: add icons

* feat: support filter on parent keys

* feat: add links to non filterable keys

* feat: minor fix

* feat: use pinned attributes accross views

* feat: update tests

* feat: hide v3

* feat: migrate to css modules

* feat: fix minor style

* feat: fix test

* feat: enable new trace details

* feat: remove unnecessary waterfall api calls if span already in the list

* feat: minor change

---------

Co-authored-by: Nikhil Soni <nikhil.soni@signoz.io>
2026-05-18 13:51:33 +00:00
SagarRajput-7
87ceba2d84 feat(role-sa-fga): role sa fga followup changes (#11330)
* feat(role-sa-fga): updated roles detail permission panel with the new allowedVerb gate

* feat(role-sa-fga): added anonymous in roles, sa routes to allow user access without managed role

* feat(role-sa-fga): gated roles create and details page behind a valid license check

* feat(role-sa-fga): added test and some refactor
2026-05-18 12:21:38 +00:00
Manika Malhotra
445dc3b290 chore(onboarding): shuffle ordering of interest in SigNoz based on version (#11336)
* chore(onboarding): shuffle ordering of interest in SigNoz based on version

* fix: formatting
2026-05-18 12:12:48 +00:00
Tushar Vats
76b35b9d8f fix: order by ignored in formula query (#10950)
* fix: order by ignored in formula query

* fix: order by ignored in formula query

* fix: added intergation test

* fix: revert integarion test changes

* fix: added an independent integration test

* fix: make py-fmt

* fix: removed comment

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
Co-authored-by: Pandey <vibhupandey28@gmail.com>
2026-05-18 11:38:40 +00:00
Tushar Vats
b860cce31d fix: enforce minimum step interval for v3 promql queries (#11293) 2026-05-18 11:27:52 +00:00
Tushar Vats
1bd4ca88de fix: cache memory leak (#10967)
* fix: added cost() to cloneable interface

* fix: added a new metrics and converted into counters

* fix: address comments

* fix: simplify test

* fix: use assert instead of require
2026-05-18 10:50:27 +00:00
74 changed files with 1085 additions and 1096 deletions

View File

@@ -80,6 +80,15 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
fineGrainedAuthz := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureUseFineGrainedAuthz, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureUseFineGrainedAuthz.String()),
Active: fineGrainedAuthz,
Usage: 0,
UsageLimit: -1,
Route: "",
})
if constants.IsDotMetricsEnabled {
for idx, feature := range featureSet {
if feature.Name == licensetypes.DotMetricsEnabled {

View File

@@ -10,6 +10,13 @@ export default defineConfig({
signoz: {
input: {
target: '../docs/api/openapi.yml',
// Perses' `common.JSONRef` (used by `DashboardGridItem.content`) has a
// field tagged `json:"$ref"`, so our spec contains a property literally
// named `$ref`.
// Orval v8's validator (`@scalar/openapi-parser`) treats every `$ref` key
// as a JSON Reference and aborts with `INVALID_REFERENCE` when the value isn't a URI string.
// Safe to disable: yes, the spec is generated by `cmd/openapi.go` and gated by backend CI, not hand-edited.
unsafeDisableValidation: true,
},
output: {
target: './src/api/generated/services',

View File

@@ -144,18 +144,18 @@ const routes: AppRoutes[] = [
// /trace-old serves V3 (URL-only access). Flip the two `component`
// values back to release V3.
{
path: ROUTES.TRACE_DETAIL,
path: ROUTES.TRACE_DETAIL_OLD,
exact: true,
component: TraceDetail,
isPrivate: true,
key: 'TRACE_DETAIL',
key: 'TRACE_DETAIL_OLD',
},
{
path: ROUTES.TRACE_DETAIL_OLD,
path: ROUTES.TRACE_DETAIL,
exact: true,
component: TraceDetailV3,
isPrivate: true,
key: 'TRACE_DETAIL_OLD',
key: 'TRACE_DETAIL',
},
{
path: ROUTES.SETTINGS,

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
export interface AlertmanagertypesChannelDTO {
/**

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,7 +3,6 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Input } from '@signozhq/ui/input';
import { Input as AntdInput } from 'antd';
import logEvent from 'api/common/logEvent';
import { ArrowRight } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { OnboardingQuestionHeader } from '../OnboardingQuestionHeader';
@@ -32,11 +33,31 @@ const interestedInOptions: Record<string, string> = {
openSourceTooling: 'Prefer open-source tooling',
};
function seededShuffle<T>(array: T[], seed: string): T[] {
const result = [...array];
let num = 0;
for (let i = 0; i < seed.length; i++) {
num = Math.imul(num + seed.charCodeAt(i), 2654435761);
num = Math.abs(num);
}
for (let i = result.length - 1; i > 0; i--) {
num = Math.abs(Math.imul(num, 1664525) + 1013904223);
const j = num % (i + 1);
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
export function AboutSigNozQuestions({
signozDetails,
setSignozDetails,
onNext,
}: AboutSigNozQuestionsProps): JSX.Element {
const { versionData } = useAppContext();
const [interestInSignoz, setInterestInSignoz] = useState<string[]>(
signozDetails?.interestInSignoz || [],
);
@@ -48,6 +69,12 @@ export function AboutSigNozQuestions({
);
const [isNextDisabled, setIsNextDisabled] = useState<boolean>(true);
const shuffledOptionKeys = useMemo(
() =>
seededShuffle(Object.keys(interestedInOptions), versionData?.version ?? ''),
[versionData?.version],
);
useEffect((): void => {
if (
discoverSignoz !== '' &&
@@ -115,7 +142,7 @@ export function AboutSigNozQuestions({
<div className="form-group">
<div className="question">What got you interested in SigNoz?</div>
<div className="checkbox-grid">
{Object.keys(interestedInOptions).map((option: string) => (
{shuffledOptionKeys.map((option: string) => (
<div key={option} className="checkbox-item">
<Checkbox
id={`checkbox-${option}`}

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useHistory, useLocation } from 'react-router-dom';
import { Redirect, useHistory, useLocation } from 'react-router-dom';
import { Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
@@ -26,7 +26,9 @@ import type { AuthzResources } from '../utils';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ROUTES from 'constants/routes';
import { capitalize } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { LicenseStatus } from 'types/api/licensesV3/getActive';
import { RoleType } from 'types/roles';
import { handleApiError, toAPIError } from 'utils/errorUtils';
@@ -52,8 +54,9 @@ function RoleDetailsPage(): JSX.Element {
const queryClient = useQueryClient();
const { showErrorModal } = useErrorModal();
const { activeLicense, isFetchingActiveLicense } = useAppContext();
const authzResources = permissionsType.data as unknown as AuthzResources;
const authzResources: AuthzResources = permissionsType.data;
// Extract roleId from URL pathname since useParams doesn't work in nested routing
const roleIdMatch = pathname.match(ROLE_ID_REGEX);
@@ -158,6 +161,22 @@ function RoleDetailsPage(): JSX.Element {
},
});
if (isFetchingActiveLicense) {
return (
<div className="role-details-page">
<Skeleton
active
paragraph={{ rows: 8 }}
className="role-details-skeleton"
/>
</div>
);
}
if (activeLicense?.status !== LicenseStatus.VALID) {
return <Redirect to={ROUTES.ROLES_SETTINGS} />;
}
if (!hasReadPermission && readPerms !== null) {
return <PermissionDeniedFullPage permissionName="role:read" />;
}

View File

@@ -5,6 +5,7 @@ import {
} from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { Route, Switch } from 'react-router-dom';
import {
fireEvent,
render,
@@ -15,6 +16,7 @@ import {
} from 'tests/test-utils';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import {
invalidLicense,
mockUseAuthZDenyAll,
mockUseAuthZGrantAll,
} from 'tests/authz-test-utils';
@@ -230,6 +232,28 @@ describe('RoleDetailsPage', () => {
).resolves.toBeInTheDocument();
});
it('redirects to the roles list when license is not valid', async () => {
render(
<Switch>
<Route path="/settings/roles/:roleId">
<RoleDetailsPage />
</Route>
<Route path="/settings/roles" exact>
<div data-testid="roles-list-redirect-target" />
</Route>
</Switch>,
undefined,
{
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
appContextOverrides: { activeLicense: invalidLicense },
},
);
await expect(
screen.findByTestId('roles-list-redirect-target'),
).resolves.toBeInTheDocument();
});
describe('permission side panel', () => {
beforeEach(() => {
// Both hooks mocked so data renders synchronously — no React Query scheduler or MSW round-trip.

View File

@@ -11,7 +11,9 @@ import { RoleListPermission } from 'hooks/useAuthZ/permissions/role.permissions'
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import useUrlQuery from 'hooks/useUrlQuery';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
import { LicenseStatus } from 'types/api/licensesV3/getActive';
import { RoleType } from 'types/roles';
import { toAPIError } from 'utils/errorUtils';
@@ -30,6 +32,9 @@ interface RolesListingTableProps {
function RolesListingTable({
searchQuery,
}: RolesListingTableProps): JSX.Element {
const { activeLicense } = useAppContext();
const isValidLicense = activeLicense?.status === LicenseStatus.VALID;
const { permissions: listPerms, isLoading: isAuthZLoading } = useAuthZ([
RoleListPermission,
]);
@@ -203,19 +208,27 @@ function RolesListingTable({
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
<div
key={role.id}
className="roles-table-row roles-table-row--clickable"
role="button"
tabIndex={0}
onClick={(): void => {
if (role.id) {
navigateToRole(role.id, role.name);
}
}}
onKeyDown={(e): void => {
if ((e.key === 'Enter' || e.key === ' ') && role.id) {
navigateToRole(role.id, role.name);
}
}}
className={`roles-table-row${isValidLicense ? ' roles-table-row--clickable' : ''}`}
role={isValidLicense ? 'button' : undefined}
tabIndex={isValidLicense ? 0 : undefined}
onClick={
isValidLicense
? (): void => {
if (role.id) {
navigateToRole(role.id, role.name);
}
}
: undefined
}
onKeyDown={
isValidLicense
? (e): void => {
if ((e.key === 'Enter' || e.key === ' ') && role.id) {
navigateToRole(role.id, role.name);
}
}
: undefined
}
>
<div className="roles-table-cell roles-table-cell--name">
{role.name ?? '—'}

View File

@@ -4,6 +4,8 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { RoleCreatePermission } from 'hooks/useAuthZ/permissions/role.permissions';
import { useAppContext } from 'providers/App/App';
import { LicenseStatus } from 'types/api/licensesV3/getActive';
import CreateRoleModal from './RolesComponents/CreateRoleModal';
import RolesListingTable from './RolesComponents/RolesListingTable';
@@ -13,6 +15,8 @@ import './RolesSettings.styles.scss';
function RolesSettings(): JSX.Element {
const [searchQuery, setSearchQuery] = useState('');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const { activeLicense } = useAppContext();
const isValidLicense = activeLicense?.status === LicenseStatus.VALID;
return (
<div className="roles-settings" data-testid="roles-settings">
@@ -38,17 +42,19 @@ function RolesSettings(): JSX.Element {
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
/>
<AuthZTooltip checks={[RoleCreatePermission]}>
<Button
variant="solid"
color="primary"
className="role-settings-toolbar-button"
onClick={(): void => setIsCreateModalOpen(true)}
>
<Plus size={14} />
Custom role
</Button>
</AuthZTooltip>
{isValidLicense && (
<AuthZTooltip checks={[RoleCreatePermission]}>
<Button
variant="solid"
color="primary"
className="role-settings-toolbar-button"
onClick={(): void => setIsCreateModalOpen(true)}
>
<Plus size={14} />
Custom role
</Button>
</AuthZTooltip>
)}
</div>
<RolesListingTable searchQuery={searchQuery} />
</div>

View File

@@ -6,7 +6,7 @@ import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { fireEvent, render, screen } from 'tests/test-utils';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import { invalidLicense, mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import RolesSettings from '../RolesSettings';
@@ -176,6 +176,26 @@ describe('RolesSettings', () => {
}
});
it('hides the create button and disables row clicks when license is not valid', async () => {
render(<RolesSettings />, undefined, {
appContextOverrides: { activeLicense: invalidLicense },
});
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
// Create button must be absent
expect(
screen.queryByRole('button', { name: /custom role/i }),
).not.toBeInTheDocument();
// Rows must not carry the clickable class or button role
const rows = document.querySelectorAll('.roles-table-row');
rows.forEach((row) => {
expect(row).not.toHaveClass('roles-table-row--clickable');
expect(row.getAttribute('role')).not.toBe('button');
});
});
it('handles invalid dates gracefully by showing fallback', async () => {
const invalidRole = {
id: 'edge-0009',

View File

@@ -1,5 +1,4 @@
import type {
CoretypesResourceRefDTO,
CoretypesObjectGroupDTO,
CoretypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -8,11 +7,7 @@ import type {
PermissionConfig,
ResourceDefinition,
} from '../PermissionSidePanel/PermissionSidePanel.types';
type AuthzResources = {
resources: CoretypesResourceRefDTO[];
relations: Record<string, string[]>;
};
import type { AuthzResources } from '../utils';
import { PermissionScope } from '../PermissionSidePanel/PermissionSidePanel.types';
import {
buildConfig,
@@ -41,12 +36,14 @@ jest.mock('../RoleDetails/constants', () => {
const dashboardResource: AuthzResources['resources'][number] = {
kind: 'dashboard',
type: 'metaresource' as CoretypesTypeDTO,
type: 'metaresource',
allowedVerbs: ['create', 'read', 'update', 'delete', 'list'],
};
const alertResource: AuthzResources['resources'][number] = {
kind: 'alert',
type: 'metaresource' as CoretypesTypeDTO,
type: 'metaresource',
allowedVerbs: ['create', 'read', 'update', 'delete', 'list'],
};
const baseAuthzResources: AuthzResources = {
@@ -57,6 +54,16 @@ const baseAuthzResources: AuthzResources = {
},
};
// API payload resource refs — only kind+type, no allowedVerbs (matches CoretypesResourceRefDTO shape)
const dashboardResourceRef = {
kind: 'dashboard',
type: 'metaresource' as CoretypesTypeDTO,
};
const alertResourceRef = {
kind: 'alert',
type: 'metaresource' as CoretypesTypeDTO,
};
const resourceDefs: ResourceDefinition[] = [
{
id: 'metaresource:dashboard',
@@ -107,7 +114,7 @@ describe('buildPatchPayload', () => {
});
expect(result.additions).toStrictEqual([
{ resource: dashboardResource, selectors: [ID_B] },
{ resource: dashboardResourceRef, selectors: [ID_B] },
]);
expect(result.deletions).toBeNull();
});
@@ -142,7 +149,7 @@ describe('buildPatchPayload', () => {
});
expect(result.deletions).toStrictEqual([
{ resource: dashboardResource, selectors: [ID_B] },
{ resource: dashboardResourceRef, selectors: [ID_B] },
]);
expect(result.additions).toBeNull();
});
@@ -207,10 +214,10 @@ describe('buildPatchPayload', () => {
});
expect(result.deletions).toStrictEqual([
{ resource: dashboardResource, selectors: ['*'] },
{ resource: dashboardResourceRef, selectors: ['*'] },
]);
expect(result.additions).toStrictEqual([
{ resource: dashboardResource, selectors: [ID_A, ID_B] },
{ resource: dashboardResourceRef, selectors: [ID_A, ID_B] },
]);
});
@@ -241,7 +248,7 @@ describe('buildPatchPayload', () => {
});
expect(result.deletions).toStrictEqual([
{ resource: dashboardResource, selectors: ['*'] },
{ resource: dashboardResourceRef, selectors: ['*'] },
]);
expect(result.additions).toBeNull();
});
@@ -264,7 +271,7 @@ describe('buildPatchPayload', () => {
});
expect(result.deletions).toStrictEqual([
{ resource: dashboardResource, selectors: ['*'] },
{ resource: dashboardResourceRef, selectors: ['*'] },
]);
expect(result.additions).toBeNull();
});
@@ -287,7 +294,7 @@ describe('buildPatchPayload', () => {
});
expect(result.additions).toStrictEqual([
{ resource: dashboardResource, selectors: ['*'] },
{ resource: dashboardResourceRef, selectors: ['*'] },
]);
expect(result.deletions).toBeNull();
});
@@ -313,7 +320,7 @@ describe('buildPatchPayload', () => {
});
expect(result.deletions).toStrictEqual([
{ resource: dashboardResource, selectors: [ID_A, ID_B] },
{ resource: dashboardResourceRef, selectors: [ID_A, ID_B] },
]);
expect(result.additions).toBeNull();
});
@@ -339,7 +346,7 @@ describe('buildPatchPayload', () => {
});
expect(result.additions).toStrictEqual([
{ resource: dashboardResource, selectors: [ID_A] },
{ resource: dashboardResourceRef, selectors: [ID_A] },
]);
expect(result.deletions).toBeNull();
});
@@ -385,7 +392,7 @@ describe('buildPatchPayload', () => {
});
expect(result.additions).toStrictEqual([
{ resource: alertResource, selectors: [ID_B] },
{ resource: alertResourceRef, selectors: [ID_B] },
]);
expect(result.deletions).toBeNull();
});
@@ -394,7 +401,7 @@ describe('buildPatchPayload', () => {
describe('objectsToPermissionConfig', () => {
it('maps a wildcard selector to ALL scope', () => {
const objects: CoretypesObjectGroupDTO[] = [
{ resource: dashboardResource, selectors: ['*'] },
{ resource: dashboardResourceRef, selectors: ['*'] },
];
const result = objectsToPermissionConfig(objects, resourceDefs);
@@ -407,7 +414,7 @@ describe('objectsToPermissionConfig', () => {
it('maps specific selectors to ONLY_SELECTED scope with the IDs', () => {
const objects: CoretypesObjectGroupDTO[] = [
{ resource: dashboardResource, selectors: [ID_A, ID_B] },
{ resource: dashboardResourceRef, selectors: [ID_A, ID_B] },
];
const result = objectsToPermissionConfig(objects, resourceDefs);
@@ -566,4 +573,41 @@ describe('deriveResourcesForRelation', () => {
deriveResourcesForRelation(baseAuthzResources, 'nonexistent'),
).toHaveLength(0);
});
describe('allowedVerbs filtering', () => {
it('excludes resources whose allowedVerbs does not include the relation', () => {
const authz: AuthzResources = {
resources: [
{
kind: 'dashboard',
type: 'metaresource',
allowedVerbs: ['create', 'read', 'update', 'delete', 'list'],
},
{
kind: 'alert',
type: 'metaresource',
allowedVerbs: ['create', 'read', 'update', 'delete', 'list', 'attach'],
},
],
relations: { attach: ['metaresource'] },
};
const result = deriveResourcesForRelation(authz, 'attach');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('metaresource:alert');
});
it('requires both type-relation match and allowedVerbs — neither condition alone is sufficient', () => {
const authz: AuthzResources = {
resources: [
{ kind: 'dashboard', type: 'metaresource', allowedVerbs: ['read'] },
{ kind: 'role', type: 'role', allowedVerbs: ['create'] },
],
relations: { create: ['metaresource'] },
};
expect(deriveResourcesForRelation(authz, 'create')).toHaveLength(0);
});
});
});

View File

@@ -1,8 +1,9 @@
import React from 'react';
import { Badge } from '@signozhq/ui/badge';
import type {
CoretypesResourceRefDTO,
CoretypesObjectGroupDTO,
CoretypesResourceRefDTO,
CoretypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { capitalize } from 'lodash-es';
@@ -21,7 +22,11 @@ import {
} from './RoleDetails/constants';
export type AuthzResources = {
resources: ReadonlyArray<CoretypesResourceRefDTO>;
resources: ReadonlyArray<{
kind: string;
type: string;
allowedVerbs: readonly string[];
}>;
relations: Readonly<Record<string, ReadonlyArray<string>>>;
};
@@ -69,7 +74,9 @@ export function deriveResourcesForRelation(
}
const supportedTypes = authzResources.relations[relation] ?? [];
return authzResources.resources
.filter((r) => supportedTypes.includes(r.type))
.filter(
(r) => supportedTypes.includes(r.type) && r.allowedVerbs.includes(relation),
)
.map((r) => ({
id: `${r.type}:${r.kind}`,
kind: r.kind,
@@ -141,7 +148,7 @@ export function buildPatchPayload({
}
const resourceDef: CoretypesResourceRefDTO = {
kind: found.kind,
type: found.type,
type: found.type as CoretypesTypeDTO,
};
const initialScope = initial?.scope ?? PermissionScope.NONE;

View File

@@ -189,7 +189,7 @@ describe('Tooltip utils', () => {
];
}
it('builds tooltip content in series-index order with isActive flag set correctly', () => {
it('builds tooltip content sorted by value descending with isActive flag set correctly', () => {
const data: AlignedData = [[0], [10], [20], [30]];
const series = createSeriesConfig();
const dataIndexes = [null, 0, 0, 0];
@@ -206,21 +206,21 @@ describe('Tooltip utils', () => {
});
expect(result).toHaveLength(2);
// Series are returned in series-index order (A=index 1 before B=index 2)
// Sorted by value descending: B (20) before A (10)
expect(result[0]).toMatchObject<Partial<TooltipContentItem>>({
label: 'A',
value: 10,
tooltipValue: 'formatted-10',
color: '#ff0000',
isActive: false,
});
expect(result[1]).toMatchObject<Partial<TooltipContentItem>>({
label: 'B',
value: 20,
tooltipValue: 'formatted-20',
color: 'color-2',
isActive: true,
});
expect(result[1]).toMatchObject<Partial<TooltipContentItem>>({
label: 'A',
value: 10,
tooltipValue: 'formatted-10',
color: '#ff0000',
isActive: false,
});
});
it('skips series with null data index or non-finite values', () => {
@@ -274,7 +274,7 @@ describe('Tooltip utils', () => {
expect(result[1].value).toBe(30);
});
it('returns items in series-index order', () => {
it('returns items sorted by value descending', () => {
// Series values in non-sorted order: 3, 1, 4, 2
const data: AlignedData = [[0], [3], [1], [4], [2]];
const series: Series[] = [
@@ -297,7 +297,7 @@ describe('Tooltip utils', () => {
decimalPrecision,
});
expect(result.map((item) => item.value)).toStrictEqual([3, 1, 4, 2]);
expect(result.map((item) => item.value)).toStrictEqual([4, 3, 2, 1]);
});
});
});

View File

@@ -142,5 +142,7 @@ export function buildTooltipContent({
}
}
items.sort((a, b) => b.value - a.value);
return items;
}

View File

@@ -473,6 +473,7 @@ export const SpanDuration = memo(function SpanDuration({
const columnDefHelper = createColumnHelper<SpanV3>();
const ROW_HEIGHT = 28;
const WATERFALL_BOTTOM_PADDING = 24;
const DEFAULT_SIDEBAR_WIDTH = 450;
const MIN_SIDEBAR_WIDTH = 240;
const MAX_SIDEBAR_WIDTH = 900;
@@ -740,53 +741,69 @@ function Success(props: ISuccessProps): JSX.Element {
);
}, [spans, sidebarWidth]);
// Scroll to the interested span only when it isn't already on screen.
// Covers every entry point uniformly: deep-link, flamegraph click,
// filter prev/next, browser back/forward all scroll only if needed;
// waterfall row clicks and chevron expand/collapse don't yank the viewport
// because the affected row is by definition already visible.
// Scroll a span to viewport center if it isn't already visible. Shared by
// the two effects below — one keyed on interestedSpanId (chevron, boundary
// pagination, deep-link to unloaded), the other on selectedSpan (in-window
// URL navigation that doesn't mutate interestedSpanId).
const scrollSpanIntoView = useCallback(
(span: SpanV3, spansList: SpanV3[]): void => {
if (!virtualizerRef.current) {
return;
}
const idx = spansList.findIndex((s) => s.span_id === span.span_id);
if (idx === -1) {
return;
}
const scrollEl = scrollContainerRef.current;
const scrollTop = scrollEl?.scrollTop ?? 0;
const viewportHeight = scrollEl?.clientHeight ?? 0;
const viewportStartIdx = Math.floor(scrollTop / ROW_HEIGHT);
const viewportEndIdx =
Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) - 1;
const isOnScreen =
viewportHeight > 0 && idx >= viewportStartIdx && idx <= viewportEndIdx;
if (isOnScreen) {
return;
}
setTimeout(() => {
virtualizerRef.current?.scrollToIndex(idx, {
align: 'center',
behavior: 'auto',
});
const sidebarScrollEl = scrollContainerRef.current?.querySelector(
'.resizable-box__content',
);
if (sidebarScrollEl) {
const targetScrollLeft = Math.max(0, span.level * CONNECTOR_WIDTH - 40);
(sidebarScrollEl as HTMLElement).scrollLeft = targetScrollLeft;
}
}, 100);
},
[],
);
useEffect(() => {
if (interestedSpanId.spanId !== '' && virtualizerRef.current) {
if (interestedSpanId.spanId !== '') {
const idx = spans.findIndex(
(span) => span.span_id === interestedSpanId.spanId,
);
if (idx !== -1) {
const visible = virtualizerRef.current.getVirtualItems();
const isOnScreen =
visible.length > 0 &&
idx >= visible[0].index &&
idx <= visible[visible.length - 1].index;
if (!isOnScreen) {
setTimeout(() => {
virtualizerRef.current?.scrollToIndex(idx, {
align: 'center',
behavior: 'auto',
});
// Auto-scroll sidebar horizontally to show the span name
const span = spans[idx];
const sidebarScrollEl = scrollContainerRef.current?.querySelector(
'.resizable-box__content',
);
if (sidebarScrollEl) {
const targetScrollLeft = Math.max(0, span.level * CONNECTOR_WIDTH - 40);
sidebarScrollEl.scrollLeft = targetScrollLeft;
}
}, 400);
}
scrollSpanIntoView(spans[idx], spans);
setSelectedSpan(spans[idx]);
}
} else {
setSelectedSpan((prev) => {
if (!prev) {
return spans[0];
}
return prev;
});
setSelectedSpan((prev) => prev ?? spans[0]);
}
}, [interestedSpanId, setSelectedSpan, spans]);
}, [interestedSpanId, setSelectedSpan, spans, scrollSpanIntoView]);
// Covers URL-driven navigation to an already-loaded span (flamegraph /
// filter / browser back) that the interestedSpanId-keyed effect doesn't see.
useEffect(() => {
if (selectedSpan) {
scrollSpanIntoView(selectedSpan, spans);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSpan, scrollSpanIntoView]);
const virtualItems = virtualizer.getVirtualItems();
const leftRows = leftTable.getRowModel().rows;
@@ -846,7 +863,7 @@ function Success(props: ISuccessProps): JSX.Element {
<div
className={styles.splitBody}
style={{
minHeight: virtualizer.getTotalSize(),
minHeight: virtualizer.getTotalSize() + WATERFALL_BOTTOM_PADDING,
height: '100%',
}}
>

View File

@@ -74,17 +74,21 @@ function TraceDetailsV3(): JSX.Element {
onClose: handleSpanDetailsClose,
});
const allSpansRef = useRef<SpanV3[]>([]);
// Refetch only when the URL target isn't already loaded. Keeps row clicks
// and other in-window URL navigation from triggering a backend window slide.
useEffect(() => {
const spanId = urlQuery.get('spanId') || '';
// Only update interestedSpanId when a new span is selected,
// not when it's cleared (panel close) — avoids unnecessary API refetch
if (!spanId) {
return;
}
setInterestedSpanId({
spanId,
isUncollapsed: true,
});
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
if (idx !== -1) {
setSelectedSpan(allSpansRef.current[idx]);
return;
}
setInterestedSpanId({ spanId, isUncollapsed: true });
}, [urlQuery]);
// Hardcoded for now — fetch aggregations for all 3 candidate color-by fields
@@ -145,6 +149,10 @@ function TraceDetailsV3(): JSX.Element {
};
}
useEffect(() => {
allSpansRef.current = allSpans;
}, [allSpans]);
// Frontend mode: expand all parents by default when full data arrives
useEffect(() => {
if (isFullDataLoaded && allSpans.length > 0) {

View File

@@ -11,6 +11,13 @@ import type {
} from 'hooks/useAuthZ/types';
import { rest } from 'msw';
import type { RestHandler } from 'msw';
import {
LicenseEvent,
LicensePlatform,
type LicenseResModel,
LicenseState,
LicenseStatus,
} from 'types/api/licensesV3/getActive';
export const AUTHZ_CHECK_URL = `${ENVIRONMENT.baseURL || ''}/api/v1/authz/check`;
@@ -97,6 +104,40 @@ export function setupAuthzAllow(
});
}
export function buildLicense(
overrides?: Partial<LicenseResModel>,
): LicenseResModel {
return {
key: 'test-key',
status: LicenseStatus.VALID,
state: LicenseState.ACTIVATED,
platform: LicensePlatform.CLOUD,
event_queue: {
created_at: '0',
event: LicenseEvent.NO_EVENT,
scheduled_at: '0',
status: '',
updated_at: '0',
},
plan: {
created_at: '0',
description: '',
is_active: true,
name: '',
updated_at: '0',
},
plan_id: '0',
free_until: '0',
updated_at: '0',
valid_from: 0,
valid_until: 0,
created_at: '0',
...overrides,
};
}
export const invalidLicense = buildLicense({ status: LicenseStatus.INVALID });
export function mockUseAuthZGrantAll(
permissions: BrandedPermission[],
_options?: UseAuthZOptions,

View File

@@ -48,7 +48,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
HOME: ['ADMIN', 'EDITOR', 'VIEWER'],
ALERTS_NEW: ['ADMIN', 'EDITOR'],
ORG_SETTINGS: ['ADMIN'],
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
SERVICE_MAP: ['ADMIN', 'EDITOR', 'VIEWER'],
ALL_CHANNELS: ['ADMIN', 'EDITOR', 'VIEWER'],
INGESTION_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -72,7 +72,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR', 'ANONYMOUS'],
PASSWORD_RESET: ['ADMIN', 'EDITOR', 'VIEWER'],
SERVICE_METRICS: ['ADMIN', 'EDITOR', 'VIEWER'],
SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
SIGN_UP: ['ADMIN', 'EDITOR', 'VIEWER'],
TRACES_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
TRACE: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -98,10 +98,10 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
GET_STARTED_AZURE_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLES_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLE_DETAILS: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLES_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
ROLE_DETAILS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
MEMBERS_SETTINGS: ['ADMIN'],
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
BILLING: ['ADMIN'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],

View File

@@ -64,7 +64,8 @@ func New(ctx context.Context, settings factory.ProviderSettings, config cache.Co
o.ObserveInt64(telemetry.setsRejected, int64(metrics.SetsRejected()), metric.WithAttributes(attributes...))
o.ObserveInt64(telemetry.getsDropped, int64(metrics.GetsDropped()), metric.WithAttributes(attributes...))
o.ObserveInt64(telemetry.getsKept, int64(metrics.GetsKept()), metric.WithAttributes(attributes...))
o.ObserveInt64(telemetry.totalCost, int64(cc.MaxCost()), metric.WithAttributes(attributes...))
o.ObserveInt64(telemetry.costUsed, int64(metrics.CostAdded())-int64(metrics.CostEvicted()), metric.WithAttributes(attributes...))
o.ObserveInt64(telemetry.totalCost, cc.MaxCost(), metric.WithAttributes(attributes...))
return nil
},
telemetry.cacheRatio,
@@ -79,6 +80,7 @@ func New(ctx context.Context, settings factory.ProviderSettings, config cache.Co
telemetry.setsRejected,
telemetry.getsDropped,
telemetry.getsKept,
telemetry.costUsed,
telemetry.totalCost,
)
if err != nil {
@@ -112,11 +114,13 @@ func (provider *provider) Set(ctx context.Context, orgID valuer.UUID, cacheKey s
}
if cloneable, ok := data.(cachetypes.Cloneable); ok {
cost := max(cloneable.Cost(), 1)
// Clamp to a minimum of 1: ristretto treats cost 0 specially and we
// never want zero-size entries to bypass admission accounting.
span.SetAttributes(attribute.Bool("memory.cloneable", true))
span.SetAttributes(attribute.Int64("memory.cost", 1))
span.SetAttributes(attribute.Int64("memory.cost", cost))
toCache := cloneable.Clone()
// In case of contention we are choosing to evict the cloneable entries first hence cost is set to 1
if ok := provider.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), toCache, 1, ttl); !ok {
if ok := provider.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), toCache, cost, ttl); !ok {
return errors.New(errors.TypeInternal, errors.CodeInternal, "error writing to cache")
}
@@ -125,15 +129,15 @@ func (provider *provider) Set(ctx context.Context, orgID valuer.UUID, cacheKey s
}
toCache, err := provider.marshalBinary(ctx, data)
cost := int64(len(toCache))
if err != nil {
return err
}
cost := max(int64(len(toCache)), 1)
span.SetAttributes(attribute.Bool("memory.cloneable", false))
span.SetAttributes(attribute.Int64("memory.cost", cost))
if ok := provider.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), toCache, 1, ttl); !ok {
if ok := provider.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), toCache, cost, ttl); !ok {
return errors.New(errors.TypeInternal, errors.CodeInternal, "error writing to cache")
}

View File

@@ -31,6 +31,10 @@ func (cloneable *CloneableA) Clone() cachetypes.Cacheable {
}
}
func (cloneable *CloneableA) Cost() int64 {
return int64(len(cloneable.Key)) + 16
}
func (cloneable *CloneableA) MarshalBinary() ([]byte, error) {
return json.Marshal(cloneable)
}
@@ -165,6 +169,45 @@ func TestSetGetWithDifferentTypes(t *testing.T) {
assert.Error(t, err)
}
// LargeCloneable reports a large byte cost so we can test ristretto eviction
// without allocating the full payload in memory.
type LargeCloneable struct {
Key string
CostHint int64
}
func (c *LargeCloneable) Clone() cachetypes.Cacheable {
return &LargeCloneable{Key: c.Key, CostHint: c.CostHint}
}
func (c *LargeCloneable) Cost() int64 { return c.CostHint }
func (c *LargeCloneable) MarshalBinary() ([]byte, error) { return json.Marshal(c) }
func (c *LargeCloneable) UnmarshalBinary(data []byte) error { return json.Unmarshal(data, c) }
func TestCloneableExceedingMaxCostIsRejected(t *testing.T) {
const maxCost int64 = 1 << 20 // 1 MiB
const oversize int64 = 2 << 20 // 2 MiB, larger than the entire cache
c, err := New(context.Background(), factorytest.NewSettings(), cache.Config{Provider: "memory", Memory: cache.Memory{
NumCounters: 10 * 1000,
MaxCost: maxCost,
}})
require.NoError(t, err)
orgID := valuer.GenerateUUID()
const key = "oversize-key"
assert.NoError(t, c.Set(context.Background(), orgID, key,
&LargeCloneable{Key: key, CostHint: oversize}, time.Minute))
// Ristretto rejects any entry with cost > MaxCost (policy.go:100). Probe
// ristretto directly to confirm no admission, instead of relying on metrics.
cc := c.(*provider).cc
_, ok := cc.Get(strings.Join([]string{orgID.StringValue(), key}, "::"))
assert.False(t, ok, "entry with Cost() > MaxCost must be rejected")
}
func TestCloneableConcurrentSetGet(t *testing.T) {
cache, err := New(context.Background(), factorytest.NewSettings(), cache.Config{Provider: "memory", Memory: cache.Memory{
NumCounters: 10 * 1000,

View File

@@ -7,17 +7,18 @@ import (
type telemetry struct {
cacheRatio metric.Float64ObservableGauge
cacheHits metric.Int64ObservableGauge
cacheMisses metric.Int64ObservableGauge
costAdded metric.Int64ObservableGauge
costEvicted metric.Int64ObservableGauge
keysAdded metric.Int64ObservableGauge
keysEvicted metric.Int64ObservableGauge
keysUpdated metric.Int64ObservableGauge
setsDropped metric.Int64ObservableGauge
setsRejected metric.Int64ObservableGauge
getsDropped metric.Int64ObservableGauge
getsKept metric.Int64ObservableGauge
cacheHits metric.Int64ObservableCounter
cacheMisses metric.Int64ObservableCounter
costAdded metric.Int64ObservableCounter
costEvicted metric.Int64ObservableCounter
keysAdded metric.Int64ObservableCounter
keysEvicted metric.Int64ObservableCounter
keysUpdated metric.Int64ObservableCounter
setsDropped metric.Int64ObservableCounter
setsRejected metric.Int64ObservableCounter
getsDropped metric.Int64ObservableCounter
getsKept metric.Int64ObservableCounter
costUsed metric.Int64ObservableGauge
totalCost metric.Int64ObservableGauge
}
@@ -28,62 +29,67 @@ func newMetrics(meter metric.Meter) (*telemetry, error) {
errs = errors.Join(errs, err)
}
cacheHits, err := meter.Int64ObservableGauge("signoz.cache.hits", metric.WithDescription("Hits is the number of Get calls where a value was found for the corresponding key."))
cacheHits, err := meter.Int64ObservableCounter("signoz.cache.hits", metric.WithDescription("Hits is the number of Get calls where a value was found for the corresponding key."))
if err != nil {
errs = errors.Join(errs, err)
}
cacheMisses, err := meter.Int64ObservableGauge("signoz.cache.misses", metric.WithDescription("Misses is the number of Get calls where a value was not found for the corresponding key"))
cacheMisses, err := meter.Int64ObservableCounter("signoz.cache.misses", metric.WithDescription("Misses is the number of Get calls where a value was not found for the corresponding key"))
if err != nil {
errs = errors.Join(errs, err)
}
costAdded, err := meter.Int64ObservableGauge("signoz.cache.cost.added", metric.WithDescription("CostAdded is the sum of costs that have been added (successful Set calls)"))
costAdded, err := meter.Int64ObservableCounter("signoz.cache.cost.added", metric.WithDescription("CostAdded is the sum of costs that have been added (successful Set calls)"))
if err != nil {
errs = errors.Join(errs, err)
}
costEvicted, err := meter.Int64ObservableGauge("signoz.cache.cost.evicted", metric.WithDescription("CostEvicted is the sum of all costs that have been evicted"))
costEvicted, err := meter.Int64ObservableCounter("signoz.cache.cost.evicted", metric.WithDescription("CostEvicted is the sum of all costs that have been evicted"))
if err != nil {
errs = errors.Join(errs, err)
}
keysAdded, err := meter.Int64ObservableGauge("signoz.cache.keys.added", metric.WithDescription("KeysAdded is the total number of Set calls where a new key-value item was added"))
keysAdded, err := meter.Int64ObservableCounter("signoz.cache.keys.added", metric.WithDescription("KeysAdded is the total number of Set calls where a new key-value item was added"))
if err != nil {
errs = errors.Join(errs, err)
}
keysEvicted, err := meter.Int64ObservableGauge("signoz.cache.keys.evicted", metric.WithDescription("KeysEvicted is the total number of keys evicted"))
keysEvicted, err := meter.Int64ObservableCounter("signoz.cache.keys.evicted", metric.WithDescription("KeysEvicted is the total number of keys evicted"))
if err != nil {
errs = errors.Join(errs, err)
}
keysUpdated, err := meter.Int64ObservableGauge("signoz.cache.keys.updated", metric.WithDescription("KeysUpdated is the total number of Set calls where the value was updated"))
keysUpdated, err := meter.Int64ObservableCounter("signoz.cache.keys.updated", metric.WithDescription("KeysUpdated is the total number of Set calls where the value was updated"))
if err != nil {
errs = errors.Join(errs, err)
}
setsDropped, err := meter.Int64ObservableGauge("signoz.cache.sets.dropped", metric.WithDescription("SetsDropped is the number of Set calls that don't make it into internal buffers (due to contention or some other reason)"))
setsDropped, err := meter.Int64ObservableCounter("signoz.cache.sets.dropped", metric.WithDescription("SetsDropped is the number of Set calls that don't make it into internal buffers (due to contention or some other reason)"))
if err != nil {
errs = errors.Join(errs, err)
}
setsRejected, err := meter.Int64ObservableGauge("signoz.cache.sets.rejected", metric.WithDescription("SetsRejected is the number of Set calls rejected by the policy (TinyLFU)"))
setsRejected, err := meter.Int64ObservableCounter("signoz.cache.sets.rejected", metric.WithDescription("SetsRejected is the number of Set calls rejected by the policy (TinyLFU)"))
if err != nil {
errs = errors.Join(errs, err)
}
getsDropped, err := meter.Int64ObservableGauge("signoz.cache.gets.dropped", metric.WithDescription("GetsDropped is the number of Get calls that don't make it into internal buffers (due to contention or some other reason)"))
getsDropped, err := meter.Int64ObservableCounter("signoz.cache.gets.dropped", metric.WithDescription("GetsDropped is the number of Get calls that don't make it into internal buffers (due to contention or some other reason)"))
if err != nil {
errs = errors.Join(errs, err)
}
getsKept, err := meter.Int64ObservableGauge("signoz.cache.gets.kept", metric.WithDescription("GetsKept is the number of Get calls that make it into internal buffers"))
getsKept, err := meter.Int64ObservableCounter("signoz.cache.gets.kept", metric.WithDescription("GetsKept is the number of Get calls that make it into internal buffers"))
if err != nil {
errs = errors.Join(errs, err)
}
totalCost, err := meter.Int64ObservableGauge("signoz.cache.total.cost", metric.WithDescription("TotalCost is the available cost configured for the cache"))
costUsed, err := meter.Int64ObservableGauge("signoz.cache.cost.used", metric.WithDescription("CostUsed is the current retained cost in the cache (CostAdded - CostEvicted)."))
if err != nil {
errs = errors.Join(errs, err)
}
totalCost, err := meter.Int64ObservableGauge("signoz.cache.total.cost", metric.WithDescription("TotalCost is the configured MaxCost ceiling for the cache."))
if err != nil {
errs = errors.Join(errs, err)
}
@@ -105,6 +111,7 @@ func newMetrics(meter metric.Meter) (*telemetry, error) {
setsRejected: setsRejected,
getsDropped: getsDropped,
getsKept: getsKept,
costUsed: costUsed,
totalCost: totalCost,
}, nil
}

View File

@@ -29,6 +29,10 @@ func (cacheable *CacheableA) Clone() cachetypes.Cacheable {
}
}
func (cacheable *CacheableA) Cost() int64 {
return int64(len(cacheable.Key)) + 16
}
func (cacheable *CacheableA) MarshalBinary() ([]byte, error) {
return json.Marshal(cacheable)
}

View File

@@ -9,7 +9,8 @@ var (
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
FeatureUseFineGrainedAuthz = featuretypes.MustNewName("use_fine_grained_authz")
)
func MustNewRegistry() featuretypes.Registry {
@@ -70,6 +71,14 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureUseFineGrainedAuthz,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether fine-grained authorization is enabled",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
)
if err != nil {
panic(err)

View File

@@ -265,15 +265,6 @@ func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string,
return nil, err
}
// TODO: This should move to readAsRaw function in consume.go but for now we are keeping it here since it's only relevant for traces
if q.spec.Signal == telemetrytypes.SignalTraces {
if raw, ok := payload.(*qbtypes.RawData); ok {
for _, rr := range raw.Rows {
mergeSpanAttributeColumns(rr.Data)
}
}
}
return &qbtypes.Result{
Type: q.kind,
Value: payload,

View File

@@ -13,7 +13,6 @@ import (
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -432,53 +431,6 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
}, nil
}
// mergeSpanAttributeColumns merges (attributes_string, attributes_number, attributes_bool, resources_string) into
// unified "attributes" and "resource" keys, and parses the stringified `events`
// and `links` columns into structured slices. Raw DB columns are removed.
func mergeSpanAttributeColumns(data map[string]any) {
attrStr, hasStr := data["attributes_string"]
attrNum, hasNum := data["attributes_number"]
attrBool, hasBool := data["attributes_bool"]
// todo(nitya): move to resource json
resStr, hasRes := data["resources_string"]
if hasStr || hasNum || hasBool || hasRes {
attributes := make(map[string]any)
if m, ok := attrStr.(map[string]string); ok {
for k, v := range m {
attributes[k] = v
}
}
if m, ok := attrNum.(map[string]float64); ok {
for k, v := range m {
attributes[k] = v
}
}
if m, ok := attrBool.(map[string]bool); ok {
for k, v := range m {
attributes[k] = v
}
}
delete(data, "attributes_string")
delete(data, "attributes_number")
delete(data, "attributes_bool")
data["attributes"] = attributes
resource := map[string]string{}
if m, ok := resStr.(map[string]string); ok {
resource = m
}
data["resource"] = resource
delete(data, "resources_string")
}
if raw, ok := data["events"]; ok {
data["events"] = spantypes.ParseEvents(raw)
}
if raw, ok := data["links"]; ok {
data["links"] = spantypes.ParseLinks(raw)
}
}
// numericAsFloat converts numeric types to float64 efficiently.
func numericAsFloat(v any) float64 {
switch x := v.(type) {

View File

@@ -1,91 +0,0 @@
package querier
import (
"reflect"
"testing"
"github.com/SigNoz/signoz/pkg/types/spantypes"
)
func TestMergeSpanAttributeColumns_ParsesEventsAndLinks(t *testing.T) {
data := map[string]any{
"attributes_string": map[string]string{"http.method": "GET"},
"attributes_number": map[string]float64{"http.status_code": 200},
"attributes_bool": map[string]bool{"is_root": true},
"resources_string": map[string]string{"service.name": "api"},
"events": []string{
`{"name":"request_received","timeUnixNano":1778489782759245000,"attributeMap":{"http.method":"GET","http.route":"/api/chat"}}`,
`{"name":"cache_lookup","timeUnixNano":1778489782811697000,"attributeMap":{"cache.hit":"true","cache.key":"user:123:prompt"}}`,
},
"links": `[{"traceId":"abc","spanId":"123","refType":"CHILD_OF"},{"traceId":"def","spanId":"456","refType":"FOLLOWS_FROM"}]`,
}
mergeSpanAttributeColumns(data)
attrs, ok := data["attributes"].(map[string]any)
if !ok {
t.Fatalf("expected attributes to be map[string]any, got %T", data["attributes"])
}
if attrs["http.method"] != "GET" || attrs["http.status_code"] != float64(200) || attrs["is_root"] != true {
t.Fatalf("attributes not merged correctly: %#v", attrs)
}
res, ok := data["resource"].(map[string]string)
if !ok || res["service.name"] != "api" {
t.Fatalf("resource not set correctly: %#v", data["resource"])
}
for _, removed := range []string{"attributes_string", "attributes_number", "attributes_bool", "resources_string"} {
if _, present := data[removed]; present {
t.Fatalf("expected %s to be removed", removed)
}
}
events, ok := data["events"].([]spantypes.Event)
if !ok {
t.Fatalf("expected events to be []spantypes.Event, got %T", data["events"])
}
wantEvents := []spantypes.Event{
{
Name: "request_received",
TimeUnixNano: 1778489782759245000,
Attributes: map[string]any{"http.method": "GET", "http.route": "/api/chat"},
},
{
Name: "cache_lookup",
TimeUnixNano: 1778489782811697000,
Attributes: map[string]any{"cache.hit": "true", "cache.key": "user:123:prompt"},
},
}
if !reflect.DeepEqual(events, wantEvents) {
t.Fatalf("events parsed incorrectly:\n got: %#v\nwant: %#v", events, wantEvents)
}
links, ok := data["links"].([]spantypes.Link)
if !ok {
t.Fatalf("expected links to be []spantypes.Link, got %T", data["links"])
}
wantLinks := []spantypes.Link{
{TraceID: "abc", SpanID: "123"},
{TraceID: "def", SpanID: "456"},
}
if !reflect.DeepEqual(links, wantLinks) {
t.Fatalf("links parsed incorrectly:\n got: %#v\nwant: %#v", links, wantLinks)
}
}
func TestMergeSpanAttributeColumns_EmptyEventsAndLinks(t *testing.T) {
data := map[string]any{
"events": []string{},
"links": "[]",
}
mergeSpanAttributeColumns(data)
if events, ok := data["events"].([]spantypes.Event); !ok || len(events) != 0 {
t.Fatalf("expected empty []spantypes.Event, got %#v", data["events"])
}
if links, ok := data["links"].([]spantypes.Link); !ok || len(links) != 0 {
t.Fatalf("expected empty []spantypes.Link, got %#v", data["links"])
}
}

View File

@@ -335,10 +335,8 @@ func (q *querier) applyFormulas(ctx context.Context, results map[string]*qbtypes
}
case qbtypes.RequestTypeScalar:
result := q.processScalarFormula(ctx, results, formula, req)
if result != nil {
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
results[name] = result
}
// For scalar results, apply limit by processScalarFormula itself since it needs to be applied before converting back to scalar format
results[name] = result
}
}
@@ -526,6 +524,9 @@ func (q *querier) processScalarFormula(
return nil
}
// Apply ordering (and limit) before converting to scalar format.
formulaSeries = qbtypes.ApplySeriesLimit(formulaSeries, formula.Order, formula.Limit)
// Convert back to scalar format
scalarResult := &qbtypes.ScalarData{
QueryName: formula.Name,

View File

@@ -1,15 +1,155 @@
package querier
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// scalarInputResult builds a ScalarData result with one group column ("service")
// and one aggregation column ("__result"), holding the provided (service, value) rows.
func scalarInputResult(queryName string, rows []struct {
service string
value float64
}) *qbtypes.Result {
serviceKey := telemetrytypes.TelemetryFieldKey{
Name: "service",
FieldDataType: telemetrytypes.FieldDataTypeString,
}
resultKey := telemetrytypes.TelemetryFieldKey{
Name: "__result",
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
}
data := make([][]any, 0, len(rows))
for _, r := range rows {
data = append(data, []any{r.service, r.value})
}
return &qbtypes.Result{
Value: &qbtypes.ScalarData{
QueryName: queryName,
Columns: []*qbtypes.ColumnDescriptor{
{
TelemetryFieldKey: serviceKey,
QueryName: queryName,
Type: qbtypes.ColumnTypeGroup,
},
{
TelemetryFieldKey: resultKey,
QueryName: queryName,
AggregationIndex: 0,
Type: qbtypes.ColumnTypeAggregation,
},
},
Data: data,
},
}
}
func TestProcessScalarFormula_AppliesOrderAndLimit(t *testing.T) {
q := &querier{
logger: instrumentationtest.New().Logger(),
}
// Mimic what a dashboard emits: orderBy keyed by the formula name ("F1"),
// which applyFormulas rewrites to __result before sorting.
orderByFormula := func(name string, dir qbtypes.OrderDirection) []qbtypes.OrderBy {
return []qbtypes.OrderBy{
{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: name,
},
},
Direction: dir,
},
}
}
// A+B per service: a=101, b=11, c=2
makeInputs := func() map[string]*qbtypes.Result {
return map[string]*qbtypes.Result{
"A": scalarInputResult("A", []struct {
service string
value float64
}{
{"a", 100},
{"b", 10},
{"c", 1},
}),
"B": scalarInputResult("B", []struct {
service string
value float64
}{
{"a", 1},
{"b", 0},
{"c", 1},
}),
}
}
makeReq := func(formula qbtypes.QueryBuilderFormula) *qbtypes.QueryRangeRequest {
return &qbtypes.QueryRangeRequest{
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{
{Type: qbtypes.QueryTypeBuilder, Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{Name: "A"}},
{Type: qbtypes.QueryTypeBuilder, Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{Name: "B"}},
{Type: qbtypes.QueryTypeFormula, Spec: formula},
},
},
}
}
t.Run("F1 desc with limit truncates and sorts", func(t *testing.T) {
formula := qbtypes.QueryBuilderFormula{
Name: "F1",
Expression: "A + B",
Order: orderByFormula("F1", qbtypes.OrderDirectionDesc),
Limit: 2,
}
out := q.applyFormulas(context.Background(), makeInputs(), makeReq(formula))
got, ok := out["F1"]
require.True(t, ok, "formula result missing")
scalar, ok := got.Value.(*qbtypes.ScalarData)
require.True(t, ok, "expected *ScalarData, got %T", got.Value)
// Limit=2 + F1 desc: the two largest __result rows in descending order.
require.Len(t, scalar.Data, 2, "limit=2 was ignored before the fix")
require.Equal(t, "a", scalar.Data[0][0])
require.InDelta(t, 101.0, scalar.Data[0][1].(float64), 1e-9)
require.Equal(t, "b", scalar.Data[1][0])
require.InDelta(t, 10.0, scalar.Data[1][1].(float64), 1e-9)
})
t.Run("F1 desc without limit sorts all rows", func(t *testing.T) {
formula := qbtypes.QueryBuilderFormula{
Name: "F1",
Expression: "A / B",
Order: orderByFormula("F1", qbtypes.OrderDirectionAsc),
}
out := q.applyFormulas(context.Background(), makeInputs(), makeReq(formula))
got, ok := out["F1"]
require.True(t, ok)
scalar, ok := got.Value.(*qbtypes.ScalarData)
require.True(t, ok)
require.Len(t, scalar.Data, 2)
require.Equal(t, "c", scalar.Data[0][0])
require.InDelta(t, 1.0, scalar.Data[0][1].(float64), 1e-9)
require.Equal(t, "a", scalar.Data[1][0])
require.InDelta(t, 100.0, scalar.Data[1][1].(float64), 1e-9)
})
}
// Multiple series with different number of labels, shouldn't panic and should align labels correctly.
func TestConvertTimeSeriesDataToScalar_RaggedLabels(t *testing.T) {
label := func(name string, value any) *qbtypes.Label {

View File

@@ -85,13 +85,6 @@ func (q *traceOperatorQuery) executeWithContext(ctx context.Context, query strin
return nil, err
}
// TODO: This should move to readAsRaw function in consume.go but for now we can keep it here since it's only relevant for traces
if raw, ok := payload.(*qbtypes.RawData); ok {
for _, rr := range raw.Rows {
mergeSpanAttributeColumns(rr.Data)
}
}
return &qbtypes.Result{
Type: q.kind,
Value: payload,

View File

@@ -1784,6 +1784,15 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
fineGrainedAuthz := aH.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureUseFineGrainedAuthz, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureUseFineGrainedAuthz.String()),
Active: fineGrainedAuthz,
Usage: 0,
UsageLimit: -1,
Route: "",
})
if constants.IsDotMetricsEnabled {
for idx, feature := range featureSet {
if feature.Name == licensetypes.DotMetricsEnabled {

View File

@@ -769,6 +769,13 @@ func ParseQueryRangeParams(r *http.Request) (*v3.QueryRangeParamsV3, *model.ApiE
return nil, &model.ApiError{Typ: model.ErrorBadData, Err: err}
}
// Clamp the top-level Step for PromQL
if queryRangeParams.CompositeQuery.QueryType == v3.QueryTypePromQL {
if minStep := common.MinAllowedStepInterval(queryRangeParams.Start, queryRangeParams.End); queryRangeParams.Step < minStep {
queryRangeParams.Step = minStep
}
}
// prepare the variables for the corresponding query type
formattedVars := make(map[string]interface{})
for name, value := range queryRangeParams.Variables {

View File

@@ -41,6 +41,11 @@ func (c *GetWaterfallSpansForTraceWithMetadataCache) Clone() cachetypes.Cacheabl
}
}
func (c *GetWaterfallSpansForTraceWithMetadataCache) Cost() int64 {
const perSpanBytes = 256
return int64(c.TotalSpans) * perSpanBytes
}
func (c *GetWaterfallSpansForTraceWithMetadataCache) MarshalBinary() (data []byte, err error) {
return json.Marshal(c)
}
@@ -66,6 +71,16 @@ func (c *GetFlamegraphSpansForTraceCache) Clone() cachetypes.Cacheable {
}
}
func (c *GetFlamegraphSpansForTraceCache) Cost() int64 {
const perSpanBytes = 128
var spans int64
for _, row := range c.SelectedSpans {
spans += int64(len(row))
}
spans += int64(len(c.TraceRoots))
return spans * perSpanBytes
}
func (c *GetFlamegraphSpansForTraceCache) MarshalBinary() (data []byte, err error) {
return json.Marshal(c)
}

View File

@@ -1,50 +1,6 @@
package telemetrytraces
import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const (
// Internal Columns.
SpanTimestampBucketStartColumn = "ts_bucket_start"
SpanResourceFingerPrintColumn = "resource_fingerprint"
// Intrinsic Columns.
SpanTimestampColumn = "timestamp"
SpanTraceIDColumn = "trace_id"
SpanSpanIDColumn = "span_id"
SpanTraceStateColumn = "trace_state"
SpanParentSpanIDColumn = "parent_span_id"
SpanFlagsColumn = "flags"
SpanNameColumn = "name"
SpanKindColumn = "kind"
SpanKindStringColumn = "kind_string"
SpanDurationNanoColumn = "duration_nano"
SpanStatusCodeColumn = "status_code"
SpanStatusMessageColumn = "status_message"
SpanStatusCodeStringColumn = "status_code_string"
SpanEventsColumn = "events"
SpanLinksColumn = "links"
// Calculated Columns.
SpanResponseStatusCodeColumn = "response_status_code"
SpanExternalHTTPURLColumn = "external_http_url"
SpanHTTPURLColumn = "http_url"
SpanExternalHTTPMethodColumn = "external_http_method"
SpanHTTPMethodColumn = "http_method"
SpanHTTPHostColumn = "http_host"
SpanDBNameColumn = "db_name"
SpanDBOperationColumn = "db_operation"
SpanHasErrorColumn = "has_error"
SpanIsRemoteColumn = "is_remote"
// Contextual Columns.
SpanAttributesStringColumn = "attributes_string"
SpanAttributesNumberColumn = "attributes_number"
SpanAttributesBoolColumn = "attributes_bool"
SpanResourcesStringColumn = "resources_string"
)
import "github.com/SigNoz/signoz/pkg/types/telemetrytypes"
var (
IntrinsicFields = map[string]telemetrytypes.TelemetryFieldKey{
@@ -378,51 +334,6 @@ var (
SpanSearchScopeRoot = "isroot"
SpanSearchScopeEntryPoint = "isentrypoint"
// IntrinsicSpanFields lists the intrinsic span columns, in the order they
// should appear when a raw query expands its SelectFields.
IntrinsicSpanFields = []telemetrytypes.TelemetryFieldKey{
{Name: SpanTimestampColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanTraceIDColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanSpanIDColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanTraceStateColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanParentSpanIDColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanFlagsColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanNameColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanKindColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanKindStringColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanDurationNanoColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanStatusCodeColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanStatusMessageColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanStatusCodeStringColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanEventsColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanLinksColumn, FieldContext: telemetrytypes.FieldContextSpan},
}
// CalculatedSpanFields lists the calculated/derived span columns, in the
// order they should appear when a raw query expands its SelectFields.
CalculatedSpanFields = []telemetrytypes.TelemetryFieldKey{
{Name: SpanResponseStatusCodeColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanExternalHTTPURLColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanHTTPURLColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanExternalHTTPMethodColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanHTTPMethodColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanHTTPHostColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanDBNameColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanDBOperationColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanHasErrorColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanIsRemoteColumn, FieldContext: telemetrytypes.FieldContextSpan},
}
// ContextualSpanColumns lists the typed attribute and resource columns
// selected raw (rather than via ColumnExpressionFor) so that consume.go
// can merge them into unified "attributes" and "resource" maps.
ContextualSpanColumns = []string{
SpanAttributesStringColumn,
SpanAttributesNumberColumn,
SpanAttributesBoolColumn,
SpanResourcesStringColumn,
}
DefaultFields = map[string]telemetrytypes.TelemetryFieldKey{
"timestamp": {
Name: "timestamp",

View File

@@ -78,17 +78,6 @@ func TestGetFieldKeyName(t *testing.T) {
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists`==true, `resource_string_deployment$$environment`, NULL)",
expectedError: nil,
},
{
// Query like `attribute.attribute_string:string` should resolve to `attributes_string['attribute_string']`.
name: "Attribute key whose name collides with contextual map column resolves as a map lookup",
key: telemetrytypes.TelemetryFieldKey{
Name: SpanAttributesStringColumn,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
expectedResult: "attributes_string['attributes_string']",
expectedError: nil,
},
{
name: "Non-existent column",
key: telemetrytypes.TelemetryFieldKey{

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
@@ -15,6 +16,7 @@ import (
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/huandu/go-sqlbuilder"
"golang.org/x/exp/maps"
)
var (
@@ -87,22 +89,49 @@ func (b *traceQueryStatementBuilder) Build(
return nil, err
}
isSelectFieldsEmpty := false
/*
Adding a tech debt note here:
This piece of code is a hot fix and should be removed once we close issue: engineering-pod/issues/3622
*/
/*
-------------------------------- Start of tech debt ----------------------------
*/
if requestType == qbtypes.RequestTypeRaw {
isSelectFieldsEmpty = len(query.SelectFields) == 0
// we are expanding here to ensure that all the conflicts are taken care in adjustKeys
// i.e if there is a conflict we strip away context of the key in adjustKeys
query = b.expandRawSelectFields(query)
}
query = b.adjustKeys(ctx, keys, query, requestType)
selectedFields := query.SelectFields
if len(selectedFields) == 0 {
sortedKeys := maps.Keys(DefaultFields)
slices.Sort(sortedKeys)
for _, key := range sortedKeys {
selectedFields = append(selectedFields, DefaultFields[key])
}
query.SelectFields = selectedFields
}
selectFieldKeys := []string{}
for _, field := range selectedFields {
selectFieldKeys = append(selectFieldKeys, field.Name)
}
for _, x := range []string{"timestamp", "span_id", "trace_id"} {
if !slices.Contains(selectFieldKeys, x) {
query.SelectFields = append(query.SelectFields, DefaultFields[x])
}
}
}
/*
-------------------------------- End of tech debt ----------------------------
*/
adjustTraceKeys(ctx, b.logger, keys, &query, requestType)
// Create SQL builder
q := sqlbuilder.NewSelectBuilder()
switch requestType {
case qbtypes.RequestTypeRaw:
return b.buildListQuery(ctx, q, query, start, end, keys, variables, isSelectFieldsEmpty)
return b.buildListQuery(ctx, q, query, start, end, keys, variables)
case qbtypes.RequestTypeTimeSeries:
return b.buildTimeSeriesQuery(ctx, q, query, start, end, keys, variables)
case qbtypes.RequestTypeScalar:
@@ -164,24 +193,25 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
return keySelectors
}
func (b *traceQueryStatementBuilder) adjustKeys(ctx context.Context, keys map[string][]*telemetrytypes.TelemetryFieldKey, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], requestType qbtypes.RequestType) qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation] {
// add deprecated fields only during statement building
// why?
// 1. to not fail filter expression that use deprecated cols
// 2. this could have been moved to metadata fetching itself, however, that
// would mean, they also show up in suggestions we we don't want to do
// 3. reason for not doing a simple append is to keep intrinsic/calculated field first so that it gets
// priority in multi_if sql expression
// mergeDeprecatedTraceKeys prepends deprecated intrinsic/calculated trace field
// definitions to the keys map so that filter expressions referencing deprecated
// columns continue to resolve. Prepending keeps the intrinsic/calculated entry
// first so it wins in the multi_if SQL expression.
func mergeDeprecatedTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey) {
for fieldKeyName, fieldKey := range IntrinsicFieldsDeprecated {
keys[fieldKeyName] = append([]*telemetrytypes.TelemetryFieldKey{&fieldKey}, keys[fieldKeyName]...)
}
for fieldKeyName, fieldKey := range CalculatedFieldsDeprecated {
keys[fieldKeyName] = append([]*telemetrytypes.TelemetryFieldKey{&fieldKey}, keys[fieldKeyName]...)
}
}
func adjustTraceKeys(ctx context.Context, logger *slog.Logger, keys map[string][]*telemetrytypes.TelemetryFieldKey, query *qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], requestType qbtypes.RequestType) {
mergeDeprecatedTraceKeys(keys)
// Adjust keys for alias expressions in aggregations
actions := querybuilder.AdjustKeysForAliasExpressions(&query, requestType)
actions := querybuilder.AdjustKeysForAliasExpressions(query, requestType)
/*
Check if user is using multiple contexts or data types for same field name
@@ -199,7 +229,7 @@ func (b *traceQueryStatementBuilder) adjustKeys(ctx context.Context, keys map[st
and make it just http.status_code and remove the duplicate entry.
*/
actions = append(actions, querybuilder.AdjustDuplicateKeys(&query)...)
actions = append(actions, querybuilder.AdjustDuplicateKeys(query)...)
/*
Now adjust each key to have correct context and data type
@@ -207,24 +237,24 @@ func (b *traceQueryStatementBuilder) adjustKeys(ctx context.Context, keys map[st
Reason for doing this is to not create an unexpected behavior for users
*/
for idx := range query.SelectFields {
actions = append(actions, b.adjustKey(&query.SelectFields[idx], keys)...)
actions = append(actions, adjustTraceKey(&query.SelectFields[idx], keys)...)
}
for idx := range query.GroupBy {
actions = append(actions, b.adjustKey(&query.GroupBy[idx].TelemetryFieldKey, keys)...)
actions = append(actions, adjustTraceKey(&query.GroupBy[idx].TelemetryFieldKey, keys)...)
}
for idx := range query.Order {
actions = append(actions, b.adjustKey(&query.Order[idx].Key.TelemetryFieldKey, keys)...)
actions = append(actions, adjustTraceKey(&query.Order[idx].Key.TelemetryFieldKey, keys)...)
}
for _, action := range actions {
// TODO: change to debug level once we are confident about the behavior
b.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
}
return query
}
func (b *traceQueryStatementBuilder) adjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []string {
// adjustTraceKey resolves a single TelemetryFieldKey against the keys map,
// preferring intrinsic/calculated field definitions when the name matches one.
func adjustTraceKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []string {
// for recording actions taken
actions := []string{}
@@ -266,7 +296,6 @@ func (b *traceQueryStatementBuilder) buildListQuery(
start, end uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
isSelectFieldsEmpty bool,
) (*qbtypes.Statement, error) {
var (
@@ -281,6 +310,7 @@ func (b *traceQueryStatementBuilder) buildListQuery(
cteArgs = append(cteArgs, args)
}
// TODO: should we deprecate `SelectFields` and return everything from a span like we do for logs?
for _, field := range query.SelectFields {
colExpr, err := b.fm.ColumnExpressionFor(ctx, start, end, &field, keys)
if err != nil {
@@ -289,12 +319,6 @@ func (b *traceQueryStatementBuilder) buildListQuery(
sb.SelectMore(colExpr)
}
if isSelectFieldsEmpty {
for _, col := range ContextualSpanColumns {
sb.SelectMore(col)
}
}
// From table
sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName))
@@ -821,30 +845,3 @@ func (b *traceQueryStatementBuilder) buildResourceFilterCTE(
variables,
)
}
// expandRawSelectFields populates SelectFields for raw (list view) queries.
// It must be called before adjustKeys so that normalization runs over the full set.
func (b *traceQueryStatementBuilder) expandRawSelectFields(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation] {
if len(query.SelectFields) == 0 {
selectFields := make([]telemetrytypes.TelemetryFieldKey, 0, len(IntrinsicSpanFields)+len(CalculatedSpanFields))
selectFields = append(selectFields, IntrinsicSpanFields...)
selectFields = append(selectFields, CalculatedSpanFields...)
query.SelectFields = selectFields
return query
}
selectFields := []telemetrytypes.TelemetryFieldKey{
{Name: SpanTimestampColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanTraceIDColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanSpanIDColumn, FieldContext: telemetrytypes.FieldContextSpan},
}
for _, field := range query.SelectFields {
// TODO(tvats): If a user specifies attribute.timestamp in the select fields, this loop will basically ignore it, as we already added a field by default. This can be fixed once we close https://github.com/SigNoz/engineering-pod/issues/3693
if field.Name == SpanTimestampColumn || field.Name == SpanTraceIDColumn || field.Name == SpanSpanIDColumn {
continue
}
selectFields = append(selectFields, field)
}
query.SelectFields = selectFields
return query
}

View File

@@ -439,7 +439,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, name AS `name`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, duration_nano AS `duration_nano`, `attribute_number_cart$$items_count` AS `cart.items_count` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT name AS `name`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, duration_nano AS `duration_nano`, `attribute_number_cart$$items_count` AS `cart.items_count`, timestamp AS `timestamp`, span_id AS `span_id`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
@@ -468,7 +468,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, trace_state AS `trace_state`, parent_span_id AS `parent_span_id`, flags AS `flags`, name AS `name`, kind AS `kind`, kind_string AS `kind_string`, duration_nano AS `duration_nano`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY attributes_string['user.id'] AS `user.id` desc LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT duration_nano AS `duration_nano`, name AS `name`, response_status_code AS `response_status_code`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, span_id AS `span_id`, timestamp AS `timestamp`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY attributes_string['user.id'] AS `user.id` desc LIMIT ?",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
@@ -512,7 +512,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, response_status_code AS `responseStatusCode` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, response_status_code AS `responseStatusCode`, timestamp AS `timestamp`, span_id AS `span_id`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
@@ -556,7 +556,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, multiIf(toString(`attribute_string_mixed$$materialization$$key`) != '', toString(`attribute_string_mixed$$materialization$$key`), toString(multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL)) != '', toString(multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL)), NULL) AS `mixed.materialization.key` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, multiIf(toString(`attribute_string_mixed$$materialization$$key`) != '', toString(`attribute_string_mixed$$materialization$$key`), toString(multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL)) != '', toString(multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL)), NULL) AS `mixed.materialization.key`, timestamp AS `timestamp`, span_id AS `span_id`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
@@ -601,7 +601,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, `attribute_string_mixed$$materialization$$key` AS `mixed.materialization.key` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, `attribute_string_mixed$$materialization$$key` AS `mixed.materialization.key`, timestamp AS `timestamp`, span_id AS `span_id`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
@@ -711,7 +711,7 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, trace_state AS `trace_state`, parent_span_id AS `parent_span_id`, flags AS `flags`, name AS `name`, kind AS `kind`, kind_string AS `kind_string`, duration_nano AS `duration_nano`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Query: "SELECT duration_nano AS `duration_nano`, name AS `name`, response_status_code AS `response_status_code`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, span_id AS `span_id`, timestamp AS `timestamp`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
@@ -744,7 +744,7 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
}},
},
expected: qbtypes.Statement{
Query: "SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, trace_state AS `trace_state`, parent_span_id AS `parent_span_id`, flags AS `flags`, name AS `name`, kind AS `kind`, kind_string AS `kind_string`, duration_nano AS `duration_nano`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY timestamp AS `timestamp` asc LIMIT ?",
Query: "SELECT duration_nano AS `duration_nano`, name AS `name`, response_status_code AS `response_status_code`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, span_id AS `span_id`, timestamp AS `timestamp`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY timestamp AS `timestamp` asc LIMIT ?",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
@@ -1125,28 +1125,13 @@ func TestAdjustKey(t *testing.T) {
},
}
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
fl := flaggertest.New(t)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
statementBuilder := NewTraceQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
fm,
cb,
aggExprRewriter,
nil,
fl,
)
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
// Create a copy of the input key to avoid modifying the original
key := c.inputKey
// Call adjustKey
statementBuilder.adjustKey(&key, c.keysMap)
adjustTraceKey(&key, c.keysMap)
// Verify the key was adjusted as expected
require.Equal(t, c.expectedKey.Name, key.Name, "key name should match")
@@ -1424,7 +1409,7 @@ func TestAdjustKeys(t *testing.T) {
}
// Call adjustKeys
c.query = statementBuilder.adjustKeys(context.Background(), keysMapCopy, c.query, qbtypes.RequestTypeScalar)
adjustTraceKeys(context.Background(), statementBuilder.logger, keysMapCopy, &c.query, qbtypes.RequestTypeScalar)
// Verify select fields were adjusted
if c.expectedSelectFields != nil {

View File

@@ -197,6 +197,14 @@ func (b *traceOperatorCTEBuilder) buildQueryCTE(ctx context.Context, queryName s
}
b.stmtBuilder.logger.DebugContext(ctx, "Retrieved keys for query", slog.String("query_name", queryName), slog.Int("keys_count", len(keys)))
// RequestTypeRaw is correct here regardless of the operator's outer
// request type: this CTE is a raw projection of spans matching the filter
// (no aggregations, no GroupBy, no OrderBy) — aggregation/grouping happens
// in buildFinalQuery on top of the CTE. AdjustKeysForAliasExpressions
// (the only requestType-sensitive step inside adjustTraceKeys) is a
// no-op for raw.
adjustTraceKeys(ctx, b.stmtBuilder.logger, keys, query, qbtypes.RequestTypeRaw)
// Build resource filter CTE for this specific query
resourceFilterCTEName := fmt.Sprintf("__resource_filter_%s", cteName)
resourceStmt, err := b.buildResourceFilterCTE(ctx, *query)
@@ -398,39 +406,31 @@ func (b *traceOperatorCTEBuilder) buildNotCTE(leftCTE, rightCTE string) (string,
}
func (b *traceOperatorCTEBuilder) buildFinalQuery(ctx context.Context, selectFromCTE string, requestType qbtypes.RequestType) (*qbtypes.Statement, error) {
// Mirror statement_builder.go::Build: for raw queries, empty selectFields
// expands to the full intrinsic + calculated set, and the list query also
// pulls in the contextual columns so the consume layer can merge them
// into unified attributes/resource (and parse events/links).
isSelectFieldsEmpty := false
if requestType == qbtypes.RequestTypeRaw {
isSelectFieldsEmpty = len(b.operator.SelectFields) == 0
if isSelectFieldsEmpty {
b.operator.SelectFields = make([]telemetrytypes.TelemetryFieldKey, 0, len(IntrinsicSpanFields)+len(CalculatedSpanFields))
b.operator.SelectFields = append(b.operator.SelectFields, IntrinsicSpanFields...)
b.operator.SelectFields = append(b.operator.SelectFields, CalculatedSpanFields...)
}
keySelectors := b.getKeySelectors()
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
if err != nil {
return nil, err
}
b.adjustOperatorKeys(keys)
switch requestType {
case qbtypes.RequestTypeRaw:
return b.buildListQuery(ctx, selectFromCTE, isSelectFieldsEmpty)
return b.buildListQuery(ctx, selectFromCTE, keys)
case qbtypes.RequestTypeTimeSeries:
return b.buildTimeSeriesQuery(ctx, selectFromCTE)
return b.buildTimeSeriesQuery(ctx, selectFromCTE, keys)
case qbtypes.RequestTypeTrace:
return b.buildTraceQuery(ctx, selectFromCTE)
return b.buildTraceQuery(ctx, selectFromCTE, keys)
case qbtypes.RequestTypeScalar:
return b.buildScalarQuery(ctx, selectFromCTE)
return b.buildScalarQuery(ctx, selectFromCTE, keys)
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type: %s", requestType)
}
}
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string, isSelectFieldsEmpty bool) (*qbtypes.Statement, error) {
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
sb := sqlbuilder.NewSelectBuilder()
// Select core fields. These are always present so the trace operator
// response shape is stable regardless of user-supplied selectFields.
// Select core fields
sb.Select(
"timestamp",
"trace_id",
@@ -449,22 +449,6 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
"parent_span_id": true,
}
// Get keys for selectFields
keySelectors := b.getKeySelectors()
for _, field := range b.operator.SelectFields {
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
Name: field.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: field.FieldContext,
FieldDataType: field.FieldDataType,
})
}
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
if err != nil {
return nil, err
}
// Add selectFields using ColumnExpressionFor since we now have all base table columns
for _, field := range b.operator.SelectFields {
if selectedFields[field.Name] {
@@ -480,12 +464,6 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
selectedFields[field.Name] = true
}
if isSelectFieldsEmpty {
for _, col := range ContextualSpanColumns {
sb.SelectMore(col)
}
}
sb.From(selectFromCTE)
// Add order by support using ColumnExpressionFor
@@ -520,6 +498,22 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
}, nil
}
// adjustOperatorKeys merges deprecated trace field definitions into keys and
// reconciles each operator-level SelectFields/GroupBy/Order key against the
// keys map, mirroring the per-field portion of adjustTraceKeys.
func (b *traceOperatorCTEBuilder) adjustOperatorKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey) {
mergeDeprecatedTraceKeys(keys)
for idx := range b.operator.SelectFields {
adjustTraceKey(&b.operator.SelectFields[idx], keys)
}
for idx := range b.operator.GroupBy {
adjustTraceKey(&b.operator.GroupBy[idx].TelemetryFieldKey, keys)
}
for idx := range b.operator.Order {
adjustTraceKey(&b.operator.Order[idx].Key.TelemetryFieldKey, keys)
}
}
func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySelector {
var keySelectors []*telemetrytypes.FieldKeySelector
@@ -547,6 +541,15 @@ func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySe
})
}
for _, sf := range b.operator.SelectFields {
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
Name: sf.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: sf.FieldContext,
FieldDataType: sf.FieldDataType,
})
}
for i := range keySelectors {
keySelectors[i].Signal = telemetrytypes.SignalTraces
}
@@ -554,7 +557,7 @@ func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySe
return keySelectors
}
func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select(fmt.Sprintf(
@@ -562,12 +565,6 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
int64(b.operator.StepInterval.Seconds()),
))
keySelectors := b.getKeySelectors()
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
if err != nil {
return nil, err
}
var allGroupByArgs []any
for _, gb := range b.operator.GroupBy {
@@ -646,8 +643,7 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
combinedArgs := append(allGroupByArgs, allAggChArgs...)
// Add HAVING clause if specified
err = b.addHavingClause(sb)
if err != nil {
if err := b.addHavingClause(sb); err != nil {
return nil, err
}
@@ -674,17 +670,11 @@ func (b *traceOperatorCTEBuilder) buildTraceSummaryCTE(selectFromCTE string) {
b.addCTE("trace_summary", sql, args, []string{"all_spans", selectFromCTE})
}
func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
b.buildTraceSummaryCTE(selectFromCTE)
sb := sqlbuilder.NewSelectBuilder()
keySelectors := b.getKeySelectors()
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
if err != nil {
return nil, err
}
var allGroupByArgs []any
for _, gb := range b.operator.GroupBy {
@@ -766,8 +756,7 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
sb.GroupBy(groupByKeys...)
}
err = b.addHavingClause(sb)
if err != nil {
if err := b.addHavingClause(sb); err != nil {
return nil, err
}
@@ -823,15 +812,9 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
}, nil
}
func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
sb := sqlbuilder.NewSelectBuilder()
keySelectors := b.getKeySelectors()
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
if err != nil {
return nil, err
}
var allGroupByArgs []any
for _, gb := range b.operator.GroupBy {
@@ -913,8 +896,7 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
combinedArgs := append(allGroupByArgs, allAggChArgs...)
// Add HAVING clause if specified
err = b.addHavingClause(sb)
if err != nil {
if err := b.addHavingClause(sb); err != nil {
return nil, err
}

View File

@@ -104,7 +104,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, trace_state AS `trace_state`, flags AS `flags`, kind AS `kind`, kind_string AS `kind_string`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM A_INDIR_DESC_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_INDIR_DESC_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "gateway", "%service.name%", "%service.name\":\"gateway%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 5},
},
expectedErr: nil,
@@ -141,7 +141,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, trace_state AS `trace_state`, flags AS `flags`, kind AS `kind`, kind_string AS `kind_string`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM A_AND_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_AND_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 15},
},
expectedErr: nil,
@@ -178,7 +178,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_OR_B AS (SELECT * FROM A UNION DISTINCT SELECT * FROM B) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, trace_state AS `trace_state`, flags AS `flags`, kind AS `kind`, kind_string AS `kind_string`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM A_OR_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_OR_B AS (SELECT * FROM A UNION DISTINCT SELECT * FROM B) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_OR_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 20},
},
expectedErr: nil,
@@ -215,7 +215,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_not_B AS (SELECT l.* FROM A AS l WHERE l.trace_id GLOBAL NOT IN (SELECT DISTINCT trace_id FROM B)) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, trace_state AS `trace_state`, flags AS `flags`, kind AS `kind`, kind_string AS `kind_string`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM A_not_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_not_B AS (SELECT l.* FROM A AS l WHERE l.trace_id GLOBAL NOT IN (SELECT DISTINCT trace_id FROM B)) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_not_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
@@ -380,7 +380,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_DIR_DESC_B AS (SELECT p.* FROM A AS p INNER JOIN B AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id), __resource_filter_C AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), C AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_C) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_D AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), D AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_D) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), C_DIR_DESC_D AS (SELECT p.* FROM C AS p INNER JOIN D AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id), A_DIR_DESC_B_AND_C_DIR_DESC_D AS (SELECT l.* FROM A_DIR_DESC_B AS l INNER JOIN C_DIR_DESC_D AS r ON l.trace_id = r.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, trace_state AS `trace_state`, flags AS `flags`, kind AS `kind`, kind_string AS `kind_string`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM A_DIR_DESC_B_AND_C_DIR_DESC_D ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_DIR_DESC_B AS (SELECT p.* FROM A AS p INNER JOIN B AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id), __resource_filter_C AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), C AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_C) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_D AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), D AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_D) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), C_DIR_DESC_D AS (SELECT p.* FROM C AS p INNER JOIN D AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id), A_DIR_DESC_B_AND_C_DIR_DESC_D AS (SELECT l.* FROM A_DIR_DESC_B AS l INNER JOIN C_DIR_DESC_D AS r ON l.trace_id = r.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_DIR_DESC_B_AND_C_DIR_DESC_D ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "auth", "%service.name%", "%service.name\":\"auth%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 5},
},
expectedErr: nil,

View File

@@ -18,6 +18,10 @@ type Cloneable interface {
// Creates a deep copy of the Cacheable. This method is useful for memory caches to avoid the need for serialization/deserialization. It also prevents
// race conditions in the memory cache.
Clone() Cacheable
// Cost returns the weight of this entry for cost-based cache accounting
// and eviction. Typically derived from the approximate retained byte size,
// but the value represents cache cost, not literal bytes.
Cost() int64
}
func NewSha1CacheKey(val string) string {

View File

@@ -59,3 +59,21 @@ func (c *CachedData) Clone() cachetypes.Cacheable {
return clonedCachedData
}
// Cost approximates the retained bytes of this CachedData for use as the
// ristretto cache cost. The dominant contributor is the serialized bucket
// values (json.RawMessage); other fields are fixed-size or small strings.
func (c *CachedData) Cost() int64 {
var size int64
for _, b := range c.Buckets {
if b == nil {
continue
}
// Value is the bulk of the payload
size += int64(len(b.Value))
}
for _, w := range c.Warnings {
size += int64(len(w))
}
return size
}

View File

@@ -236,7 +236,6 @@ type RawStream struct {
Error chan error
}
func roundToNonZeroDecimals(val float64, n int) float64 {
if val == 0 || math.IsNaN(val) || math.IsInf(val, 0) {
return val

View File

@@ -1,59 +0,0 @@
package spantypes
import "encoding/json"
type Event struct {
Name string `json:"name"`
TimeUnixNano uint64 `json:"timeUnixNano"`
Attributes map[string]any `json:"attributes,omitempty"`
}
// Link is the response shape for a span link.
// The refType field is intentionally not decoded; it's a Jaeger-era
// concept that OTel doesn't model, so we drop it on the way out.
type Link struct {
TraceID string `json:"traceId,omitempty"`
SpanID string `json:"spanId,omitempty"`
}
// dbEvent matches the JSON object stored in the ClickHouse `events`
// Array(String) column.
type dbEvent struct {
Name string `json:"name"`
TimeUnixNano uint64 `json:"timeUnixNano"`
AttributeMap map[string]any `json:"attributeMap"`
}
// ParseEvents column (Array(String) of JSON-encoded events) into a slice of Event values.
// Malformed entries are skipped.
func ParseEvents(raw any) []Event {
strs, ok := raw.([]string)
if !ok {
return []Event{}
}
events := make([]Event, 0, len(strs))
for _, s := range strs {
var e dbEvent
if err := json.Unmarshal([]byte(s), &e); err != nil {
continue
}
events = append(events, Event{
Name: e.Name,
TimeUnixNano: e.TimeUnixNano,
Attributes: e.AttributeMap,
})
}
return events
}
func ParseLinks(raw any) []Link {
s, ok := raw.(string)
if !ok || s == "" {
return []Link{}
}
var links []Link
if err := json.Unmarshal([]byte(s), &links); err != nil {
return []Link{}
}
return links
}

View File

@@ -200,6 +200,8 @@ def build_formula_query(
*,
functions: list[dict] | None = None,
disabled: bool = False,
order: list[dict] | None = None,
limit: int | None = None,
) -> dict:
spec: dict[str, Any] = {
"name": name,
@@ -208,6 +210,10 @@ def build_formula_query(
}
if functions:
spec["functions"] = functions
if order:
spec["order"] = order
if limit is not None:
spec["limit"] = limit
return {"type": "builder_formula", "spec": spec}

View File

@@ -236,9 +236,8 @@ class Traces(ABC):
attributes_number: dict[str, np.float64]
attributes_bool: dict[str, bool]
resources_string: dict[str, str]
# Accepting parsed events and links, but will be stored as list[str], str in db
events: list[dict[str, Any]]
links: list[dict[str, Any]]
events: list[str]
links: str
response_status_code: str
external_http_url: str
http_url: str
@@ -424,17 +423,10 @@ class Traces(ABC):
)
)
# Process events and derive error events. self.events holds the parsed
# response shape; np_arr() encodes back to the DB format on insert.
# Process events and derive error events
self.events = []
for event in events:
self.events.append(
{
"name": event.name,
"timeUnixNano": int(event.time_unix_nano),
"attributes": dict(event.attribute_map),
}
)
self.events.append(json.dumps([event.name, event.time_unix_nano, event.attribute_map]))
# Create error events for exception events (following Go exporter logic)
if event.name == "exception":
@@ -456,26 +448,7 @@ class Traces(ABC):
),
)
# self.links holds the parsed response shape (trace_id/span_id only;
# ref_type is dropped to match the API). np_arr() re-encodes for DB insert.
self.links = [{"traceId": link.trace_id, "spanId": link.span_id} for link in links_copy]
self._links_db = json.dumps(
[link.__dict__() for link in links_copy],
separators=(",", ":"),
)
# DB shape per event: {"name", "timeUnixNano", "attributeMap"}. Must match
# what the consume-layer parser in pkg/types/spantypes expects.
self._events_db = [
json.dumps(
{
"name": event.name,
"timeUnixNano": int(event.time_unix_nano),
"attributeMap": dict(event.attribute_map),
},
separators=(",", ":"),
)
for event in events
]
self.links = json.dumps([link.__dict__() for link in links_copy], separators=(",", ":"))
# Initialize resource
self.resource = []
@@ -590,8 +563,8 @@ class Traces(ABC):
self.attributes_number,
self.attributes_bool,
self.resources_string,
self._events_db,
self._links_db,
self.events,
self.links,
self.response_status_code,
self.external_http_url,
self.http_url,

View File

@@ -11,6 +11,11 @@ from fixtures.logs import Logs
from fixtures.querier import (
assert_identical_query_response,
assert_minutely_bucket_values,
build_formula_query,
build_group_by_field,
build_logs_aggregation,
build_order_by,
build_scalar_query,
find_named_result,
index_series_by_label,
make_query_request,
@@ -2111,3 +2116,180 @@ def test_logs_fill_zero_formula_with_group_by(
expected_by_ts=expectations[service_name],
context=f"logs/fillZero/F1/{service_name}",
)
def test_logs_formula_orderby_and_limit(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""
Test that formula results are correctly ordered and limited when
order and limit are applied on the formula.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
logs: list[Logs] = []
# For service-i (i in 0..9): insert (10 - i) ERROR logs and 2 INFO logs.
# A counts ERROR, B counts INFO, so A/B = (10 - i) / 2.
# service-0 ratio = 5.0 (highest), service-9 ratio = 0.5 (lowest).
for i in range(10):
for j in range(10 - i):
logs.append(
Logs(
timestamp=now - timedelta(minutes=j + 1),
resources={"service.name": f"service-{i}"},
attributes={"code.file": "test.py"},
body=f"Error log {i}-{j}",
severity_text="ERROR",
)
)
for k in range(2):
logs.append(
Logs(
timestamp=now - timedelta(minutes=k + 1),
resources={"service.name": f"service-{i}"},
attributes={"code.file": "test.py"},
body=f"Info log {i}-{k}",
severity_text="INFO",
)
)
# Extra INFO-only services that appear in B but not in A. The formula
for name in ("service-info-only-1", "service-info-only-2"):
for k in range(2):
logs.append(
Logs(
timestamp=now - timedelta(minutes=k + 1),
resources={"service.name": name},
attributes={"code.file": "test.py"},
body=f"Info log {name}-{k}",
severity_text="INFO",
)
)
# Logs look like this (columns = minutes before `now`; query range is
# (now - 15m, now], so the `now` column is the exclusive upper bound and
# no log lands there). E = ERROR, I = INFO, X = both at that minute.
#
# t-10 t-9 t-8 t-7 t-6 t-5 t-4 t-3 t-2 t-1 |now | A B A/B
# service-0: E E E E E E E E X X | | 10 2 5.0
# service-1: . E E E E E E E X X | | 9 2 4.5
# service-2: . . E E E E E E X X | | 8 2 4.0
# service-3: . . . E E E E E X X | | 7 2 3.5
# service-4: . . . . E E E E X X | | 6 2 3.0
# service-5: . . . . . E E E X X | | 5 2 2.5
# service-6: . . . . . . E E X X | | 4 2 2.0
# service-7: . . . . . . . E X X | | 3 2 1.5
# service-8: . . . . . . . . X X | | 2 2 1.0
# service-9: . . . . . . . . I X | | 1 2 0.5
# info-only-1: . . . . . . . . I I | | 0* 2 0.0
# info-only-2: . . . . . . . . I I | | 0* 2 0.0
#
# * A is missing for the info-only services; because A is count(), the
# formula evaluator defaults missing A to 0, yielding A/B = 0.
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
result = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=15)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="scalar",
queries=[
build_scalar_query(
name="A",
signal="logs",
aggregations=[build_logs_aggregation("count()")],
group_by=[build_group_by_field("service.name")],
filter_expression="severity_text = 'ERROR'",
disabled=True,
),
build_scalar_query(
name="B",
signal="logs",
aggregations=[build_logs_aggregation("count()")],
group_by=[build_group_by_field("service.name")],
filter_expression="severity_text = 'INFO'",
disabled=True,
),
build_formula_query(
"F1",
"A / B",
order=[build_order_by("__result", "desc")],
limit=3,
),
build_formula_query(
"F2",
"A / B",
order=[build_order_by("__result", "desc")],
),
build_formula_query(
"F3",
"A / B",
order=[build_order_by("__result", "asc")],
limit=3,
),
build_formula_query(
"F4",
"A / B",
order=[build_order_by("__result", "asc")],
),
],
)
assert result.status_code == HTTPStatus.OK
assert result.json()["status"] == "success"
results = result.json()["data"]["data"]["results"]
def extract_services_and_values(query_name: str) -> tuple[list, list]:
res = find_named_result(results, query_name)
assert res is not None, f"Expected formula result named {query_name}"
cols = res["columns"]
s_col = next(i for i, c in enumerate(cols) if c["name"] == "service.name")
v_col = next(i for i, c in enumerate(cols) if c["name"] == "__result")
rows = res["data"]
return [row[s_col] for row in rows], [row[v_col] for row in rows]
# Because A is count(), canDefaultZero["A"] is true; the formula evaluator
# defaults A to 0 for services that exist only in B. So the two INFO-only
# services appear in the formula result with value 0.0 (extreme bottom in
# desc order, extreme top in asc order). Their relative ordering is not
# deterministic across separate formula evaluations (tied values).
info_only_services = {"service-info-only-1", "service-info-only-2"}
# F2: desc, no limit -> 12 rows in descending order by value.
f2_services, f2_values = extract_services_and_values("F2")
assert len(f2_services) == 12, f"F2: expected 12 rows with no limit, got {len(f2_services)}"
assert f2_values == [5.0, 4.5, 4.0, 3.5, 3.0, 2.5, 2.0, 1.5, 1.0, 0.5, 0.0, 0.0], f2_values
# Top 10 have distinct positive values -> deterministic service ordering.
assert f2_services[:10] == [f"service-{i}" for i in range(10)], f2_services[:10]
# Tail 2 are the INFO-only services tied at 0.0 (order between them not guaranteed).
assert set(f2_services[10:]) == info_only_services, f2_services[10:]
# F1: desc + limit 3 -> must be exactly the first 3 rows of F2.
# Top 3 are not in the tie region, so prefix equality is safe.
f1_services, f1_values = extract_services_and_values("F1")
assert len(f1_services) == 3, f"F1: expected 3 rows after limit, got {len(f1_services)}"
assert f1_services == f2_services[:3], f"F1 services {f1_services} are not the prefix of F2 services {f2_services}"
assert f1_values == f2_values[:3], f"F1 values {f1_values} are not the prefix of F2 values {f2_values}"
# F4: asc, no limit -> 12 rows in ascending order by value.
f4_services, f4_values = extract_services_and_values("F4")
assert len(f4_services) == 12, f"F4: expected 12 rows with no limit, got {len(f4_services)}"
assert f4_values == sorted(f4_values), f"F4 not ascending: {f4_values}"
# First 2 are the INFO-only services tied at 0.0 (order between them not guaranteed).
assert set(f4_services[:2]) == info_only_services, f4_services[:2]
assert f4_values[:2] == [0.0, 0.0], f4_values[:2]
# Tail 10 are service-9 down to service-0 by value.
assert f4_services[2:] == [f"service-{i}" for i in reversed(range(10))], f4_services[2:]
assert f4_values[2:] == [(10 - i) / 2 for i in reversed(range(10))], f4_values[2:]
# F3: asc + limit 3 -> values must match F4[:3] exactly; service set must
# match too. Direct prefix equality on services would be flaky because the
# two tied INFO-only entries can swap order between formula evaluations.
f3_services, f3_values = extract_services_and_values("F3")
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]}"

View File

@@ -17,51 +17,7 @@ from fixtures.querier import (
index_series_by_label,
make_query_request,
)
from fixtures.traces import (
TraceIdGenerator,
Traces,
TracesEvent,
TracesKind,
TracesLink,
TracesRefType,
TracesStatusCode,
)
# All keys returned by the trace list endpoint when selectFields is empty:
# every intrinsic and calculated column, plus the merged `attributes` and
# `resource` maps that wrap the contextual columns in the response layer.
ALL_SELECT_FIELDS = [
# all intrinsic columns
"timestamp",
"trace_id",
"span_id",
"trace_state",
"parent_span_id",
"flags",
"name",
"kind",
"kind_string",
"duration_nano",
"status_code",
"status_message",
"status_code_string",
"events",
"links",
# all calculated columns
"response_status_code",
"external_http_url",
"http_url",
"external_http_method",
"http_method",
"http_host",
"db_name",
"db_operation",
"has_error",
"is_remote",
# all contextual columns (merged in response layer)
"attributes",
"resource",
]
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
def test_traces_list(
@@ -517,9 +473,7 @@ def test_traces_list(
@pytest.mark.parametrize(
"payload,status_code,results",
[
# Case 1: order by timestamp; empty selectFields returns the full
# response shape (all intrinsic + calculated columns plus the merged
# `attributes` and `resource` maps). x[3] (topic-service) is latest.
# Case 1: order by timestamp field which there in attributes as well
pytest.param(
{
"type": "builder_query",
@@ -533,42 +487,19 @@ def test_traces_list(
},
HTTPStatus.OK,
lambda x: [
{
**x[3].attribute_string,
**x[3].attributes_number,
**x[3].attributes_bool,
}, # attributes
x[3].db_name,
x[3].db_operation,
int(x[3].duration_nano),
x[3].events,
x[3].external_http_method,
x[3].external_http_url,
int(x[3].flags),
x[3].has_error,
x[3].http_host,
x[3].http_method,
x[3].http_url,
x[3].is_remote,
int(x[3].kind),
x[3].kind_string,
x[3].links,
x[3].duration_nano,
x[3].name,
x[3].parent_span_id,
x[3].resources_string,
x[3].response_status_code,
x[3].service_name,
x[3].span_id,
int(x[3].status_code),
x[3].status_code_string,
x[3].status_message,
format_timestamp(x[3].timestamp),
x[3].trace_id,
x[3].trace_state,
], # type: Callable[[List[Traces]], List[Any]]
),
# Case 2: order by attribute.timestamp. The key resolves to the
# intrinsic span.timestamp column, so the latest span (x[3]) is
# returned with the same full response shape as Case 1.
# Case 2: order by attribute timestamp field which is there in attributes as well
# This should break but it doesn't because attribute.timestamp gets adjusted to timestamp
# because of default trace.timestamp gets added by default and bug in field mapper picks
# instrinsic field
pytest.param(
{
"type": "builder_query",
@@ -582,37 +513,13 @@ def test_traces_list(
},
HTTPStatus.OK,
lambda x: [
{
**x[3].attribute_string,
**x[3].attributes_number,
**x[3].attributes_bool,
}, # attributes
x[3].db_name,
x[3].db_operation,
int(x[3].duration_nano),
x[3].events,
x[3].external_http_method,
x[3].external_http_url,
int(x[3].flags),
x[3].has_error,
x[3].http_host,
x[3].http_method,
x[3].http_url,
x[3].is_remote,
int(x[3].kind),
x[3].kind_string,
x[3].links,
x[3].duration_nano,
x[3].name,
x[3].parent_span_id,
x[3].resources_string,
x[3].response_status_code,
x[3].service_name,
x[3].span_id,
int(x[3].status_code),
x[3].status_code_string,
x[3].status_message,
format_timestamp(x[3].timestamp),
x[3].trace_id,
x[3].trace_state,
], # type: Callable[[List[Traces]], List[Any]]
),
# Case 3: select timestamp with empty order by
@@ -635,7 +542,7 @@ def test_traces_list(
], # type: Callable[[List[Traces]], List[Any]]
),
# Case 4: select attribute.timestamp with empty order by
# This returns the one span which has attribute.timestamp
# This doesn't return any data because of where_clause using aliased timestamp
pytest.param(
{
"type": "builder_query",
@@ -649,11 +556,7 @@ def test_traces_list(
},
},
HTTPStatus.OK,
lambda x: [
x[0].span_id,
format_timestamp(x[0].timestamp),
x[0].trace_id,
], # type: Callable[[List[Traces]], List[Any]]
lambda x: [], # type: Callable[[List[Traces]], List[Any]]
),
# Case 5: select timestamp with timestamp order by
pytest.param(
@@ -790,157 +693,132 @@ def test_traces_list_with_corrupt_data(
assert data[key] == value
def _verify_events_links_full(rows: list[dict], traces: list[Traces]) -> None:
"""Empty-selectFields case: events/links arrive parsed into structured objects.
Every row's events/links should match the fixture's stored parsed shape
(the fixture's `.events`/`.links` mirror the API response shape directly).
"""
for row, trace in zip(rows, traces, strict=True):
assert row["data"]["events"] == trace.events
assert row["data"]["links"] == trace.links
# Jaeger-era `refType` is dropped at the consume layer.
for link in row["data"]["links"]:
assert "refType" not in link
def _verify_events_links_skip(rows: list[dict], traces: list[Traces]) -> None:
"""Projected-selectFields case: nothing to verify beyond the key set."""
@pytest.mark.parametrize(
"select_fields,status_code,expected_keys,verify_values",
"payload,status_code,results",
[
pytest.param(
[],
HTTPStatus.OK,
ALL_SELECT_FIELDS,
_verify_events_links_full,
),
# Case 1: builder CTE filters use deprecated intrinsic field durationNano
pytest.param(
[
{"name": "service.name"},
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"disabled": True,
"filter": {"expression": 'durationNano = "3s"'},
},
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "traces",
"disabled": True,
"filter": {"expression": 'durationNano = "5s"'},
},
},
{
"type": "builder_trace_operator",
"spec": {
"name": "C",
"expression": "A => B",
"limit": 1,
},
},
],
HTTPStatus.OK,
["timestamp", "trace_id", "span_id", "service.name"],
_verify_events_links_skip,
lambda x: {
"duration_nano": x[0].duration_nano,
"name": x[0].name,
"parent_span_id": x[0].parent_span_id,
"span_id": x[0].span_id,
"timestamp": format_timestamp(x[0].timestamp),
"trace_id": x[0].trace_id,
}, # type: Callable[[List[Traces]], Dict[str, Any]]
),
# Case 2: builder CTE filter uses deprecated calculated field responseStatusCode
pytest.param(
[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"disabled": True,
"filter": {"expression": 'responseStatusCode = "200"'},
},
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "traces",
"disabled": True,
"filter": {"expression": 'durationNano = "5s"'},
},
},
{
"type": "builder_trace_operator",
"spec": {
"name": "C",
"expression": "A => B",
"limit": 1,
},
},
],
HTTPStatus.OK,
lambda x: {
"duration_nano": x[0].duration_nano,
"name": x[0].name,
"parent_span_id": x[0].parent_span_id,
"span_id": x[0].span_id,
"timestamp": format_timestamp(x[0].timestamp),
"trace_id": x[0].trace_id,
}, # type: Callable[[List[Traces]], Dict[str, Any]]
),
],
)
def test_traces_list_with_select_fields(
def test_traces_operator_cte_with_adjusted_keys(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
select_fields: list[dict],
payload: list[dict[str, Any]],
status_code: HTTPStatus,
expected_keys: list[str],
verify_values: Callable[[list[dict], list[Traces]], None],
results: Callable[[list[Traces]], dict[str, Any]],
) -> None:
"""
Setup:
Insert a root span with no events/links and a child span carrying two
events and one user-supplied link.
Tests:
1. Empty select fields should return all the fields, and the `events` /
`links` columns should arrive parsed into structured objects (events
carry `attributes`, links carry only `traceId`/`spanId` — refType is
dropped at the consume layer).
2. Non-empty select field should return the select field along with
timestamp, trace_id and span_id.
Trace operators compile each referenced disabled builder query into a CTE.
Those CTE filters must adjust deprecated trace keys before preparing the
where clause, otherwise these payloads fail with "key not found".
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
parent_trace_id = TraceIdGenerator.trace_id()
parent_span_id = TraceIdGenerator.span_id()
child_span_id = TraceIdGenerator.span_id()
linked_trace_id = TraceIdGenerator.trace_id()
linked_span_id = TraceIdGenerator.span_id()
event_one = TracesEvent(
name="request_received",
timestamp=now - timedelta(seconds=3, microseconds=500_000),
attribute_map={"http.method": "GET", "http.route": "/api/chat"},
)
event_two = TracesEvent(
name="cache_lookup",
timestamp=now - timedelta(seconds=3, microseconds=400_000),
attribute_map={"cache.hit": "true", "cache.key": "user:123:prompt"},
)
user_link = TracesLink(
trace_id=linked_trace_id,
span_id=linked_span_id,
ref_type=TracesRefType.REF_TYPE_FOLLOWS_FROM,
)
traces = [
# Root span: no events, no links. Verifies the empty-case parsed shape.
Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=3),
trace_id=parent_trace_id,
span_id=parent_span_id,
parent_span_id="",
name="root span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "events-links-service"},
attributes={"http.request.method": "GET"},
),
# Child span: two events + one user-supplied link. The fixture
# auto-inserts a CHILD_OF link for the parent, so the parsed response
# contains two links total — the auto-inserted one first.
Traces(
timestamp=now - timedelta(seconds=3),
duration=timedelta(seconds=1),
trace_id=parent_trace_id,
span_id=child_span_id,
parent_span_id=parent_span_id,
name="child span",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "events-links-service"},
attributes={"http.request.method": "GET"},
events=[event_one, event_two],
links=[user_link],
),
]
traces = generate_traces_with_corrupt_metadata()
insert_traces(traces)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
payload = {
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"filter": {"expression": "resource.service.name = 'events-links-service'"},
"selectFields": select_fields,
"order": [{"key": {"name": "timestamp"}, "direction": "asc"}],
"limit": 10,
},
}
response = make_query_request(
signoz,
token,
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
request_type="raw",
queries=[payload],
queries=payload,
)
assert response.status_code == status_code
if response.status_code != HTTPStatus.OK:
return
assert response.status_code == status_code, response.text
rows = response.json()["data"]["data"]["results"][0]["rows"]
assert len(rows) == 2
for row in rows:
assert set(row["data"].keys()) == set(expected_keys)
verify_values(rows, traces)
if response.status_code == HTTPStatus.OK:
operator_result = find_named_result(response.json()["data"]["data"]["results"], "C")
assert operator_result is not None
rows = operator_result["rows"]
if not results(traces):
assert rows is None
else:
assert rows is not None
data = rows[0]["data"]
for key, value in results(traces).items():
assert data[key] == value
@pytest.mark.parametrize(
@@ -2373,178 +2251,3 @@ def test_traces_list_filter_by_trace_id(
past_rows = _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)}"
# Hardcoded core columns the trace_operator buildListQuery always projects,
# in addition to any user-supplied selectFields.
TRACE_OPERATOR_CORE_FIELDS = [
"timestamp",
"trace_id",
"span_id",
"name",
"duration_nano",
"parent_span_id",
]
def _verify_full_expansion(rows: list[dict], parent_trace: Traces) -> None:
"""Empty-selectFields case: every column from the builder_query parity set
arrives, and events/links are parsed into structured form (refType is
dropped at the consume layer).
"""
assert len(rows) == 1
parent_row = rows[0]["data"]
assert set(parent_row.keys()) == set(ALL_SELECT_FIELDS)
assert parent_row["events"] == parent_trace.events
assert parent_row["links"] == parent_trace.links
for link in parent_row["links"]:
assert "refType" not in link
def _verify_explicit_projection(rows: list[dict], parent_trace: Traces) -> None: # pylint: disable=unused-argument
"""Explicit-selectFields case: only the 6 hardcoded core fields plus the
user-supplied resource.service.name come back. Contextual columns
(events/links/attributes/resource) and the rest of the intrinsics never
appear because the consume-layer merge isn't triggered.
"""
assert len(rows) == 1
parent_row = rows[0]["data"]
assert set(parent_row.keys()) == set(TRACE_OPERATOR_CORE_FIELDS + ["service.name"])
@pytest.mark.parametrize(
"select_fields,verify_values",
[
pytest.param([], _verify_full_expansion, id="empty-select-fields"),
pytest.param(
[{"name": "service.name", "fieldContext": "resource"}],
_verify_explicit_projection,
id="explicit-service-name",
),
],
)
def test_trace_operator_select_fields(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
select_fields: list[dict[str, Any]],
verify_values: Callable[[list[dict], Traces], None],
) -> None:
"""
Setup:
Insert a parent (operation.type = 'parent') with one event and one
user-supplied link, plus a child span (operation.type = 'child').
Tests:
1. With selectFields=[], the `A => B` trace_operator returns every column
in ALL_SELECT_FIELDS, mirroring the builder_query path. Events arrive
as {name, timeUnixNano, attributes} and links as {traceId, spanId}
with refType dropped at the consume layer.
2. With an explicit selectFields=[{"name": "service.name"}], only the 6
hardcoded core columns plus service.name come back — no auto-expansion
to the full set.
See:
- pkg/telemetrytraces/trace_operator_cte_builder.go::buildFinalQuery for
the expansion gate.
- pkg/telemetrytraces/trace_operator_cte_builder.go::buildListQuery for
the per-row SELECT.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
trace_id = TraceIdGenerator.trace_id()
parent_span_id = TraceIdGenerator.span_id()
child_span_id = TraceIdGenerator.span_id()
parent_event = TracesEvent(
name="request_received",
timestamp=now - timedelta(seconds=4, microseconds=500_000),
attribute_map={"http.method": "GET"},
)
linked_trace_id = TraceIdGenerator.trace_id()
linked_span_id = TraceIdGenerator.span_id()
user_link = TracesLink(
trace_id=linked_trace_id,
span_id=linked_span_id,
ref_type=TracesRefType.REF_TYPE_FOLLOWS_FROM,
)
parent_trace = Traces(
timestamp=now - timedelta(seconds=5),
duration=timedelta(seconds=4),
trace_id=trace_id,
span_id=parent_span_id,
parent_span_id="",
name="parent-operation",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "trace-operator-query"},
attributes={"operation.type": "parent"},
events=[parent_event],
links=[user_link],
)
child_trace = Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=1),
trace_id=trace_id,
span_id=child_span_id,
parent_span_id=parent_span_id,
name="child-operation",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "trace-operator-query"},
attributes={"operation.type": "child"},
)
insert_traces([parent_trace, child_trace])
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
operator_spec: dict[str, Any] = {
"name": "C",
"expression": "A => B",
"limit": 10,
"order": [{"key": {"name": "timestamp"}, "direction": "asc"}],
}
if select_fields:
operator_spec["selectFields"] = select_fields
queries = [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"filter": {"expression": "operation.type = 'parent'"},
"limit": 100,
"disabled": True,
},
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "traces",
"filter": {"expression": "operation.type = 'child'"},
"limit": 100,
"disabled": True,
},
},
{"type": "builder_trace_operator", "spec": operator_spec},
]
response = make_query_request(
signoz,
token,
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
request_type="raw",
queries=queries,
)
assert response.status_code == HTTPStatus.OK
results = response.json()["data"]["data"]["results"]
trace_operator_result = find_named_result(results, "C")
rows = trace_operator_result["rows"]
verify_values(rows, parent_trace)