diff --git a/.gitignore b/.gitignore index e40a817..7128881 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ go.work # OS specific files .DS_Store Thumbs.db +/docs/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ebd1963..44d7749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.0] - 2026-06-20 + +### Added + +- `integrations/otelmetric`: opt-in OpenTelemetry metrics exporter. Implements `sdkmetric.Exporter` and bridges counters, gauges and histograms into the LogTide pipeline as log entries, inheriting the client's service name, environment, tags and resource attributes (closes #2) + - exemplar support: when a data point carries an exemplar with trace context, the entry is linked to that `trace_id`/`span_id` and the full exemplar list is recorded under `metadata.metric.exemplars` + ## [0.9.4] - 2026-06-11 ### Added diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 4f1dd60..7cb3eb4 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -33,3 +33,4 @@ why a scenario does not apply. TODO entries are tracked work. | C26 | log/trace correlation: active span ids on entries | ✅ | `tracing_test.go` (OTel context extraction) | | C27 | middleware error capture rethrows after logging | ✅ | `integrations/nethttp/nethttp_test.go` | | C28 | logging-bridge level mapping and scope context | ✅ | `integrations/logtideslog/logtideslog_test.go` | +| C29 | OTLP metric export (counters/gauges/histograms) with resource attributes | ✅ | `integrations/otelmetric/otelmetric_test.go` (OTel-native path) | diff --git a/README.md b/README.md index 6d0933f..fa8a369 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ - **Automatic batching** — configurable batch size and flush interval - **Retry with backoff** — exponential backoff with jitter - **Circuit breaker** — prevents cascading failures -- **OpenTelemetry** — trace/span IDs extracted automatically; span exporter included +- **OpenTelemetry** — trace/span IDs extracted automatically; span and metric exporters included - **net/http middleware** — per-request scope isolation out of the box - **Thread-safe** — safe for concurrent use @@ -224,6 +224,43 @@ tp := sdktrace.NewTracerProvider( ) ``` +### Metric exporter + +Export OpenTelemetry metrics (counters, gauges, histograms) to LogTide. Each +data point becomes a log entry carrying the same service name, environment, tags +and resource attributes as your logs and traces. It is opt-in — register it only +if you need metrics. + +```go +import ( + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + + "github.com/logtide-dev/logtide-sdk-go/integrations/otelmetric" +) + +integration := otelmetric.New() + +flush := logtide.Init(logtide.ClientOptions{ + DSN: "https://lp_abc@api.logtide.dev", + Service: "my-service", + Integrations: func(defaults []logtide.Integration) []logtide.Integration { + return append(defaults, integration) + }, +}) +defer flush() + +mp := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(sdkmetric.NewPeriodicReader(integration.Exporter())), +) +otel.SetMeterProvider(mp) +``` + +When metric exemplars are enabled (an exemplar filter is configured and a sampled +span is active during measurement), each linked data point's entry inherits the +exemplar's `trace_id`/`span_id`, and the full exemplar list is recorded under +`metadata.metric.exemplars` — correlating metrics with the traces that produced +them. + --- ## Flush & shutdown @@ -278,6 +315,7 @@ client, _ := logtide.NewClient(logtide.ClientOptions{ | [examples/echo](./examples/echo) | Echo framework integration | | [examples/stdlib](./examples/stdlib) | Standard library net/http | | [examples/otel](./examples/otel) | OpenTelemetry distributed tracing | +| [examples/otelmetric](./examples/otelmetric) | OpenTelemetry metrics export | --- diff --git a/examples/otelmetric/main.go b/examples/otelmetric/main.go new file mode 100644 index 0000000..c48fa9e --- /dev/null +++ b/examples/otelmetric/main.go @@ -0,0 +1,88 @@ +// Command otelmetric demonstrates exporting OpenTelemetry metrics through +// LogTide. Counters, gauges and histograms recorded against the MeterProvider +// are converted to LogTide log entries carrying the same service name, +// environment, tags and resource attributes as your logs and traces. +package main + +import ( + "context" + "log" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + + logtide "github.com/logtide-dev/logtide-sdk-go" + "github.com/logtide-dev/logtide-sdk-go/integrations/otelmetric" +) + +func main() { + // Create the metric integration and register it with LogTide. + integration := otelmetric.New() + + flush := logtide.Init(logtide.ClientOptions{ + DSN: "https://lp_your_api_key_here@api.logtide.dev", + Service: "otelmetric-example", + Integrations: func(defaults []logtide.Integration) []logtide.Integration { + return append(defaults, integration) + }, + }) + defer flush() + + // Wire the exporter into a MeterProvider. A PeriodicReader collects metrics + // on an interval and hands them to the exporter. + mp := sdkmetric.NewMeterProvider( + sdkmetric.WithReader( + sdkmetric.NewPeriodicReader( + integration.Exporter(), + sdkmetric.WithInterval(2*time.Second), + ), + ), + ) + defer func() { + if err := mp.Shutdown(context.Background()); err != nil { + log.Printf("Error shutting down meter provider: %v", err) + } + }() + otel.SetMeterProvider(mp) + + meter := mp.Meter("logtide-metric-example") + + // A counter. + requests, err := meter.Int64Counter( + "http.requests", + metric.WithDescription("Number of handled HTTP requests"), + metric.WithUnit("1"), + ) + if err != nil { + log.Fatalf("create counter: %v", err) + } + + // A histogram. + latency, err := meter.Float64Histogram( + "http.latency", + metric.WithDescription("Request latency"), + metric.WithUnit("ms"), + ) + if err != nil { + log.Fatalf("create histogram: %v", err) + } + + ctx := context.Background() + + // Record some measurements. + for i := 0; i < 5; i++ { + requests.Add(ctx, 1) + latency.Record(ctx, float64(40+i*7)) + time.Sleep(200 * time.Millisecond) + } + + // Force a final collection before exit so the metrics are flushed. + if err := mp.ForceFlush(ctx); err != nil { + log.Printf("force flush: %v", err) + } + + log.Println("OpenTelemetry metrics example completed!") + log.Println("Counter and histogram values were exported to LogTide as log entries.") +} diff --git a/go.mod b/go.mod index 470ebad..8e1fe4d 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,10 @@ module github.com/logtide-dev/logtide-sdk-go go 1.23.0 require ( + go.opentelemetry.io/otel v1.38.0 + go.opentelemetry.io/otel/metric v1.38.0 go.opentelemetry.io/otel/sdk v1.38.0 + go.opentelemetry.io/otel/sdk/metric v1.38.0 go.opentelemetry.io/otel/trace v1.38.0 ) @@ -12,7 +15,5 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/google/uuid v1.6.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel v1.38.0 // indirect - go.opentelemetry.io/otel/metric v1.38.0 // indirect golang.org/x/sys v0.35.0 // indirect ) diff --git a/integrations/otelmetric/otelmetric.go b/integrations/otelmetric/otelmetric.go new file mode 100644 index 0000000..eaf3d83 --- /dev/null +++ b/integrations/otelmetric/otelmetric.go @@ -0,0 +1,305 @@ +// Package otelmetric provides a LogTide integration that bridges OpenTelemetry +// metrics (counters, gauges, histograms) into the LogTide pipeline. +// +// It implements sdkmetric.Exporter, converting each metric data point into a +// logtide.LogEntry sent through the existing Client. Metrics therefore carry the +// same service name, environment, tags and resource attributes as logs and +// traces. It is opt-in: register it only if you need metrics. +// +// Usage: +// +// integration := otelmetric.New() +// flush := logtide.Init(logtide.ClientOptions{ +// DSN: "https://lp_abc@api.logtide.dev", +// Service: "my-service", +// Integrations: func(defaults []logtide.Integration) []logtide.Integration { +// return append(defaults, integration) +// }, +// }) +// defer flush() +// +// // Register the metric exporter with your MeterProvider: +// mp := sdkmetric.NewMeterProvider( +// sdkmetric.WithReader(sdkmetric.NewPeriodicReader(integration.Exporter())), +// ) +// otel.SetMeterProvider(mp) +package otelmetric + +import ( + "context" + "encoding/hex" + "fmt" + "time" + + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + + logtide "github.com/logtide-dev/logtide-sdk-go" +) + +// Exporter implements sdkmetric.Exporter. Each metric data point is converted to +// a logtide.LogEntry and sent via the Client. +type Exporter struct { + client *logtide.Client +} + +// Temporality implements sdkmetric.Exporter. It uses the default selector, +// which reports all instrument kinds as cumulative. +func (e *Exporter) Temporality(kind sdkmetric.InstrumentKind) metricdata.Temporality { + return sdkmetric.DefaultTemporalitySelector(kind) +} + +// Aggregation implements sdkmetric.Exporter using the default aggregation +// selector (Sum for counters, LastValue for gauges, explicit-bucket histogram +// for histograms). +func (e *Exporter) Aggregation(kind sdkmetric.InstrumentKind) sdkmetric.Aggregation { + return sdkmetric.DefaultAggregationSelector(kind) +} + +// Export implements sdkmetric.Exporter. It walks the ResourceMetrics tree and +// emits one LogEntry per data point. +func (e *Exporter) Export(ctx context.Context, rm *metricdata.ResourceMetrics) error { + if e.client == nil || rm == nil { + return nil + } + + var resourceAttrs map[string]any + if rm.Resource != nil { + resourceAttrs = iterToMap(rm.Resource.Iter()) + } + + for _, sm := range rm.ScopeMetrics { + var scopeMeta map[string]any + if sm.Scope.Name != "" || sm.Scope.Version != "" { + scopeMeta = map[string]any{"name": sm.Scope.Name} + if sm.Scope.Version != "" { + scopeMeta["version"] = sm.Scope.Version + } + } + + for _, m := range sm.Metrics { + for _, metric := range metricToEntries(m) { + meta := map[string]any{"metric": metric.meta} + if resourceAttrs != nil { + meta["resource"] = resourceAttrs + } + if scopeMeta != nil { + meta["scope"] = scopeMeta + } + + // When a data point carries an exemplar with trace context, + // link the entry to that trace/span. Use context.Background() + // as the base so no ambient span in ctx overrides it (the + // client extracts span IDs before applying the scope). + entryCtx := ctx + if metric.traceID != "" { + scope := logtide.NewScope(0) + scope.SetTraceContext(metric.traceID, metric.spanID) + entryCtx = logtide.WithScope(context.Background(), scope) + } + + e.client.Info(entryCtx, metric.message, meta) + } + } + } + return nil +} + +// ForceFlush implements sdkmetric.Exporter. +func (e *Exporter) ForceFlush(ctx context.Context) error { + if e.client != nil { + e.client.Flush(ctx) + } + return nil +} + +// Shutdown implements sdkmetric.Exporter. +func (e *Exporter) Shutdown(ctx context.Context) error { + if e.client != nil { + e.client.Flush(ctx) + } + return nil +} + +// Integration implements logtide.Integration and holds the Exporter reference. +// Call Exporter() after Setup has run to obtain the configured exporter. +type Integration struct { + exporter *Exporter +} + +// New creates an Integration. Register it in ClientOptions.Integrations, then +// call Exporter() after Init to obtain the configured sdkmetric.Exporter. +func New() *Integration { + return &Integration{exporter: &Exporter{}} +} + +// Name implements logtide.Integration. +func (i *Integration) Name() string { return "OTelMetricExport" } + +// Setup implements logtide.Integration. Stores the client in the Exporter. +func (i *Integration) Setup(client *logtide.Client) { + i.exporter.client = client +} + +// Exporter returns the sdkmetric.Exporter backed by this integration's Client. +func (i *Integration) Exporter() *Exporter { + return i.exporter +} + +// --- metric → LogEntry conversion --- + +// entry is the intermediate result of converting a single data point. +type entry struct { + message string + meta map[string]any + // traceID/spanID are taken from the data point's most recent exemplar + // (empty when the point carries no trace-linked exemplar). + traceID string + spanID string +} + +// metricToEntries converts one Metrics aggregation into one entry per data point. +func metricToEntries(m metricdata.Metrics) []entry { + switch data := m.Data.(type) { + case metricdata.Sum[int64]: + return sumEntries(m, data.DataPoints, data.IsMonotonic) + case metricdata.Sum[float64]: + return sumEntries(m, data.DataPoints, data.IsMonotonic) + case metricdata.Gauge[int64]: + return gaugeEntries(m, data.DataPoints) + case metricdata.Gauge[float64]: + return gaugeEntries(m, data.DataPoints) + case metricdata.Histogram[int64]: + return histogramEntries(m, data.DataPoints) + case metricdata.Histogram[float64]: + return histogramEntries(m, data.DataPoints) + default: + return nil + } +} + +func sumEntries[N int64 | float64](m metricdata.Metrics, dps []metricdata.DataPoint[N], monotonic bool) []entry { + out := make([]entry, 0, len(dps)) + for _, dp := range dps { + meta := baseMeta(m, "counter", dp.Attributes, dp.StartTime, dp.Time) + meta["value"] = dp.Value + meta["is_monotonic"] = monotonic + e := entry{ + message: fmt.Sprintf("metric %s = %v", m.Name, dp.Value), + meta: meta, + } + applyExemplars(&e, dp.Exemplars) + out = append(out, e) + } + return out +} + +func gaugeEntries[N int64 | float64](m metricdata.Metrics, dps []metricdata.DataPoint[N]) []entry { + out := make([]entry, 0, len(dps)) + for _, dp := range dps { + meta := baseMeta(m, "gauge", dp.Attributes, dp.StartTime, dp.Time) + meta["value"] = dp.Value + e := entry{ + message: fmt.Sprintf("metric %s = %v", m.Name, dp.Value), + meta: meta, + } + applyExemplars(&e, dp.Exemplars) + out = append(out, e) + } + return out +} + +func histogramEntries[N int64 | float64](m metricdata.Metrics, dps []metricdata.HistogramDataPoint[N]) []entry { + out := make([]entry, 0, len(dps)) + for _, dp := range dps { + meta := baseMeta(m, "histogram", dp.Attributes, dp.StartTime, dp.Time) + meta["count"] = dp.Count + meta["sum"] = dp.Sum + meta["bucket_counts"] = dp.BucketCounts + meta["explicit_bounds"] = dp.Bounds + if v, ok := dp.Min.Value(); ok { + meta["min"] = v + } + if v, ok := dp.Max.Value(); ok { + meta["max"] = v + } + e := entry{ + message: fmt.Sprintf("metric %s count=%d sum=%v", m.Name, dp.Count, dp.Sum), + meta: meta, + } + applyExemplars(&e, dp.Exemplars) + out = append(out, e) + } + return out +} + +// applyExemplars records the data point's exemplars in metadata and links the +// entry to the trace/span of the most recent trace-bearing exemplar. Exemplars +// are only populated when an exemplar filter is configured and a sampled span is +// active during measurement, so this is a no-op for the common case. +func applyExemplars[N int64 | float64](e *entry, exemplars []metricdata.Exemplar[N]) { + if len(exemplars) == 0 { + return + } + list := make([]map[string]any, 0, len(exemplars)) + for _, ex := range exemplars { + em := map[string]any{"value": ex.Value} + if !ex.Time.IsZero() { + em["time"] = ex.Time.Format(time.RFC3339Nano) + } + if len(ex.TraceID) > 0 { + tid := hex.EncodeToString(ex.TraceID) + em["trace_id"] = tid + e.traceID = tid + } + if len(ex.SpanID) > 0 { + sid := hex.EncodeToString(ex.SpanID) + em["span_id"] = sid + e.spanID = sid + } + if len(ex.FilteredAttributes) > 0 { + attrs := make(map[string]any, len(ex.FilteredAttributes)) + for _, a := range ex.FilteredAttributes { + attrs[string(a.Key)] = a.Value.AsInterface() + } + em["filtered_attributes"] = attrs + } + list = append(list, em) + } + e.meta["exemplars"] = list +} + +// baseMeta builds the common metric metadata shared by every data-point type. +func baseMeta(m metricdata.Metrics, typ string, attrs attribute.Set, start, ts time.Time) map[string]any { + meta := map[string]any{ + "name": m.Name, + "type": typ, + } + if m.Description != "" { + meta["description"] = m.Description + } + if m.Unit != "" { + meta["unit"] = m.Unit + } + if attrs.Len() > 0 { + meta["attributes"] = iterToMap(attrs.Iter()) + } + if !start.IsZero() { + meta["start_time"] = start.Format(time.RFC3339Nano) + } + if !ts.IsZero() { + meta["time"] = ts.Format(time.RFC3339Nano) + } + return meta +} + +// iterToMap collects an attribute.Iterator into a plain map. +func iterToMap(it attribute.Iterator) map[string]any { + m := make(map[string]any, it.Len()) + for it.Next() { + kv := it.Attribute() + m[string(kv.Key)] = kv.Value.AsInterface() + } + return m +} diff --git a/integrations/otelmetric/otelmetric_test.go b/integrations/otelmetric/otelmetric_test.go new file mode 100644 index 0000000..d3ba6cb --- /dev/null +++ b/integrations/otelmetric/otelmetric_test.go @@ -0,0 +1,379 @@ +package otelmetric_test + +import ( + "context" + "sync" + "testing" + + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + "go.opentelemetry.io/otel/sdk/resource" + + logtide "github.com/logtide-dev/logtide-sdk-go" + "github.com/logtide-dev/logtide-sdk-go/integrations/otelmetric" +) + +// captureTransport records every LogEntry the client dispatches. +type captureTransport struct { + mu sync.Mutex + entries []logtide.LogEntry +} + +func (t *captureTransport) Send(e *logtide.LogEntry) { + t.mu.Lock() + defer t.mu.Unlock() + t.entries = append(t.entries, *e) +} + +func (t *captureTransport) Flush(context.Context) bool { return true } +func (t *captureTransport) Close() {} + +func (t *captureTransport) snapshot() []logtide.LogEntry { + t.mu.Lock() + defer t.mu.Unlock() + out := make([]logtide.LogEntry, len(t.entries)) + copy(out, t.entries) + return out +} + +func TestNewCreatesIntegration(t *testing.T) { + i := otelmetric.New() + if i == nil { + t.Fatal("New() returned nil") + } + if i.Name() != "OTelMetricExport" { + t.Errorf("Name() = %q, want OTelMetricExport", i.Name()) + } +} + +func TestExporterBeforeSetupHasNilClient(t *testing.T) { + i := otelmetric.New() + // Before Setup, the exporter has a nil client: Export must be a no-op. + if err := i.Exporter().Export(context.Background(), &metricdata.ResourceMetrics{}); err != nil { + t.Errorf("Export with nil client = %v, want nil", err) + } +} + +func TestExporterShutdownWithNilClient(t *testing.T) { + i := otelmetric.New() + if err := i.Exporter().Shutdown(context.Background()); err != nil { + t.Errorf("Shutdown with nil client = %v, want nil", err) + } +} + +func TestExporterForceFlushWithNilClient(t *testing.T) { + i := otelmetric.New() + if err := i.Exporter().ForceFlush(context.Background()); err != nil { + t.Errorf("ForceFlush with nil client = %v, want nil", err) + } +} + +func TestSetupWiresClientToExporter(t *testing.T) { + client, err := logtide.NewClient(logtide.ClientOptions{ + Service: "test", + Transport: logtide.NoopTransport{}, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + defer client.Close() + + i := otelmetric.New() + i.Setup(client) + + if err := i.Exporter().Shutdown(context.Background()); err != nil { + t.Errorf("Shutdown after Setup = %v, want nil", err) + } +} + +func TestIntegrationRegisteredViaClientOptions(t *testing.T) { + mInt := otelmetric.New() + client, err := logtide.NewClient(logtide.ClientOptions{ + Service: "test", + Transport: logtide.NoopTransport{}, + Integrations: func(defaults []logtide.Integration) []logtide.Integration { + return append(defaults, mInt) + }, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + defer client.Close() + + // After NewClient, Setup has run, so Export of empty metrics succeeds. + if err := mInt.Exporter().Export(context.Background(), &metricdata.ResourceMetrics{}); err != nil { + t.Errorf("Export after init = %v, want nil", err) + } +} + +func TestTemporalityIsCumulative(t *testing.T) { + i := otelmetric.New() + got := i.Exporter().Temporality(sdkmetric.InstrumentKindCounter) + if got != metricdata.CumulativeTemporality { + t.Errorf("Temporality = %v, want Cumulative", got) + } +} + +func TestAggregationIsDefault(t *testing.T) { + i := otelmetric.New() + // Default selector maps a Counter to a Sum aggregation. + got := i.Exporter().Aggregation(sdkmetric.InstrumentKindCounter) + if _, ok := got.(sdkmetric.AggregationSum); !ok { + t.Errorf("Aggregation for Counter = %T, want AggregationSum", got) + } +} + +func TestExportConvertsEachDataPointToEntry(t *testing.T) { + tr := &captureTransport{} + client, err := logtide.NewClient(logtide.ClientOptions{ + Service: "metrics-test", + Transport: tr, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + defer client.Close() + + i := otelmetric.New() + i.Setup(client) + + rm := &metricdata.ResourceMetrics{ + Resource: resource.NewSchemaless(attribute.String("host.name", "box-1")), + ScopeMetrics: []metricdata.ScopeMetrics{ + { + Metrics: []metricdata.Metrics{ + { + Name: "http.requests", + Description: "request count", + Unit: "1", + Data: metricdata.Sum[int64]{ + IsMonotonic: true, + Temporality: metricdata.CumulativeTemporality, + DataPoints: []metricdata.DataPoint[int64]{ + {Value: 42, Attributes: attribute.NewSet(attribute.String("method", "GET"))}, + }, + }, + }, + { + Name: "cpu.usage", + Unit: "%", + Data: metricdata.Gauge[float64]{ + DataPoints: []metricdata.DataPoint[float64]{ + {Value: 3.5}, + }, + }, + }, + { + Name: "http.latency", + Unit: "ms", + Data: metricdata.Histogram[float64]{ + Temporality: metricdata.CumulativeTemporality, + DataPoints: []metricdata.HistogramDataPoint[float64]{ + { + Count: 2, + Sum: 10, + Bounds: []float64{1, 5}, + BucketCounts: []uint64{0, 1, 1}, + Min: metricdata.NewExtrema[float64](2), + Max: metricdata.NewExtrema[float64](8), + }, + }, + }, + }, + }, + }, + }, + } + + if err := i.Exporter().Export(context.Background(), rm); err != nil { + t.Fatalf("Export: %v", err) + } + + entries := tr.snapshot() + if len(entries) != 3 { + t.Fatalf("got %d entries, want 3 (one per data point)", len(entries)) + } + + byName := map[string]logtide.LogEntry{} + for _, e := range entries { + if e.Level != logtide.LevelInfo { + t.Errorf("entry level = %q, want info", e.Level) + } + m, ok := e.Metadata["metric"].(map[string]any) + if !ok { + t.Fatalf("entry metadata missing metric map: %#v", e.Metadata) + } + name, _ := m["name"].(string) + byName[name] = e + } + + // Counter + counter := byName["http.requests"].Metadata["metric"].(map[string]any) + if counter["type"] != "counter" { + t.Errorf("counter type = %v, want counter", counter["type"]) + } + if counter["value"] != int64(42) { + t.Errorf("counter value = %v (%T), want int64(42)", counter["value"], counter["value"]) + } + attrs, _ := counter["attributes"].(map[string]any) + if attrs["method"] != "GET" { + t.Errorf("counter attributes.method = %v, want GET", attrs["method"]) + } + + // Gauge + gauge := byName["cpu.usage"].Metadata["metric"].(map[string]any) + if gauge["type"] != "gauge" { + t.Errorf("gauge type = %v, want gauge", gauge["type"]) + } + if gauge["value"] != float64(3.5) { + t.Errorf("gauge value = %v, want 3.5", gauge["value"]) + } + + // Histogram + hist := byName["http.latency"].Metadata["metric"].(map[string]any) + if hist["type"] != "histogram" { + t.Errorf("histogram type = %v, want histogram", hist["type"]) + } + if hist["count"] != uint64(2) { + t.Errorf("histogram count = %v, want 2", hist["count"]) + } + if hist["sum"] != float64(10) { + t.Errorf("histogram sum = %v, want 10", hist["sum"]) + } + if hist["min"] != float64(2) { + t.Errorf("histogram min = %v, want 2", hist["min"]) + } + if hist["max"] != float64(8) { + t.Errorf("histogram max = %v, want 8", hist["max"]) + } + + // Resource attributes flow into metadata. + res, ok := byName["cpu.usage"].Metadata["resource"].(map[string]any) + if !ok || res["host.name"] != "box-1" { + t.Errorf("resource attributes not propagated: %#v", byName["cpu.usage"].Metadata["resource"]) + } +} + +func TestExportLinksExemplarTraceContext(t *testing.T) { + tr := &captureTransport{} + client, err := logtide.NewClient(logtide.ClientOptions{ + Service: "metrics-test", + Transport: tr, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + defer client.Close() + + i := otelmetric.New() + i.Setup(client) + + traceID := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10} + spanID := []byte{0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8} + + rm := &metricdata.ResourceMetrics{ + ScopeMetrics: []metricdata.ScopeMetrics{ + { + Metrics: []metricdata.Metrics{ + { + Name: "http.requests", + Data: metricdata.Sum[int64]{ + IsMonotonic: true, + Temporality: metricdata.CumulativeTemporality, + DataPoints: []metricdata.DataPoint[int64]{ + { + Value: 7, + Exemplars: []metricdata.Exemplar[int64]{ + { + Value: 1, + TraceID: traceID, + SpanID: spanID, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + if err := i.Exporter().Export(context.Background(), rm); err != nil { + t.Fatalf("Export: %v", err) + } + + entries := tr.snapshot() + if len(entries) != 1 { + t.Fatalf("got %d entries, want 1", len(entries)) + } + e := entries[0] + + const wantTrace = "0102030405060708090a0b0c0d0e0f10" + const wantSpan = "a1a2a3a4a5a6a7a8" + if e.TraceID != wantTrace { + t.Errorf("entry TraceID = %q, want %q", e.TraceID, wantTrace) + } + if e.SpanID != wantSpan { + t.Errorf("entry SpanID = %q, want %q", e.SpanID, wantSpan) + } + + m := e.Metadata["metric"].(map[string]any) + exemplars, ok := m["exemplars"].([]map[string]any) + if !ok || len(exemplars) != 1 { + t.Fatalf("metadata.metric.exemplars missing or wrong shape: %#v", m["exemplars"]) + } + if exemplars[0]["trace_id"] != wantTrace { + t.Errorf("exemplar trace_id = %v, want %q", exemplars[0]["trace_id"], wantTrace) + } + if exemplars[0]["span_id"] != wantSpan { + t.Errorf("exemplar span_id = %v, want %q", exemplars[0]["span_id"], wantSpan) + } +} + +func TestExportWithoutExemplarsHasNoTraceContext(t *testing.T) { + tr := &captureTransport{} + client, err := logtide.NewClient(logtide.ClientOptions{ + Service: "metrics-test", + Transport: tr, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + defer client.Close() + + i := otelmetric.New() + i.Setup(client) + + rm := &metricdata.ResourceMetrics{ + ScopeMetrics: []metricdata.ScopeMetrics{ + { + Metrics: []metricdata.Metrics{ + { + Name: "cpu.usage", + Data: metricdata.Gauge[float64]{ + DataPoints: []metricdata.DataPoint[float64]{{Value: 1.5}}, + }, + }, + }, + }, + }, + } + + if err := i.Exporter().Export(context.Background(), rm); err != nil { + t.Fatalf("Export: %v", err) + } + + entries := tr.snapshot() + if len(entries) != 1 { + t.Fatalf("got %d entries, want 1", len(entries)) + } + if entries[0].TraceID != "" || entries[0].SpanID != "" { + t.Errorf("expected no trace context, got trace=%q span=%q", entries[0].TraceID, entries[0].SpanID) + } + if _, present := entries[0].Metadata["metric"].(map[string]any)["exemplars"]; present { + t.Errorf("expected no exemplars key when none present") + } +}