From f40f5c13c6b16ee83fc2a1dfabaa5238e8a0cecb Mon Sep 17 00:00:00 2001 From: Polliog <40077351+Polliog@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:23:44 +0200 Subject: [PATCH 1/4] ignore /docs/ directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/ From 42787f8c0ce42b64109c53406b2dd23fa992a2d5 Mon Sep 17 00:00:00 2001 From: Polliog <40077351+Polliog@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:29:14 +0200 Subject: [PATCH 2/4] add OpenTelemetry metric exporter integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New opt-in integrations/otelmetric package implementing sdkmetric.Exporter. It bridges OTel counters, gauges and histograms into the LogTide pipeline as log entries (one per data point), inheriting the client's service name, environment, tags and resource attributes — the same pattern as the existing span exporter. The LogTide ingest endpoint accepts log entries only, so this reuses the existing transport rather than speaking OTLP to a separate backend. - one LogEntry per data point with metadata.metric {name, type, value/count /sum/min/max/bucket_counts/explicit_bounds, attributes, timestamps} - resource attributes and instrumentation scope propagated into metadata - nil-client safe before Setup (Export/Flush/Shutdown no-op) - examples/otelmetric demonstrating counter + histogram export - README, CHANGELOG and CONFORMANCE (C29) updated Closes #2 --- CHANGELOG.md | 6 + CONFORMANCE.md | 1 + README.md | 34 ++- examples/otelmetric/main.go | 88 +++++++ go.mod | 5 +- integrations/otelmetric/otelmetric.go | 246 ++++++++++++++++++++ integrations/otelmetric/otelmetric_test.go | 256 +++++++++++++++++++++ 7 files changed, 633 insertions(+), 3 deletions(-) create mode 100644 examples/otelmetric/main.go create mode 100644 integrations/otelmetric/otelmetric.go create mode 100644 integrations/otelmetric/otelmetric_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ebd1963..7d8e50a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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). +## [Unreleased] + +### 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) + ## [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..d7a3c21 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,37 @@ 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) +``` + --- ## Flush & shutdown @@ -278,6 +309,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..a642a81 --- /dev/null +++ b/integrations/otelmetric/otelmetric.go @@ -0,0 +1,246 @@ +// 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" + "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 + } + e.client.Info(ctx, 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 +} + +// 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 + out = append(out, entry{ + message: fmt.Sprintf("metric %s = %v", m.Name, dp.Value), + meta: meta, + }) + } + 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 + out = append(out, entry{ + message: fmt.Sprintf("metric %s = %v", m.Name, dp.Value), + meta: meta, + }) + } + 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 + } + out = append(out, entry{ + message: fmt.Sprintf("metric %s count=%d sum=%v", m.Name, dp.Count, dp.Sum), + meta: meta, + }) + } + return out +} + +// 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..4e95dae --- /dev/null +++ b/integrations/otelmetric/otelmetric_test.go @@ -0,0 +1,256 @@ +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"]) + } +} From 0afe01bb4d0d308730f1996d421fe6ffc35d4feb Mon Sep 17 00:00:00 2001 From: Polliog <40077351+Polliog@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:57:33 +0200 Subject: [PATCH 3/4] release 1.0.0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d8e50a..b8fd5fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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). -## [Unreleased] +## [1.0.0] - 2026-06-20 ### Added From e5198d47ff85aa2e853d2646d504c717fe613144 Mon Sep 17 00:00:00 2001 From: Polliog <40077351+Polliog@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:33:19 +0200 Subject: [PATCH 4/4] otelmetric: link metric exemplars to trace context When a data point carries an exemplar with trace context, the resulting entry is linked to that trace_id/span_id (via a scope, mirroring the span exporter), and the full exemplar list is recorded under metadata.metric.exemplars (value, time, trace_id, span_id, filtered_attributes). Exemplars are only populated when an exemplar filter is configured and a sampled span is active during measurement, so this is a no-op otherwise. --- CHANGELOG.md | 1 + README.md | 6 + integrations/otelmetric/otelmetric.go | 73 ++++++++++-- integrations/otelmetric/otelmetric_test.go | 123 +++++++++++++++++++++ 4 files changed, 196 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8fd5fc..44d7749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 diff --git a/README.md b/README.md index d7a3c21..fa8a369 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,12 @@ mp := sdkmetric.NewMeterProvider( 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 diff --git a/integrations/otelmetric/otelmetric.go b/integrations/otelmetric/otelmetric.go index a642a81..eaf3d83 100644 --- a/integrations/otelmetric/otelmetric.go +++ b/integrations/otelmetric/otelmetric.go @@ -27,6 +27,7 @@ package otelmetric import ( "context" + "encoding/hex" "fmt" "time" @@ -86,7 +87,19 @@ func (e *Exporter) Export(ctx context.Context, rm *metricdata.ResourceMetrics) e if scopeMeta != nil { meta["scope"] = scopeMeta } - e.client.Info(ctx, metric.message, meta) + + // 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) } } } @@ -140,6 +153,10 @@ func (i *Integration) Exporter() *Exporter { 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. @@ -168,10 +185,12 @@ func sumEntries[N int64 | float64](m metricdata.Metrics, dps []metricdata.DataPo meta := baseMeta(m, "counter", dp.Attributes, dp.StartTime, dp.Time) meta["value"] = dp.Value meta["is_monotonic"] = monotonic - out = append(out, entry{ + e := entry{ message: fmt.Sprintf("metric %s = %v", m.Name, dp.Value), meta: meta, - }) + } + applyExemplars(&e, dp.Exemplars) + out = append(out, e) } return out } @@ -181,10 +200,12 @@ func gaugeEntries[N int64 | float64](m metricdata.Metrics, dps []metricdata.Data for _, dp := range dps { meta := baseMeta(m, "gauge", dp.Attributes, dp.StartTime, dp.Time) meta["value"] = dp.Value - out = append(out, entry{ + e := entry{ message: fmt.Sprintf("metric %s = %v", m.Name, dp.Value), meta: meta, - }) + } + applyExemplars(&e, dp.Exemplars) + out = append(out, e) } return out } @@ -203,14 +224,52 @@ func histogramEntries[N int64 | float64](m metricdata.Metrics, dps []metricdata. if v, ok := dp.Max.Value(); ok { meta["max"] = v } - out = append(out, entry{ + 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{ diff --git a/integrations/otelmetric/otelmetric_test.go b/integrations/otelmetric/otelmetric_test.go index 4e95dae..d3ba6cb 100644 --- a/integrations/otelmetric/otelmetric_test.go +++ b/integrations/otelmetric/otelmetric_test.go @@ -254,3 +254,126 @@ func TestExportConvertsEachDataPointToEntry(t *testing.T) { 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") + } +}