Compare commits

...

7 Commits

Author SHA1 Message Date
Nikhil Soni
3e93814b9e chore: avoid sorting on every traversal 2026-04-01 21:38:02 +05:30
Nikhil Soni
5add200a47 chore: add same test cases as for old waterfall api 2026-04-01 18:23:49 +05:30
Nikhil Soni
fb74637a97 refactor: convert waterfall api to modules format 2026-04-01 18:02:15 +05:30
Nikhil Soni
4e9b3f3b0f fix: update span.attributes to map of string to any
To support otel format of diffrent types of attributes
2026-04-01 16:08:11 +05:30
Nikhil Soni
ed5c30012d chore: add reason for using snake case in response 2026-04-01 15:27:57 +05:30
Nikhil Soni
fdaa52f2b0 refactor: move type conversion logic to types pkg 2026-04-01 14:04:53 +05:30
Nikhil Soni
e0a3392d02 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
2026-04-01 12:27:41 +05:30
12 changed files with 1423 additions and 0 deletions

View File

@@ -23,6 +23,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types"
@@ -57,6 +58,7 @@ type provider struct {
factoryHandler factory.Handler
cloudIntegrationHandler cloudintegration.Handler
ruleStateHistoryHandler rulestatehistory.Handler
traceDetailHandler tracedetail.Handler
}
func NewFactory(
@@ -83,6 +85,7 @@ func NewFactory(
factoryHandler factory.Handler,
cloudIntegrationHandler cloudintegration.Handler,
ruleStateHistoryHandler rulestatehistory.Handler,
traceDetailHandler tracedetail.Handler,
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
return newProvider(
@@ -112,6 +115,7 @@ func NewFactory(
factoryHandler,
cloudIntegrationHandler,
ruleStateHistoryHandler,
traceDetailHandler,
)
})
}
@@ -143,6 +147,7 @@ func newProvider(
factoryHandler factory.Handler,
cloudIntegrationHandler cloudintegration.Handler,
ruleStateHistoryHandler rulestatehistory.Handler,
traceDetailHandler tracedetail.Handler,
) (apiserver.APIServer, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
router := mux.NewRouter().UseEncodedPath()
@@ -172,6 +177,7 @@ func newProvider(
factoryHandler: factoryHandler,
cloudIntegrationHandler: cloudIntegrationHandler,
ruleStateHistoryHandler: ruleStateHistoryHandler,
traceDetailHandler: traceDetailHandler,
}
provider.authZ = middleware.NewAuthZ(settings.Logger(), orgGetter, authz)
@@ -272,6 +278,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addTraceDetailRoutes(router); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,33 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
"github.com/gorilla/mux"
)
func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
if err := router.Handle("/api/v3/traces/{traceId}/waterfall", handler.New(
provider.authZ.ViewAccess(provider.traceDetailHandler.GetWaterfall),
handler.OpenAPIDef{
ID: "GetWaterfall",
Tags: []string{"tracedetail"},
Summary: "Get waterfall view for a trace",
Description: "Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination",
Request: new(tracedetailtypes.WaterfallRequest),
RequestContentType: "application/json",
Response: new(tracedetailtypes.WaterfallResponse),
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

@@ -0,0 +1,56 @@
package impltracedetail
import (
"net/http"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
type handler struct {
module tracedetail.Module
}
func NewHandler(module tracedetail.Module) tracedetail.Handler {
return &handler{module: module}
}
func (h *handler) GetWaterfall(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
traceID := mux.Vars(r)["traceId"]
if traceID == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "traceId is required"))
return
}
var req tracedetailtypes.WaterfallRequest
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
render.Error(rw, err)
return
}
result, err := h.module.GetWaterfall(r.Context(), orgID, traceID, &req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

View File

@@ -0,0 +1,272 @@
package impltracedetail
import (
"context"
"database/sql"
"fmt"
"log/slog"
"sort"
"strconv"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
"github.com/SigNoz/signoz/pkg/valuer"
tracedetailv2 "github.com/SigNoz/signoz/pkg/query-service/app/traces/tracedetail"
)
const (
traceDB = "signoz_traces"
traceTable = "distributed_signoz_index_v3"
traceSummaryTable = "distributed_trace_summary"
cacheTTL = 5 * time.Minute
fluxInterval = 2 * time.Minute
)
type module struct {
telemetryStore telemetrystore.TelemetryStore
cache cache.Cache
logger *slog.Logger
}
func NewModule(telemetryStore telemetrystore.TelemetryStore, cache cache.Cache, providerSettings factory.ProviderSettings) tracedetail.Module {
return &module{
telemetryStore: telemetryStore,
cache: cache,
logger: providerSettings.Logger,
}
}
func (m *module) GetWaterfall(ctx context.Context, orgID valuer.UUID, traceID string, req *tracedetailtypes.WaterfallRequest) (*tracedetailtypes.WaterfallResponse, error) {
response := new(tracedetailtypes.WaterfallResponse)
var startTime, endTime, durationNano, totalErrorSpans, totalSpans uint64
var spanIDToSpanNodeMap = map[string]*tracedetailtypes.Span{}
var traceRoots []*tracedetailtypes.Span
var serviceNameToTotalDurationMap = map[string]uint64{}
var serviceNameIntervalMap = map[string][]tracedetailv2.Interval{}
var hasMissingSpans bool
// Try cache first
cachedTraceData, err := m.getFromCache(ctx, orgID, traceID)
if err == nil {
startTime = cachedTraceData.StartTime
endTime = cachedTraceData.EndTime
durationNano = cachedTraceData.DurationNano
spanIDToSpanNodeMap = cachedTraceData.SpanIDToSpanNodeMap
serviceNameToTotalDurationMap = cachedTraceData.ServiceNameToTotalDurationMap
traceRoots = cachedTraceData.TraceRoots
totalSpans = cachedTraceData.TotalSpans
totalErrorSpans = cachedTraceData.TotalErrorSpans
hasMissingSpans = cachedTraceData.HasMissingSpans
}
if err != nil {
m.logger.Info("cache miss for v3 waterfall", "traceID", traceID)
// Query trace summary for time boundaries
var summary tracedetailtypes.TraceSummary
summaryQuery := fmt.Sprintf(
"SELECT trace_id, min(start) AS start, max(end) AS end, sum(num_spans) AS num_spans FROM %s.%s WHERE trace_id=$1 GROUP BY trace_id",
traceDB, traceSummaryTable,
)
err := m.telemetryStore.ClickhouseDB().QueryRow(ctx, summaryQuery, traceID).Scan(
&summary.TraceID, &summary.Start, &summary.End, &summary.NumSpans,
)
if err != nil {
if err == sql.ErrNoRows {
return response, nil
}
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "error querying trace summary: %v", err)
}
// Query span details
detailsQuery := fmt.Sprintf(
"SELECT DISTINCT ON (span_id) "+
"timestamp, duration_nano, span_id, trace_id, has_error, kind, "+
"resource_string_service$$name, name, links as references, "+
"attributes_string, attributes_number, attributes_bool, resources_string, "+
"events, status_message, status_code_string, kind_string, parent_span_id, "+
"flags, is_remote, trace_state, status_code, "+
"db_name, db_operation, http_method, http_url, http_host, "+
"external_http_method, external_http_url, response_status_code "+
"FROM %s.%s WHERE trace_id=$1 AND ts_bucket_start>=$2 AND ts_bucket_start<=$3 "+
"ORDER BY timestamp ASC, name ASC",
traceDB, traceTable,
)
var spanItems []tracedetailtypes.SpanModel
err = m.telemetryStore.ClickhouseDB().Select(
ctx, &spanItems, detailsQuery,
traceID,
strconv.FormatInt(summary.Start.Unix()-1800, 10),
strconv.FormatInt(summary.End.Unix(), 10),
)
if err != nil {
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "error querying trace spans: %v", err)
}
if len(spanItems) == 0 {
return response, nil
}
totalSpans = uint64(len(spanItems))
spanIDToSpanNodeMap = make(map[string]*tracedetailtypes.Span, len(spanItems))
// Build span nodes
for _, item := range spanItems {
span := item.ToSpan()
startTimeUnixNano := span.TimeUnixNano
// Metadata calculation
if startTime == 0 || startTimeUnixNano < startTime {
startTime = startTimeUnixNano
}
if endTime == 0 || (startTimeUnixNano+span.DurationNano) > endTime {
endTime = startTimeUnixNano + span.DurationNano
}
if durationNano == 0 || span.DurationNano > durationNano {
durationNano = span.DurationNano
}
if span.HasError {
totalErrorSpans++
}
// Collect intervals for service execution time calculation
serviceNameIntervalMap[span.ServiceName] = append(
serviceNameIntervalMap[span.ServiceName],
tracedetailv2.Interval{StartTime: startTimeUnixNano, Duration: span.DurationNano, Service: span.ServiceName},
)
spanIDToSpanNodeMap[span.SpanID] = span
}
// Build tree: parent-child relationships and missing spans
for _, spanNode := range spanIDToSpanNodeMap {
if spanNode.ParentSpanID != "" {
if parentNode, exists := spanIDToSpanNodeMap[spanNode.ParentSpanID]; exists {
parentNode.Children = append(parentNode.Children, spanNode)
} else {
// Insert missing span
missingSpan := &tracedetailtypes.Span{
SpanID: spanNode.ParentSpanID,
TraceID: spanNode.TraceID,
Name: "Missing Span",
TimeUnixNano: spanNode.TimeUnixNano,
DurationNano: spanNode.DurationNano,
Events: make([]tracedetailtypes.Event, 0),
Children: make([]*tracedetailtypes.Span, 0),
Attributes: make(map[string]any),
Resources: make(map[string]string),
}
missingSpan.Children = append(missingSpan.Children, spanNode)
spanIDToSpanNodeMap[missingSpan.SpanID] = missingSpan
traceRoots = append(traceRoots, missingSpan)
hasMissingSpans = true
}
} else if !containsSpan(traceRoots, spanNode) {
traceRoots = append(traceRoots, spanNode)
}
}
// Sort children of each span for consistent ordering across requests.
for _, root := range traceRoots {
SortSpanChildren(root)
}
// Sort trace roots
sort.Slice(traceRoots, func(i, j int) bool {
if traceRoots[i].TimeUnixNano == traceRoots[j].TimeUnixNano {
return traceRoots[i].Name < traceRoots[j].Name
}
return traceRoots[i].TimeUnixNano < traceRoots[j].TimeUnixNano
})
serviceNameToTotalDurationMap = tracedetailv2.CalculateServiceTime(serviceNameIntervalMap)
// Cache the processed data
traceCache := &tracedetailtypes.WaterfallCache{
StartTime: startTime,
EndTime: endTime,
DurationNano: durationNano,
TotalSpans: totalSpans,
TotalErrorSpans: totalErrorSpans,
SpanIDToSpanNodeMap: spanIDToSpanNodeMap,
ServiceNameToTotalDurationMap: serviceNameToTotalDurationMap,
TraceRoots: traceRoots,
HasMissingSpans: hasMissingSpans,
}
cacheKey := strings.Join([]string{"v3_waterfall", traceID}, "-")
if cacheErr := m.cache.Set(ctx, orgID, cacheKey, traceCache, cacheTTL); cacheErr != nil {
m.logger.DebugContext(ctx, "failed to store v3 waterfall cache", "traceID", traceID, "error", cacheErr)
}
}
// Span selection: all spans or windowed
limit := min(req.Limit, MaxLimitToSelectAllSpans)
selectAllSpans := totalSpans <= uint64(limit)
var (
selectedSpans []*tracedetailtypes.Span
uncollapsedSpans []string
rootServiceName, rootServiceEntryPoint string
)
if selectAllSpans {
selectedSpans, rootServiceName, rootServiceEntryPoint = GetAllSpans(traceRoots)
} else {
selectedSpans, uncollapsedSpans, rootServiceName, rootServiceEntryPoint = GetSelectedSpans(
req.UncollapsedSpans, req.SelectedSpanID, traceRoots, spanIDToSpanNodeMap, req.IsSelectedSpanIDUnCollapsed,
)
}
// Convert timestamps to milliseconds for service duration map
for serviceName, totalDuration := range serviceNameToTotalDurationMap {
serviceNameToTotalDurationMap[serviceName] = totalDuration / 1000000
}
response.Spans = selectedSpans
response.UncollapsedSpans = uncollapsedSpans
response.StartTimestampMillis = startTime / 1000000
response.EndTimestampMillis = endTime / 1000000
response.DurationNano = durationNano
response.TotalSpansCount = totalSpans
response.TotalErrorSpansCount = totalErrorSpans
response.RootServiceName = rootServiceName
response.RootServiceEntryPoint = rootServiceEntryPoint
response.ServiceNameToTotalDurationMap = serviceNameToTotalDurationMap
response.HasMissingSpans = hasMissingSpans
return response, nil
}
func (m *module) getFromCache(ctx context.Context, orgID valuer.UUID, traceID string) (*tracedetailtypes.WaterfallCache, error) {
cachedData := new(tracedetailtypes.WaterfallCache)
cacheKey := strings.Join([]string{"v3_waterfall", traceID}, "-")
err := m.cache.Get(ctx, orgID, cacheKey, cachedData)
if err != nil {
return nil, err
}
// Skip cache if trace end time falls within flux interval
if time.Since(time.UnixMilli(int64(cachedData.EndTime))) < fluxInterval {
m.logger.InfoContext(ctx, "trace end time within flux interval, skipping v3 waterfall cache", "traceID", traceID)
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "trace end time within flux interval, traceID: %s", traceID)
}
m.logger.InfoContext(ctx, "cache hit for v3 waterfall", "traceID", traceID)
return cachedData, nil
}
func containsSpan(spans []*tracedetailtypes.Span, target *tracedetailtypes.Span) bool {
for _, s := range spans {
if s.SpanID == target.SpanID {
return true
}
}
return false
}

