Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
363 changes: 363 additions & 0 deletions tools/profcheck/check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,363 @@
// Copyright The OpenTelemetry Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"errors"
"fmt"

profiles "go.opentelemetry.io/proto/otlp/profiles/v1development"
"google.golang.org/protobuf/proto"
)

func CheckConformance(data *profiles.ProfilesData) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If ebpf-profiler should make use of this, some additional work is needed to transform go.opentelemetry.io/proto/otlp/profiles/v1development to go.opentelemetry.io/collector/pdata/pprofile.

dict := data.Dictionary
if len(data.ResourceProfiles) == 0 {
return errors.New("resource profiles are empty")
}
for _, resourceProfiles := range data.ResourceProfiles {
// TODO: Check attributes?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a check to verify that a ResourceProfile holds at least one ScopeProfile.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As Attributes in ResourceProfile are not part of the ProfilesDictionary, I think we don't need to check them.

for _, scopeProfiles := range resourceProfiles.ScopeProfiles {
// TODO: Check attributes?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a check to verify that a ScopeProfile holds at least one Profiles.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As Attributes in ScopeProfiles are not part of the ProfilesDictionary, I think we don't need to check them.

for i, profile := range scopeProfiles.Profiles {
if err := checkProfile(profile, dict); err != nil {
return fmt.Errorf("profile %d: %v", i, err)
}
}
}
}
return checkDictionary(dict)
}

func checkProfile(prof *profiles.Profile, dict *profiles.ProfilesDictionary) error {
var errs error
if err := checkAttributeIndices(prof.AttributeIndices, dict); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "attribute_indices"))
}
if err := checkValueType(prof.SampleType, dict); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "sample_type"))
}
if err := checkValueType(prof.PeriodType, dict); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "period_type"))
}
for i, s := range prof.Sample {
if err := checkSample(s, prof.TimeUnixNano, prof.TimeUnixNano+prof.DurationNano, dict); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "sample[%d]", i))
}
// TODO: Check uniqueness of samples?
// Key: {stack_index, sorted(attribute_indices), link_index}
// Related: https://github.com/open-telemetry/opentelemetry-proto/issues/706.
}
for i, strIdx := range prof.CommentStrindices {
if err := checkIndex(len(dict.StringTable), strIdx); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "comment_strindices[%d]", i))
}
}
Comment on lines +63 to +67
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As the idea is to drop this field in the proto, we maybe can already drop the check here.

Suggested change
for i, strIdx := range prof.CommentStrindices {
if err := checkIndex(len(dict.StringTable), strIdx); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "comment_strindices[%d]", i))
}
}

return errs
}

func checkSample(s *profiles.Sample, startUnixNano uint64, endUnixNano uint64, dict *profiles.ProfilesDictionary) error {
var errs error
if err := checkIndex(len(dict.StackTable), s.StackIndex); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "stack_index"))
}
if err := checkAttributeIndices(s.AttributeIndices, dict); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "attribute_indices"))
}
if err := checkIndex(len(dict.LinkTable), s.LinkIndex); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "link_index"))
}
for i, tsUnixNano := range s.TimestampsUnixNano {
if tsUnixNano < startUnixNano || tsUnixNano > endUnixNano {
errs = errors.Join(errs, fmt.Errorf("timestamps_unix_nano[%d]=%d is outside profile time range [%d, %d]", i, tsUnixNano, startUnixNano, endUnixNano))
}
}
// TODO: Add a check for the value vs timestamp shapes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might also add a check for the number of values/timestamps.

// A Sample MUST have have at least one values or timestamps_unix_nano entry. If
// both fields are populated, they MUST contain the same number of elements

return errs
}

func checkDictionary(dict *profiles.ProfilesDictionary) error {
var errs error

if err := checkMappingTable(dict.GetMappingTable(), dict); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "mapping_table"))
}

if err := checkLocationTable(dict.GetLocationTable(), dict); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "location_table"))
}

if err := checkFunctionTable(dict.GetFunctionTable(), dict); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "function_table"))
}

if err := checkLinkTable(dict.GetLinkTable()); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "link_table"))
}

if err := checkStringTable(dict.GetStringTable()); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "string_table"))
}

if err := checkAttributeTable(dict.GetAttributeTable(), len(dict.GetStringTable())); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "attribute_table"))
}

if err := checkStackTable(dict.GetStackTable(), len(dict.GetLocationTable())); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "stack_table"))
}

