Compare commits

...

12 Commits

Author SHA1 Message Date
Nikhil Soni
8015b029f9 chore: use query builder for duration query as well 2026-05-27 19:17:02 +05:30
Nikhil Soni
dbfd60639d fix: type cast issue in sum function 2026-05-27 18:39:57 +05:30
Nikhil Soni
32c197db91 chore: use notEmpty function instead of having 2026-05-27 16:32:58 +05:30
Nikhil Soni
0070d9b517 chore: move end_ns computation to first cte for simplicity 2026-05-27 15:41:26 +05:30
Nikhil Soni
8d6f6af145 chore: again extract out inner query 2026-05-27 15:22:59 +05:30
Nikhil Soni
d67f63eafe chore: extract out inner query as cte 2026-05-27 15:09:07 +05:30
Nikhil Soni
534e0428e1 chore: remove support for aggregations on attributes
Only supporting resource fields for now, since this
is not user input but hard coded client experience
2026-05-27 14:52:56 +05:30
Nikhil Soni
67bed2e9ea chore: use query builder for count by field 2026-05-27 01:59:09 +05:30
Nikhil Soni
851d857807 feat: add implementation for aggregation store 2026-05-27 00:25:01 +05:30
Nikhil Soni
dd76af883a chore: update openapi specs 2026-05-26 16:15:43 +05:30
Nikhil Soni
412ec1813a chore: use v1 for aggregations api 2026-05-26 16:15:43 +05:30
Nikhil Soni
cb7b183171 feat: add endpoint & module for trace aggregations 2026-05-26 16:15:43 +05:30
10 changed files with 446 additions and 0 deletions

View File

@@ -5672,6 +5672,14 @@ components:
required:
- items
type: object
SpantypesGettableTraceAggregations:
properties:
aggregations:
items:
$ref: '#/components/schemas/SpantypesSpanAggregationResult'
nullable: true
type: array
type: object
SpantypesGettableWaterfallTrace:
properties:
aggregations:
@@ -5737,6 +5745,14 @@ components:
- name
- condition
type: object
SpantypesPostableTraceAggregations:
properties:
aggregations:
items:
$ref: '#/components/schemas/SpantypesSpanAggregation'
nullable: true
type: array
type: object
SpantypesPostableWaterfall:
properties:
aggregations:
@@ -11364,6 +11380,75 @@ paths:
summary: Test notification channel (deprecated)
tags:
- channels
/api/v1/traces/{traceID}/aggregations:
post:
deprecated: false
description: Computes span aggregations grouped by requested field.
operationId: GetTraceAggregations
parameters:
- in: path
name: traceID
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SpantypesPostableTraceAggregations'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableTraceAggregations'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get aggregations for a trace
tags:
- tracedetail
/api/v1/user:
get:
deprecated: false

View File

