otlp.go (618 lines) keeps the queues and the sender; the endpoint configuration, the slog handler and the metrics building move to files of their own, and building metrics is split into the per-second frame windows, the operation histogram and the counters. TestOTLPExport checks requests, logs, spans and metrics in a helper each.
- commit
- f1d433a8f642e15bad354c6e9274f66bcabbb834
- parent
- d794ca3b51d738c9df1528b416c433d333e42d6b
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 286 | +------------------------------ |
| M | internal/ | 28 | +++ |
| A | internal/ | 120 | +++++++++++++ |
| A | internal/ | 67 | +++++++ |
| A | internal/ | 133 | ++++++++++++++ |
@@ -12,12 +12,8 @@ import (
1212 "maps"1313 "math/rand/v2"1414 "net/http"15- "net/url"16- "os"1715 "runtime"1816 "slices"19- "strconv"20- "strings"2117 "sync"2218 "time"2319 )@@ -28,117 +24,10 @@ import (
2824 // operation durations as metrics. A background goroutine batches them2925 // from bounded queues; when a queue is full the newest items are dropped3026 // and counted, so the UI never waits on the network.31-32-// OTLPConfig says where OTLP goes. ConfigFromEnv reads it from the33-// standard OTEL_* variables. Nothing is sent unless an endpoint is set.34-type OTLPConfig struct {35- // Endpoint is the base URL, like http://localhost:4318, to which36- // /v1/logs, /v1/traces and /v1/metrics are appended37- // (OTEL_EXPORTER_OTLP_ENDPOINT, or 012 --otlp).38- Endpoint string39- // LogsEndpoint, TracesEndpoint and MetricsEndpoint are full URLs for40- // one signal each, used as they are41- // (OTEL_EXPORTER_OTLP_{LOGS,TRACES,METRICS}_ENDPOINT).42- LogsEndpoint, TracesEndpoint, MetricsEndpoint string43- // NoLogs, NoTraces and NoMetrics leave a signal out44- // (OTEL_{LOGS,TRACES,METRICS}_EXPORTER=none).45- NoLogs, NoTraces, NoMetrics bool46- // Headers go with every request, e.g. for authentication47- // (OTEL_EXPORTER_OTLP_HEADERS). They are never logged.48- Headers map[string]string49- // Resource adds resource attributes (OTEL_RESOURCE_ATTRIBUTES);50- // service.name stays 012.51- Resource map[string]string52- // Timeout bounds each request (OTEL_EXPORTER_OTLP_TIMEOUT, in53- // milliseconds); 3 s when zero.54- Timeout time.Duration55- // NoCompression sends plain JSON instead of gzip56- // (OTEL_EXPORTER_OTLP_COMPRESSION=none).57- NoCompression bool58- // Disabled turns OTLP off whatever else is set (OTEL_SDK_DISABLED).59- Disabled bool60-}61-62-const defaultTimeout = 3 * time.Second63-64-// otlpFromEnv reads the OTEL_* variables.65-func otlpFromEnv() OTLPConfig {66- env := func(signal string) string {67- return os.Getenv("OTEL_EXPORTER_OTLP_" + signal + "ENDPOINT")68- }69- none := func(signal string) bool {70- return strings.EqualFold(strings.TrimSpace(os.Getenv("OTEL_"+signal+"_EXPORTER")), "none")71- }72- c := OTLPConfig{73- Endpoint: env(""),74- LogsEndpoint: env("LOGS_"),75- TracesEndpoint: env("TRACES_"),76- MetricsEndpoint: env("METRICS_"),77- NoLogs: none("LOGS"),78- NoTraces: none("TRACES"),79- NoMetrics: none("METRICS"),80- Headers: keyValues(os.Getenv("OTEL_EXPORTER_OTLP_HEADERS")),81- Resource: keyValues(os.Getenv("OTEL_RESOURCE_ATTRIBUTES")),82- NoCompression: strings.EqualFold(os.Getenv("OTEL_EXPORTER_OTLP_COMPRESSION"), "none"),83- Disabled: strings.EqualFold(strings.TrimSpace(os.Getenv("OTEL_SDK_DISABLED")), "true"),84- }85- if ms, err := strconv.Atoi(os.Getenv("OTEL_EXPORTER_OTLP_TIMEOUT")); err == nil && ms > 0 {86- c.Timeout = time.Duration(ms) * time.Millisecond87- }88- return c89-}90-91-// keyValues parses the "k1=v1,k2=v2" lists of OTEL_EXPORTER_OTLP_HEADERS92-// and OTEL_RESOURCE_ATTRIBUTES, whose values are percent-encoded.93-func keyValues(s string) map[string]string {94- var m map[string]string95- for kv := range strings.SplitSeq(s, ",") {96- k, v, ok := strings.Cut(kv, "=")97- k = strings.TrimSpace(k)98- if !ok || k == "" {99- continue100- }101- if d, err := url.PathUnescape(strings.TrimSpace(v)); err == nil {102- v = d103- }104- if m == nil {105- m = map[string]string{}106- }107- m[k] = strings.TrimSpace(v)108- }109- return m110-}111-112-// urls are where each signal goes; empty leaves it out.113-type urls struct{ logs, traces, metrics string }114-115-func (u urls) any() bool { return u.logs != "" || u.traces != "" || u.metrics != "" }116-117-func (c OTLPConfig) urls() urls {118- if c.Disabled {119- return urls{}120- }121- base := c.Endpoint122- if base != "" && !strings.Contains(base, "://") {123- base = "http://" + base124- }125- pick := func(own, path string, off bool) string {126- switch {127- case off:128- return ""129- case own != "":130- return own131- case base != "":132- return strings.TrimRight(base, "/") + path133- }134- return ""135- }136- return urls{137- logs: pick(c.LogsEndpoint, "/v1/logs", c.NoLogs),138- traces: pick(c.TracesEndpoint, "/v1/traces", c.NoTraces),139- metrics: pick(c.MetricsEndpoint, "/v1/metrics", c.NoMetrics),140- }141-}27+//28+// This file is the exporter's queues and sender; otlpconfig.go says29+// where to send, otlplog.go is the slog handler queuing log records,30+// otlpmetrics.go builds the metrics and otlpjson.go is the wire format.14231 14332 const (14433 sigLogs = iota@@ -159,42 +48,6 @@ var (
15948 flushEvery = 2 * time.Second16049 )16150 162-// durationBounds are the histogram buckets for operation durations, in163-// milliseconds.164-var durationBounds = []float64{0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 30000}165-166-type durationHist struct {167- counts []uint64168- count uint64169- sum, min, max float64170-}171-172-func (h *durationHist) add(v float64) {173- if h.counts == nil {174- h.counts = make([]uint64, len(durationBounds)+1)175- h.min, h.max = v, v176- }177- i, _ := slices.BinarySearch(durationBounds, v)178- h.counts[i]++179- h.count++180- h.sum += v181- h.min, h.max = min(h.min, v), max(h.max, v)182-}183-184-// frameWindow is one second of frames, from the frame summary.185-type frameWindow struct {186- start, end time.Time187- render, key stats188- heap int64189- gauges map[string]int64190-}191-192-type stats struct {193- count int194- sum float64 // ms195- p50, p95, pmax float64 // ms196-}197-19851 type exporter struct {19952 urls urls20053 headers map[string]string@@ -427,78 +280,6 @@ func (e *exporter) flush(ctx context.Context, final bool) {
427280 }428281 }429282 430-// metricsLocked builds the metrics: the queued frame windows as431-// summaries and gauges, and the cumulative counters and histograms as432-// they stand at now.433-func (e *exporter) metricsLocked(windows []frameWindow, now time.Time) []metric {434- var out []metric435- if len(windows) > 0 {436- render := &summary{}437- key := &summary{}438- heap := &gauge{}439- gauges := map[string]*gauge{}440- for _, w := range windows {441- render.DataPoints = append(render.DataPoints, w.render.point(w.start, w.end))442- if w.key.count > 0 {443- key.DataPoints = append(key.DataPoints, w.key.point(w.start, w.end))444- }445- heap.DataPoints = append(heap.DataPoints, intPoint(time.Time{}, w.end, w.heap))446- for k, v := range w.gauges {447- g := gauges[k]448- if g == nil {449- g = &gauge{}450- gauges[k] = g451- }452- g.DataPoints = append(g.DataPoints, intPoint(time.Time{}, w.end, v))453- }454- }455- out = append(out,456- metric{Name: "o12.frame.duration", Unit: "ms", Description: "Time to draw a frame (View), per second", Summary: render},457- metric{Name: "o12.heap", Unit: "By", Description: "Heap held by live and unswept objects", Gauge: heap})458- if len(key.DataPoints) > 0 {459- out = append(out, metric{Name: "o12.key.latency", Unit: "ms", Description: "From a key press to the end of its frame, per second", Summary: key})460- }461- for _, k := range slices.Sorted(maps.Keys(gauges)) {462- out = append(out, metric{Name: "o12." + k, Gauge: gauges[k]})463- }464- }465- out = append(out, metric{Name: "o12.frames", Unit: "{frame}", Description: "Frames drawn", Sum: &sum{466- DataPoints: []numberPoint{intPoint(e.started, now, e.frames)},467- AggregationTemporality: temporalityCumulative, IsMonotonic: true,468- }})469- if len(e.ops) > 0 {470- h := &histogram{AggregationTemporality: temporalityCumulative}471- for _, name := range slices.Sorted(maps.Keys(e.ops)) {472- d := e.ops[name]473- counts := make([]u64, len(d.counts))474- for i, c := range d.counts {475- counts[i] = u64(c)476- }477- h.DataPoints = append(h.DataPoints, histogramPoint{478- Attributes: []keyValue{{Key: "event", Value: str(name)}},479- StartTimeUnixNano: nanos(e.started), TimeUnixNano: nanos(now),480- Count: u64(d.count), Sum: d.sum, BucketCounts: counts, ExplicitBounds: durationBounds,481- Min: d.min, Max: d.max,482- })483- }484- out = append(out, metric{Name: "o12.operation.duration", Unit: "ms", Description: "Duration of operations such as recalc, import and sort", Histogram: h})485- }486- dropped := &sum{AggregationTemporality: temporalityCumulative, IsMonotonic: true}487- for i, n := range e.dropped {488- dropped.DataPoints = append(dropped.DataPoints, intPoint(e.started, now, n+e.failed[i], keyValue{Key: "signal", Value: str(signalNames[i])}))489- }490- out = append(out, metric{Name: "o12.telemetry.dropped", Unit: "{item}", Description: "Telemetry items not delivered: queue full or request failed", Sum: dropped})491- return out492-}493-494-func (s stats) point(start, end time.Time) summaryPoint {495- return summaryPoint{496- StartTimeUnixNano: nanos(start), TimeUnixNano: nanos(end),497- Count: u64(s.count), Sum: s.sum,498- QuantileValues: []quantileValue{{0.5, s.p50}, {0.95, s.p95}, {1, s.pmax}},499- }500-}501-502283 // send posts one request; failures are counted, never retried.503284 func (e *exporter) send(ctx context.Context, sig, items int, body any) {504285 url := [3]string{e.urls.logs, e.urls.traces, e.urls.metrics}[sig]@@ -557,62 +338,3 @@ func (e *exporter) counts() (sent, dropped, failed [3]int64) {
557338 defer e.mu.Unlock()558339 return e.sent, e.dropped, e.failed559340 }560-561-// spanKey carries a span's ids from log to the OTLP log handler, so the562-// log record of an operation links to its span.563-type spanKey struct{}564-565-type spanIDs struct {566- trace traceID567- span spanID568-}569-570-// otlpHandler is a slog.Handler that queues OTLP log records.571-type otlpHandler struct {572- e *exporter573- level slog.Leveler574- attrs []keyValue575- prefix string576-}577-578-func (h *otlpHandler) Enabled(_ context.Context, l slog.Level) bool {579- return l >= h.level.Level()580-}581-582-func (h *otlpHandler) Handle(ctx context.Context, r slog.Record) error {583- rec := logRecord{584- TimeUnixNano: nanos(r.Time),585- ObservedTimeUnixNano: nanos(time.Now()),586- SeverityNumber: severity(r.Level),587- SeverityText: r.Level.String(),588- Body: str(r.Message),589- Attributes: slices.Clip(h.attrs),590- }591- r.Attrs(func(a slog.Attr) bool {592- rec.Attributes = appendAttr(rec.Attributes, h.prefix, a)593- return true594- })595- if ids, ok := ctx.Value(spanKey{}).(spanIDs); ok {596- rec.TraceID, rec.SpanID = ids.trace, ids.span597- }598- h.e.log(rec)599- return nil600-}601-602-func (h *otlpHandler) WithAttrs(as []slog.Attr) slog.Handler {603- h2 := *h604- h2.attrs = slices.Clip(h.attrs)605- for _, a := range as {606- h2.attrs = appendAttr(h2.attrs, h.prefix, a)607- }608- return &h2609-}610-611-func (h *otlpHandler) WithGroup(name string) slog.Handler {612- if name == "" {613- return h614- }615- h2 := *h616- h2.prefix += name + "."617- return &h2618-}@@ -153,6 +153,16 @@ func TestOTLPExport(t *testing.T) {
153153 t.Fatal(err)154154 }155155 156+ checkRequests(t, c)157+ imp := checkLogs(t, c)158+ checkSpans(t, c, imp)159+ checkMetrics(t, c)160+}161+162+// checkRequests checks every request is gzipped JSON with the configured163+// headers, and that all three signals were sent.164+func checkRequests(t *testing.T, c *collector) {165+ t.Helper()156166 reqs := c.requests()157167 paths := map[string]bool{}158168 for _, r := range reqs {@@ -168,6 +178,12 @@ func TestOTLPExport(t *testing.T) {
168178 t.Fatalf("paths %v", paths)169179 }170180 181+}182+183+// checkLogs checks the resource and the log records, returning the184+// import's record for checkSpans.185+func checkLogs(t *testing.T, c *collector) map[string]any {186+ t.Helper()171187 logs, res := c.items("/v1/logs")172188 for k, want := range map[string]string{"service.name": "012", "service.version": "v-test", "os.type": "", "host.arch": "", "service.instance.id": ""} {173189 v, ok := res[k].(map[string]any)["stringValue"].(string)@@ -210,6 +226,12 @@ func TestOTLPExport(t *testing.T) {
210226 t.Errorf("otlp event %v", oa)211227 }212228 229+ return imp230+}231+232+// checkSpans checks the spans, and that the import's matches its log.233+func checkSpans(t *testing.T, c *collector, imp map[string]any) {234+ t.Helper()213235 spans, _ := c.items("/v1/traces")214236 if len(spans) != 2 {215237 t.Fatalf("%d spans: %v", len(spans), spans)@@ -233,6 +255,12 @@ func TestOTLPExport(t *testing.T) {
233255 t.Errorf("failed span %v", spans[1])234256 }235257 258+}259+260+// checkMetrics checks the frame summary, counters, gauges and the261+// operation histogram.262+func checkMetrics(t *testing.T, c *collector) {263+ t.Helper()236264 metrics, _ := c.items("/v1/metrics")237265 byName := map[string]map[string]any{}238266 for _, m := range metrics {@@ -0,0 +1,120 @@
1+package telemetry2+3+import (4+ "net/url"5+ "os"6+ "strconv"7+ "strings"8+ "time"9+)10+11+// OTLPConfig says where OTLP goes. ConfigFromEnv reads it from the12+// standard OTEL_* variables. Nothing is sent unless an endpoint is set.13+type OTLPConfig struct {14+ // Endpoint is the base URL, like http://localhost:4318, to which15+ // /v1/logs, /v1/traces and /v1/metrics are appended16+ // (OTEL_EXPORTER_OTLP_ENDPOINT, or 012 --otlp).17+ Endpoint string18+ // LogsEndpoint, TracesEndpoint and MetricsEndpoint are full URLs for19+ // one signal each, used as they are20+ // (OTEL_EXPORTER_OTLP_{LOGS,TRACES,METRICS}_ENDPOINT).21+ LogsEndpoint, TracesEndpoint, MetricsEndpoint string22+ // NoLogs, NoTraces and NoMetrics leave a signal out23+ // (OTEL_{LOGS,TRACES,METRICS}_EXPORTER=none).24+ NoLogs, NoTraces, NoMetrics bool25+ // Headers go with every request, e.g. for authentication26+ // (OTEL_EXPORTER_OTLP_HEADERS). They are never logged.27+ Headers map[string]string28+ // Resource adds resource attributes (OTEL_RESOURCE_ATTRIBUTES);29+ // service.name stays 012.30+ Resource map[string]string31+ // Timeout bounds each request (OTEL_EXPORTER_OTLP_TIMEOUT, in32+ // milliseconds); 3 s when zero.33+ Timeout time.Duration34+ // NoCompression sends plain JSON instead of gzip35+ // (OTEL_EXPORTER_OTLP_COMPRESSION=none).36+ NoCompression bool37+ // Disabled turns OTLP off whatever else is set (OTEL_SDK_DISABLED).38+ Disabled bool39+}40+41+const defaultTimeout = 3 * time.Second42+43+// otlpFromEnv reads the OTEL_* variables.44+func otlpFromEnv() OTLPConfig {45+ env := func(signal string) string {46+ return os.Getenv("OTEL_EXPORTER_OTLP_" + signal + "ENDPOINT")47+ }48+ none := func(signal string) bool {49+ return strings.EqualFold(strings.TrimSpace(os.Getenv("OTEL_"+signal+"_EXPORTER")), "none")50+ }51+ c := OTLPConfig{52+ Endpoint: env(""),53+ LogsEndpoint: env("LOGS_"),54+ TracesEndpoint: env("TRACES_"),55+ MetricsEndpoint: env("METRICS_"),56+ NoLogs: none("LOGS"),57+ NoTraces: none("TRACES"),58+ NoMetrics: none("METRICS"),59+ Headers: keyValues(os.Getenv("OTEL_EXPORTER_OTLP_HEADERS")),60+ Resource: keyValues(os.Getenv("OTEL_RESOURCE_ATTRIBUTES")),61+ NoCompression: strings.EqualFold(os.Getenv("OTEL_EXPORTER_OTLP_COMPRESSION"), "none"),62+ Disabled: strings.EqualFold(strings.TrimSpace(os.Getenv("OTEL_SDK_DISABLED")), "true"),63+ }64+ if ms, err := strconv.Atoi(os.Getenv("OTEL_EXPORTER_OTLP_TIMEOUT")); err == nil && ms > 0 {65+ c.Timeout = time.Duration(ms) * time.Millisecond66+ }67+ return c68+}69+70+// keyValues parses the "k1=v1,k2=v2" lists of OTEL_EXPORTER_OTLP_HEADERS71+// and OTEL_RESOURCE_ATTRIBUTES, whose values are percent-encoded.72+func keyValues(s string) map[string]string {73+ var m map[string]string74+ for kv := range strings.SplitSeq(s, ",") {75+ k, v, ok := strings.Cut(kv, "=")76+ k = strings.TrimSpace(k)77+ if !ok || k == "" {78+ continue79+ }80+ if d, err := url.PathUnescape(strings.TrimSpace(v)); err == nil {81+ v = d82+ }83+ if m == nil {84+ m = map[string]string{}85+ }86+ m[k] = strings.TrimSpace(v)87+ }88+ return m89+}90+91+// urls are where each signal goes; empty leaves it out.92+type urls struct{ logs, traces, metrics string }93+94+func (u urls) any() bool { return u.logs != "" || u.traces != "" || u.metrics != "" }95+96+func (c OTLPConfig) urls() urls {97+ if c.Disabled {98+ return urls{}99+ }100+ base := c.Endpoint101+ if base != "" && !strings.Contains(base, "://") {102+ base = "http://" + base103+ }104+ pick := func(own, path string, off bool) string {105+ switch {106+ case off:107+ return ""108+ case own != "":109+ return own110+ case base != "":111+ return strings.TrimRight(base, "/") + path112+ }113+ return ""114+ }115+ return urls{116+ logs: pick(c.LogsEndpoint, "/v1/logs", c.NoLogs),117+ traces: pick(c.TracesEndpoint, "/v1/traces", c.NoTraces),118+ metrics: pick(c.MetricsEndpoint, "/v1/metrics", c.NoMetrics),119+ }120+}@@ -0,0 +1,67 @@
1+package telemetry2+3+import (4+ "context"5+ "log/slog"6+ "slices"7+ "time"8+)9+10+// spanKey carries a span's ids from log to the OTLP log handler, so the11+// log record of an operation links to its span.12+type spanKey struct{}13+14+type spanIDs struct {15+ trace traceID16+ span spanID17+}18+19+// otlpHandler is a slog.Handler that queues OTLP log records.20+type otlpHandler struct {21+ e *exporter22+ level slog.Leveler23+ attrs []keyValue24+ prefix string25+}26+27+func (h *otlpHandler) Enabled(_ context.Context, l slog.Level) bool {28+ return l >= h.level.Level()29+}30+31+func (h *otlpHandler) Handle(ctx context.Context, r slog.Record) error {32+ rec := logRecord{33+ TimeUnixNano: nanos(r.Time),34+ ObservedTimeUnixNano: nanos(time.Now()),35+ SeverityNumber: severity(r.Level),36+ SeverityText: r.Level.String(),37+ Body: str(r.Message),38+ Attributes: slices.Clip(h.attrs),39+ }40+ r.Attrs(func(a slog.Attr) bool {41+ rec.Attributes = appendAttr(rec.Attributes, h.prefix, a)42+ return true43+ })44+ if ids, ok := ctx.Value(spanKey{}).(spanIDs); ok {45+ rec.TraceID, rec.SpanID = ids.trace, ids.span46+ }47+ h.e.log(rec)48+ return nil49+}50+51+func (h *otlpHandler) WithAttrs(as []slog.Attr) slog.Handler {52+ h2 := *h53+ h2.attrs = slices.Clip(h.attrs)54+ for _, a := range as {55+ h2.attrs = appendAttr(h2.attrs, h.prefix, a)56+ }57+ return &h258+}59+60+func (h *otlpHandler) WithGroup(name string) slog.Handler {61+ if name == "" {62+ return h63+ }64+ h2 := *h65+ h2.prefix += name + "."66+ return &h267+}@@ -0,0 +1,133 @@
1+package telemetry2+3+import (4+ "maps"5+ "slices"6+ "time"7+)8+9+// The metrics OTLP sends: per-second frame windows as summaries and10+// gauges, and cumulative counters and operation histograms.11+12+// durationBounds are the histogram buckets for operation durations, in13+// milliseconds.14+var durationBounds = []float64{0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 30000}15+16+type durationHist struct {17+ counts []uint6418+ count uint6419+ sum, min, max float6420+}21+22+func (h *durationHist) add(v float64) {23+ if h.counts == nil {24+ h.counts = make([]uint64, len(durationBounds)+1)25+ h.min, h.max = v, v26+ }27+ i, _ := slices.BinarySearch(durationBounds, v)28+ h.counts[i]++29+ h.count++30+ h.sum += v31+ h.min, h.max = min(h.min, v), max(h.max, v)32+}33+34+// frameWindow is one second of frames, from the frame summary.35+type frameWindow struct {36+ start, end time.Time37+ render, key stats38+ heap int6439+ gauges map[string]int6440+}41+42+type stats struct {43+ count int44+ sum float64 // ms45+ p50, p95, pmax float64 // ms46+}47+48+// metricsLocked builds the metrics: the queued frame windows as49+// summaries and gauges, and the cumulative counters and histograms as50+// they stand at now.51+func (e *exporter) metricsLocked(windows []frameWindow, now time.Time) []metric {52+ out := windowMetrics(windows)53+ out = append(out, metric{Name: "o12.frames", Unit: "{frame}", Description: "Frames drawn", Sum: &sum{54+ DataPoints: []numberPoint{intPoint(e.started, now, e.frames)},55+ AggregationTemporality: temporalityCumulative, IsMonotonic: true,56+ }})57+ if len(e.ops) > 0 {58+ out = append(out, metric{Name: "o12.operation.duration", Unit: "ms", Description: "Duration of operations such as recalc, import and sort", Histogram: e.opsHistogram(now)})59+ }60+ dropped := &sum{AggregationTemporality: temporalityCumulative, IsMonotonic: true}61+ for i, n := range e.dropped {62+ dropped.DataPoints = append(dropped.DataPoints, intPoint(e.started, now, n+e.failed[i], keyValue{Key: "signal", Value: str(signalNames[i])}))63+ }64+ out = append(out, metric{Name: "o12.telemetry.dropped", Unit: "{item}", Description: "Telemetry items not delivered: queue full or request failed", Sum: dropped})65+ return out66+}67+68+// windowMetrics are the frame windows as a summary of frame durations,69+// one of key latencies, and gauges of the heap and whatever Set70+// recorded; none when there are no windows.71+func windowMetrics(windows []frameWindow) []metric {72+ if len(windows) == 0 {73+ return nil74+ }75+ render := &summary{}76+ key := &summary{}77+ heap := &gauge{}78+ gauges := map[string]*gauge{}79+ for _, w := range windows {80+ render.DataPoints = append(render.DataPoints, w.render.point(w.start, w.end))81+ if w.key.count > 0 {82+ key.DataPoints = append(key.DataPoints, w.key.point(w.start, w.end))83+ }84+ heap.DataPoints = append(heap.DataPoints, intPoint(time.Time{}, w.end, w.heap))85+ for k, v := range w.gauges {86+ g := gauges[k]87+ if g == nil {88+ g = &gauge{}89+ gauges[k] = g90+ }91+ g.DataPoints = append(g.DataPoints, intPoint(time.Time{}, w.end, v))92+ }93+ }94+ out := []metric{95+ {Name: "o12.frame.duration", Unit: "ms", Description: "Time to draw a frame (View), per second", Summary: render},96+ {Name: "o12.heap", Unit: "By", Description: "Heap held by live and unswept objects", Gauge: heap},97+ }98+ if len(key.DataPoints) > 0 {99+ out = append(out, metric{Name: "o12.key.latency", Unit: "ms", Description: "From a key press to the end of its frame, per second", Summary: key})100+ }101+ for _, k := range slices.Sorted(maps.Keys(gauges)) {102+ out = append(out, metric{Name: "o12." + k, Gauge: gauges[k]})103+ }104+ return out105+}106+107+// opsHistogram is the operation durations since the start, one point per108+// operation.109+func (e *exporter) opsHistogram(now time.Time) *histogram {110+ h := &histogram{AggregationTemporality: temporalityCumulative}111+ for _, name := range slices.Sorted(maps.Keys(e.ops)) {112+ d := e.ops[name]113+ counts := make([]u64, len(d.counts))114+ for i, c := range d.counts {115+ counts[i] = u64(c)116+ }117+ h.DataPoints = append(h.DataPoints, histogramPoint{118+ Attributes: []keyValue{{Key: "event", Value: str(name)}},119+ StartTimeUnixNano: nanos(e.started), TimeUnixNano: nanos(now),120+ Count: u64(d.count), Sum: d.sum, BucketCounts: counts, ExplicitBounds: durationBounds,121+ Min: d.min, Max: d.max,122+ })123+ }124+ return h125+}126+127+func (s stats) point(start, end time.Time) summaryPoint {128+ return summaryPoint{129+ StartTimeUnixNano: nanos(start), TimeUnixNano: nanos(end),130+ Count: u64(s.count), Sum: s.sum,131+ QuantileValues: []quantileValue{{0.5, s.p50}, {0.95, s.p95}, {1, s.pmax}},132+ }133+}