return errs
}

func checkValueType(valueType *profiles.ValueType, dict *profiles.ProfilesDictionary) error {
var errs error
if err := checkIndex(len(dict.StringTable), valueType.UnitStrindex); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "unit_strindex"))
}
if err := checkIndex(len(dict.StringTable), valueType.TypeStrindex); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "type_strindex"))
}
return nil
}

func checkMappingTable(mappingTable []*profiles.Mapping, dict *profiles.ProfilesDictionary) error {
if err := checkZeroVal(mappingTable); err != nil {
return err
}
var errs error
for idx, m := range mappingTable {
if err := checkIndex(len(dict.StringTable), m.FilenameStrindex); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "[%d].filename_strindex", idx))
}
if err := checkAttributeIndices(m.AttributeIndices, dict); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "[%d].attribute_indices", idx))
}
if !(m.MemoryStart == 0 && m.MemoryLimit == 0) && !(m.MemoryStart < m.MemoryLimit) {
errs = errors.Join(errs, fmt.Errorf("[%d]: memory_start=%016x, memory_limit=%016x: must be both zero or start < limit", idx, m.MemoryStart, m.MemoryLimit))
}
}
// TODO: Add optional uniqueness check.
// TODO: Add optional unreferenced entries check.
return errs
}

func checkLocationTable(locTable []*profiles.Location, dict *profiles.ProfilesDictionary) error {
if err := checkZeroVal(locTable); err != nil {
return err
}
var errs error
for locIdx, loc := range locTable {
if err := checkIndex(len(dict.MappingTable), loc.MappingIndex); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "[%d].mapping_index", locIdx))
}
if err := checkAttributeIndices(loc.AttributeIndices, dict); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "[%d].attribute_indices", locIdx))
}
for lineIdx, line := range loc.Line {
if err := checkLine(line, dict); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "[%d].line[%d]", locIdx, lineIdx))
}
}
}
// TODO: Add optional uniqueness check.
// TODO: Add optional unreferenced entries check.
return errs
}

func checkLine(line *profiles.Line, dict *profiles.ProfilesDictionary) error {
var errs error
if err := checkIndex(len(dict.FunctionTable), line.FunctionIndex); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "function_index"))
}
if err := checkNonNegative(line.Line); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "line"))
}
if err := checkNonNegative(line.Column); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "column"))
}
return errs
}

func checkFunctionTable(funcTable []*profiles.Function, dict *profiles.ProfilesDictionary) error {
if err := checkZeroVal(funcTable); err != nil {
return err
}
var errs error
for idx, fnc := range funcTable {
if err := checkIndex(len(dict.StringTable), fnc.NameStrindex); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "[%d].name_strindex", idx))
}
if err := checkIndex(len(dict.StringTable), fnc.SystemNameStrindex); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "[%d].system_name_strindex", idx))
}
if err := checkIndex(len(dict.StringTable), fnc.FilenameStrindex); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "[%d].filename_strindex", idx))
}
if err := checkNonNegative(fnc.StartLine); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "[%d].start_line", idx))
}
}
// TODO: Add optional uniqueness check.
// TODO: Add optional unreferenced entries check.
return errs
}

func checkLinkTable(linkTable []*profiles.Link) error {
if err := checkZeroVal(linkTable); err != nil {
return err
}
var errs error
for idx, link := range linkTable[1:] {
if gotLen, wantLen := len(link.TraceId), 16; gotLen != wantLen {
errs = errors.Join(errs, fmt.Errorf("len([%d].trace_id) == %d, want %d", idx, gotLen, wantLen))
}
if gotLen, wantLen := len(link.SpanId), 8; gotLen != wantLen {
errs = errors.Join(errs, fmt.Errorf("len([%d].span_id) == %d, want %d", idx, gotLen, wantLen))
}
}
// TODO: Add optional uniqueness check.
// TODO: Add optional unreferenced entries check.
return errs
}