View File

@@ -0,0 +1,193 @@
package impltracedetail
import (
"maps"
"slices"
"sort"
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
)
var (
spanLimitPerRequest float64 = 500
maxDepthForSelectedChildren int = 5
MaxLimitToSelectAllSpans uint = 10_000
)
type traverseOpts struct {
uncollapsedSpans map[string]struct{}
selectedSpanID string
isSelectedSpanUncollapsed bool
selectAll bool
}
func traverseTrace(
span *tracedetailtypes.Span,
opts traverseOpts,
level uint64,
isPartOfPreOrder bool,
hasSibling bool,
autoExpandDepth int,
) ([]*tracedetailtypes.Span, []string) {
preOrderTraversal := []*tracedetailtypes.Span{}
autoExpandedSpans := []string{}
span.SubTreeNodeCount = 0
nodeWithoutChildren := span.CopyWithoutChildren(level, hasSibling)
if isPartOfPreOrder {
preOrderTraversal = append(preOrderTraversal, nodeWithoutChildren)
}
remainingAutoExpandDepth := 0
if span.SpanID == opts.selectedSpanID && opts.isSelectedSpanUncollapsed {
remainingAutoExpandDepth = maxDepthForSelectedChildren
} else if autoExpandDepth > 0 {
remainingAutoExpandDepth = autoExpandDepth - 1
}
_, isAlreadyUncollapsed := opts.uncollapsedSpans[span.SpanID]
for index, child := range span.Children {
isChildWithinMaxDepth := remainingAutoExpandDepth > 0
childIsPartOfPreOrder := opts.selectAll || (isPartOfPreOrder && (isAlreadyUncollapsed || isChildWithinMaxDepth))
if isPartOfPreOrder && isChildWithinMaxDepth && !isAlreadyUncollapsed {
if !slices.Contains(autoExpandedSpans, span.SpanID) {
autoExpandedSpans = append(autoExpandedSpans, span.SpanID)
}
}
childTraversal, childAutoExpanded := traverseTrace(child, opts, level+1, childIsPartOfPreOrder, index != (len(span.Children)-1), remainingAutoExpandDepth)
preOrderTraversal = append(preOrderTraversal, childTraversal...)
autoExpandedSpans = append(autoExpandedSpans, childAutoExpanded...)
nodeWithoutChildren.SubTreeNodeCount += child.SubTreeNodeCount + 1
span.SubTreeNodeCount += child.SubTreeNodeCount + 1
}
nodeWithoutChildren.SubTreeNodeCount += 1
return preOrderTraversal, autoExpandedSpans
}
func GetSelectedSpans(uncollapsedSpans []string, selectedSpanID string, traceRoots []*tracedetailtypes.Span, spanIDToSpanNodeMap map[string]*tracedetailtypes.Span, isSelectedSpanIDUnCollapsed bool) ([]*tracedetailtypes.Span, []string, string, string) {
var preOrderTraversal = make([]*tracedetailtypes.Span, 0)
var rootServiceName, rootServiceEntryPoint string
uncollapsedSpanMap := make(map[string]struct{})
for _, spanID := range uncollapsedSpans {
uncollapsedSpanMap[spanID] = struct{}{}
}
selectedSpanIndex := -1
for _, rootSpanID := range traceRoots {
if rootNode, exists := spanIDToSpanNodeMap[rootSpanID.SpanID]; exists {
present, spansFromRootToNode := getPathFromRootToSelectedSpanID(rootNode, selectedSpanID)
if present {
for _, spanID := range spansFromRootToNode {
if selectedSpanID == spanID && !isSelectedSpanIDUnCollapsed {
continue
}
uncollapsedSpanMap[spanID] = struct{}{}
}
}
opts := traverseOpts{
uncollapsedSpans: uncollapsedSpanMap,
selectedSpanID: selectedSpanID,
isSelectedSpanUncollapsed: isSelectedSpanIDUnCollapsed,
}
traversal, autoExpanded := traverseTrace(rootNode, opts, 0, true, false, 0)
for _, spanID := range autoExpanded {
uncollapsedSpanMap[spanID] = struct{}{}
}
idx := findIndexForSelectedSpan(traversal, selectedSpanID)
if idx != -1 {
selectedSpanIndex = idx + len(preOrderTraversal)
}
preOrderTraversal = append(preOrderTraversal, traversal...)
if rootServiceName == "" {
rootServiceName = rootNode.ServiceName
}
if rootServiceEntryPoint == "" {
rootServiceEntryPoint = rootNode.Name
}
}
}
if selectedSpanIndex == -1 && selectedSpanID != "" {
selectedSpanIndex = 0
}
// Window: 40% before, 60% after selected span
startIndex := selectedSpanIndex - int(spanLimitPerRequest*0.4)
endIndex := selectedSpanIndex + int(spanLimitPerRequest*0.6)
if startIndex < 0 {
endIndex = endIndex - startIndex
startIndex = 0
}
if endIndex > len(preOrderTraversal) {
startIndex = startIndex - (endIndex - len(preOrderTraversal))
endIndex = len(preOrderTraversal)
}
if startIndex < 0 {
startIndex = 0
}
return preOrderTraversal[startIndex:endIndex], slices.Collect(maps.Keys(uncollapsedSpanMap)), rootServiceName, rootServiceEntryPoint
}
func GetAllSpans(traceRoots []*tracedetailtypes.Span) (spans []*tracedetailtypes.Span, rootServiceName, rootEntryPoint string) {
if len(traceRoots) > 0 {
rootServiceName = traceRoots[0].ServiceName
rootEntryPoint = traceRoots[0].Name
}
for _, root := range traceRoots {
childSpans, _ := traverseTrace(root, traverseOpts{selectAll: true}, 0, true, false, 0)
spans = append(spans, childSpans...)
}
return
}
func getPathFromRootToSelectedSpanID(node *tracedetailtypes.Span, selectedSpanID string) (bool, []string) {
path := []string{node.SpanID}
if node.SpanID == selectedSpanID {
return true, path
}
for _, child := range node.Children {
found, childPath := getPathFromRootToSelectedSpanID(child, selectedSpanID)
if found {
path = append(path, childPath...)
return true, path
}
}
return false, nil
}
func findIndexForSelectedSpan(spans []*tracedetailtypes.Span, selectedSpanID string) int {
for i, span := range spans {
if span.SpanID == selectedSpanID {
return i
}
}
return -1
}
// SortSpanChildren recursively sorts children of each span by TimeUnixNano then Name.
// Must be called once after the span tree is fully built so that traverseTrace
// sees a consistent ordering without needing to re-sort on every call.
func SortSpanChildren(span *tracedetailtypes.Span) {
sort.Slice(span.Children, func(i, j int) bool {
if span.Children[i].TimeUnixNano == span.Children[j].TimeUnixNano {
return span.Children[i].Name < span.Children[j].Name
}
return span.Children[i].TimeUnixNano < span.Children[j].TimeUnixNano
})
for _, child := range span.Children {
SortSpanChildren(child)
}
}

