diff --git a/lightstep/instrumentation/go.mod b/lightstep/instrumentation/go.mod index b081809d..f3cd2889 100644 --- a/lightstep/instrumentation/go.mod +++ b/lightstep/instrumentation/go.mod @@ -7,7 +7,6 @@ require ( github.com/stretchr/testify v1.8.1 go.opentelemetry.io/otel v1.11.2 go.opentelemetry.io/otel/metric v0.34.0 - go.opentelemetry.io/otel/sdk v1.11.2 go.opentelemetry.io/otel/sdk/metric v0.34.0 ) @@ -22,6 +21,7 @@ require ( github.com/tklauser/go-sysconf v0.3.10 // indirect github.com/tklauser/numcpus v0.4.0 // indirect github.com/yusufpapurcu/wmi v1.2.2 // indirect + go.opentelemetry.io/otel/sdk v1.11.2 // indirect go.opentelemetry.io/otel/trace v1.11.2 // indirect golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/lightstep/instrumentation/runtime/builtin.go b/lightstep/instrumentation/runtime/builtin.go index b700d1af..37c30f04 100644 --- a/lightstep/instrumentation/runtime/builtin.go +++ b/lightstep/instrumentation/runtime/builtin.go @@ -18,7 +18,6 @@ import ( "context" "fmt" "runtime/metrics" - "strings" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -28,6 +27,9 @@ import ( "go.opentelemetry.io/otel/metric/unit" ) +// namePrefix is prefixed onto OTel instrument names. +const namePrefix = "process.runtime.go" + // LibraryName is the value of instrumentation.Library.Name. const LibraryName = "otel-launcher-go/runtime" @@ -80,22 +82,28 @@ func Start(opts ...Option) error { ) r := newBuiltinRuntime(meter, metrics.All, metrics.Read) - return r.register() + return r.register(expectRuntimeMetrics()) } +// allFunc is the function signature of metrics.All() type allFunc = func() []metrics.Description + +// readFunc is the function signature of metrics.Read() type readFunc = func([]metrics.Sample) +// builtinRuntime instruments all supported kinds of runtime/metrics. type builtinRuntime struct { meter metric.Meter allFunc allFunc readFunc readFunc } +// int64Observer is any async int64 instrument. type int64Observer interface { Observe(ctx context.Context, x int64, attrs ...attribute.KeyValue) } +// float64Observer is any async float64 instrument. type float64Observer interface { Observe(ctx context.Context, x float64, attrs ...attribute.KeyValue) } @@ -108,165 +116,85 @@ func newBuiltinRuntime(meter metric.Meter, af allFunc, rf readFunc) *builtinRunt } } -func getTotalizedAttributeName(n string) string { - x := strings.Split(n, ".") - // It's a plural, make it singular. - switch x[len(x)-1] { - case "cycles": - return "cycle" - case "usage": - return "class" - } - panic(fmt.Sprint("unrecognized attribute name: ", n)) -} - -func getTotalizedMetricName(n, u string) string { - if !strings.HasSuffix(n, ".classes") { - return n - } - - s := n[:len(n)-len("classes")] - - // Note that ".classes" is (apparently) intended as a generic - // suffix, while ".cycles" is an exception. - // The ideal name depends on what we know. - switch u { - case "bytes": - return s + "usage" - case "cpu-seconds": - return s + "time" - default: - panic("unrecognized metric suffix") - } -} - -func (r *builtinRuntime) register() error { +// register parses each name and registers metric instruments for all +// the recognized instruments. +func (r *builtinRuntime) register(desc *builtinDescriptor) error { all := r.allFunc() - totals := map[string]bool{} - counts := map[string]int{} - toName := func(in string) (string, string) { - n, statedUnits, _ := strings.Cut(in, ":") - n = "process.runtime.go" + strings.ReplaceAll(n, "/", ".") - return n, statedUnits - } - for _, m := range all { - name, _ := toName(m.Name) - - // Totals map includes the '.' suffix. - if strings.HasSuffix(name, ".total") { - totals[name[:len(name)-len("total")]] = true - } - - counts[name]++ - } - - var samples []metrics.Sample var instruments []instrument.Asynchronous - var totalAttrs [][]attribute.KeyValue + var samples []metrics.Sample + var instAttrs [][]attribute.KeyValue for _, m := range all { - n, statedUnits := toName(m.Name) - - if strings.HasSuffix(n, ".total") { + // each should match one + mname, munit, pattern, attrs, kind, err := desc.findMatch(m.Name) + if err != nil { + // skip unrecognized metric names + otel.Handle(fmt.Errorf("unrecognized runtime/metrics name: %s", m.Name)) continue } - - var u string - switch statedUnits { - case "bytes", "seconds": - // Real units - u = statedUnits - default: - // Pseudo-units - u = "{" + statedUnits + "}" + if kind == builtinSkip { + // skip e.g., totalized metrics + continue } - // Remove any ".total" suffix, this is redundant for Prometheus. - var totalAttrVal string - for totalize := range totals { - if strings.HasPrefix(n, totalize) { - // Units is unchanged. - // Name becomes the overall prefix. - // Remember which attribute to use. - totalAttrVal = n[len(totalize):] - n = getTotalizedMetricName(totalize[:len(totalize)-1], u) - break + if kind == builtinHistogram { + // skip unsupported data types + if m.Kind != metrics.KindFloat64Histogram { + otel.Handle(fmt.Errorf("expected histogram runtime/metrics: %s", mname)) } + continue } - if counts[n] > 1 { - if totalAttrVal != "" { - // This has not happened, hopefully never will. - // Indicates the special case for objects/bytes - // overlaps with the special case for total. - panic("special case collision") - } - - // This is treated as a special case, we know this happens - // with "objects" and "bytes" in the standard Go 1.19 runtime. - switch statedUnits { - case "objects": - // In this case, use `.objects` suffix. - n = n + ".objects" - u = "{objects}" - case "bytes": - // In this case, use no suffix. In Prometheus this will - // be appended as a suffix. - default: - panic(fmt.Sprint( - "unrecognized duplicate metrics names, ", - "attention required: ", - n, - )) - } - } + description := fmt.Sprintf("%s from runtime/metrics", pattern) opts := []instrument.Option{ - instrument.WithUnit(unit.Unit(u)), - instrument.WithDescription(m.Description), + instrument.WithUnit(unit.Unit(munit)), + instrument.WithDescription(description), } var inst instrument.Asynchronous - var err error - if m.Cumulative { + switch kind { + case builtinCounter: switch m.Kind { case metrics.KindUint64: - inst, err = r.meter.AsyncInt64().Counter(n, opts...) + // e.g., alloc bytes + inst, err = r.meter.AsyncInt64().Counter(mname, opts...) case metrics.KindFloat64: - inst, err = r.meter.AsyncFloat64().Counter(n, opts...) - case metrics.KindFloat64Histogram: - // Not implemented Histogram[float64]. - continue + // e.g., cpu time (1.20) + inst, err = r.meter.AsyncFloat64().Counter(mname, opts...) } - } else { + case builtinUpDownCounter: switch m.Kind { case metrics.KindUint64: - inst, err = r.meter.AsyncInt64().UpDownCounter(n, opts...) + // e.g., memory size + inst, err = r.meter.AsyncInt64().UpDownCounter(mname, opts...) case metrics.KindFloat64: - // Note: this has never been used. - inst, err = r.meter.AsyncFloat64().Gauge(n, opts...) - case metrics.KindFloat64Histogram: - // Not implemented GaugeHistogram[float64]. - continue + // not used through 1.20 + inst, err = r.meter.AsyncFloat64().UpDownCounter(mname, opts...) + } + case builtinGauge: + switch m.Kind { + case metrics.KindUint64: + inst, err = r.meter.AsyncInt64().Gauge(mname, opts...) + case metrics.KindFloat64: + // not used through 1.20 + inst, err = r.meter.AsyncFloat64().Gauge(mname, opts...) } } if err != nil { return err } + if inst == nil { + otel.Handle(fmt.Errorf("unexpected runtime/metrics %v: %s", kind, mname)) + continue + } samp := metrics.Sample{ Name: m.Name, } samples = append(samples, samp) instruments = append(instruments, inst) - if totalAttrVal == "" { - totalAttrs = append(totalAttrs, nil) - } else { - // Append a singleton list. - totalAttrs = append(totalAttrs, []attribute.KeyValue{ - attribute.String(getTotalizedAttributeName(n), totalAttrVal), - }) - } + instAttrs = append(instAttrs, attrs) } if err := r.meter.RegisterCallback(instruments, func(ctx context.Context) { @@ -276,9 +204,9 @@ func (r *builtinRuntime) register() error { switch samp.Value.Kind() { case metrics.KindUint64: - instruments[idx].(int64Observer).Observe(ctx, int64(samp.Value.Uint64()), totalAttrs[idx]...) + instruments[idx].(int64Observer).Observe(ctx, int64(samp.Value.Uint64()), instAttrs[idx]...) case metrics.KindFloat64: - instruments[idx].(float64Observer).Observe(ctx, samp.Value.Float64(), totalAttrs[idx]...) + instruments[idx].(float64Observer).Observe(ctx, samp.Value.Float64(), instAttrs[idx]...) default: // KindFloat64Histogram (unsupported in OTel) and KindBad // (unsupported by runtime/metrics). Neither should happen diff --git a/lightstep/instrumentation/runtime/builtin_118_test.go b/lightstep/instrumentation/runtime/builtin_118_test.go deleted file mode 100644 index 2e5e9858..00000000 --- a/lightstep/instrumentation/runtime/builtin_118_test.go +++ /dev/null @@ -1,30 +0,0 @@ -// 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. - -//go:build go1.18 && !go1.19 - -package runtime - -var expectRuntimeMetrics = map[string]int{ - "gc.cycles": 2, - "gc.heap.allocs": 1, - "gc.heap.allocs.objects": 1, - "gc.heap.frees": 1, - "gc.heap.frees.objects": 1, - "gc.heap.goal": 1, - "gc.heap.objects": 1, - "gc.heap.tiny.allocs": 1, - "memory.usage": 13, - "sched.goroutines": 1, -} diff --git a/lightstep/instrumentation/runtime/builtin_119_test.go b/lightstep/instrumentation/runtime/builtin_119_test.go deleted file mode 100644 index 886c911f..00000000 --- a/lightstep/instrumentation/runtime/builtin_119_test.go +++ /dev/null @@ -1,34 +0,0 @@ -// 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. - -//go:build go1.19 - -package runtime - -var expectRuntimeMetrics = map[string]int{ - "cgo.go-to-c-calls": 1, - "gc.cycles": 2, - "gc.heap.allocs": 1, - "gc.heap.allocs.objects": 1, - "gc.heap.frees": 1, - "gc.heap.frees.objects": 1, - "gc.heap.goal": 1, - "gc.heap.objects": 1, - "gc.heap.tiny.allocs": 1, - "gc.limiter.last-enabled": 1, - "gc.stack.starting-size": 1, - "memory.usage": 13, - "sched.gomaxprocs": 1, - "sched.goroutines": 1, -} diff --git a/lightstep/instrumentation/runtime/builtin_test.go b/lightstep/instrumentation/runtime/builtin_test.go index 7955d0b3..0da0517c 100644 --- a/lightstep/instrumentation/runtime/builtin_test.go +++ b/lightstep/instrumentation/runtime/builtin_test.go @@ -4,17 +4,19 @@ // 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 +// 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 runtime import ( "context" + "fmt" "runtime/metrics" "strings" "testing" @@ -22,76 +24,43 @@ import ( "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/sdk/instrumentation" + "go.opentelemetry.io/otel/metric/unit" "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/metric/metricdata" ) -// prefix is mandatory for this library, however the "go." part is not. -const expectPrefix = "process.runtime.go." +const ( + classKey = attribute.Key("class") + subclassKey = attribute.Key("class2") + subsubclassKey = attribute.Key("class3") +) -var expectScope = instrumentation.Scope{ - Name: "otel-launcher-go/runtime", +func TestMetricTranslation1(t *testing.T) { + testMetricTranslation(t, makeTestCase1) } -// TestBuiltinRuntimeMetrics tests the real output of the library to -// ensure expected prefix, instrumentation scope, and empty -// attributes. -func TestBuiltinRuntimeMetrics(t *testing.T) { - reader := metric.NewManualReader() - provider := metric.NewMeterProvider(metric.WithReader(reader)) - - err := Start(WithMeterProvider(provider)) - require.NoError(t, err) - - data, err := reader.Collect(context.Background()) - require.NoError(t, err) - - require.Equal(t, 1, len(data.ScopeMetrics)) - require.Equal(t, expectScope, data.ScopeMetrics[0].Scope) - - expect := expectRuntimeMetrics - allNames := map[string]int{} - - // Note: metrictest library lacks a way to distinguish - // monotonic vs not or to test the unit. This will be fixed in - // the new SDK, all the pieces untested here. - for _, inst := range data.ScopeMetrics[0].Metrics { - require.True(t, strings.HasPrefix(inst.Name, expectPrefix), "%s", inst.Name) - name := inst.Name[len(expectPrefix):] - var attrs attribute.Set - switch dt := inst.Data.(type) { - case metricdata.Gauge[int64]: - require.Equal(t, 1, len(dt.DataPoints)) - attrs = dt.DataPoints[0].Attributes - case metricdata.Gauge[float64]: - require.Equal(t, 1, len(dt.DataPoints)) - attrs = dt.DataPoints[0].Attributes - case metricdata.Sum[int64]: - require.Equal(t, 1, len(dt.DataPoints)) - attrs = dt.DataPoints[0].Attributes - case metricdata.Sum[float64]: - require.Equal(t, 1, len(dt.DataPoints)) - attrs = dt.DataPoints[0].Attributes - } - - if expect[name] > 1 { - require.Equal(t, 1, attrs.Len()) - } else { - require.Equal(t, 1, expect[name], "for %v", inst.Name) - require.Equal(t, 0, attrs.Len()) - } - allNames[name]++ - } +func TestMetricTranslation2(t *testing.T) { + testMetricTranslation(t, makeTestCase2) +} - require.Equal(t, expect, allNames) +func TestMetricTranslationBuiltin(t *testing.T) { + testMetricTranslation(t, makeTestCaseBuiltin) } -func makeTestCase() (allFunc, readFunc, map[string]map[string]metrics.Value) { +// makeAllInts retrieves real metric.Values. We use real +// runtime/metrics values b/c these can't be constructed outside the +// library. +// +// Note that all current metrics through go-1.20 are either histogram +// or integer valued, so although Float64 values are supported in +// theory, they are not tested _and can't be tested because the +// library never produces them_. +func makeAllInts() (allInts, allFloats []metrics.Value) { // Note: the library provides no way to generate values, so use the // builtin library to get some. Since we can't generate a Float64 value // we can't even test the Gauge logic in this package. ints := map[metrics.Value]bool{} + floats := map[metrics.Value]bool{} real := metrics.All() realSamples := make([]metrics.Sample, len(real)) @@ -103,140 +72,449 @@ func makeTestCase() (allFunc, readFunc, map[string]map[string]metrics.Value) { switch real[i].Kind { case metrics.KindUint64: ints[rs.Value] = true + case metrics.KindFloat64: + floats[rs.Value] = true default: - // Histograms and Floats are not tested. - // The 1.19 runtime generates no Floats and - // exports no test constructors. + // Histograms are not tested. } } - - var allInts []metrics.Value - for iv := range ints { allInts = append(allInts, iv) } + for fv := range floats { + allFloats = append(allFloats, fv) + } + return allInts, allFloats +} + +// testMapping implements a synthetic metrics reader. +type testMapping map[string]metrics.Value + +// read is like metrics.Read w/ synthetic data. +func (m testMapping) read(samples []metrics.Sample) { + for i := range samples { + v, ok := m[samples[i].Name] + if ok { + samples[i].Value = v + } else { + panic("outcome uncertain") + } + } +} + +// readFrom turns real runtime/metrics data into a test expectation. +func (m testMapping) readFrom(af allFunc, rf readFunc) { + all := af() + samples := make([]metrics.Sample, len(all)) + for i := range all { + switch all[i].Kind { + case metrics.KindUint64, metrics.KindFloat64: + default: + continue + } + samples[i].Name = all[i].Name + } + rf(samples) + for i := range samples { + m[samples[i].Name] = samples[i].Value + } +} + +// testExpectation allows validating the behavior using +// hand-constructed test cases. +type testExpectation map[string]*testExpectMetric + +// testExpectMetric sets a test expectation consisting of name, unit, +// and known cardinal values. +type testExpectMetric struct { + desc string + unit unit.Unit + kind builtinKind + vals map[attribute.Set]metrics.Value +} + +// makeTestCase1 covers the following cases: +// - single counter, updowncounter, gauge +// - bytes/objects counter +// - classes counter (gc-cycles) +func makeTestCase1(t *testing.T) (allFunc, readFunc, *builtinDescriptor, testExpectation) { + allInts, _ := makeAllInts() af := func() []metrics.Description { return []metrics.Description{ { - Name: "/cntr/things:things", - Description: "a counter of things", - Kind: metrics.KindUint64, - Cumulative: true, + Name: "/cntr/things:things", + Kind: metrics.KindUint64, + Cumulative: true, + }, + { + Name: "/updowncntr/things:things", + Kind: metrics.KindUint64, + Cumulative: false, + }, + { + Name: "/process/count:objects", + Kind: metrics.KindUint64, + Cumulative: true, + }, + { + Name: "/cpu/temp:C", + Kind: metrics.KindUint64, // TODO: post Go-1.20 make this Float64 + Cumulative: false, + }, + { + Name: "/process/count:bytes", + Kind: metrics.KindUint64, + Cumulative: true, + }, + { + Name: "/waste/cycles/ocean:gc-cycles", + Kind: metrics.KindUint64, + Cumulative: true, + }, + { + Name: "/waste/cycles/sea:gc-cycles", + Kind: metrics.KindUint64, + Cumulative: true, + }, + { + Name: "/waste/cycles/lake:gc-cycles", + Kind: metrics.KindUint64, + Cumulative: true, }, { - Name: "/updowncntr/things:things", - Description: "an updowncounter of things", - Kind: metrics.KindUint64, - Cumulative: false, + Name: "/waste/cycles/pond:gc-cycles", + Kind: metrics.KindUint64, + Cumulative: true, }, { - Name: "/process/count:objects", - Description: "a process counter of objects", - Kind: metrics.KindUint64, - Cumulative: true, + Name: "/waste/cycles/puddle:gc-cycles", + Kind: metrics.KindUint64, + Cumulative: true, }, { - Name: "/process/count:bytes", - Description: "a process counter of bytes", - Kind: metrics.KindUint64, - Cumulative: true, + Name: "/waste/cycles/total:gc-cycles", + Kind: metrics.KindUint64, + Cumulative: true, }, } } - mapping := map[string]metrics.Value{ - "/cntr/things:things": allInts[0], - "/updowncntr/things:things": allInts[1], - "/process/count:objects": allInts[2], - "/process/count:bytes": allInts[3], - "/waste/cycles/ocean:cycles": allInts[4], - "/waste/cycles/sea:cycles": allInts[5], - "/waste/cycles/lake:cycles": allInts[6], - "/waste/cycles/pond:cycles": allInts[7], - "/waste/cycles/puddle:cycles": allInts[8], - "/waste/cycles/total:cycles": allInts[9], - } - rf := func(samples []metrics.Sample) { - for i := range samples { - v, ok := mapping[samples[i].Name] - if ok { - samples[i].Value = v - } else { - panic("outcome uncertain") - } + mapping := testMapping{ + "/cntr/things:things": allInts[0], + "/updowncntr/things:things": allInts[1], + "/process/count:objects": allInts[2], + "/process/count:bytes": allInts[3], + "/waste/cycles/ocean:gc-cycles": allInts[4], + "/waste/cycles/sea:gc-cycles": allInts[5], + "/waste/cycles/lake:gc-cycles": allInts[6], + "/waste/cycles/pond:gc-cycles": allInts[7], + "/waste/cycles/puddle:gc-cycles": allInts[8], + "/waste/cycles/total:gc-cycles": allInts[9], + + // Note: this would be a nice float test, but 1.19 doesn't have + // any, so we wait for this repo's min Go version to support a + // metrics.KindFloat64 value for testing. + "/cpu/temp:C": allInts[10], + } + bd := newBuiltinDescriptor() + bd.singleCounter("/cntr/things:things") + bd.singleUpDownCounter("/updowncntr/things:things") + bd.singleGauge("/cpu/temp:C") + bd.objectBytesCounter("/process/count:*") + bd.classesCounter("/waste/cycles/*:gc-cycles") + return af, mapping.read, bd, testExpectation{ + "cntr.things": &testExpectMetric{ + unit: "{things}", + desc: "/cntr/things:things from runtime/metrics", + kind: builtinCounter, + vals: map[attribute.Set]metrics.Value{ + emptySet: allInts[0], + }, + }, + "updowncntr.things": &testExpectMetric{ + unit: "{things}", + desc: "/updowncntr/things:things from runtime/metrics", + kind: builtinUpDownCounter, + vals: map[attribute.Set]metrics.Value{ + emptySet: allInts[1], + }, + }, + "process.count.objects": &testExpectMetric{ + unit: "", + desc: "/process/count:objects from runtime/metrics", + kind: builtinCounter, + vals: map[attribute.Set]metrics.Value{ + emptySet: allInts[2], + }, + }, + "process.count": &testExpectMetric{ + unit: unit.Bytes, + kind: builtinCounter, + desc: "/process/count:bytes from runtime/metrics", + vals: map[attribute.Set]metrics.Value{ + emptySet: allInts[3], + }, + }, + "waste.cycles": &testExpectMetric{ + unit: "{gc-cycles}", + desc: "/waste/cycles/*:gc-cycles from runtime/metrics", + kind: builtinCounter, + vals: map[attribute.Set]metrics.Value{ + attribute.NewSet(classKey.String("ocean")): allInts[4], + attribute.NewSet(classKey.String("sea")): allInts[5], + attribute.NewSet(classKey.String("lake")): allInts[6], + attribute.NewSet(classKey.String("pond")): allInts[7], + attribute.NewSet(classKey.String("puddle")): allInts[8], + }, + }, + "cpu.temp": &testExpectMetric{ + // This is made-up. We don't recognize this + // unit, code defaults to pseudo-units. + unit: "{C}", + kind: builtinGauge, + desc: "/cpu/temp:C from runtime/metrics", + vals: map[attribute.Set]metrics.Value{ + emptySet: allInts[10], + }, + }, + } +} + +// makeTestCase2 covers the following cases: +// - classes counter (bytes) +// - classes counter (cpu-seconds) +func makeTestCase2(t *testing.T) (allFunc, readFunc, *builtinDescriptor, testExpectation) { + allInts, _ := makeAllInts() + + af := func() []metrics.Description { + return []metrics.Description{ + // classes (bytes) + { + Name: "/objsize/classes/presos:bytes", + Kind: metrics.KindUint64, + Cumulative: false, + }, + { + Name: "/objsize/classes/sheets:bytes", + Kind: metrics.KindUint64, + Cumulative: false, + }, + { + Name: "/objsize/classes/docs/word:bytes", + Kind: metrics.KindUint64, + Cumulative: false, + }, + { + Name: "/objsize/classes/docs/pdf:bytes", + Kind: metrics.KindUint64, + Cumulative: false, + }, + { + Name: "/objsize/classes/docs/total:bytes", + Kind: metrics.KindUint64, + Cumulative: false, + }, + { + Name: "/objsize/classes/total:bytes", + Kind: metrics.KindUint64, + Cumulative: false, + }, + // classes (time) + { + Name: "/socchip/classes/pru:cpu-seconds", + Kind: metrics.KindUint64, + Cumulative: true, + }, + { + Name: "/socchip/classes/dsp:cpu-seconds", + Kind: metrics.KindUint64, + Cumulative: true, + }, + { + Name: "/socchip/classes/total:cpu-seconds", + Kind: metrics.KindUint64, + Cumulative: true, + }, } } - return af, rf, map[string]map[string]metrics.Value{ - "cntr.things": {"": allInts[0]}, - "updowncntr.things": {"": allInts[1]}, - "process.count.objects": {"": allInts[2]}, - "process.count": {"": allInts[3]}, - - // This uses "cycles", one of the two known - // multi-variate metrics as of go-1.19. - "waste.cycles": { - "ocean": allInts[4], - "sea": allInts[5], - "lake": allInts[6], - "pond": allInts[7], - "puddle": allInts[8], + mapping := testMapping{ + "/objsize/classes/presos:bytes": allInts[0], + "/objsize/classes/sheets:bytes": allInts[1], + "/objsize/classes/docs/word:bytes": allInts[2], + "/objsize/classes/docs/pdf:bytes": allInts[3], + "/objsize/classes/docs/total:bytes": allInts[4], + "/objsize/classes/total:bytes": allInts[5], + "/socchip/classes/pru:cpu-seconds": allInts[6], + "/socchip/classes/dsp:cpu-seconds": allInts[7], + "/socchip/classes/total:cpu-seconds": allInts[8], + } + bd := newBuiltinDescriptor() + bd.classesUpDownCounter("/objsize/classes/*:bytes") + bd.classesCounter("/socchip/classes/*:cpu-seconds") + return af, mapping.read, bd, testExpectation{ + "objsize.usage": &testExpectMetric{ + unit: unit.Bytes, + desc: "/objsize/classes/*:bytes from runtime/metrics", + kind: builtinUpDownCounter, + vals: map[attribute.Set]metrics.Value{ + attribute.NewSet(classKey.String("presos")): allInts[0], + attribute.NewSet(classKey.String("sheets")): allInts[1], + attribute.NewSet(classKey.String("docs"), subclassKey.String("word")): allInts[2], + attribute.NewSet(classKey.String("docs"), subclassKey.String("pdf")): allInts[3], + }, + }, + "socchip.time": &testExpectMetric{ + unit: "{cpu-seconds}", + desc: "/socchip/classes/*:cpu-seconds from runtime/metrics", + kind: builtinCounter, + vals: map[attribute.Set]metrics.Value{ + attribute.NewSet(classKey.String("pru")): allInts[6], + attribute.NewSet(classKey.String("dsp")): allInts[7], + }, }, } } -// TestMetricTranslation validates the translation logic using -// synthetic metric names and values. -func TestMetricTranslation(t *testing.T) { +// makeTestCaseBuiltin fabricates a test expectation from the +// version-specific portion of the descriptor, synthesizes values and +// checks the result for a match. +func makeTestCaseBuiltin(t *testing.T) (allFunc, readFunc, *builtinDescriptor, testExpectation) { + testMap := testMapping{} + testMap.readFrom(metrics.All, metrics.Read) + + realDesc := expectRuntimeMetrics() + + expect := testExpectation{} + + for goname, realval := range testMap { + mname, munit, descPat, attrs, kind, err := realDesc.findMatch(goname) + if err != nil || mname == "" { + continue // e.g., async histogram data, totalized metrics + } + noprefix := mname[len(namePrefix)+1:] + te, ok := expect[noprefix] + if !ok { + te = &testExpectMetric{ + desc: fmt.Sprint(descPat, " from runtime/metrics"), + unit: unit.Unit(munit), + kind: kind, + vals: map[attribute.Set]metrics.Value{}, + } + expect[noprefix] = te + } + te.vals[attribute.NewSet(attrs...)] = realval + } + + return metrics.All, testMap.read, realDesc, expect +} + +// testMetricTranslation registers the metrics allFunc and readFunc +// functions using the descriptor and validates the test expectation. +func testMetricTranslation(t *testing.T, makeTestCase func(t *testing.T) (allFunc, readFunc, *builtinDescriptor, testExpectation)) { reader := metric.NewManualReader() provider := metric.NewMeterProvider(metric.WithReader(reader)) - af, rf, mapping := makeTestCase() + af, rf, desc, expectation := makeTestCase(t) br := newBuiltinRuntime(provider.Meter("test"), af, rf) - err := br.register() + err := br.register(desc) require.NoError(t, err) - expectRecords := 0 - for _, values := range mapping { - expectRecords += len(values) - if len(values) > 1 { - // Counts the total - expectRecords++ - } - } - data, err := reader.Collect(context.Background()) require.NoError(t, err) - require.Equal(t, 10, expectRecords) + + require.Equal(t, 1, len(data.ScopeMetrics)) require.Equal(t, 1, len(data.ScopeMetrics)) require.Equal(t, "test", data.ScopeMetrics[0].Scope.Name) + // Compare the name sets, to make the test output readable. + haveNames := map[string]bool{} + expectNames := map[string]bool{} + + for _, m := range data.ScopeMetrics[0].Metrics { + require.True(t, strings.HasPrefix(m.Name, namePrefix)) + haveNames[m.Name[len(namePrefix)+1:]] = true + } + for n := range expectation { + expectNames[n] = true + } + require.Equal(t, expectNames, haveNames) + for _, inst := range data.ScopeMetrics[0].Metrics { - // Test the special cases are present always: + // Test name, description, and unit. + require.True(t, strings.HasPrefix(inst.Name, namePrefix+"."), "%s", inst.Name) - require.True(t, strings.HasPrefix(inst.Name, expectPrefix), "%s", inst.Name) - name := inst.Name[len(expectPrefix):] + name := inst.Name[len(namePrefix)+1:] + exm := expectation[name] - require.Equal(t, 1, len(inst.Data.(metricdata.Sum[int64]).DataPoints)) + require.Equal(t, exm.desc, inst.Description) + require.Equal(t, exm.unit, inst.Unit) - sum := inst.Data.(metricdata.Sum[int64]).DataPoints[0].Value - attrs := inst.Data.(metricdata.Sum[int64]).DataPoints[0].Attributes + // The counter and gauge branches do make the same + // checks, just have to be split b/c the underlying + // types are different. + switch exm.kind { + case builtinCounter, builtinUpDownCounter: + // Handle both int/float cases. Note: If we could write + // in-line generic code, this could be less repetitive. + if _, isInt := inst.Data.(metricdata.Sum[int64]); isInt { + // Integer - // Note: only int64 is tested, we have no way to - // generate Float64 values and Float64Hist values are - // not implemented for testing. - m := mapping[name] - if len(m) == 1 { - require.Equal(t, mapping[name][""].Uint64(), uint64(sum)) + _, isSum := inst.Data.(metricdata.Sum[int64]) + // Expect a sum data point w/ correct monotonicity. + require.True(t, isSum, "%v", exm) + require.Equal(t, exm.kind == builtinCounter, inst.Data.(metricdata.Sum[int64]).IsMonotonic, "%v", exm) + require.Equal(t, metricdata.CumulativeTemporality, inst.Data.(metricdata.Sum[int64]).Temporality, "%v", exm) - // no attributes - require.Equal(t, 0, attrs.Len()) - } else { - require.Equal(t, 5, len(m)) - require.Equal(t, 1, attrs.Len()) - require.Equal(t, attrs.ToSlice()[0].Key, "class") - feature := attrs.ToSlice()[0].Value.AsString() - require.Equal(t, mapping[name][feature].Uint64(), uint64(sum)) + // Check expected values. + for _, point := range inst.Data.(metricdata.Sum[int64]).DataPoints { + lookup, ok := exm.vals[point.Attributes] + require.True(t, ok, "lookup failed: %v: %v", exm.vals, point.Attributes) + require.Equal(t, lookup.Uint64(), uint64(point.Value)) + } + } else { + // Floating point + + _, isSum := inst.Data.(metricdata.Sum[float64]) + // Expect a sum data point w/ correct monotonicity. + require.True(t, isSum, "%v", exm) + require.Equal(t, exm.kind == builtinCounter, inst.Data.(metricdata.Sum[float64]).IsMonotonic, "%v", exm) + require.Equal(t, metricdata.CumulativeTemporality, inst.Data.(metricdata.Sum[float64]).Temporality, "%v", exm) + + // Check expected values. + for _, point := range inst.Data.(metricdata.Sum[float64]).DataPoints { + lookup, ok := exm.vals[point.Attributes] + require.True(t, ok, "lookup failed: %v: %v", exm.vals, point.Attributes) + require.Equal(t, lookup.Float64(), float64(point.Value)) + } + } + case builtinGauge: + if _, isInt := inst.Data.(metricdata.Gauge[int64]); isInt { + // Integer + _, isGauge := inst.Data.(metricdata.Gauge[int64]) + require.True(t, isGauge, "%v", exm) + + // Check expected values. + for _, point := range inst.Data.(metricdata.Gauge[int64]).DataPoints { + lookup, ok := exm.vals[point.Attributes] + require.True(t, ok, "lookup failed: %v: %v", exm.vals, point.Attributes) + require.Equal(t, lookup.Uint64(), uint64(point.Value)) + } + } else { + // Floating point + _, isGauge := inst.Data.(metricdata.Gauge[float64]) + require.True(t, isGauge, "%v", exm) + + // Check expected values. + for _, point := range inst.Data.(metricdata.Gauge[float64]).DataPoints { + lookup, ok := exm.vals[point.Attributes] + require.True(t, ok, "lookup failed: %v: %v", exm.vals, point.Attributes) + require.Equal(t, lookup.Float64(), float64(point.Value)) + } + } + default: + t.Errorf("unexpected runtimes/metric test case: %v", exm) + continue } } } diff --git a/lightstep/instrumentation/runtime/defs.go b/lightstep/instrumentation/runtime/defs.go new file mode 100644 index 00000000..5767f05f --- /dev/null +++ b/lightstep/instrumentation/runtime/defs.go @@ -0,0 +1,38 @@ +// 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 runtime + +func expectRuntimeMetrics() *builtinDescriptor { + bd := newBuiltinDescriptor() + bd.classesCounter("/cpu/classes/*:cpu-seconds") + bd.classesCounter("/gc/cycles/*:gc-cycles") + bd.classesUpDownCounter("/memory/classes/*:bytes") + bd.ignoreHistogram("/gc/heap/allocs-by-size:bytes") + bd.ignoreHistogram("/gc/heap/frees-by-size:bytes") + bd.ignoreHistogram("/gc/pauses:seconds") + bd.ignoreHistogram("/sched/latencies:seconds") + bd.objectBytesCounter("/gc/heap/allocs:*") + bd.objectBytesCounter("/gc/heap/frees:*") + bd.singleCounter("/cgo/go-to-c-calls:calls") + bd.singleCounter("/gc/heap/tiny/allocs:objects") + bd.singleCounter("/sync/mutex/wait/total:seconds") + bd.singleGauge("/gc/heap/goal:bytes") + bd.singleGauge("/gc/limiter/last-enabled:gc-cycle") + bd.singleGauge("/gc/stack/starting-size:bytes") + bd.singleGauge("/sched/gomaxprocs:threads") + bd.singleUpDownCounter("/gc/heap/objects:objects") + bd.singleUpDownCounter("/sched/goroutines:goroutines") + return bd +} diff --git a/lightstep/instrumentation/runtime/descriptor.go b/lightstep/instrumentation/runtime/descriptor.go new file mode 100644 index 00000000..647a4d25 --- /dev/null +++ b/lightstep/instrumentation/runtime/descriptor.go @@ -0,0 +1,236 @@ +// 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 runtime + +import ( + "fmt" + "strings" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric/unit" +) + +type builtinKind int + +var ( + emptySet = attribute.NewSet() + + ErrUnmatchedBuiltin = fmt.Errorf("builtin unmatched") + ErrOvermatchedBuiltin = fmt.Errorf("builtin overmatched") +) + +const ( + builtinSkip builtinKind = iota + builtinCounter + builtinObjectBytesCounter + builtinUpDownCounter + builtinGauge + builtinHistogram +) + +type builtinMetricFamily struct { + pattern string + matches int + kind builtinKind +} + +type builtinDescriptor struct { + families []*builtinMetricFamily +} + +func (k builtinKind) String() string { + switch k { + case builtinCounter: + return "counter" + case builtinObjectBytesCounter: + return "object/bytes counter" + case builtinUpDownCounter: + return "up/down counter" + case builtinGauge: + return "gauge" + case builtinHistogram: + return "histogram" + case builtinSkip: + } + return "skipped" +} + +func toOTelNameAndStatedUnit(nameAndUnit string) (on, un string) { + on, un, _ = strings.Cut(nameAndUnit, ":") + return toOTelName(on), un +} + +func toOTelName(name string) string { + return namePrefix + strings.ReplaceAll(name, "/", ".") +} + +// attributeName returns "class", "class2", "class3", ... +func attributeName(order int) string { + if order == 0 { + return "class" + } + return fmt.Sprintf("class%d", order+1) +} + +func newBuiltinDescriptor() *builtinDescriptor { + return &builtinDescriptor{} +} + +func (bd *builtinDescriptor) add(pattern string, kind builtinKind) { + bd.families = append(bd.families, &builtinMetricFamily{ + pattern: pattern, + kind: kind, + }) +} + +func (bd *builtinDescriptor) singleCounter(pattern string) { + bd.add(pattern, builtinCounter) +} + +func (bd *builtinDescriptor) classesCounter(pattern string) { + bd.add(pattern, builtinCounter) +} + +func (bd *builtinDescriptor) classesUpDownCounter(pattern string) { + bd.add(pattern, builtinUpDownCounter) +} + +func (bd *builtinDescriptor) objectBytesCounter(pattern string) { + bd.add(pattern, builtinObjectBytesCounter) +} + +func (bd *builtinDescriptor) singleUpDownCounter(pattern string) { + bd.add(pattern, builtinUpDownCounter) +} + +func (bd *builtinDescriptor) singleGauge(pattern string) { + bd.add(pattern, builtinGauge) +} + +func (bd *builtinDescriptor) ignoreHistogram(pattern string) { + bd.add(pattern, builtinHistogram) +} + +func (bd *builtinDescriptor) findMatch(goname string) (mname, munit, descPattern string, attrs []attribute.KeyValue, kind builtinKind, _ error) { + fam, err := bd.findFamily(goname) + if err != nil { + return "", "", "", nil, builtinSkip, err + } + fam.matches++ + + kind = fam.kind + + // Set the name, unit and pattern. + if wildCnt := strings.Count(fam.pattern, "*"); wildCnt == 0 { + mname, munit = toOTelNameAndStatedUnit(goname) + descPattern = goname + } else if strings.HasSuffix(fam.pattern, ":*") { + // Special case for bytes/objects w/ same prefix: two + // counters, different names. One has "By" (UCUM for + // "bytes") units and no suffix. One has no units and + // a ".objects" suffix. (In Prometheus, this becomes + // _objects and _bytes as you would expect.) + mname, munit = toOTelNameAndStatedUnit(goname) + descPattern = goname + kind = builtinCounter + if munit == "objects" { + mname += "." + munit + munit = "" + } + } else { + pfx, sfx, _ := strings.Cut(fam.pattern, "/*:") + mname = toOTelName(pfx) + munit = sfx + asubstr := goname[len(pfx):] + asubstr = asubstr[1 : len(asubstr)-len(sfx)-1] + splitVals := strings.Split(asubstr, "/") + for order, val := range splitVals { + attrs = append(attrs, attribute.Key(attributeName(order)).String(val)) + } + // Ignore subtotals + if splitVals[len(splitVals)-1] == "total" { + return "", "", "", nil, builtinSkip, nil + } + descPattern = fam.pattern + } + + // Fix the units for UCUM. + switch munit { + case "bytes": + munit = string(unit.Bytes) + case "seconds": + munit = "s" + case "": + default: + // Pseudo-units + munit = "{" + munit + "}" + } + + // Fix the name if it ends in ".classes" + if strings.HasSuffix(mname, ".classes") { + + s := mname[:len(mname)-len("classes")] + + // Note that ".classes" is (apparently) intended as a generic + // suffix, while ".cycles" is an exception. + // The ideal name depends on what we know. + switch munit { + case "By": + // OTel has similar conventions for memory usage, disk usage, etc, so + // for metrics with /classes/*:bytes we create a .usage metric. + mname = s + "usage" + case "{cpu-seconds}": + // Same argument above, except OTel uses .time for + // cpu-timing metrics instead of .usage. + mname = s + "time" + } + } + + // Note: we may be returning the special builtinObjectBytes. + // if it was not fixed for patterns w/ trailing wildcard (see above). + return mname, munit, descPattern, attrs, kind, err +} + +func (bd *builtinDescriptor) findFamily(name string) (family *builtinMetricFamily, _ error) { + matches := 0 + + for _, f := range bd.families { + pat := f.pattern + wilds := strings.Count(pat, "*") + if wilds > 1 { + return nil, fmt.Errorf("too many wildcards: %s", pat) + } + if wilds == 0 && name == pat { + matches++ + family = f + continue + } + pfx, sfx, _ := strings.Cut(pat, "*") + + if len(name) > len(pat) && strings.HasPrefix(name, pfx) && strings.HasSuffix(name, sfx) { + matches++ + family = f + continue + } + } + if matches == 0 { + return nil, fmt.Errorf("%s: %w", name, ErrUnmatchedBuiltin) + } + if matches > 1 { + return nil, fmt.Errorf("%s: %w", name, ErrOvermatchedBuiltin) + } + family.matches++ + return family, nil +} diff --git a/lightstep/instrumentation/runtime/doc.go b/lightstep/instrumentation/runtime/doc.go index 8adfa436..be144bdb 100644 --- a/lightstep/instrumentation/runtime/doc.go +++ b/lightstep/instrumentation/runtime/doc.go @@ -12,51 +12,68 @@ // See the License for the specific language governing permissions and // limitations under the License. -// package runtime geneartes metrics run the Golang runtime/metrics package. -// -// There are two special policies that are used to translate these -// metrics into the OpenTelemetry model. -// -// 1. The runtime/metrics name is split into its name and unit part; -// when there are two metrics with the same name and different -// units, the only known case is where "objects" and "bytes" are -// present. In this case, the outputs are a unitless metric (with -// suffix, e.g., ending `gc.heap.allocs.objects`) and a unitful -// metric with no suffix (e.g., ending `gc.heap.allocs` having -// bytes units). -// 2. When there are >= 2 metrics with the same prefix and one -// matching `prefix.total`, the total is skipped and the other -// members are assembled into a single Counter or UpDownCounter -// metric with multiple attribute values. The supported cases -// are for `class` and `cycle` attributes. -// -// The following metrics are generated in go-1.19. -// -// Name Unit Instrument -// ------------------------------------------------------------------------------------ -// process.runtime.go.cgo.go-to-c-calls {calls} Counter[int64] -// process.runtime.go.gc.cycles{cycle=forced,automatic} {gc-cycles} Counter[int64] -// process.runtime.go.gc.heap.allocs bytes (*) Counter[int64] -// process.runtime.go.gc.heap.allocs.objects {objects} (*) Counter[int64] -// process.runtime.go.gc.heap.allocs-by-size bytes Histogram[float64] (**) -// process.runtime.go.gc.heap.frees bytes (*) Counter[int64] -// process.runtime.go.gc.heap.frees.objects {objects} (*) Counter[int64] -// process.runtime.go.gc.heap.frees-by-size bytes Histogram[float64] (**) -// process.runtime.go.gc.heap.goal bytes UpDownCounter[int64] -// process.runtime.go.gc.heap.objects {objects} UpDownCounter[int64] -// process.runtime.go.gc.heap.tiny.allocs {objects} Counter[int64] -// process.runtime.go.gc.limiter.last-enabled {gc-cycle} UpDownCounter[int64] -// process.runtime.go.gc.pauses seconds Histogram[float64] (**) -// process.runtime.go.gc.stack.starting-size bytes UpDownCounter[int64] -// process.runtime.go.memory.usage{class=...} bytes UpDownCounter[int64] -// process.runtime.go.sched.gomaxprocs {threads} UpDownCounter[int64] -// process.runtime.go.sched.goroutines {goroutines} UpDownCounter[int64] -// process.runtime.go.sched.latencies seconds GaugeHistogram[float64] (**) -// -// (*) Empty unit strings are cases where runtime/metric produces -// duplicate names ignoring the unit string (see policy #1). -// (**) Histograms are not currently implemented, see the related -// issues for an explanation: +// package runtime generates metrics from the Golang runtime/metrics package. +// +// There are several conventions used to translate these metrics into +// the OpenTelemetry model. Builtin metrics are defined in terms of +// the expected OpenTelemetry instrument kind in defs.go. +// +// 1. Single Counter, UpDownCounter, and Gauge instruments. No +// wildcards are used. For example: +// +// /cgo/go-to-c-calls:calls +// +// becomes: +// +// process.runtime.go.cgo.go-to-c-calls (unit: {calls}) +// +// 2. Objects/Bytes Counter. There are two runtime/metrics with the +// same name and different units. The objects counter has a suffix, +// the bytes counter has a unit, to disambiguate. For example: +// +// /gc/heap/allocs:* +// +// becomes: +// +// process.runtime.go.gc.heap.allocs (unit: bytes) +// process.runtime.go.gc.heap.allocs.objects (unitless) +// +// 3. Multi-dimensional Counter/UpDownCounter (generally), ignore any +// "total" elements to avoid double-counting. For example: +// +// /gc/cycles/*:gc-cycles +// +// becomes: +// +// process.runtime.go.gc.cycles (unit: gc-cycles) +// +// with two attribute setes: +// +// class=automatic +// class=forced +// +// 4. Multi-dimensional Counter/UpDownCounter (named ".classes"), map +// to ".usage" for bytes and ".time" for cpu-seconds. For example: +// +// /cpu/classes/*:cpu-seconds +// +// becomes: +// +// process.runtime.go.cpu.time (unit: cpu-seconds) +// +// with multi-dimensional attributes: +// +// class=gc,class2=mark,class3=assist +// class=gc,class2=mark,class3=dedicated +// class=gc,class2=mark,class3=idle +// class=gc,class2=pause +// class=scavenge,class2=assist +// class=scavenge,class2=background +// class=idle +// class=user +// +// Histograms are not currently implemented, see the related issues +// for an explanation: // https://github.com/open-telemetry/opentelemetry-specification/issues/2713 // https://github.com/open-telemetry/opentelemetry-specification/issues/2714