Compare commits

..

1 Commits

Author SHA1 Message Date
Pradeep Kumar
39e64973aa feat(spanmapper): add preview endpoint for span attribute mapping
Adds POST /api/v1/span_mapper_groups/preview so users can see what their
attribute mappings will do to a span before/while configuring them.

You send a sample input either a set of attributes, a single OTLP
span, or a full OTLP trace and get back the transformed result in the
same form. By default it runs against all the org's enabled mappings;
pass groupId to scope it to one group.

The actual signozspanmapper collector processor not merged yet
https://github.com/SigNoz/signoz-otel-collector/pull/796
2026-06-12 15:05:51 +05:30
10 changed files with 772 additions and 61 deletions

View File

@@ -7185,6 +7185,39 @@ components:
- operation
- priority
type: object
SpantypesSpanMappingPreviewRequest:
properties:
attributes:
$ref: '#/components/schemas/SpantypesSpanMappingPreviewSpan'
groupId:
nullable: true
type: string
otlpTraces:
additionalProperties: {}
nullable: true
type: object
type: object
SpantypesSpanMappingPreviewResponse:
properties:
attributes:
$ref: '#/components/schemas/SpantypesSpanMappingPreviewSpan'
otlpTraces:
additionalProperties: {}
nullable: true
type: object
type: object
SpantypesSpanMappingPreviewSpan:
nullable: true
properties:
resourceAttributes:
additionalProperties: {}
nullable: true
type: object
spanAttributes:
additionalProperties: {}
nullable: true
type: object
type: object
SpantypesUpdatableSpanMapper:
properties:
config:
@@ -12790,6 +12823,70 @@ paths:
summary: Update a span mapper
tags:
- spanmapper
/api/v1/span_mapper_groups/preview:
post:
deprecated: false
description: Previews how the org's saved attribute mappings would transform
a sample span.
operationId: PreviewSpanMapping
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SpantypesSpanMappingPreviewRequest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesSpanMappingPreviewResponse'
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: Preview span attribute mapping against a sample span
tags:
- spanmapper
/api/v1/testChannel:
post:
deprecated: true

View File