@@ -6775,6 +6775,13 @@ export interface SpantypesSpanAggregationResultDTO {
value?: SpantypesSpanAggregationResultDTOValue;
}
export interface SpantypesGettableTraceAggregationsDTO {
/**
* @type array,null
*/
aggregations?: SpantypesSpanAggregationResultDTO[] | null;
}
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
[key: string]: unknown;
};
@@ -7018,6 +7025,13 @@ export interface SpantypesSpanAggregationDTO {
field?: TelemetrytypesTelemetryFieldKeyDTO;
}
export interface SpantypesPostableTraceAggregationsDTO {
/**
* @type array,null
*/
aggregations?: SpantypesSpanAggregationDTO[] | null;
}
export interface SpantypesPostableWaterfallDTO {
/**
* @type array,null
@@ -8351,6 +8365,17 @@ export type UpdateSpanMapperPathParameters = {
groupId: string;
mapperId: string;
};
export type GetTraceAggregationsPathParameters = {
traceID: string;
};
export type GetTraceAggregations200 = {
data: SpantypesGettableTraceAggregationsDTO;
/**
* @type string
*/
status: string;
};
export type ListUsersDeprecated200 = {
/**
* @type array

View File

@@ -12,17 +12,120 @@ import type {
} from 'react-query';
import type {
GetTraceAggregations200,
GetTraceAggregationsPathParameters,
GetWaterfall200,
GetWaterfallPathParameters,
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
SpantypesPostableTraceAggregationsDTO,
SpantypesPostableWaterfallDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* Computes span aggregations grouped by requested field.
* @summary Get aggregations for a trace
*/
export const getTraceAggregations = (
{ traceID }: GetTraceAggregationsPathParameters,
spantypesPostableTraceAggregationsDTO?: BodyType<SpantypesPostableTraceAggregationsDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetTraceAggregations200>({
url: `/api/v1/traces/${traceID}/aggregations`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: spantypesPostableTraceAggregationsDTO,
signal,
});
};
export const getGetTraceAggregationsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getTraceAggregations>>,
TError,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof getTraceAggregations>>,
TError,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
},
TContext
> => {
const mutationKey = ['getTraceAggregations'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof getTraceAggregations>>,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return getTraceAggregations(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type GetTraceAggregationsMutationResult = NonNullable<
Awaited<ReturnType<typeof getTraceAggregations>>
>;
export type GetTraceAggregationsMutationBody =
| BodyType<SpantypesPostableTraceAggregationsDTO>
| undefined;
export type GetTraceAggregationsMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get aggregations for a trace
*/
export const useGetTraceAggregations = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getTraceAggregations>>,
TError,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof getTraceAggregations>>,
TError,
{
pathParams: GetTraceAggregationsPathParameters;
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
},
TContext
> => {
return useMutation(getGetTraceAggregationsMutationOptions(options));
};
/**
* Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination
* @summary Get waterfall view for a trace

View File

@@ -48,5 +48,24 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/traces/{traceID}/aggregations", handler.New(
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetTraceAggregations),
handler.OpenAPIDef{
ID: "GetTraceAggregations",
Tags: []string{"tracedetail"},
Summary: "Get aggregations for a trace",
Description: "Computes span aggregations grouped by requested field.",
Request: new(spantypes.PostableTraceAggregations),
RequestContentType: "application/json",
Response: new(spantypes.GettableTraceAggregations),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

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

View File

@@ -105,6 +105,49 @@ func (m *module) getFullWaterfall(ctx context.Context, traceID string, summary *
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, nil, true, nil), nil
}
func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
return nil, err
}
traceDurationNs := uint64(summary.End.UnixNano()) - uint64(summary.Start.UnixNano())
results := make([]spantypes.SpanAggregationResult, 0, len(req.Aggregations))
for _, agg := range req.Aggregations {
result := spantypes.SpanAggregationResult{Field: agg.Field, Aggregation: agg.Aggregation}
switch agg.Aggregation {
case spantypes.SpanAggregationSpanCount:
result.Value, err = m.store.GetSpanCountByField(ctx, traceID, summary, agg.Field)
if err != nil {
return nil, err
}
case spantypes.SpanAggregationDuration:
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, summary, agg.Field)
if err2 != nil {
return nil, err2
}
result.Value = make(map[string]uint64, len(durationNs))
for k, ns := range durationNs {
result.Value[k] = ns / 1_000_000
}
case spantypes.SpanAggregationExecutionTimePercentage:
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, summary, agg.Field)
if err2 != nil {
return nil, err2
}
result.Value = make(map[string]uint64, len(durationNs))
if traceDurationNs > 0 {
for k, ns := range durationNs {
result.Value[k] = ns * 100 / traceDurationNs
}
}
}
results = append(results, result)
}
return &spantypes.GettableTraceAggregations{Aggregations: results}, nil
}
// getWindowedWaterfall builds the waterfall tree with minimal data and then returns only a window of full spans.
func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpanID string, uncollapsedSpans []string, start, end time.Time) (*spantypes.GettableWaterfallTrace, error) {
// Step 1: minimal fetch → build full tree → select visible window

View File

@@ -11,10 +11,30 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const colServiceName = `resource_string_service$$$$name` // $ gets escaped so $$$$ converts to $$.
func buildFieldExpr(fieldKey telemetrytypes.TelemetryFieldKey) (string, error) {
switch fieldKey.FieldContext {
case telemetrytypes.FieldContextResource:
// String cast required — Variant/Dynamic is rejected by GROUP BY.
return fmt.Sprintf("resource.`%s`::String", fieldKey.Name), nil
}
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported field context: %v", fieldKey.FieldContext)
}
type spanCountRow struct {
FieldValue string `ch:"field_value"`
Count uint64 `ch:"count"`
}
type spanDurationRow struct {
FieldValue string `ch:"field_value"`
TotalNs uint64 `ch:"total_ns"`
}
type traceStore struct {
telemetryStore telemetrystore.TelemetryStore
}
@@ -133,3 +153,95 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
}
return spans, nil
}
func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, summary *spantypes.TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
fieldExpr, err := buildFieldExpr(fieldKey)
if err != nil {
return nil, err
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select(fieldExpr+" AS field_value", "count(DISTINCT span_id) AS count")
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
sb.Where(
sb.E("trace_id", traceID),
sb.GE("ts_bucket_start", summary.Start.Unix()-1800),
sb.LE("ts_bucket_start", summary.End.Unix()),
"notEmpty("+fieldExpr+")",
)
sb.GroupBy("field_value")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var rows []spanCountRow
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &rows, query, args...); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying span count by field")
}
result := make(map[string]uint64, len(rows))
for _, r := range rows {
result[r.FieldValue] = r.Count
}
return result, nil
}
func (s *traceStore) GetSpanDurationByField(ctx context.Context, traceID string, summary *spantypes.TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
fieldExpr, err := buildFieldExpr(fieldKey)
if err != nil {
return nil, err
}
// CTE 1: all span with start and end timestamps.
allSpansSB := sqlbuilder.NewSelectBuilder()
allSpansSB.Select(
"DISTINCT ON (span_id) "+fieldExpr+" AS field_value",
"toUnixTimestamp64Nano(timestamp) AS start_ns",
"start_ns + duration_nano AS end_ns",
)
allSpansSB.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
allSpansSB.Where(
allSpansSB.E("trace_id", traceID),
allSpansSB.GE("ts_bucket_start", summary.Start.Unix()-1800),
allSpansSB.LE("ts_bucket_start", summary.End.Unix()),
"notEmpty(field_value)",
)
allSpansSB.OrderByAsc("timestamp")
allSpansSB.OrderByAsc("name")
// CTE 2: find max end time of all preceding spans.
effectiveStartSB := sqlbuilder.NewSelectBuilder()
effectiveStartSB.Select(
"field_value", "end_ns",
`greatest(
start_ns,
ifNull(
max(end_ns) OVER (
PARTITION BY field_value
ORDER BY start_ns
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
),
toUInt64(0)
)
) AS effective_start_ns`,
)
effectiveStartSB.From("all_spans")
// Final SELECT: each span contributes only the tail past its effective start.
sb := sqlbuilder.With(
sqlbuilder.CTEQuery("all_spans").As(allSpansSB),
sqlbuilder.CTEQuery("effective_start").As(effectiveStartSB),
).Select(
"field_value",
"sum(toUInt64(greatest(end_ns - effective_start_ns, 0))) AS total_ns",
)
sb.From("effective_start")
sb.GroupBy("field_value")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var rows []spanDurationRow
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &rows, query, args...); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying span duration by field")
}
result := make(map[string]uint64, len(rows))
for _, r := range rows {
result[r.FieldValue] = r.TotalNs
}
return result, nil
}

View File

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

View File

@@ -1,6 +1,7 @@
package spantypes
import (
"regexp"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
@@ -8,6 +9,8 @@ import (
"github.com/SigNoz/signoz/pkg/valuer"
)
var validAggregationFieldName = regexp.MustCompile(`^[a-zA-Z0-9._\-]+$`)
const maxAggregationItems = 10
var ErrTooManyAggregationItems = errors.NewInvalidInputf(errors.CodeInvalidInput, "aggregations request exceeds maximum of %d items", maxAggregationItems)
@@ -48,3 +51,32 @@ func (SpanAggregationType) Enum() []any {
func (s SpanAggregationType) isValid() bool {
return slices.ContainsFunc(s.Enum(), func(v any) bool { return v == s })
}
// PostableTraceAggregations is the request body for the V4 aggregations endpoint.
type PostableTraceAggregations struct {
Aggregations []SpanAggregation `json:"aggregations"`
}
func (p *PostableTraceAggregations) Validate() error {
if len(p.Aggregations) > maxAggregationItems {
return ErrTooManyAggregationItems
}
for _, a := range p.Aggregations {
if !a.Aggregation.isValid() {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown aggregation type: %q", a.Aggregation)
}
if a.Field.FieldContext != telemetrytypes.FieldContextResource {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "aggregation field context must be %q, got %q",
telemetrytypes.FieldContextResource, a.Field.FieldContext)
}
if !validAggregationFieldName.MatchString(a.Field.Name) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid field name: %q", a.Field.Name)
}
}
return nil
}
// GettableTraceAggregations is the response for the V4 aggregations endpoint.
type GettableTraceAggregations struct {
Aggregations []SpanAggregationResult `json:"aggregations"`
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -29,4 +30,7 @@ type TraceStore interface {
GetTraceSpans(ctx context.Context, traceID string, summary *TraceSummary) ([]StorableSpan, error)
GetMinimalSpans(ctx context.Context, traceID string, start, end time.Time) ([]MinimalSpan, error)
GetTraceSpansByIDs(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
GetSpanCountByField(ctx context.Context, traceID string, summary *TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
GetSpanDurationByField(ctx context.Context, traceID string, summary *TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
}