func checkStringTable(strTable []string) error {
if len(strTable) == 0 {
return errors.New("empty string table, must have at least empty string")
}
if strTable[0] != "" {
return fmt.Errorf("must have empty string at index 0, got %q", strTable[0])
}
var errs error
strIdxs := map[string]int{}
for idx, s := range strTable {
if origIdx, ok := strIdxs[s]; ok {
errs = errors.Join(errs, fmt.Errorf("duplicate string at index %d, orig index %d: %s", idx, origIdx, s))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this part of the spec? I cannot find it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We discussed in a recent SIG meeting or two that we want to add a couple of things at "should do, unless you know what you are doing":

  1. All dictionary tables should generally have no duplicate entries.
  2. All dictionary tables should generally have no orphaned entries (entries that are not referenced).

But we also said that these are not mandates. I need to figure out what's the best way to handle this in the conformance checker. I thought of switching from the error model to some kind of logging model with severity etc, but this will complicate things. Currently I'm thinking of simply having a flag for optional checks, off by default, and guard the two mentioned checks above with it.

And here specifically it's a bug that I need to fix - in that this should be an optional check.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, thanks for the clarification.

continue
}
strIdxs[s] = idx
}
return errs
}

func checkAttributeTable(attrTable []*profiles.KeyValueAndUnit, lenStrTable int) error {
if err := checkZeroVal(attrTable); err != nil {
return err
}
var errs error
for pos, kvu := range attrTable {
if err := checkIndex(lenStrTable, kvu.KeyStrindex); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "[%d].key_strindex", pos))
}
if err := checkIndex(lenStrTable, kvu.UnitStrindex); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "[%d].unit_strindex", pos))
}
}
// TODO: Add optional uniqueness check.
// TODO: Add optional unreferenced entries check.
return errs
}

func checkStackTable(stackTable []*profiles.Stack, lenLocTable int) error {
if err := checkZeroVal(stackTable); err != nil {
return err
Copy link

@fandreuz fandreuz Oct 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to treat this error differently than the others? Perhaps you could run run the for loop below regardless of what happens here, and prepend this error if it was found. It's valuable to see all errors in the table, even if I don't have the zero-entry for some reason.

Copy link
Member Author

@aalexand aalexand Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, makes sense, I think it should just append and not bail out early. Basically it should behave like other checks. Will do.

}
var errs error
for i, stack := range stackTable {
for j, locIndex := range stack.LocationIndices {
if err := checkIndex(lenLocTable, locIndex); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "[%d].location_indices[%d]", i, j))
}
}
}
// TODO: Add optional uniqueness check.
// TODO: Add optional unreferenced entries check.
return errs
}

// checkZeroVal verifies that the given slice meets Profiles dictionary
// conventions: the slice is not empty and has zero value at index zero.
func checkZeroVal[T any, P interface {
*T
proto.Message
}](table []P) error {
if len(table) == 0 {
return errors.New("empty table, must have at least zero value entry")
}
var zeroVal P = new(T)
if !proto.Equal(table[0], zeroVal) {
return fmt.Errorf("must have zero value %#v at index 0, got %#v", zeroVal, table[0])
}
return nil
}

func checkAttributeIndices(attrIndices []int32, dict *profiles.ProfilesDictionary) error {
var errs error
keys := map[string]int{}
for pos, attrIdx := range attrIndices {
if err := checkIndex(len(dict.AttributeTable), attrIdx); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "[%d]", pos))
continue
}
attr := dict.AttributeTable[attrIdx]
if err := checkIndex(len(dict.StringTable), attr.KeyStrindex); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "[%d].key_strindex", pos))
continue
}
key := dict.StringTable[attr.KeyStrindex]
if prevPos, ok := keys[key]; ok {
errs = errors.Join(errs, fmt.Errorf("[%d].key_strindex: duplicate key %q, previously seen at [%d].key_strindex", pos, key, prevPos))
} else {
keys[key] = pos
}
}
return errs
}

func checkIndices(length int, indices []int32) error {
var errs error
for i, idx := range indices {
if err := checkIndex(length, idx); err != nil {
errs = errors.Join(errs, prefixErrorf(err, "[%d]", i))
}
}
return errs
}

func checkIndex(length int, idx int32) error {
if idx < 0 || int(idx) >= length {
return fmt.Errorf("index %d is out of range [0..%d)", idx, length)
}
return nil
}

func checkNonNegative(n int64) error {
if n < 0 {
return fmt.Errorf("%d < 0, must be non-negative", n)
}
return nil
}

func prefixErrorf(err error, format string, args ...any) error {
prefix := fmt.Sprintf(format, args...)
if merr, ok := err.(interface{ Unwrap() []error }); ok {
errs := merr.Unwrap()
for i, e := range errs {
errs[i] = fmt.Errorf("%s: %w", prefix, e)
}
return errors.Join(errs...)
}
return fmt.Errorf("%s: %w", prefix, err)
}
Loading