View File

@@ -0,0 +1,580 @@
// Package impltracedetail tests — waterfall
//
// # Background
//
// The waterfall view renders a trace as a scrollable list of spans in
// pre-order (parent before children, siblings left-to-right). Because a trace
// can have thousands of spans, only a window of ~500 is returned per request.
// The window is centred on the selected span.
//
// # Key concepts
//
// uncollapsedSpans
//
// The set of span IDs the user has manually expanded in the UI.
// Only the direct children of an uncollapsed span are included in the
// output; grandchildren stay hidden until their parent is also uncollapsed.
// When multiple spans are uncollapsed their children are all visible at once.
//
// selectedSpanID
//
// The span currently focused — set when the user clicks a span in the
// waterfall or selects one from the flamegraph. The output window is always
// centred on this span. The path from the trace root down to the selected
// span is automatically uncollapsed so ancestors are visible even if they are
// not in uncollapsedSpans.
//
// isSelectedSpanIDUnCollapsed
//
// Controls whether the selected span's own children are shown:
// true — user expanded the span (click-to-open in waterfall or flamegraph);
// direct children of the selected span are included.
// false — user selected without expanding;
// the span is visible but its children remain hidden.
//
// traceRoots
//
// Root spans of the trace — spans with no parent in the current dataset.
// Normally one, but multiple roots are common when upstream services are
// not instrumented or their spans were not sampled/exported.
package impltracedetail
import (
"fmt"
"testing"
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
"github.com/stretchr/testify/assert"
)
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
func mkSpan(id, service string, children ...*tracedetailtypes.Span) *tracedetailtypes.Span {
return &tracedetailtypes.Span{
SpanID: id,
ServiceName: service,
Name: id + "-op",
Children: children,
}
}
func spanIDs(spans []*tracedetailtypes.Span) []string {
ids := make([]string, len(spans))
for i, s := range spans {
ids[i] = s.SpanID
}
return ids
}
func buildSpanMap(roots ...*tracedetailtypes.Span) map[string]*tracedetailtypes.Span {
m := map[string]*tracedetailtypes.Span{}
var walk func(*tracedetailtypes.Span)
walk = func(s *tracedetailtypes.Span) {
m[s.SpanID] = s
for _, c := range s.Children {
walk(c)
}
}
for _, r := range roots {
SortSpanChildren(r)
walk(r)
}
return m
}
// makeChain builds a linear trace: span0 → span1 → … → span(n-1).
func makeChain(n int) (*tracedetailtypes.Span, map[string]*tracedetailtypes.Span, []string) {
spans := make([]*tracedetailtypes.Span, n)
for i := n - 1; i >= 0; i-- {
if i == n-1 {
spans[i] = mkSpan(fmt.Sprintf("span%d", i), "svc")
} else {
spans[i] = mkSpan(fmt.Sprintf("span%d", i), "svc", spans[i+1])
}
}
uncollapsed := make([]string, n)
for i := range spans {
uncollapsed[i] = fmt.Sprintf("span%d", i)
}
return spans[0], buildSpanMap(spans[0]), uncollapsed
}
// ─────────────────────────────────────────────────────────────────────────────
// GetSelectedSpans — span ordering and visibility
// ─────────────────────────────────────────────────────────────────────────────
func TestGetSelectedSpans_SpanOrdering(t *testing.T) {
tests := []struct {
name string
buildRoots func() ([]*tracedetailtypes.Span, map[string]*tracedetailtypes.Span)
uncollapsedSpans []string
selectedSpanID string
isSelectedSpanIDUnCollapsed bool
wantSpanIDs []string
}{
{
// Pre-order traversal is preserved: parent before children, siblings left-to-right.
name: "pre_order_traversal",
buildRoots: func() ([]*tracedetailtypes.Span, map[string]*tracedetailtypes.Span) {
root := mkSpan("root", "svc",
mkSpan("child1", "svc", mkSpan("grandchild", "svc")),
mkSpan("child2", "svc"),
)
return []*tracedetailtypes.Span{root}, buildSpanMap(root)
},
uncollapsedSpans: []string{"root", "child1"},
selectedSpanID: "root",
isSelectedSpanIDUnCollapsed: false,
wantSpanIDs: []string{"root", "child1", "grandchild", "child2"},
},
{
// Multiple spans uncollapsed simultaneously: children of all uncollapsed spans are visible at once.
//
// root
// ├─ childA (uncollapsed) → grandchildA ✓
// └─ childB (uncollapsed) → grandchildB ✓
name: "multiple_uncollapsed",
buildRoots: func() ([]*tracedetailtypes.Span, map[string]*tracedetailtypes.Span) {
root := mkSpan("root", "svc",
mkSpan("childA", "svc", mkSpan("grandchildA", "svc")),
mkSpan("childB", "svc", mkSpan("grandchildB", "svc")),
)
return []*tracedetailtypes.Span{root}, buildSpanMap(root)
},
uncollapsedSpans: []string{"root", "childA", "childB"},
selectedSpanID: "root",
isSelectedSpanIDUnCollapsed: false,
wantSpanIDs: []string{"root", "childA", "grandchildA", "childB", "grandchildB"},
},
{
// Collapsing a span with other uncollapsed spans.
//
// root
// ├─ childA (previously expanded — in uncollapsedSpans)
// │ ├─ grandchild1 ✓
// │ │ └─ greatGrandchild ✗ (grandchild1 not in uncollapsedSpans)
// │ └─ grandchild2 ✓
// └─ childB ← selected (not expanded)
name: "manual_uncollapse",
buildRoots: func() ([]*tracedetailtypes.Span, map[string]*tracedetailtypes.Span) {
root := mkSpan("root", "svc",
mkSpan("childA", "svc",
mkSpan("grandchild1", "svc", mkSpan("greatGrandchild", "svc")),
mkSpan("grandchild2", "svc"),
),
mkSpan("childB", "svc"),
)
return []*tracedetailtypes.Span{root}, buildSpanMap(root)
},
uncollapsedSpans: []string{"childA"},
selectedSpanID: "childB",
isSelectedSpanIDUnCollapsed: false,
wantSpanIDs: []string{"root", "childA", "grandchild1", "grandchild2", "childB"},
},
{
// A collapsed span hides all children.
name: "collapsed_span_hides_children",
buildRoots: func() ([]*tracedetailtypes.Span, map[string]*tracedetailtypes.Span) {
root := mkSpan("root", "svc",
mkSpan("child1", "svc"),
mkSpan("child2", "svc"),
)
return []*tracedetailtypes.Span{root}, buildSpanMap(root)
},
uncollapsedSpans: []string{},
selectedSpanID: "root",
isSelectedSpanIDUnCollapsed: false,
wantSpanIDs: []string{"root"},
},
{
// Selecting a span auto-uncollpases the path from root to that span so it is visible.
//
// root → parent → selected
name: "path_to_selected_is_uncollapsed",
buildRoots: func() ([]*tracedetailtypes.Span, map[string]*tracedetailtypes.Span) {
root := mkSpan("root", "svc",
mkSpan("parent", "svc",
mkSpan("selected", "svc"),
),
)
return []*tracedetailtypes.Span{root}, buildSpanMap(root)
},
uncollapsedSpans: []string{},
selectedSpanID: "selected",
isSelectedSpanIDUnCollapsed: false,
wantSpanIDs: []string{"root", "parent", "selected"},
},
{
// Siblings of ancestors are rendered as collapsed nodes but their subtrees must NOT be expanded.
//
// root
// ├─ unrelated → unrelated-child (✗)
// └─ parent → selected
name: "siblings_not_expanded",
buildRoots: func() ([]*tracedetailtypes.Span, map[string]*tracedetailtypes.Span) {
root := mkSpan("root", "svc",
mkSpan("unrelated", "svc", mkSpan("unrelated-child", "svc")),
mkSpan("parent", "svc",
mkSpan("selected", "svc"),
),
)
return []*tracedetailtypes.Span{root}, buildSpanMap(root)
},
uncollapsedSpans: []string{},
selectedSpanID: "selected",
isSelectedSpanIDUnCollapsed: false,
// children of root sort alphabetically: parent < unrelated; unrelated-child stays hidden
wantSpanIDs: []string{"root", "parent", "selected", "unrelated"},
},
{
// An unknown selectedSpanID must not panic; returns a window from index 0.
name: "unknown_selected_span",
buildRoots: func() ([]*tracedetailtypes.Span, map[string]*tracedetailtypes.Span) {
root := mkSpan("root", "svc", mkSpan("child", "svc"))
return []*tracedetailtypes.Span{root}, buildSpanMap(root)
},
uncollapsedSpans: []string{},
selectedSpanID: "nonexistent",
isSelectedSpanIDUnCollapsed: false,
wantSpanIDs: []string{"root"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
roots, spanMap := tc.buildRoots()
spans, _, _, _ := GetSelectedSpans(tc.uncollapsedSpans, tc.selectedSpanID, roots, spanMap, tc.isSelectedSpanIDUnCollapsed)
assert.Equal(t, tc.wantSpanIDs, spanIDs(spans))
})
}
}
// Multiple roots: both trees are flattened into a single pre-order list with
// root1's subtree before root2's. Service/entry-point come from the first root.
//
// root1 svc-a ← selected
// └─ child1
// root2 svc-b
// └─ child2
//
// Expected output order: root1 → child1 → root2 → child2
func TestGetSelectedSpans_MultipleRoots(t *testing.T) {
root1 := mkSpan("root1", "svc-a", mkSpan("child1", "svc-a"))
root2 := mkSpan("root2", "svc-b", mkSpan("child2", "svc-b"))
spanMap := buildSpanMap(root1, root2)
spans, _, svcName, entryPoint := GetSelectedSpans([]string{"root1", "root2"}, "root1", []*tracedetailtypes.Span{root1, root2}, spanMap, false)
assert.Equal(t, []string{"root1", "child1", "root2", "child2"}, spanIDs(spans), "root1 subtree must precede root2 subtree")
assert.Equal(t, "svc-a", svcName, "metadata comes from first root")
assert.Equal(t, "root1-op", entryPoint, "metadata comes from first root")
}
// ─────────────────────────────────────────────────────────────────────────────
// GetSelectedSpans — uncollapsed span tracking
// ─────────────────────────────────────────────────────────────────────────────
func TestGetSelectedSpans_UncollapsedTracking(t *testing.T) {
tests := []struct {
name string
buildRoot func() (*tracedetailtypes.Span, map[string]*tracedetailtypes.Span)
uncollapsedSpans []string
selectedSpanID string
isSelectedSpanIDUnCollapsed bool
wantSpanIDs []string
checkUncollapsed func(t *testing.T, uncollapsed []string)
}{
{
// The path-to-selected spans are returned in updatedUncollapsedSpans.
name: "path_returned_in_uncollapsed",
buildRoot: func() (*tracedetailtypes.Span, map[string]*tracedetailtypes.Span) {
root := mkSpan("root", "svc",
mkSpan("parent", "svc",
mkSpan("selected", "svc"),
),
)
return root, buildSpanMap(root)
},
uncollapsedSpans: []string{},
selectedSpanID: "selected",
isSelectedSpanIDUnCollapsed: false,
wantSpanIDs: []string{"root", "parent", "selected"},
checkUncollapsed: func(t *testing.T, uncollapsed []string) {
assert.ElementsMatch(t, []string{"root", "parent"}, uncollapsed)
},
},
{
// Siblings of ancestors are not tracked as uncollapsed.
name: "siblings_not_in_uncollapsed",
buildRoot: func() (*tracedetailtypes.Span, map[string]*tracedetailtypes.Span) {
root := mkSpan("root", "svc",
mkSpan("unrelated", "svc", mkSpan("unrelated-child", "svc")),
mkSpan("parent", "svc",
mkSpan("selected", "svc"),
),
)
return root, buildSpanMap(root)
},
uncollapsedSpans: []string{},
selectedSpanID: "selected",
isSelectedSpanIDUnCollapsed: false,
wantSpanIDs: []string{"root", "parent", "selected", "unrelated"},
checkUncollapsed: func(t *testing.T, uncollapsed []string) {
assert.ElementsMatch(t, []string{"root", "parent"}, uncollapsed)
},
},
{
// Auto-expanded span IDs from ALL branches are returned in updatedUncollapsedSpans.
// Only internal nodes (spans with children) are tracked — leaf spans are never added.
//
// root (selected, expanded)
// ├─ childA (internal ✓)
// │ └─ grandchildA (internal ✓)
// │ └─ leafA (leaf ✗)
// └─ childB (internal ✓)
// └─ grandchildB (internal ✓)
// └─ leafB (leaf ✗)
name: "auto_expanded_spans_returned",
buildRoot: func() (*tracedetailtypes.Span, map[string]*tracedetailtypes.Span) {
root := mkSpan("root", "svc",
mkSpan("childA", "svc",
mkSpan("grandchildA", "svc",
mkSpan("leafA", "svc"),
),
),
mkSpan("childB", "svc",
mkSpan("grandchildB", "svc",
mkSpan("leafB", "svc"),
),
),
)
return root, buildSpanMap(root)
},
uncollapsedSpans: []string{},
selectedSpanID: "root",
isSelectedSpanIDUnCollapsed: true,
checkUncollapsed: func(t *testing.T, uncollapsed []string) {
assert.Contains(t, uncollapsed, "root")
assert.Contains(t, uncollapsed, "childA", "internal node depth 1, branch A")
assert.Contains(t, uncollapsed, "childB", "internal node depth 1, branch B")
assert.Contains(t, uncollapsed, "grandchildA", "internal node depth 2, branch A")
assert.Contains(t, uncollapsed, "grandchildB", "internal node depth 2, branch B")
assert.NotContains(t, uncollapsed, "leafA", "leaf spans are never added to uncollapsedSpans")
assert.NotContains(t, uncollapsed, "leafB", "leaf spans are never added to uncollapsedSpans")
},
},
{
// If the selected span is already in uncollapsedSpans AND isSelectedSpanIDUnCollapsed=true,
// it should appear exactly once in the result.
name: "duplicate_in_uncollapsed",
buildRoot: func() (*tracedetailtypes.Span, map[string]*tracedetailtypes.Span) {
root := mkSpan("root", "svc",
mkSpan("selected", "svc", mkSpan("child", "svc")),
)
return root, buildSpanMap(root)
},
uncollapsedSpans: []string{"selected"}, // already present
selectedSpanID: "selected",
isSelectedSpanIDUnCollapsed: true,
checkUncollapsed: func(t *testing.T, uncollapsed []string) {
count := 0
for _, id := range uncollapsed {
if id == "selected" {
count++
}
}
assert.Equal(t, 1, count, "should appear once")
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
root, spanMap := tc.buildRoot()
spans, uncollapsed, _, _ := GetSelectedSpans(tc.uncollapsedSpans, tc.selectedSpanID, []*tracedetailtypes.Span{root}, spanMap, tc.isSelectedSpanIDUnCollapsed)
if tc.wantSpanIDs != nil {
assert.Equal(t, tc.wantSpanIDs, spanIDs(spans))
}
if tc.checkUncollapsed != nil {
tc.checkUncollapsed(t, uncollapsed)
}
})
}
}
// ─────────────────────────────────────────────────────────────────────────────
// GetSelectedSpans — span metadata
// ─────────────────────────────────────────────────────────────────────────────
// Test to check if Level, HasChildren, HasSiblings, and SubTreeNodeCount are populated correctly.
//
// root level=0, hasChildren=true, hasSiblings=false, subTree=4
// child1 level=1, hasChildren=true, hasSiblings=true, subTree=2
// grandchild level=2, hasChildren=false, hasSiblings=false, subTree=1
// child2 level=1, hasChildren=false, hasSiblings=false, subTree=1
func TestGetSelectedSpans_SpanMetadata(t *testing.T) {
root := mkSpan("root", "svc",
mkSpan("child1", "svc", mkSpan("grandchild", "svc")),
mkSpan("child2", "svc"),
)
spanMap := buildSpanMap(root)
spans, _, _, _ := GetSelectedSpans([]string{"root", "child1"}, "root", []*tracedetailtypes.Span{root}, spanMap, false)
byID := map[string]*tracedetailtypes.Span{}
for _, s := range spans {
byID[s.SpanID] = s
}
tests := []struct {
spanID string
wantLevel uint64
wantHasChildren bool
wantHasSiblings bool
wantSubTree uint64
}{
{"root", 0, true, false, 4},
{"child1", 1, true, true, 2},
{"child2", 1, false, false, 1},
{"grandchild", 2, false, false, 1},
}
for _, tc := range tests {
t.Run(tc.spanID, func(t *testing.T) {
s := byID[tc.spanID]
assert.Equal(t, tc.wantLevel, s.Level)
assert.Equal(t, tc.wantHasChildren, s.HasChildren)
assert.Equal(t, tc.wantHasSiblings, s.HasSiblings)
assert.Equal(t, tc.wantSubTree, s.SubTreeNodeCount)
})
}
}
// ─────────────────────────────────────────────────────────────────────────────
// GetSelectedSpans — windowing
// ─────────────────────────────────────────────────────────────────────────────
func TestGetSelectedSpans_Window(t *testing.T) {
tests := []struct {
name string
selectedSpanID string
wantLen int
wantFirst string
wantLast string
wantSelectedPos int
}{
{
// The selected span is centred: 200 spans before it, 300 after (0.4 / 0.6 split).
name: "centred_on_selected",
selectedSpanID: "span300",
wantLen: 500,
wantFirst: "span100",
wantLast: "span599",
wantSelectedPos: 200,
},
{
// When the selected span is near the start, the window shifts right so no
// negative index is used — the result is still 500 spans.
name: "shifts_at_start",
selectedSpanID: "span10",
wantLen: 500,
wantFirst: "span0",
wantSelectedPos: 10,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
root, spanMap, uncollapsed := makeChain(600)
spans, _, _, _ := GetSelectedSpans(uncollapsed, tc.selectedSpanID, []*tracedetailtypes.Span{root}, spanMap, false)
assert.Equal(t, tc.wantLen, len(spans), "window size")
assert.Equal(t, tc.wantFirst, spans[0].SpanID, "first span in window")
if tc.wantLast != "" {
assert.Equal(t, tc.wantLast, spans[len(spans)-1].SpanID, "last span in window")
}
assert.Equal(t, tc.selectedSpanID, spans[tc.wantSelectedPos].SpanID, "selected span position")
})
}
}
// ─────────────────────────────────────────────────────────────────────────────
// GetSelectedSpans — depth limit
// ─────────────────────────────────────────────────────────────────────────────
// Depth is measured from the selected span, not the trace root.
// Ancestors appear via the path-to-root logic, not the depth limit.
// Each depth level has two children to confirm the limit is enforced on all
// branches, not just the first.
//
// root
// └─ A ancestor ✓ (path-to-root)
// └─ selected
// ├─ d1a depth 1 ✓
// │ ├─ d2a depth 2 ✓
// │ │ ├─ d3a depth 3 ✓
// │ │ │ ├─ d4a depth 4 ✓
// │ │ │ │ ├─ d5a depth 5 ✓
// │ │ │ │ │ └─ d6a depth 6 ✗
// │ │ │ │ └─ d5b depth 5 ✓
// │ │ │ └─ d4b depth 4 ✓
// │ │ └─ d3b depth 3 ✓
// │ └─ d2b depth 2 ✓
// └─ d1b depth 1 ✓
func TestGetSelectedSpans_DepthCountedFromSelectedSpan(t *testing.T) {
selected := mkSpan("selected", "svc",
mkSpan("d1a", "svc",
mkSpan("d2a", "svc",
mkSpan("d3a", "svc",
mkSpan("d4a", "svc",
mkSpan("d5a", "svc",
mkSpan("d6a", "svc"), // depth 6 — excluded
),
mkSpan("d5b", "svc"), // depth 5 — included
),
mkSpan("d4b", "svc"), // depth 4 — included
),
mkSpan("d3b", "svc"), // depth 3 — included
),
mkSpan("d2b", "svc"), // depth 2 — included
),
mkSpan("d1b", "svc"), // depth 1 — included
)
root := mkSpan("root", "svc", mkSpan("A", "svc", selected))
spanMap := buildSpanMap(root)
spans, _, _, _ := GetSelectedSpans([]string{}, "selected", []*tracedetailtypes.Span{root}, spanMap, true)
ids := spanIDs(spans)
assert.Contains(t, ids, "root", "ancestor shown via path-to-root")
assert.Contains(t, ids, "A", "ancestor shown via path-to-root")
for _, id := range []string{"d1a", "d1b", "d2a", "d2b", "d3a", "d3b", "d4a", "d4b", "d5a", "d5b"} {
assert.Contains(t, ids, id, "depth ≤ 5 — must be included")
}
assert.NotContains(t, ids, "d6a", "depth 6 > limit — excluded")
}
// ─────────────────────────────────────────────────────────────────────────────
// GetAllSpans
// ─────────────────────────────────────────────────────────────────────────────
func TestGetAllSpans(t *testing.T) {
root := mkSpan("root", "svc",
mkSpan("childA", "svc",
mkSpan("grandchildA", "svc",
mkSpan("leafA", "svc2"),
),
),
mkSpan("childB", "svc3",
mkSpan("grandchildB", "svc",
mkSpan("leafB", "svc2"),
),
),
)
spans, rootServiceName, rootEntryPoint := GetAllSpans([]*tracedetailtypes.Span{root})
assert.ElementsMatch(t, spanIDs(spans), []string{"root", "childA", "grandchildA", "leafA", "childB", "grandchildB", "leafB"})
assert.Equal(t, "svc", rootServiceName)
assert.Equal(t, "root-op", rootEntryPoint)
}

