Skip to content

Commit 5586100

Browse files
authored
feature: OTLP format support (#173)
* analysis: native multi-format profile model (pprof + OTLP) Teaches the analyzer to read OTLP (OpenTelemetry profiles) in addition to google/pprof, so scenarios can assert on profilers that emit OTLP. Rather than convert OTLP --> pprof (which loses what pprof can't represent, e.g. trace/span links), both formats are read into one small format-neutral model and assertions run on that. So the same expected_profile.json can, in principle, work across several formats. * analysis: add cmd/prof-dump and ignore volatile OTLP attrs in capture cmd/prof-dump loads a profile file (pprof or OTLP) via the public LoadProfileSet API and prints the neutral ProfileSet — per-type duration, values, stacks and labels. Useful for seeing how each format's tags/resource attributes land as labels and for authoring expectations. Running it on real host-profiler OTLP showed resource/sample attributes are flattened onto samples (intended), including some that vary per sample or per run: cpu.logical_number, container.id, process.context.label.check_id. Add these to captureKeysToIgnore so they don't split otherwise-identical stacks in the bootstrap JSON. Assertions are unaffected.
1 parent 4c68e8e commit 5586100

11 files changed

Lines changed: 1446 additions & 78 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,22 @@ Describe what you expect in a json file within the same folder. *This data is ca
108108
}
109109
```
110110
111+
### Profile input formats
112+
113+
The analyzer reads both **pprof** and **OTLP** (OpenTelemetry profiles), so the
114+
same `expected_profile.json` can be used whichever format a profiler emits.
115+
Drop OTLP files with a `.otlp` (protobuf) or `.otlp.json` suffix; everything
116+
else is treated as pprof.
117+
118+
### Semantic differences between formats
119+
120+
Formats express the same concept in different ways (for example a span link is
121+
a plain label in pprof but a `LinkTable` entry in OTLP). The goal is that one
122+
expectation in `expected_profile.json` works across formats, with each format's
123+
native encoding normalized to a shared set of label keys (see `canonKey` in
124+
`analysis/model.go`). This normalization is not complete yet, so some
125+
expectations may still need format-specific values.
126+
111127
### Run your test
112128
113129
```

analysis/analysis.go

Lines changed: 54 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import (
1414
"path/filepath"
1515
"regexp"
1616
"sort"
17-
"strconv"
1817
"strings"
1918

2019
"github.com/google/pprof/profile"
@@ -221,11 +220,17 @@ func containsStr(s []string, v string) bool {
221220
// entry instead of producing a separate JSON line each.
222221
var captureKeysToIgnore = []string{
223222
"thread native id",
224-
"thread id",
225-
"process_id",
223+
LabelThreadID,
224+
LabelProcessID,
226225
"end_timestamp_ns",
227-
"span id",
228-
"local root span id",
226+
LabelTraceID,
227+
LabelSpanID,
228+
LabelLocalRootSID,
229+
// OTLP resource/sample attributes that vary per sample or per run and would
230+
// otherwise split otherwise-identical stacks in the bootstrap JSON.
231+
"cpu.logical_number", // per-sample: which CPU the sample hit
232+
"container.id", // per-run
233+
"process.context.label.check_id", // per-run
229234
}
230235

231236
// labelsKey produces a stable string key from a label set (which has already
@@ -240,7 +245,7 @@ func labelsKey(labels []Labels) string {
240245
for _, l := range labels {
241246
b.WriteString(l.Key)
242247
b.WriteByte('=')
243-
for _, v := range l.Values { // Values is already sorted by getProfileType
248+
for _, v := range l.Values { // Values is already sorted by the format adapter
244249
b.WriteString(v)
245250
b.WriteByte(',')
246251
}
@@ -249,16 +254,20 @@ func labelsKey(labels []Labels) string {
249254
return b.String()
250255
}
251256

252-
func captureProfData(r Reporter, prof *profile.Profile, path string, testName string, profileDuration float64) {
257+
func captureProfData(r Reporter, ps *ProfileSet, path string, testName string) {
253258
var capturedData StackTestData
254259
capturedData.TestName = testName
255260

256-
for _, sampleType := range prof.SampleType {
261+
for _, sampleType := range ps.SampleTypes() {
257262
var typedStack TypedStacks
258-
typedStack.ProfileType = sampleType.Type
263+
typedStack.ProfileType = sampleType
259264
typedStack.ErrorMargin = 1
260265

261-
typedProf := getProfileType(r, prof, sampleType.Type)
266+
// Rate-scale each type by its own duration (a file may mix, e.g., a
267+
// 10s allocation profile with a 60s CPU profile).
268+
profileDuration := ps.Duration(sampleType)
269+
270+
typedProf, _ := ps.Samples(sampleType)
262271

263272
// Group samples by (stack, kept-labels) and sum their values. Without
264273
// this, ephemeral labels like end_timestamp_ns produce one entry per
@@ -324,7 +333,7 @@ func captureProfData(r Reporter, prof *profile.Profile, path string, testName st
324333
capturedData.Stacks = append(capturedData.Stacks, typedStack)
325334
}
326335

327-
jsonPath := filepath.Join(filepath.Dir(path), fileNameWithoutExt(filepath.Base(path))) + ".json"
336+
jsonPath := captureJSONPath(path)
328337

329338
err := writeToJSONFile(capturedData, jsonPath)
330339
if err != nil {
@@ -334,57 +343,19 @@ func captureProfData(r Reporter, prof *profile.Profile, path string, testName st
334343
}
335344
}
336345

337-
func getProfileType(r Reporter, prof *profile.Profile, type_ string) []StackSample {
338-
typeIdx := -1
339-
for i, sampleType := range prof.SampleType {
340-
if sampleType.Type == type_ {
341-
typeIdx = i
342-
}
343-
}
344-
if typeIdx == -1 {
345-
r.Fatalf("Couldn't find sample type %s", type_)
346-
}
347-
348-
if err := prof.Aggregate(true, true, false, false, false, false); err != nil {
349-
r.Fatalf("Error aggregating profile samples: %v", err)
350-
}
351-
prof = prof.Compact()
352-
sort.Slice(prof.Sample, func(i, j int) bool {
353-
return prof.Sample[i].Value[0] > prof.Sample[j].Value[0]
354-
})
355-
356-
var out []StackSample
357-
for _, sample := range prof.Sample {
358-
var frames []string
359-
for i := range sample.Location {
360-
loc := sample.Location[len(sample.Location)-i-1]
361-
for j := range loc.Line {
362-
line := loc.Line[len(loc.Line)-j-1]
363-
name := line.Function.Name
364-
frames = append(frames, name)
365-
}
366-
}
367-
labels := make(map[string][]string)
368-
for k, v := range sample.Label {
369-
// ease the comparison by sorting string values
370-
sort.Strings(v)
371-
labels[k] = v
372-
}
373-
for k, v := range sample.NumLabel {
374-
for _, i := range v {
375-
labels[k] = append(labels[k], strconv.FormatInt(i, 10))
376-
}
377-
sort.Strings(labels[k])
378-
}
379-
380-
ss := StackSample{
381-
Stack: strings.Join(frames, ";"),
382-
Val: sample.Value[typeIdx],
383-
Labels: labels,
384-
}
385-
out = append(out, ss)
386-
}
387-
return out
346+
// captureJSONPath is where captureProfData writes the observed-stacks JSON: the
347+
// profile's basename with its extension replaced by .json. It guards against
348+
// clobbering the source: inputs whose own extension is already .json (e.g.
349+
// foo.otlp.json) would otherwise resolve back to the input path, so a
350+
// .capture.json variant is used instead.
351+
func captureJSONPath(path string) string {
352+
dir := filepath.Dir(path)
353+
base := filepath.Base(path)
354+
jsonPath := filepath.Join(dir, fileNameWithoutExt(base)+".json")
355+
if jsonPath == path {
356+
jsonPath = filepath.Join(dir, fileNameWithoutExt(base)+".capture.json")
357+
}
358+
return jsonPath
388359
}
389360

390361
func checkLabels(r Reporter, labels map[string][]string, expectedLabels []Labels) bool {
@@ -608,15 +579,10 @@ func getMatchingFiles(folder string, filenameRegex *regexp.Regexp) ([]string, er
608579
return matchingFiles, nil
609580
}
610581

611-
// ReadPprofFile reads a pprof file from disk, transparently decompressing lz4
612-
// or zstd frames if present, and returns the parsed profile.
613-
func ReadPprofFile(pprofFile string) (*profile.Profile, error) {
614-
file, err := os.Open(pprofFile)
615-
if err != nil {
616-
return nil, err
617-
}
618-
defer file.Close()
619-
content, err := io.ReadAll(file)
582+
// readAndDecompress reads a profile file, transparently decompressing lz4 or
583+
// zstd frames if present, and returns the raw payload bytes.
584+
func readAndDecompress(path string) ([]byte, error) {
585+
content, err := os.ReadFile(path)
620586
if err != nil {
621587
return nil, err
622588
}
@@ -651,36 +617,46 @@ func ReadPprofFile(pprofFile string) (*profile.Profile, error) {
651617
content = decompressed
652618
}
653619
}
654-
prof, err := profile.ParseData(content)
620+
return content, nil
621+
}
622+
623+
// ReadPprofFile reads a pprof file from disk (decompressing lz4/zstd if needed)
624+
// and returns the parsed google/pprof profile. Retained for consumers that
625+
// want the raw pprof model; the analyzer itself uses LoadProfileSet.
626+
func ReadPprofFile(pprofFile string) (*profile.Profile, error) {
627+
content, err := readAndDecompress(pprofFile)
655628
if err != nil {
656629
return nil, err
657630
}
658-
return prof, nil
631+
return profile.ParseData(content)
659632
}
660633

661634
// AnalyzePprofFile reads a single pprof file and asserts the given typedStacks
662635
// expectations against it. If captureData is true, a JSON dump of the actual
663636
// stacks observed in the profile is written next to the pprof file (useful to
664637
// bootstrap an expected_profile.json).
665638
func AnalyzePprofFile(r Reporter, pprofFile string, typedStacks TypedStacks, testName string, captureData bool, scaleByDuration bool, allowFailure bool) {
666-
prof, err := ReadPprofFile(pprofFile)
639+
ps, err := LoadProfileSet(pprofFile)
667640
if err != nil {
668-
r.Fatalf("Error reading file %s", pprofFile)
641+
r.Fatalf("Error reading file %s: %v", pprofFile, err)
669642
}
670643
r.Logf("Analyzing results in %s for profile type %s", pprofFile, typedStacks.ProfileType)
671644

672-
profileDuration := float64(prof.DurationNanos) / 1000000000.0
645+
profileDuration := ps.Duration(typedStacks.ProfileType)
673646
r.Logf("Found a profile duration of %.1f seconds (in %s)", profileDuration, filepath.Base(pprofFile))
674647

675648
// Store current data in a json file to help users create their tests
676649
if captureData {
677-
captureProfData(r, prof, pprofFile, testName, profileDuration)
650+
captureProfData(r, ps, pprofFile, testName)
678651
}
679652
if !scaleByDuration {
680653
// ignore duration, values can be considered absolute
681654
profileDuration = 0
682655
}
683-
typedProf := getProfileType(r, prof, typedStacks.ProfileType)
656+
typedProf, ok := ps.Samples(typedStacks.ProfileType)
657+
if !ok {
658+
r.Fatalf("Couldn't find sample type %s", typedStacks.ProfileType)
659+
}
684660
analyzeProfDataWithFailureHandling(r, typedProf, typedStacks, profileDuration, allowFailure)
685661
}
686662

0 commit comments

Comments
 (0)