@@ -8440,6 +8440,83 @@ export interface SpantypesSpanMapperDTO {
updatedBy?: string;
}
export type SpantypesSpanMappingPreviewRequestDTOOtlpTracesAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMappingPreviewRequestDTOOtlpTraces =
SpantypesSpanMappingPreviewRequestDTOOtlpTracesAnyOf | null;
export type SpantypesSpanMappingPreviewSpanDTOAnyOfResourceAttributesAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMappingPreviewSpanDTOAnyOfResourceAttributes =
SpantypesSpanMappingPreviewSpanDTOAnyOfResourceAttributesAnyOf | null;
export type SpantypesSpanMappingPreviewSpanDTOAnyOfSpanAttributesAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMappingPreviewSpanDTOAnyOfSpanAttributes =
SpantypesSpanMappingPreviewSpanDTOAnyOfSpanAttributesAnyOf | null;
export type SpantypesSpanMappingPreviewSpanDTOAnyOf = {
/**
* @type object,null
*/
resourceAttributes?: SpantypesSpanMappingPreviewSpanDTOAnyOfResourceAttributes;
/**
* @type object,null
*/
spanAttributes?: SpantypesSpanMappingPreviewSpanDTOAnyOfSpanAttributes;
};
/**
* @nullable
*/
export type SpantypesSpanMappingPreviewSpanDTO =
SpantypesSpanMappingPreviewSpanDTOAnyOf | null;
export interface SpantypesSpanMappingPreviewRequestDTO {
attributes?: SpantypesSpanMappingPreviewSpanDTO | null;
/**
* @type string,null
*/
groupId?: string | null;
/**
* @type object,null
*/
otlpTraces?: SpantypesSpanMappingPreviewRequestDTOOtlpTraces;
}
export type SpantypesSpanMappingPreviewResponseDTOOtlpTracesAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMappingPreviewResponseDTOOtlpTraces =
SpantypesSpanMappingPreviewResponseDTOOtlpTracesAnyOf | null;
export interface SpantypesSpanMappingPreviewResponseDTO {
attributes?: SpantypesSpanMappingPreviewSpanDTO | null;
/**
* @type object,null
*/
otlpTraces?: SpantypesSpanMappingPreviewResponseDTOOtlpTraces;
}
export interface SpantypesUpdatableSpanMapperDTO {
config?: SpantypesSpanMapperConfigDTO;
/**
@@ -9746,6 +9823,14 @@ export type UpdateSpanMapperPathParameters = {
groupId: string;
mapperId: string;
};
export type PreviewSpanMapping200 = {
data: SpantypesSpanMappingPreviewResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetTraceAggregationsPathParameters = {
traceID: string;
};

View File

@@ -27,9 +27,11 @@ import type {
ListSpanMapperGroupsParams,
ListSpanMappers200,
ListSpanMappersPathParameters,
PreviewSpanMapping200,
RenderErrorResponseDTO,
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesSpanMappingPreviewRequestDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
UpdateSpanMapperGroupPathParameters,
@@ -780,3 +782,86 @@ export const useUpdateSpanMapper = <
> => {
return useMutation(getUpdateSpanMapperMutationOptions(options));
};
/**
* Previews how the org's saved attribute mappings would transform a sample span.
* @summary Preview span attribute mapping against a sample span
*/
export const previewSpanMapping = (
spantypesSpanMappingPreviewRequestDTO?: BodyType<SpantypesSpanMappingPreviewRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<PreviewSpanMapping200>({
url: `/api/v1/span_mapper_groups/preview`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: spantypesSpanMappingPreviewRequestDTO,
signal,
});
};
export const getPreviewSpanMappingMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof previewSpanMapping>>,
TError,
{ data?: BodyType<SpantypesSpanMappingPreviewRequestDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof previewSpanMapping>>,
TError,
{ data?: BodyType<SpantypesSpanMappingPreviewRequestDTO> },
TContext
> => {
const mutationKey = ['previewSpanMapping'];
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 previewSpanMapping>>,
{ data?: BodyType<SpantypesSpanMappingPreviewRequestDTO> }
> = (props) => {
const { data } = props ?? {};
return previewSpanMapping(data);
};
return { mutationFn, ...mutationOptions };
};
export type PreviewSpanMappingMutationResult = NonNullable<
Awaited<ReturnType<typeof previewSpanMapping>>
>;
export type PreviewSpanMappingMutationBody =
| BodyType<SpantypesSpanMappingPreviewRequestDTO>
| undefined;
export type PreviewSpanMappingMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Preview span attribute mapping against a sample span
*/
export const usePreviewSpanMapping = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof previewSpanMapping>>,
TError,
{ data?: BodyType<SpantypesSpanMappingPreviewRequestDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof previewSpanMapping>>,
TError,
{ data?: BodyType<SpantypesSpanMappingPreviewRequestDTO> },
TContext
> => {
return useMutation(getPreviewSpanMappingMutationOptions(options));
};

View File

@@ -51,6 +51,26 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/span_mapper_groups/preview", handler.New(
provider.authzMiddleware.ViewAccess(provider.spanMapperHandler.PreviewMapping),
handler.OpenAPIDef{
ID: "PreviewSpanMapping",
Tags: []string{"spanmapper"},
Summary: "Preview span attribute mapping against a sample span",
Description: "Previews how the org's saved attribute mappings would transform a sample span.",
Request: new(spantypes.SpanMappingPreviewRequest),
RequestContentType: "application/json",
Response: new(spantypes.SpanMappingPreviewResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/span_mapper_groups/{groupId}", handler.New(
provider.authzMiddleware.AdminAccess(provider.spanMapperHandler.UpdateGroup),
handler.OpenAPIDef{

View File

@@ -273,6 +273,35 @@ func (h *handler) DeleteMapper(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusNoContent, nil)
}
// PreviewMapping handles POST /api/v1/span_mapper_groups/preview.
// used to get preview of attributes after remapping.
func (h *handler) PreviewMapping(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
req := new(spantypes.SpanMappingPreviewRequest)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
result, err := h.module.PreviewMapping(ctx, orgID, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}
// groupIDFromPath extracts and validates the {id} or {groupId} path variable.
func groupIDFromPath(r *http.Request) (valuer.UUID, error) {
vars := mux.Vars(r)

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/types/opamptypes"
@@ -102,6 +103,64 @@ func (module *module) DeleteMapper(ctx context.Context, orgID, groupID, id value
return nil
}
// PreviewMapping resolves the org's saved mappings for a sample input
// and returns the transformed result.
func (module *module) PreviewMapping(ctx context.Context, orgID valuer.UUID, req *spantypes.SpanMappingPreviewRequest) (*spantypes.SpanMappingPreviewResponse, error) {
groups, err := module.resolvePreviewGroups(ctx, orgID, req)
if err != nil {
return nil, err
}
hasAttrs := req.Attributes != nil
hasOTLP := len(req.OtlpTraces) > 0
if hasAttrs == hasOTLP {
return nil, errors.New(errors.TypeInvalidInput, spantypes.ErrCodeMappingInvalidInput, "exactly one of 'attributes' or 'otlpTraces' must be provided")
}
if hasAttrs {
outResource, outSpan := spantypes.SimulateSpanMapping(groups, req.Attributes.ResourceAttributes, req.Attributes.SpanAttributes)
return &spantypes.SpanMappingPreviewResponse{
Attributes: &spantypes.SpanMappingPreviewSpan{ResourceAttributes: outResource, SpanAttributes: outSpan},
}, nil
}
in, err := json.Marshal(req.OtlpTraces)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, spantypes.ErrCodeMappingInvalidInput, "could not serialize otlpTraces payload")
}
out, err := spantypes.SimulateSpanMappingOTLP(groups, in)
if err != nil {
return nil, err
}
var transformed map[string]any
if err := json.Unmarshal(out, &transformed); err != nil {
return nil, errors.WrapInternalf(err, spantypes.ErrCodeMappingPreviewFailed, "could not deserialize transformed traces")
}
return &spantypes.SpanMappingPreviewResponse{OtlpTraces: transformed}, nil
}
// resolvePreviewGroups resolves the config to preview against a specific saved
// group when GroupID is set, otherwise all the org's enabled saved mappings.
func (module *module) resolvePreviewGroups(ctx context.Context, orgID valuer.UUID, req *spantypes.SpanMappingPreviewRequest) ([]*spantypes.SpanMapperGroupWithMappers, error) {
if req.GroupID != nil && *req.GroupID != "" {
id, err := valuer.NewUUID(*req.GroupID)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, spantypes.ErrCodeMappingInvalidInput, "group id is not a valid uuid")
}
group, err := module.store.GetGroup(ctx, orgID, id)
if err != nil {
return nil, err
}
mappers, err := module.store.ListMappers(ctx, orgID, id)
if err != nil {
return nil, err
}
return []*spantypes.SpanMapperGroupWithMappers{{Group: group, Mappers: mappers}}, nil
}
return module.listEnabledGroupsWithMappers(ctx, orgID)
}
func (module *module) AgentFeatureType() agentConf.AgentFeatureType {
return spantypes.SpanAttrMappingFeatureType
}

View File

@@ -27,6 +27,7 @@ type Module interface {
CreateMapper(ctx context.Context, orgID, groupID valuer.UUID, mapper *spantypes.SpanMapper) error
UpdateMapper(ctx context.Context, orgID, groupID, id valuer.UUID, fieldContext spantypes.FieldContext, config *spantypes.SpanMapperConfig, enabled *bool, updatedBy string) error
DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error
PreviewMapping(ctx context.Context, orgID valuer.UUID, req *spantypes.SpanMappingPreviewRequest) (*spantypes.SpanMappingPreviewResponse, error)
}
// Handler defines the HTTP handler interface for mapping group and mapper endpoints.
@@ -42,4 +43,5 @@ type Handler interface {
CreateMapper(rw http.ResponseWriter, r *http.Request)
UpdateMapper(rw http.ResponseWriter, r *http.Request)
DeleteMapper(rw http.ResponseWriter, r *http.Request)
PreviewMapping(rw http.ResponseWriter, r *http.Request)
}

View File

@@ -135,9 +135,6 @@ type seriesLookup struct {
data map[string]map[int64]float64
// seriesKey -> original series for metadata preservation
seriesMetadata map[string]*TimeSeries
// maps a variable to its series keys, letting evaluation iterate a single
// variable's series directly.
variableToSeriesKeys map[string][]string
}
// FormulaEvaluator handles formula evaluation b/w time series from different aggregations
@@ -294,35 +291,34 @@ func (fe *FormulaEvaluator) EvaluateFormula(timeSeriesData map[string]*TimeSerie
// Find all unique label combinations across referenced series
uniqueLabelSets := fe.findUniqueLabelSets(lookup)
// Work per label-set is cheap enough that spawning a goroutine per item
// costs more in scheduler signaling than it saves in parallelism.
const numWorkers = 4
workCh := make(chan []*Label, len(uniqueLabelSets))
resultChan := make(chan *TimeSeries, len(uniqueLabelSets))
// Process each unique label set
var resultSeries []*TimeSeries
var wg sync.WaitGroup
wg.Add(numWorkers)
for range numWorkers {
go func() {
defer wg.Done()
for labels := range workCh {
series := fe.evaluateForLabelSet(labels, lookup)
if series != nil && len(series.Values) > 0 {
resultChan <- series
}
}
}()
}
resultChan := make(chan *TimeSeries, len(uniqueLabelSets))
maxSeries := make(chan struct{}, 4)
// For each candidate label set, evaluate the formula expression
// and store the result in the resultChan
for _, labelSet := range uniqueLabelSets {
workCh <- labelSet
wg.Add(1)
go func(labels []*Label) {
defer wg.Done()
maxSeries <- struct{}{}
defer func() { <-maxSeries }()
// main workhorse of the formula evaluation
series := fe.evaluateForLabelSet(labels, lookup)
if series != nil && len(series.Values) > 0 {
resultChan <- series
}
}(labelSet)
}
close(workCh)
wg.Wait()
close(resultChan)
go func() {
wg.Wait()
close(resultChan)
}()
resultSeries := make([]*TimeSeries, 0, len(uniqueLabelSets))
for series := range resultChan {
resultSeries = append(resultSeries, series)
}
@@ -344,8 +340,6 @@ func (fe *FormulaEvaluator) buildSeriesLookup(timeSeriesData map[string]*TimeSer
// when the series is returned to the caller
// It's also used for finding matching series for a variable
seriesMetadata: make(map[string]*TimeSeries),
variableToSeriesKeys: make(map[string][]string),
}
for variable, aggRef := range fe.aggRefs {
@@ -397,7 +391,6 @@ func (fe *FormulaEvaluator) buildSeriesLookup(timeSeriesData map[string]*TimeSer
if _, exists := lookup.data[seriesKey]; !exists {
lookup.data[seriesKey] = make(map[int64]float64, len(series.Values))
lookup.seriesMetadata[seriesKey] = series
lookup.variableToSeriesKeys[variable] = append(lookup.variableToSeriesKeys[variable], seriesKey)
}
// Store all timestamp-value pairs
@@ -480,37 +473,35 @@ func (fe *FormulaEvaluator) findUniqueLabelSets(lookup *seriesLookup) [][]*Label
// Find unique label sets using proper label comparison
var uniqueSets [][]*Label
var uniqueMaps []map[string]any
for _, labelSet := range allLabelSets {
isUnique := true
for _, uniqueMap := range uniqueMaps {
if isSubset(uniqueMap, labelSet) {
for _, uniqueSet := range uniqueSets {
if fe.isSubset(uniqueSet, labelSet) {
isUnique = false
break
}
}
if isUnique {
uniqueSets = append(uniqueSets, labelSet)
uniqueMaps = append(uniqueMaps, labelsToMap(labelSet))
}
}
return uniqueSets
}
func labelsToMap(labels []*Label) map[string]any {
m := make(map[string]any, len(labels))
for _, label := range labels {
m[label.Key.Name] = label.Value
}
return m
}
func (fe *FormulaEvaluator) isSubset(labels1, labels2 []*Label) bool {
labelMap1 := make(map[string]any)
labelMap2 := make(map[string]any)
// isSubset reports whether every label in subset is present with the same value in
// supersetMap (i.e. subset ⊆ superset).
func isSubset(supersetMap map[string]any, subset []*Label) bool {
for _, label := range subset {
if val, ok := supersetMap[label.Key.Name]; !ok || val != label.Value {
for _, label := range labels1 {
labelMap1[label.Key.Name] = label.Value
}
for _, label := range labels2 {
labelMap2[label.Key.Name] = label.Value
}
for k, v := range labelMap2 {
if val, ok := labelMap1[k]; !ok || val != v {
return false
}
}
@@ -526,14 +517,10 @@ func (fe *FormulaEvaluator) evaluateForLabelSet(targetLabels []*Label, lookup *s
// for the variable
var allTimestamps = make(map[int64]struct{})
// targetLabels is fixed for this call, so build its lookup once and reuse it
// across every series comparison below.
targetMap := labelsToMap(targetLabels)
for variable := range fe.aggRefs {
// only this variable's series.
for _, seriesKey := range lookup.variableToSeriesKeys[variable] {
if isSubset(targetMap, lookup.seriesMetadata[seriesKey].Labels) {
// Find series with matching labels for this variable
for seriesKey, series := range lookup.seriesMetadata {
if strings.HasPrefix(seriesKey, variable+"|") && fe.isSubset(targetLabels, series.Labels) {
if timestampData, exists := lookup.data[seriesKey]; exists {
variableData[variable] = timestampData
// Collect all timestamps
@@ -559,11 +546,8 @@ func (fe *FormulaEvaluator) evaluateForLabelSet(targetLabels []*Label, lookup *s
}
slices.Sort(timestamps)
// backing slab-allocates all values in one block; resultValues holds interior
// pointers into it. Fixed length and never appended to, so it never moves.
backing := make([]TimeSeriesValue, len(timestamps))
resultValues := make([]*TimeSeriesValue, 0, len(timestamps))
n := 0
// Evaluate formula at each timestamp
var resultValues []*TimeSeriesValue
values := fe.valuesPool.Get().(map[string]any)
defer fe.valuesPool.Put(values)
@@ -608,12 +592,10 @@ func (fe *FormulaEvaluator) evaluateForLabelSet(targetLabels []*Label, lookup *s
continue
}
backing[n] = TimeSeriesValue{
resultValues = append(resultValues, &TimeSeriesValue{
Timestamp: timestamp,
Value: value,
}
resultValues = append(resultValues, &backing[n])
n++
})
}
if len(resultValues) == 0 {

View File

@@ -0,0 +1,160 @@
package spantypes
import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"go.opentelemetry.io/collector/pdata/ptrace"
)
var resourceSourcePrefix = FieldContextResource.StringValue() + "."
var ErrCodeMappingPreviewFailed = errors.MustNewCode("span_attribute_mapping_preview_failed")
type SpanMappingPreviewSpan struct {
ResourceAttributes map[string]any `json:"resourceAttributes" nullable:"true"`
SpanAttributes map[string]any `json:"spanAttributes" nullable:"true"`
}
type SpanMappingPreviewRequest struct {
Attributes *SpanMappingPreviewSpan `json:"attributes" nullable:"true"`
OtlpTraces map[string]any `json:"otlpTraces" nullable:"true"`
GroupID *string `json:"groupId" nullable:"true"`
}
type SpanMappingPreviewResponse struct {
Attributes *SpanMappingPreviewSpan `json:"attributes,omitempty" nullable:"true"`
OtlpTraces map[string]any `json:"otlpTraces,omitempty" nullable:"true"`
}
func SimulateSpanMapping(groups []*SpanMapperGroupWithMappers, resourceAttrs, spanAttrs map[string]any) (outResource, outSpan map[string]any) {
cfg := buildProcessorConfig(filterEnabledGroupsWithMappers(groups))
outResource = cloneAttrs(resourceAttrs)
outSpan = cloneAttrs(spanAttrs)
applyEnabledGroups(cfg, outSpan, outResource)
return outResource, outSpan
}
func SimulateSpanMappingOTLP(groups []*SpanMapperGroupWithMappers, otlp []byte) ([]byte, error) {
td, err := (&ptrace.JSONUnmarshaler{}).UnmarshalTraces(otlp)
if err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeMappingInvalidInput, "invalid OTLP traces payload")
}
cfg := buildProcessorConfig(filterEnabledGroupsWithMappers(groups))
rss := td.ResourceSpans()
for i := 0; i < rss.Len(); i++ {
rs := rss.At(i)
resourceAttrs := rs.Resource().Attributes().AsRaw()
scopeSpans := rs.ScopeSpans()
for j := 0; j < scopeSpans.Len(); j++ {
spans := scopeSpans.At(j).Spans()
for k := 0; k < spans.Len(); k++ {
span := spans.At(k)
spanAttrs := span.Attributes().AsRaw()
applyEnabledGroups(cfg, spanAttrs, resourceAttrs)
if err := span.Attributes().FromRaw(spanAttrs); err != nil {
return nil, errors.WrapInternalf(err, ErrCodeMappingPreviewFailed, "could not write transformed span attributes")
}
}
}
if err := rs.Resource().Attributes().FromRaw(resourceAttrs); err != nil {
return nil, errors.WrapInternalf(err, ErrCodeMappingPreviewFailed, "could not write transformed resource attributes")
}
}
out, err := (&ptrace.JSONMarshaler{}).MarshalTraces(td)
if err != nil {
return nil, errors.WrapInternalf(err, ErrCodeMappingPreviewFailed, "could not marshal transformed traces")
}
return out, nil
}
// TODO(spanmapper-preview): the apply logic below is a temporary copy of the signozspanmapper processor — remove it and call the real processor once signoz-otel-collector#796 is merged.
func applyEnabledGroups(cfg *spanMapperProcessorConfig, spanAttrs, resourceAttrs map[string]any) {
for i := range cfg.Groups {
g := &cfg.Groups[i]
if !spanMapperConditionMet(g.ExistsAny, spanAttrs, resourceAttrs) {
continue
}
for j := range g.Attributes {
applySpanMapperRule(&g.Attributes[j], spanAttrs, resourceAttrs)
}
}
}
// filterEnabledGroupsWithMappers keeps only enabled groups and their enabled
// mappers, dropping groups left with no enabled mappers.
func filterEnabledGroupsWithMappers(groups []*SpanMapperGroupWithMappers) []*SpanMapperGroupWithMappers {
out := make([]*SpanMapperGroupWithMappers, 0, len(groups))
for _, gm := range groups {
if gm == nil || gm.Group == nil || !gm.Group.Enabled {
continue
}
enabled := make([]*SpanMapper, 0, len(gm.Mappers))
for _, m := range gm.Mappers {
if m != nil && m.Enabled {
enabled = append(enabled, m)
}
}
if len(enabled) > 0 {
out = append(out, &SpanMapperGroupWithMappers{Group: gm.Group, Mappers: enabled})
}
}
return out
}
func spanMapperConditionMet(cond spanMapperProcessorExistsAny, spanAttrs, resourceAttrs map[string]any) bool {
return anyKeyContains(spanAttrs, cond.Attributes) || anyKeyContains(resourceAttrs, cond.Resource)
}
func anyKeyContains(attrs map[string]any, patterns []string) bool {
for k := range attrs {
for _, p := range patterns {
if strings.Contains(k, p) {
return true
}
}
}
return false
}
func applySpanMapperRule(rule *spanMapperProcessorAttribute, spanAttrs, resourceAttrs map[string]any) {
dst := spanAttrs
if rule.Context == FieldContextResource.StringValue() {
dst = resourceAttrs
}
for i := range rule.Sources {
src := &rule.Sources[i]
bare, isResource := strings.CutPrefix(src.Key, resourceSourcePrefix)
from := spanAttrs
if isResource {
from = resourceAttrs
}
val, ok := from[bare]
if !ok {
continue
}
dst[rule.Target] = val
if src.Action == SpanMapperOperationMove.StringValue() {
delete(from, bare)
}
return
}
}
func cloneAttrs(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}

View File

@@ -0,0 +1,192 @@
package spantypes
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/collector/pdata/ptrace"
)
func simGroup(name string, attrCond, resCond []string, mappers ...*SpanMapper) *SpanMapperGroupWithMappers {
return &SpanMapperGroupWithMappers{
Group: &SpanMapperGroup{
Name: name,
Condition: SpanMapperGroupCondition{Attributes: attrCond, Resource: resCond},
Enabled: true,
},
Mappers: mappers,
}
}
func simMapper(target string, ctx FieldContext, sources ...SpanMapperSource) *SpanMapper {
return &SpanMapper{
Name: target,
FieldContext: ctx,
Config: SpanMapperConfig{Sources: sources},
Enabled: true,
}
}
func simAttrSrc(key string, op SpanMapperOperation, priority int) SpanMapperSource {
return SpanMapperSource{Key: key, Context: FieldContextSpanAttribute, Operation: op, Priority: priority}
}
func simResSrc(key string, op SpanMapperOperation, priority int) SpanMapperSource {
return SpanMapperSource{Key: key, Context: FieldContextResource, Operation: op, Priority: priority}
}
func TestSimulate_MatchInSpanAttrs(t *testing.T) {
groups := []*SpanMapperGroupWithMappers{
simGroup("llm", []string{"model"}, nil,
simMapper("gen_ai.request.model", FieldContextSpanAttribute,
simAttrSrc("llm.model", SpanMapperOperationCopy, 1)),
),
}
_, outSpan := SimulateSpanMapping(groups, nil, map[string]any{"llm.model": "gpt-4", "gen_ai.llm.model": "gpt-40"})
assert.Equal(t, "gpt-4", outSpan["gen_ai.request.model"])
}
func TestSimulate_MatchInResourceAttrs(t *testing.T) {
groups := []*SpanMapperGroupWithMappers{
simGroup("llm", nil, []string{"service.name"},
simMapper("gen_ai.request.model", FieldContextSpanAttribute,
simResSrc("service.name", SpanMapperOperationCopy, 1)),
),
}
_, outSpan := SimulateSpanMapping(groups, map[string]any{"service.name": "my-llm-service"}, nil)
assert.Equal(t, "my-llm-service", outSpan["gen_ai.request.model"])
}
func TestSimulate_NoMatchSkipsGroup(t *testing.T) {
groups := []*SpanMapperGroupWithMappers{
simGroup("llm", []string{"model"}, nil,
simMapper("gen_ai.request.model", FieldContextSpanAttribute,
simAttrSrc("llm.model", SpanMapperOperationCopy, 1)),
),
}
_, outSpan := SimulateSpanMapping(groups, nil, map[string]any{"some.other.key": "value"})
_, ok := outSpan["gen_ai.request.model"]
assert.False(t, ok, "target must not be set when condition is not met")
}
func TestSimulate_SourceFirstMatchWins(t *testing.T) {
groups := []*SpanMapperGroupWithMappers{
simGroup("tokens", []string{"llm"}, nil,
simMapper("gen_ai.request.tokens", FieldContextSpanAttribute,
simAttrSrc("gen_ai.request_tokens", SpanMapperOperationCopy, 2),
simAttrSrc("llm.tokens", SpanMapperOperationCopy, 1)),
),
}
_, outSpan := SimulateSpanMapping(groups, nil, map[string]any{"gen_ai.request_tokens": "100", "llm.tokens": "200"})
assert.Equal(t, "100", outSpan["gen_ai.request.tokens"])
}
func TestSimulate_SourceFallsBackToSecond(t *testing.T) {
groups := []*SpanMapperGroupWithMappers{
simGroup("tokens", []string{"llm"}, nil,
simMapper("gen_ai.request.tokens", FieldContextSpanAttribute,
simAttrSrc("gen_ai.request_tokens", SpanMapperOperationCopy, 2),
simAttrSrc("llm.tokens", SpanMapperOperationCopy, 1)),
),
}
_, outSpan := SimulateSpanMapping(groups, nil, map[string]any{"llm.tokens": "200"})
assert.Equal(t, "200", outSpan["gen_ai.request.tokens"])
}
func TestSimulate_ActionMove(t *testing.T) {
groups := []*SpanMapperGroupWithMappers{
simGroup("input", []string{"gen_ai"}, nil,
simMapper("gen_ai.request.input", FieldContextSpanAttribute,
simAttrSrc("gen_ai.input", SpanMapperOperationMove, 1)),
),
}
_, outSpan := SimulateSpanMapping(groups, nil, map[string]any{"gen_ai.input": "hello"})
assert.Equal(t, "hello", outSpan["gen_ai.request.input"])
_, srcPresent := outSpan["gen_ai.input"]
assert.False(t, srcPresent, "source key must be deleted when action=move")
}
func TestSimulate_WriteToResourceContext(t *testing.T) {
groups := []*SpanMapperGroupWithMappers{
simGroup("llm", []string{"llm"}, nil,
simMapper("gen_ai.request.model", FieldContextResource,
simAttrSrc("llm.model", SpanMapperOperationCopy, 1)),
),
}
outResource, outSpan := SimulateSpanMapping(groups, nil, map[string]any{"llm.model": "gpt-4"})
assert.Equal(t, "gpt-4", outResource["gen_ai.request.model"], "target must be written to resource attributes")
_, inSpan := outSpan["gen_ai.request.model"]
assert.False(t, inSpan)
}
func TestSimulate_DisabledGroupsAndMappersSkipped(t *testing.T) {
disabledGroup := simGroup("g1", []string{"llm"}, nil,
simMapper("gen_ai.request.model", FieldContextSpanAttribute,
simAttrSrc("llm.model", SpanMapperOperationCopy, 1)))
disabledGroup.Group.Enabled = false
_, outSpan := SimulateSpanMapping([]*SpanMapperGroupWithMappers{disabledGroup}, nil, map[string]any{"llm.model": "gpt-4"})
_, ok := outSpan["gen_ai.request.model"]
assert.False(t, ok, "disabled groups must not be evaluated")
}
func TestSimulate_DoesNotMutateInput(t *testing.T) {
input := map[string]any{"gen_ai.input": "hi"}
groups := []*SpanMapperGroupWithMappers{
simGroup("input", []string{"gen_ai"}, nil,
simMapper("gen_ai.request.input", FieldContextSpanAttribute,
simAttrSrc("gen_ai.input", SpanMapperOperationMove, 1))),
}
_, _ = SimulateSpanMapping(groups, nil, input)
// Original input map must be untouched (move would have deleted the key).
_, ok := input["gen_ai.input"]
assert.True(t, ok, "input map must not be mutated by the preview")
}
func TestSimulateOTLP_TransformsSpan(t *testing.T) {
groups := []*SpanMapperGroupWithMappers{
simGroup("llm", []string{"model"}, nil,
simMapper("gen_ai.request.model", FieldContextSpanAttribute,
simAttrSrc("llm.model", SpanMapperOperationMove, 1)),
),
}
otlp := []byte(`{
"resourceSpans": [{
"resource": {"attributes": [{"key": "service.name", "value": {"stringValue": "checkout"}}]},
"scopeSpans": [{
"spans": [{"attributes": [{"key": "llm.model", "value": {"stringValue": "gpt-4"}}]}]
}]
}]
}`)
out, err := SimulateSpanMappingOTLP(groups, otlp)
require.NoError(t, err)
td, err := (&ptrace.JSONUnmarshaler{}).UnmarshalTraces(out)
require.NoError(t, err)
span := td.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0)
v, ok := span.Attributes().Get("gen_ai.request.model")
require.True(t, ok)
assert.Equal(t, "gpt-4", v.Str())
// move must have deleted the source.
_, srcPresent := span.Attributes().Get("llm.model")
assert.False(t, srcPresent)
}
func TestSimulateOTLP_Invalid(t *testing.T) {
_, err := SimulateSpanMappingOTLP(nil, []byte(`{ not json`))
assert.Error(t, err)
}