View File

@@ -0,0 +1,19 @@
package tracedetail
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/types/tracedetailtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// Handler exposes HTTP handlers for trace detail APIs.
type Handler interface {
GetWaterfall(http.ResponseWriter, *http.Request)
}
// Module defines the business logic for trace detail operations.
type Module interface {
GetWaterfall(ctx context.Context, orgID valuer.UUID, traceID string, req *tracedetailtypes.WaterfallRequest) (*tracedetailtypes.WaterfallResponse, error)
}

View File

@@ -34,6 +34,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/services/implservices"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
"github.com/SigNoz/signoz/pkg/querier"
@@ -62,6 +64,7 @@ type Handlers struct {
RegistryHandler factory.Handler
CloudIntegrationHandler cloudintegration.Handler
RuleStateHistory rulestatehistory.Handler
TraceDetail tracedetail.Handler
}
func NewHandlers(
@@ -99,5 +102,6 @@ func NewHandlers(
RegistryHandler: registryHandler,
CloudIntegrationHandler: implcloudintegration.NewHandler(),
RuleStateHistory: implrulestatehistory.NewHandler(modules.RuleStateHistory),
TraceDetail: impltracedetail.NewHandler(modules.TraceDetail),
}
}

View File

@@ -37,6 +37,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/session/implsession"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
"github.com/SigNoz/signoz/pkg/modules/user"
@@ -72,6 +74,7 @@ type Modules struct {
Promote promote.Module
ServiceAccount serviceaccount.Module
RuleStateHistory rulestatehistory.Module
TraceDetail tracedetail.Module
}
func NewModules(
@@ -119,5 +122,6 @@ func NewModules(
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
ServiceAccount: implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), authz, emailing, providerSettings),
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger)),
TraceDetail: impltracedetail.NewModule(telemetryStore, cache, providerSettings),
}
}

