github.com/grafana/pyroscope@v1.18.0/pkg/ingester/otlp/convert.go (about)

     1  package otlp
     2  
     3  import (
     4  	"encoding/hex"
     5  	"fmt"
     6  	"strings"
     7  	"time"
     8  
     9  	otelProfile "go.opentelemetry.io/proto/otlp/profiles/v1development"
    10  
    11  	googleProfile "github.com/grafana/pyroscope/api/gen/proto/go/google/v1"
    12  	typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
    13  	pyromodel "github.com/grafana/pyroscope/pkg/model"
    14  	"github.com/grafana/pyroscope/pkg/pprof"
    15  )
    16  
    17  const serviceNameKey = "service.name"
    18  
    19  type convertedProfile struct {
    20  	profile *googleProfile.Profile
    21  	name    *typesv1.LabelPair
    22  }
    23  
    24  func at[T any](arr []T, i int32) (T, error) {
    25  	if i >= 0 && int(i) < len(arr) {
    26  		return arr[i], nil
    27  	}
    28  	var zero T
    29  	return zero, fmt.Errorf("index %d out of bounds", i)
    30  }
    31  
    32  // ConvertOtelToGoogle converts an OpenTelemetry profile to a Google profile.
    33  func ConvertOtelToGoogle(src *otelProfile.Profile, dictionary *otelProfile.ProfilesDictionary) (map[string]convertedProfile, error) {
    34  	svc2Profile := make(map[string]*profileBuilder)
    35  	for _, sample := range src.Samples {
    36  		svc, err := serviceNameFromSample(sample, dictionary)
    37  		if err != nil {
    38  			return make(map[string]convertedProfile), nil
    39  		}
    40  
    41  		p, ok := svc2Profile[svc]
    42  		if !ok {
    43  			p, err = newProfileBuilder(src, dictionary)
    44  			if err != nil {
    45  				return nil, err
    46  			}
    47  			svc2Profile[svc] = p
    48  		}
    49  		if _, err := p.convertSampleBack(sample, dictionary); err != nil {
    50  			return nil, err
    51  		}
    52  	}
    53  
    54  	result := make(map[string]convertedProfile)
    55  	for svc, p := range svc2Profile {
    56  		result[svc] = convertedProfile{p.dst, p.name}
    57  	}
    58  
    59  	return result, nil
    60  }
    61  
    62  type sampleConversionType int
    63  
    64  const (
    65  	sampleConversionTypeNone           sampleConversionType = 0
    66  	sampleConversionTypeSamplesToNanos sampleConversionType = 1
    67  	sampleConversionTypeSumEvents      sampleConversionType = 2
    68  )
    69  
    70  type profileBuilder struct {
    71  	src                     *otelProfile.Profile
    72  	dst                     *googleProfile.Profile
    73  	stringMap               map[string]int64
    74  	functionMap             map[*otelProfile.Function]uint64
    75  	unsymbolziedFuncNameMap map[string]uint64
    76  	locationMap             map[*otelProfile.Location]uint64
    77  	mappingMap              map[*otelProfile.Mapping]uint64
    78  
    79  	sampleProcessingTypes []sampleConversionType
    80  	name                  *typesv1.LabelPair
    81  }
    82  
    83  func newProfileBuilder(src *otelProfile.Profile, dictionary *otelProfile.ProfilesDictionary) (*profileBuilder, error) {
    84  	res := &profileBuilder{
    85  		src:                     src,
    86  		stringMap:               make(map[string]int64),
    87  		functionMap:             make(map[*otelProfile.Function]uint64),
    88  		locationMap:             make(map[*otelProfile.Location]uint64),
    89  		mappingMap:              make(map[*otelProfile.Mapping]uint64),
    90  		unsymbolziedFuncNameMap: make(map[string]uint64),
    91  		dst: &googleProfile.Profile{
    92  			TimeNanos:     int64(src.TimeUnixNano),
    93  			DurationNanos: int64(src.DurationNano),
    94  			Period:        src.Period,
    95  		},
    96  	}
    97  	res.addstr("")
    98  
    99  	if src.SampleType == nil {
   100  		return nil, fmt.Errorf("sample type is missing")
   101  	}
   102  	sampleType, err := res.convertSampleTypeBack(src.SampleType, dictionary)
   103  	if err != nil {
   104  		return nil, err
   105  	}
   106  	res.dst.SampleType = []*googleProfile.ValueType{sampleType}
   107  
   108  	periodType, err := res.convertValueTypeBack(src.PeriodType, dictionary)
   109  	if err != nil {
   110  		return nil, err
   111  	}
   112  	res.dst.PeriodType = periodType
   113  
   114  	var defaultSampleTypeLabel string
   115  	if src.SampleType != nil {
   116  		defaultSampleType := src.SampleType
   117  		defaultSampleTypeLabel, err = at(dictionary.StringTable, defaultSampleType.TypeStrindex)
   118  		if err != nil {
   119  			return nil, fmt.Errorf("could not access default sample type label: %w", err)
   120  		}
   121  	} else {
   122  		defaultSampleTypeLabel = "samples"
   123  	}
   124  	res.dst.DefaultSampleType = res.addstr(defaultSampleTypeLabel)
   125  
   126  	if len(res.dst.SampleType) == 0 {
   127  		res.dst.SampleType = []*googleProfile.ValueType{{
   128  			Type: res.addstr(defaultSampleTypeLabel),
   129  			Unit: res.addstr("ms"),
   130  		}}
   131  		res.dst.DefaultSampleType = res.addstr(defaultSampleTypeLabel)
   132  	}
   133  	res.sampleProcessingTypes = make([]sampleConversionType, len(res.dst.SampleType))
   134  	for i := 0; i < len(res.dst.SampleType); i++ {
   135  		profileType := res.profileType(i)
   136  		if profileType == "samples:count:cpu:nanoseconds" {
   137  			res.dst.SampleType[i] = &googleProfile.ValueType{
   138  				Type: res.addstr("cpu"),
   139  				Unit: res.addstr("nanoseconds"),
   140  			}
   141  			if len(res.dst.SampleType) == 1 {
   142  				res.name = &typesv1.LabelPair{
   143  					Name:  pyromodel.LabelNameProfileName,
   144  					Value: "process_cpu",
   145  				}
   146  			}
   147  			res.sampleProcessingTypes[i] = sampleConversionTypeSamplesToNanos
   148  		} else if profileType == "events:nanoseconds::" && len(res.dst.SampleType) == 1 { // Identify off-CPU profiles
   149  
   150  			res.sampleProcessingTypes[i] = sampleConversionTypeSumEvents
   151  			res.name = &typesv1.LabelPair{
   152  				Name:  pyromodel.LabelNameProfileName,
   153  				Value: pyromodel.ProfileNameOffCpu,
   154  			}
   155  		} else { // Custom profile type
   156  			// Try to extract profile name from the type, e.g. "wall:time:cpu:milliseconds" -> "wall"
   157  			parts := strings.Split(profileType, `:`)
   158  			if len(parts) >= 3 {
   159  				res.name = &typesv1.LabelPair{
   160  					Name:  pyromodel.LabelNameProfileName,
   161  					Value: parts[2],
   162  				}
   163  				res.sampleProcessingTypes[i] = sampleConversionTypeNone
   164  			}
   165  		}
   166  	}
   167  	if res.name == nil {
   168  		res.name = &typesv1.LabelPair{
   169  			Name:  pyromodel.LabelNameProfileName,
   170  			Value: "process_cpu", // guess
   171  		}
   172  	}
   173  
   174  	if res.dst.TimeNanos == 0 {
   175  		res.dst.TimeNanos = time.Now().UnixNano()
   176  	}
   177  	if res.dst.DurationNanos == 0 {
   178  		res.dst.DurationNanos = (time.Second * 10).Nanoseconds()
   179  	}
   180  	return res, nil
   181  }
   182  
   183  func (p *profileBuilder) profileType(idx int) string {
   184  	var (
   185  		periodType, periodUnit string
   186  	)
   187  	if p.dst.PeriodType != nil && p.dst.Period != 0 {
   188  		periodType = p.dst.StringTable[p.dst.PeriodType.Type]
   189  		periodUnit = p.dst.StringTable[p.dst.PeriodType.Unit]
   190  	}
   191  	return fmt.Sprintf("%s:%s:%s:%s",
   192  		p.dst.StringTable[p.dst.SampleType[idx].Type],
   193  		p.dst.StringTable[p.dst.SampleType[idx].Unit],
   194  		periodType,
   195  		periodUnit,
   196  	)
   197  }
   198  
   199  func (p *profileBuilder) addstr(s string) int64 {
   200  	if i, ok := p.stringMap[s]; ok {
   201  		return i
   202  	}
   203  	idx := int64(len(p.dst.StringTable))
   204  	p.stringMap[s] = idx
   205  	p.dst.StringTable = append(p.dst.StringTable, s)
   206  	return idx
   207  }
   208  
   209  func serviceNameFromSample(sample *otelProfile.Sample, dictionary *otelProfile.ProfilesDictionary) (string, error) {
   210  	return getAttributeValueByKeyOrEmpty(sample.AttributeIndices, dictionary, serviceNameKey)
   211  }
   212  
   213  func (p *profileBuilder) convertSampleTypeBack(ost *otelProfile.ValueType, dictionary *otelProfile.ProfilesDictionary) (*googleProfile.ValueType, error) {
   214  	gst, err := p.convertValueTypeBack(ost, dictionary)
   215  	if err != nil {
   216  		return nil, fmt.Errorf("could not process sample type: %w", err)
   217  	}
   218  	return gst, nil
   219  }
   220  
   221  func (p *profileBuilder) convertValueTypeBack(ovt *otelProfile.ValueType, dictionary *otelProfile.ProfilesDictionary) (*googleProfile.ValueType, error) {
   222  	if ovt == nil {
   223  		return nil, nil
   224  	}
   225  	typeLabel, err := at(dictionary.StringTable, ovt.TypeStrindex)
   226  	if err != nil {
   227  		return nil, fmt.Errorf("could not access type string: %w", err)
   228  	}
   229  	unitLabel, err := at(dictionary.StringTable, ovt.UnitStrindex)
   230  	if err != nil {
   231  		return nil, fmt.Errorf("could not access unit string: %w", err)
   232  	}
   233  	return &googleProfile.ValueType{Type: p.addstr(typeLabel), Unit: p.addstr(unitLabel)}, nil
   234  }
   235  
   236  func (p *profileBuilder) convertLocationBack(ol *otelProfile.Location, dictionary *otelProfile.ProfilesDictionary) (uint64, error) {
   237  	if i, ok := p.locationMap[ol]; ok {
   238  		return i, nil
   239  	}
   240  	lmi := ol.GetMappingIndex()
   241  	om, err := at(dictionary.MappingTable, lmi)
   242  	if err != nil {
   243  		return 0, fmt.Errorf("could not access mapping: %w", err)
   244  	}
   245  
   246  	mappingId, ok := p.mappingMap[om]
   247  	if !ok {
   248  		return 0, fmt.Errorf("mapping not found in mappingMap")
   249  	}
   250  	gl := &googleProfile.Location{
   251  		MappingId: mappingId,
   252  		Address:   ol.Address,
   253  		Line:      make([]*googleProfile.Line, len(ol.Lines)),
   254  	}
   255  
   256  	for i, line := range ol.Lines {
   257  		gl.Line[i], err = p.convertLineBack(line, dictionary)
   258  		if err != nil {
   259  			return 0, fmt.Errorf("could not process line at index %d: %w", i, err)
   260  		}
   261  	}
   262  
   263  	p.dst.Location = append(p.dst.Location, gl)
   264  	gl.Id = uint64(len(p.dst.Location))
   265  	p.locationMap[ol] = gl.Id
   266  	return gl.Id, nil
   267  }
   268  
   269  // convertLineBack converts an OpenTelemetry Line to a Google Line.
   270  func (p *profileBuilder) convertLineBack(ol *otelProfile.Line, dictionary *otelProfile.ProfilesDictionary) (*googleProfile.Line, error) {
   271  	function, err := at(dictionary.FunctionTable, ol.FunctionIndex)
   272  	if err != nil {
   273  		return nil, fmt.Errorf("could not access function: %w", err)
   274  	}
   275  	functionId, err := p.convertFunctionBack(function, dictionary)
   276  	if err != nil {
   277  		return nil, err
   278  	}
   279  	return &googleProfile.Line{FunctionId: functionId, Line: ol.Line}, nil
   280  }
   281  
   282  func (p *profileBuilder) convertFunctionBack(of *otelProfile.Function, dictionary *otelProfile.ProfilesDictionary) (uint64, error) {
   283  	if i, ok := p.functionMap[of]; ok {
   284  		return i, nil
   285  	}
   286  	nameLabel, err := at(dictionary.StringTable, of.NameStrindex)
   287  	if err != nil {
   288  		return 0, fmt.Errorf("could not access function name string: %w", err)
   289  	}
   290  	systemNameLabel, err := at(dictionary.StringTable, of.SystemNameStrindex)
   291  	if err != nil {
   292  		return 0, fmt.Errorf("could not access function system name string: %w", err)
   293  	}
   294  	filenameLabel, err := at(dictionary.StringTable, of.FilenameStrindex)
   295  	if err != nil {
   296  		return 0, fmt.Errorf("could not access function file name string: %w", err)
   297  	}
   298  	gf := &googleProfile.Function{
   299  		Name:       p.addstr(nameLabel),
   300  		SystemName: p.addstr(systemNameLabel),
   301  		Filename:   p.addstr(filenameLabel),
   302  		StartLine:  of.StartLine,
   303  	}
   304  	p.dst.Function = append(p.dst.Function, gf)
   305  	gf.Id = uint64(len(p.dst.Function))
   306  	p.functionMap[of] = gf.Id
   307  	return gf.Id, nil
   308  }
   309  
   310  func (p *profileBuilder) convertSampleBack(os *otelProfile.Sample, dictionary *otelProfile.ProfilesDictionary) (*googleProfile.Sample, error) {
   311  	gs := &googleProfile.Sample{
   312  		Value: os.Values,
   313  	}
   314  
   315  	// According to spec, samples can come without values, in which case we assume that each timestamp occurrence has value of 1.
   316  	// See: https://github.com/open-telemetry/opentelemetry-proto/blob/81d6676cdc30dddb0ec1f87d080e6dac07ab214f/opentelemetry/proto/profiles/v1development/profiles.proto#L351-L353
   317  	if len(gs.Value) == 0 && len(os.TimestampsUnixNano) > 0 {
   318  		gs.Value = []int64{int64(len(os.TimestampsUnixNano))}
   319  	}
   320  
   321  	if len(gs.Value) == 0 {
   322  		return nil, fmt.Errorf("sample value is required")
   323  	}
   324  
   325  	for i, typ := range p.sampleProcessingTypes {
   326  		switch typ {
   327  		case sampleConversionTypeSamplesToNanos:
   328  			gs.Value[i] *= p.src.Period
   329  		case sampleConversionTypeSumEvents:
   330  			// For off-CPU profiles, aggregate all sample values into a single sum
   331  			// since pprof cannot represent variable-length sample values
   332  			sum := int64(0)
   333  			for _, v := range gs.Value {
   334  				sum += v
   335  			}
   336  			gs.Value = []int64{sum}
   337  		}
   338  	}
   339  	if p.dst.Period != 0 && p.dst.PeriodType != nil && len(gs.Value) != len(p.dst.SampleType) {
   340  		return nil, fmt.Errorf("sample values length mismatch %d %d", len(gs.Value), len(p.dst.SampleType))
   341  	}
   342  
   343  	err := p.convertSampleAttributesToLabelsBack(os, dictionary, gs)
   344  	if err != nil {
   345  		return nil, err
   346  	}
   347  
   348  	stackIndex := os.GetStackIndex()
   349  	if stackIndex < 0 || int(stackIndex) >= len(dictionary.StackTable) {
   350  		return nil, fmt.Errorf("invalid stack index: %d", stackIndex)
   351  	}
   352  	stack := dictionary.StackTable[stackIndex]
   353  
   354  	// First, gather map of locations pointing to each mapping
   355  	locationMap := make(map[*otelProfile.Mapping][]*otelProfile.Location)
   356  
   357  	for _, locIdx := range stack.LocationIndices {
   358  		loc, err := at(dictionary.LocationTable, locIdx)
   359  		if err != nil {
   360  			return nil, fmt.Errorf("could not access location at index %d: %w", locIdx, err)
   361  		}
   362  		mapping, err := at(dictionary.MappingTable, loc.GetMappingIndex())
   363  		if err != nil {
   364  			return nil, fmt.Errorf("could not access mapping at index %d: %w", loc.GetMappingIndex(), err)
   365  		}
   366  		locationMap[mapping] = append(locationMap[mapping], loc)
   367  	}
   368  
   369  	// Now, convert each mapping, based on information from all locations that point to it
   370  	for mapping, locs := range locationMap {
   371  		_, err := p.convertMappingBack(locs, mapping, dictionary)
   372  		if err != nil {
   373  			return nil, err
   374  		}
   375  	}
   376  
   377  	for _, olocIdx := range stack.LocationIndices {
   378  		oloc, err := at(dictionary.LocationTable, olocIdx)
   379  		if err != nil {
   380  			return nil, fmt.Errorf("could not access location at index %d: %w", olocIdx, err)
   381  		}
   382  		loc, err := p.convertLocationBack(oloc, dictionary)
   383  		if err != nil {
   384  			return nil, err
   385  		}
   386  		gs.LocationId = append(gs.LocationId, loc)
   387  	}
   388  
   389  	p.dst.Sample = append(p.dst.Sample, gs)
   390  
   391  	return gs, nil
   392  }
   393  
   394  func (p *profileBuilder) convertSampleAttributesToLabelsBack(os *otelProfile.Sample, dictionary *otelProfile.ProfilesDictionary, gs *googleProfile.Sample) error {
   395  	gs.Label = make([]*googleProfile.Label, 0, len(os.AttributeIndices))
   396  	for i, attributeIdx := range os.AttributeIndices {
   397  		attribute, err := at(dictionary.AttributeTable, attributeIdx)
   398  		if err != nil {
   399  			return fmt.Errorf("could not access attribute at index %d: %w", i, err)
   400  		}
   401  		if keyStr, err := at(dictionary.StringTable, attribute.KeyStrindex); err == nil && keyStr == serviceNameKey {
   402  			continue
   403  		}
   404  		if attribute.Value.GetStringValue() != "" {
   405  			keyStr, err := at(dictionary.StringTable, attribute.KeyStrindex)
   406  			if err != nil {
   407  				return fmt.Errorf("could not access attribute key: %w", err)
   408  			}
   409  			gs.Label = append(gs.Label, &googleProfile.Label{
   410  				Key: p.addstr(keyStr),
   411  				Str: p.addstr(attribute.Value.GetStringValue()),
   412  			})
   413  		}
   414  	}
   415  
   416  	if os.GetLinkIndex() != 0 {
   417  		link, err := at(dictionary.LinkTable, os.GetLinkIndex())
   418  		if err != nil {
   419  			return fmt.Errorf("could not access link at index %d: %w", os.GetLinkIndex(), err)
   420  		}
   421  		gs.Label = append(gs.Label, &googleProfile.Label{
   422  			Key: p.addstr(pprof.SpanIDLabelName),
   423  			Str: p.addstr(hex.EncodeToString(link.GetSpanId())),
   424  		})
   425  	}
   426  
   427  	return nil
   428  }
   429  
   430  // convertMappingBack converts an OpenTelemetry Mapping to a Google Mapping taking into account availability
   431  // of symbol data in all locations that point to this mapping.
   432  func (p *profileBuilder) convertMappingBack(ols []*otelProfile.Location, om *otelProfile.Mapping, dictionary *otelProfile.ProfilesDictionary) (uint64, error) {
   433  	hasLines := true
   434  	hasFunctions := true
   435  	hasInlineFrames := true
   436  	hasFilenames := true
   437  
   438  	/*
   439  		We survey all locations that point to this mapping to determine
   440  		whether the mapping has functions, filenames, line numbers, inline frames.
   441  		Note that even if a single location does not have symbol information,
   442  		the mapping is marked as not having that information.
   443  	*/
   444  	for _, ol := range ols {
   445  		// If at least one location belonging to mapping does not have lines, we must flag whole mapping as not having symbol info.
   446  		if len(ol.Lines) == 0 {
   447  			hasLines = false
   448  			hasFunctions = false
   449  			hasInlineFrames = false
   450  			hasFilenames = false
   451  		}
   452  
   453  		// If by this point we know that mapping has no symbol info, we can stop checking other locations.
   454  		if !hasLines && !hasFunctions && !hasInlineFrames && !hasFilenames {
   455  			break
   456  		}
   457  
   458  		for i, line := range ol.Lines {
   459  			hasFunctions = hasFunctions && line.FunctionIndex > 0
   460  			hasLines = hasLines && line.Line > 0
   461  			hasInlineFrames = hasInlineFrames && i >= 1
   462  
   463  			if line.FunctionIndex > 0 {
   464  				function, _ := at(dictionary.FunctionTable, line.FunctionIndex)
   465  				if function != nil {
   466  					hasFilenames = hasFilenames && function.FilenameStrindex > 0
   467  				}
   468  			}
   469  		}
   470  	}
   471  
   472  	buildID, _ := getAttributeValueByKeyOrEmpty(om.AttributeIndices, dictionary, "process.executable.build_id.gnu")
   473  	filenameLabel, err := at(dictionary.StringTable, om.FilenameStrindex)
   474  	if err != nil {
   475  		return 0, fmt.Errorf("could not access mapping file name string: %w", err)
   476  	}
   477  	gm := &googleProfile.Mapping{
   478  		MemoryStart:     om.MemoryStart,
   479  		MemoryLimit:     om.MemoryLimit,
   480  		FileOffset:      om.FileOffset,
   481  		Filename:        p.addstr(filenameLabel),
   482  		BuildId:         p.addstr(buildID),
   483  		HasFunctions:    hasFunctions,
   484  		HasFilenames:    hasFilenames,
   485  		HasLineNumbers:  hasLines,
   486  		HasInlineFrames: hasInlineFrames,
   487  	}
   488  	p.dst.Mapping = append(p.dst.Mapping, gm)
   489  	gm.Id = uint64(len(p.dst.Mapping))
   490  	p.mappingMap[om] = gm.Id
   491  	return gm.Id, nil
   492  }
   493  
   494  // This function extracts the value of a specific attribute key from a list of attribute indices.
   495  // TODO: build a map instead of iterating every time.
   496  func getAttributeValueByKeyOrEmpty(attributeIndices []int32, dictionary *otelProfile.ProfilesDictionary, key string) (string, error) {
   497  	for i, attributeIndex := range attributeIndices {
   498  		attr, err := at(dictionary.AttributeTable, attributeIndex)
   499  		if err != nil {
   500  			return "", fmt.Errorf("attribute not found: %d: %w", i, err)
   501  		}
   502  		keyStr, err := at(dictionary.StringTable, attr.KeyStrindex)
   503  		if err != nil {
   504  			return "", fmt.Errorf("attribute key string not found %d: %w", i, err)
   505  		}
   506  		if keyStr == key {
   507  			return attr.Value.GetStringValue(), nil
   508  		}
   509  	}
   510  	return "", nil
   511  }