View File

@@ -28,6 +28,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -69,6 +70,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ factory.Handler }{},
struct{ cloudintegration.Handler }{},
struct{ rulestatehistory.Handler }{},
struct{ tracedetail.Handler }{},
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
if err != nil {
return nil, err

View File

@@ -281,6 +281,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.RegistryHandler,
handlers.CloudIntegrationHandler,
handlers.RuleStateHistory,
handlers.TraceDetail,
),
)
}

View File

@@ -0,0 +1,249 @@
package tracedetailtypes
import (
"encoding/json"
"maps"
"time"
"github.com/SigNoz/signoz/pkg/types/cachetypes"
)
// WaterfallRequest is the request body for the v3 waterfall API.
type WaterfallRequest struct {
SelectedSpanID string `json:"selectedSpanId"`
IsSelectedSpanIDUnCollapsed bool `json:"isSelectedSpanIDUnCollapsed"`
UncollapsedSpans []string `json:"uncollapsedSpans"`
Limit uint `json:"limit"`
}
// WaterfallResponse is the response for the v3 waterfall API.
type WaterfallResponse struct {
StartTimestampMillis uint64 `json:"startTimestampMillis"`
EndTimestampMillis uint64 `json:"endTimestampMillis"`
DurationNano uint64 `json:"durationNano"`
RootServiceName string `json:"rootServiceName"`
RootServiceEntryPoint string `json:"rootServiceEntryPoint"`
TotalSpansCount uint64 `json:"totalSpansCount"`
TotalErrorSpansCount uint64 `json:"totalErrorSpansCount"`
ServiceNameToTotalDurationMap map[string]uint64 `json:"serviceNameToTotalDurationMap"`
Spans []*Span `json:"spans"`
HasMissingSpans bool `json:"hasMissingSpans"`
UncollapsedSpans []string `json:"uncollapsedSpans"`
}
// Event represents a span event.
type Event struct {
Name string `json:"name,omitempty"`
TimeUnixNano uint64 `json:"timeUnixNano,omitempty"`
AttributeMap map[string]any `json:"attributeMap,omitempty"`
IsError bool `json:"isError,omitempty"`
}
// Span represents the span in waterfall response,
// this uses snake_case keys for response as a special case since these
// keys can be directly used to query spans and client need to know the actual fields.
// This pattern should not be copied elsewhere.
type Span struct {
Attributes map[string]any `json:"attributes"`
DBName string `json:"db_name"`
DBOperation string `json:"db_operation"`
DurationNano uint64 `json:"duration_nano"`
Events []Event `json:"events"`
ExternalHTTPMethod string `json:"external_http_method"`
ExternalHTTPURL string `json:"external_http_url"`
Flags uint32 `json:"flags"`
HasError bool `json:"has_error"`
HTTPHost string `json:"http_host"`
HTTPMethod string `json:"http_method"`
HTTPURL string `json:"http_url"`
IsRemote string `json:"is_remote"`
Kind int32 `json:"kind"`
KindString string `json:"kind_string"`
Links string `json:"links"`
Name string `json:"name"`
ParentSpanID string `json:"parent_span_id"`
Resources map[string]string `json:"resources"`
ResponseStatusCode string `json:"response_status_code"`
SpanID string `json:"span_id"`
StatusCode int16 `json:"status_code"`
StatusCodeString string `json:"status_code_string"`
StatusMessage string `json:"status_message"`
Timestamp string `json:"timestamp"`
TraceID string `json:"trace_id"`
TraceState string `json:"trace_state"`
// Tree structure fields
Children []*Span `json:"children"`
SubTreeNodeCount uint64 `json:"sub_tree_node_count"`
HasChildren bool `json:"has_children"`
HasSiblings bool `json:"has_siblings"`
Level uint64 `json:"level"`
// timeUnixNano is an internal field used for tree building and sorting.
// It is not serialized in the JSON response.
TimeUnixNano uint64 `json:"-"`
// serviceName is an internal field used for service time calculation.
ServiceName string `json:"-"`
}
// CopyWithoutChildren creates a shallow copy and reset computed tree fields.
func (s *Span) CopyWithoutChildren(level uint64, hasSiblings bool) *Span {
cp := *s
cp.Level = level
cp.HasChildren = len(s.Children) > 0
cp.HasSiblings = hasSiblings
cp.Children = make([]*Span, 0)
cp.SubTreeNodeCount = 0
return &cp
}
// SpanModel is the ClickHouse scan struct for the v3 waterfall query.
type SpanModel struct {
TimeUnixNano time.Time `ch:"timestamp"`
DurationNano uint64 `ch:"duration_nano"`
SpanID string `ch:"span_id"`
TraceID string `ch:"trace_id"`
HasError bool `ch:"has_error"`
Kind int8 `ch:"kind"`
ServiceName string `ch:"resource_string_service$$name"`
Name string `ch:"name"`
References string `ch:"references"`
AttributesString map[string]string `ch:"attributes_string"`
AttributesNumber map[string]float64 `ch:"attributes_number"`
AttributesBool map[string]bool `ch:"attributes_bool"`
ResourcesString map[string]string `ch:"resources_string"`
Events []string `ch:"events"`
StatusMessage string `ch:"status_message"`
StatusCodeString string `ch:"status_code_string"`
SpanKind string `ch:"kind_string"`
ParentSpanID string `ch:"parent_span_id"`
Flags uint32 `ch:"flags"`
IsRemote string `ch:"is_remote"`
TraceState string `ch:"trace_state"`
StatusCode int16 `ch:"status_code"`
DBName string `ch:"db_name"`
DBOperation string `ch:"db_operation"`
HTTPMethod string `ch:"http_method"`
HTTPURL string `ch:"http_url"`
HTTPHost string `ch:"http_host"`
ExternalHTTPMethod string `ch:"external_http_method"`
ExternalHTTPURL string `ch:"external_http_url"`
ResponseStatusCode string `ch:"response_status_code"`
}
// ToSpan converts a SpanModel (ClickHouse scan result) into a Span for the waterfall response.
func (item *SpanModel) ToSpan() *Span {
// Merge attributes_string, attributes_number, attributes_bool preserving native types
attributes := make(map[string]any, len(item.AttributesString)+len(item.AttributesNumber)+len(item.AttributesBool))
for k, v := range item.AttributesString {
attributes[k] = v
}
for k, v := range item.AttributesNumber {
attributes[k] = v
}
for k, v := range item.AttributesBool {
attributes[k] = v
}
resources := make(map[string]string)
maps.Copy(resources, item.ResourcesString)
events := make([]Event, 0, len(item.Events))
for _, eventStr := range item.Events {
var event Event
if err := json.Unmarshal([]byte(eventStr), &event); err != nil {
continue
}
events = append(events, event)
}
return &Span{
Attributes: attributes,
DBName: item.DBName,
DBOperation: item.DBOperation,
DurationNano: item.DurationNano,
Events: events,
ExternalHTTPMethod: item.ExternalHTTPMethod,
ExternalHTTPURL: item.ExternalHTTPURL,
Flags: item.Flags,
HasError: item.HasError,
HTTPHost: item.HTTPHost,
HTTPMethod: item.HTTPMethod,
HTTPURL: item.HTTPURL,
IsRemote: item.IsRemote,
Kind: int32(item.Kind),
KindString: item.SpanKind,
Links: item.References,
Name: item.Name,
ParentSpanID: item.ParentSpanID,
Resources: resources,
ResponseStatusCode: item.ResponseStatusCode,
SpanID: item.SpanID,
StatusCode: item.StatusCode,
StatusCodeString: item.StatusCodeString,
StatusMessage: item.StatusMessage,
Timestamp: item.TimeUnixNano.Format(time.RFC3339Nano),
TraceID: item.TraceID,
TraceState: item.TraceState,
Children: make([]*Span, 0),
TimeUnixNano: uint64(item.TimeUnixNano.UnixNano()),
ServiceName: item.ServiceName,
}
}
// TraceSummary is the ClickHouse scan struct for the trace_summary query.
type TraceSummary struct {
TraceID string `ch:"trace_id"`
Start time.Time `ch:"start"`
End time.Time `ch:"end"`
NumSpans uint64 `ch:"num_spans"`
}
// OtelSpanRef is used for parsing the references/links JSON from ClickHouse.
type OtelSpanRef struct {
TraceId string `json:"traceId,omitempty"`
SpanId string `json:"spanId,omitempty"`
RefType string `json:"refType,omitempty"`
}
// WaterfallCache holds pre-processed trace data for caching.
type WaterfallCache struct {
StartTime uint64 `json:"startTime"`
EndTime uint64 `json:"endTime"`
DurationNano uint64 `json:"durationNano"`
TotalSpans uint64 `json:"totalSpans"`
TotalErrorSpans uint64 `json:"totalErrorSpans"`
ServiceNameToTotalDurationMap map[string]uint64 `json:"serviceNameToTotalDurationMap"`
SpanIDToSpanNodeMap map[string]*Span `json:"spanIdToSpanNodeMap"`
TraceRoots []*Span `json:"traceRoots"`
HasMissingSpans bool `json:"hasMissingSpans"`
}
func (c *WaterfallCache) Clone() cachetypes.Cacheable {
copyOfServiceNameToTotalDurationMap := make(map[string]uint64)
maps.Copy(copyOfServiceNameToTotalDurationMap, c.ServiceNameToTotalDurationMap)
copyOfSpanIDToSpanNodeMap := make(map[string]*Span)
maps.Copy(copyOfSpanIDToSpanNodeMap, c.SpanIDToSpanNodeMap)
copyOfTraceRoots := make([]*Span, len(c.TraceRoots))
copy(copyOfTraceRoots, c.TraceRoots)
return &WaterfallCache{
StartTime: c.StartTime,
EndTime: c.EndTime,
DurationNano: c.DurationNano,
TotalSpans: c.TotalSpans,
TotalErrorSpans: c.TotalErrorSpans,
ServiceNameToTotalDurationMap: copyOfServiceNameToTotalDurationMap,
SpanIDToSpanNodeMap: copyOfSpanIDToSpanNodeMap,
TraceRoots: copyOfTraceRoots,
HasMissingSpans: c.HasMissingSpans,
}
}
func (c *WaterfallCache) MarshalBinary() (data []byte, err error) {
return json.Marshal(c)
}
func (c *WaterfallCache) UnmarshalBinary(data []byte) error {
return json.Unmarshal(data, c)
}