Repository: open-telemetry/opentelemetry-go (Go)

Issue:
Logs SDK observability - otlploghttp exporter metrics
Add the following [OTel Observability](https://github.com/open-telemetry/semantic-conventions/blob/v1.36.0/docs/otel/sdk-metrics.md) for [`otlploghttp`](https://pkg.go.dev/go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp):

- `otel.sdk.exporter.log.inflight`
- `otel.sdk.exporter.log.exported`
- `otel.sdk.exporter.operation.duration`

## Observability Implementation Checklist

Based on the [project Observability guidelines], ensure the following are completed:

[project Observability guidelines]: https://github.com/open-telemetry/opentelemetry-go/blob/e4ab3141123d0811125a69823dbbe4d9ec5a9b8f/CONTRIBUTING.md#observability

### Environment Variable Activation

- [ ] Observability features are disabled by default
- [ ] Features are activated through the `OTEL_GO_X_OBSERVABILITY` environment variable
- [ ] Use consistent pattern with `x.Observability.Enabled()` check[^1]
- [ ] Follow established experimental feature pattern[^2][^3]

[^1]: <https://github.com/open-telemetry/opentelemetry-go/blob/e4ab3141123d0811125a69823dbbe4d9ec5a9b8f/exporters/stdout/stdouttrace/internal/observ/instrumentation.go#L101-L103>
[^2]: <https://github.com/open-telemetry/opentelemetry-go/blob/e4ab3141123d0811125a69823dbbe4d9ec5a9b8f/exporters/stdout/stdouttrace/internal/x/x.go>
[^3]: <https://github.com/open-telemetry/opentelemetry-go/blob/e4ab3141123d0811125a69823dbbe4d9ec5a9b8f/sdk/internal/x/x.go>

### Encapsulation

- [ ] Instrumentation is encapsulated within a dedicated `struct` (e.g., [`Instrumentation`])
- [ ] Instrumentation is not mixed into the instrumented component
- [ ] Instrumentation code is in its own file or package if complex/reused
- [ ] Instrumentation setup doesn't bloat the main component code

[`Instrumentation`]: https://github.com/open-telemetry/opentelemetry-go/blob/e4ab3141123d0811125a69823dbbe4d9ec5a9b8f/exporters/stdout/stdouttrace/internal/observ/instrumentation.go#L86-L94

### Initialization

- [ ] Initialization is only done when observability is enabled
- [ ] Setup is explicit and side-effect free
- [ ] Return errors from initialization when appropriate
- [ ] Use the global Meter provider (e.g., `otel.GetMeterProvider()`)
- [ ] Include proper meter configuration with:
  - [ ] Instrumentation package name is used for the Meter
  - [ ] Instrumentation version (e.g. [`Version`])
  - [ ] Schema URL (e.g. [`SchemaURL`])

[`Version`]: https://github.com/open-telemetry/opentelemetry-go/blob/e4ab3141123d0811125a69823dbbe4d9ec5a9b8f/exporters/stdout/stdouttrace/internal/observ/instrumentation.go#L40-L43
[`SchemaURL`]: https://github.com/open-telemetry/opentelemetry-go/blob/e4ab3141123d0811125a69823dbbe4d9ec5a9b8f/exporters/stdout/stdouttrace/internal/observ/instrumentation.go#L36-L38

### Performance

- [ ] Little to no overhead when observability is disabled
- [ ] Expensive operations are only executed when observability is enabled
- [ ] When enabled, instrumentation code paths are optimized to reduce allocation/computation overhead

#### Attribute and Option Allocation Management

- [ ] Use `sync.Pool` for attribute slices and options with dynamic attributes
- [ ] Pool objects are properly reset before returning to pool
- [ ] Pools are scoped for maximum efficiency while ensuring correctness

#### Caching

- [ ] Static attribute sets known at compile time are pre-computed and cached
- [ ] Common attribute combinations use lookup tables/maps

#### Benchmarking

- [ ] Benchmarks provided for all instrumentation code
- [ ] Benchmark scenarios include both enabled and disabled observability
- [ ] Benchmark results show impact on allocs/op, B/op, and ns/op (use `b.ReportAllocs()` in benchmarks)

### Error Handling and Robustness

- [ ] Errors are reported back to caller when possible
- [ ] Partial failures are handled gracefully
- [ ] Use partially initialized components when available
- [ ] Return errors to caller instead of only using `otel.Handle()`
- [ ] Use `otel.Handle()` only when component cannot report error to user

### Context Propagation

- [ ] Observability measurements receive the context from the function being measured (don't break context propagation by using `context.Background()`)

### Semantic Conventions Compliance

- [ ] All metrics follow [OpenTelemetry Semantic Conventions]
- [ ] Use the [`otelconv`] convenience package for metric semantic conventions
- [ ] Component names follow semantic conventions
- [ ] Use package path scope type as stable identifier for non-standard components
- [ ] Component names are stable unique identifiers
- [ ] Use global counter for uniqueness if necessary
- [ ] Component ID counter is resettable for deterministic testing

[OpenTelemetry Semantic Conventions]: https://github.com/open-telemetry/semantic-conventions/blob/5ee549b1ce30fe11fcb9b7e3bd35ebfb363f467f/docs/otel/sdk-metrics.md
[`otelconv`]: https://pkg.go.dev/go.opentelemetry.io/otel@v1.38.0/semconv/v1.37.0/otelconv

### Testing

- [ ] Use deterministic testing with isolated state
- [ ] Restore previous state after tests (`t.Cleanup()`)
- [ ] Isolate meter provider for testing
- [ ] Use `t.Setenv()` for environment variable testing
- [ ] Reset component ID counter for deterministic component names
- [ ] Test order doesn't affect results



Discussion:
@pellared , I'd like to work on this also.
@yumosx are you still able to work on this issue?
yep,  I'm currently working on it.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index da0769199c0..d38a27d0484 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
 - Add experimental observability metrics in `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc`. (#7459)
 - Add experimental observability metrics in `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp`. (#7486)
 - Add experimental observability metrics for simple span processor in `go.opentelemetry.io/otel/sdk/trace`. (#7374)
+- Add experimental observability metrics in `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp`. (#7512)
 
 ### Fixed
 
diff --git a/exporters/otlp/otlplog/otlploghttp/client.go b/exporters/otlp/otlplog/otlploghttp/client.go
index 70a4a6ac566..46ef11369a6 100644
--- a/exporters/otlp/otlplog/otlploghttp/client.go
+++ b/exporters/otlp/otlplog/otlploghttp/client.go
@@ -16,12 +16,15 @@ import (
 	"strconv"
 	"strings"
 	"sync"
+	"sync/atomic"
 	"time"
 
 	collogpb "go.opentelemetry.io/proto/otlp/collector/logs/v1"
 	logpb "go.opentelemetry.io/proto/otlp/logs/v1"
 	"google.golang.org/protobuf/proto"
 
+	"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp/internal"
+	"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp/internal/observ"
 	"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp/internal/retry"
 )
 
@@ -40,6 +43,14 @@ func newNoopClient() *client {
 	return &client{}
 }
 
+var exporterN atomic.Int64
+
+// nextExporterID returns the next unique ID for an exporter.
+func nextExporterID() int64 {
+	const inc = 1
+	return exporterN.Add(inc) - inc
+}
+
 // newHTTPClient creates a new HTTP log client.
 func newHTTPClient(cfg config) (*client, error) {
 	hc := cfg.httpClient
@@ -92,7 +103,11 @@ func newHTTPClient(cfg config) (*client, error) {
 		requestFunc: cfg.retryCfg.Value.RequestFunc(evaluate),
 		client:      hc,
 	}
-	return &client{uploadLogs: c.uploadLogs}, nil
+
+	id := nextExporterID()
+	c.inst, err = observ.NewInstrumentation(id, cfg.endpoint.Value)
+
+	return &client{uploadLogs: c.uploadLogs}, err
 }
 
 type httpClient struct {
@@ -101,6 +116,8 @@ type httpClient struct {
 	compression Compression
 	requestFunc retry.RequestFunc
 	client      *http.Client
+
+	inst *observ.Instrumentation
 }
 
 // Keep it in sync with golang's DefaultTransport from net/http! We
@@ -134,6 +151,12 @@ func (c *httpClient) uploadLogs(ctx context.Context, data []*logpb.ResourceLogs)
 		return err
 	}
 
+	var statusCode int
+	if c.inst != nil {
+		op := c.inst.ExportLogs(ctx, int64(len(data)))
+		defer func() { op.End(uploadErr, statusCode) }()
+	}
+
 	return errors.Join(uploadErr, c.requestFunc(ctx, func(iCtx context.Context) error {
 		select {
 		case <-iCtx.Done():
@@ -158,6 +181,7 @@ func (c *httpClient) uploadLogs(ctx context.Context, data []*logpb.ResourceLogs)
 			}()
 		}
 
+		statusCode = resp.StatusCode
 		if sc := resp.StatusCode; sc >= 200 && sc <= 299 {
 			// Success, do not retry.
 
@@ -180,7 +204,7 @@ func (c *httpClient) uploadLogs(ctx context.Context, data []*logpb.ResourceLogs)
 					msg := respProto.PartialSuccess.GetErrorMessage()
 					n := respProto.PartialSuccess.GetRejectedLogRecords()
 					if n != 0 || msg != "" {
-						err := errPartial{msg: msg, n: n}
+						err := internal.LogPartialSuccessError(n, msg)
 						uploadErr = errors.Join(uploadErr, err)
 					}
 				}
@@ -217,23 +241,6 @@ func (c *httpClient) uploadLogs(ctx context.Context, data []*logpb.ResourceLogs)
 	}))
 }
 
-type errPartial struct {
-	msg string
-	n   int64
-}
-
-var _ error = errPartial{}
-
-func (e errPartial) Error() string {
-	const form = "OTLP partial success: %s (%d log records rejected)"
-	return fmt.Sprintf(form, e.msg, e.n)
-}
-
-func (errPartial) Is(target error) bool {
-	_, ok := target.(errPartial)
-	return ok
-}
-
 var gzPool = sync.Pool{
 	New: func() any {
 		w := gzip.NewWriter(io.Discard)


Test patch:
diff --git a/exporters/otlp/otlplog/otlploghttp/client_test.go b/exporters/otlp/otlplog/otlploghttp/client_test.go
index de0a7f295d5..5511ab7bb0e 100644
--- a/exporters/otlp/otlplog/otlploghttp/client_test.go
+++ b/exporters/otlp/otlplog/otlploghttp/client_test.go
@@ -35,8 +35,19 @@ import (
 	rpb "go.opentelemetry.io/proto/otlp/resource/v1"
 	"google.golang.org/protobuf/proto"
 
+	"go.opentelemetry.io/otel"
+
+	"go.opentelemetry.io/otel/attribute"
+	"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp/internal"
+	"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp/internal/observ"
+
+	"go.opentelemetry.io/otel/sdk/instrumentation"
 	"go.opentelemetry.io/otel/sdk/log"
+	"go.opentelemetry.io/otel/sdk/metric"
+	"go.opentelemetry.io/otel/sdk/metric/metricdata"
+	"go.opentelemetry.io/otel/sdk/metric/metricdata/metricdatatest"
 	semconv "go.opentelemetry.io/otel/semconv/v1.37.0"
+	"go.opentelemetry.io/otel/semconv/v1.37.0/otelconv"
 )
 
 var (
@@ -548,7 +559,7 @@ func TestClient(t *testing.T) {
 		ctx := t.Context()
 		client, _ := factory(rCh)
 
-		assert.ErrorIs(t, client.UploadLogs(ctx, resourceLogs), errPartial{})
+		assert.ErrorIs(t, client.UploadLogs(ctx, resourceLogs), internal.PartialSuccess{})
 		assert.NoError(t, client.UploadLogs(ctx, resourceLogs))
 		assert.NoError(t, client.UploadLogs(ctx, resourceLogs))
 	})
@@ -811,3 +822,179 @@ func TestConfig(t *testing.T) {
 		assert.ErrorContains(t, err, exporterErr.Error())
 	})
 }
+
+// SetExporterID sets the exporter ID counter to v and returns the previous
+// value.
+//
+// This function is useful for testing purposes, allowing you to reset the
+// counter. It should not be used in production code.
+func SetExporterID(v int64) int64 {
+	return exporterN.Swap(v)
+}
+
+func newFactory(t testing.TB) func(rCh <-chan exportResult) (*client, *httpCollector, string) {
+	return func(rCh <-chan exportResult) (*client, *httpCollector, string) {
+		coll, err := newHTTPCollector("", rCh)
+		require.NoError(t, err)
+
+		addr := coll.Addr().String()
+		opts := []Option{WithEndpoint(addr), WithInsecure()}
+		cfg := newConfig(opts)
+		client, err := newHTTPClient(cfg)
+		require.NoError(t, err)
+		return client, coll, addr
+	}
+}
+
+func TestClientInstrumentation(t *testing.T) {
+	// Enable instrumentation for this test.
+	t.Setenv("OTEL_GO_X_OBSERVABILITY", "true")
+
+	// Reset client ID to be a deterministic.
+	const id = 0
+	SetExporterID(id)
+
+	// Save original meter provider and restore at end of test.
+	orig := otel.GetMeterProvider()
+	t.Cleanup(func() {
+		otel.SetMeterProvider(orig)
+	})
+
+	// Create a new meter provider to captrue metrics.
+	reader := metric.NewManualReader()
+	mp := metric.NewMeterProvider(metric.WithReader(reader))
+	otel.SetMeterProvider(mp)
+
+	factory := newFactory(t)
+
+	rCh := make(chan exportResult, 1)
+	rCh <- exportResult{
+		Response: &collogpb.ExportLogsServiceResponse{
+			PartialSuccess: &collogpb.ExportLogsPartialSuccess{
+				RejectedLogRecords: 2,
+				ErrorMessage:       "partially successful",
+			},
+		},
+	}
+
+	client, coll, addr := factory(rCh)
+	t.Cleanup(func() {
+		assert.NoError(t, coll.Shutdown(t.Context()))
+	})
+	assert.ErrorIs(t, client.UploadLogs(t.Context(), resourceLogs), internal.PartialSuccess{})
+
+	var got metricdata.ResourceMetrics
+	require.NoError(t, reader.Collect(t.Context(), &got))
+
+	baseAttrs := []attribute.KeyValue{
+		semconv.OTelComponentName(observ.GetComponentName(id)),
+		semconv.OTelComponentTypeOtlpHTTPLogExporter,
+	}
+	baseAttrs = append(baseAttrs, observ.ServerAddrAttrs(addr)...)
+
+	want := metricdata.ScopeMetrics{
+		Scope: instrumentation.Scope{
+			Name:      observ.ScopeName,
+			Version:   observ.Version,
+			SchemaURL: semconv.SchemaURL,
+		},
+		Metrics: []metricdata.Metrics{
+			{
+				Name:        otelconv.SDKExporterLogInflight{}.Name(),
+				Description: otelconv.SDKExporterLogInflight{}.Description(),
+				Unit:        otelconv.SDKExporterLogInflight{}.Unit(),
+				Data: metricdata.Sum[int64]{
+					DataPoints: []metricdata.DataPoint[int64]{
+						{Attributes: attribute.NewSet(baseAttrs...)},
+					},
+					Temporality: metricdata.CumulativeTemporality,
+				},
+			},
+			{
+				Name:        otelconv.SDKExporterLogExported{}.Name(),
+				Description: otelconv.SDKExporterLogExported{}.Description(),
+				Unit:        otelconv.SDKExporterLogExported{}.Unit(),
+				Data: metricdata.Sum[int64]{
+					DataPoints: []metricdata.DataPoint[int64]{
+						{Attributes: attribute.NewSet(baseAttrs...)},
+						{Attributes: attribute.NewSet(append(
+							baseAttrs,
+							otelconv.SDKExporterLogExported{}.AttrErrorType("*errors.joinError"),
+						)...)},
+					},
+					Temporality: 0x1,
+					IsMonotonic: true,
+				},
+			},
+			{
+				Name:        otelconv.SDKExporterOperationDuration{}.Name(),
+				Description: otelconv.SDKExporterOperationDuration{}.Description(),
+				Unit:        otelconv.SDKExporterOperationDuration{}.Unit(),
+				Data: metricdata.Histogram[float64]{
+					DataPoints: []metricdata.HistogramDataPoint[float64]{
+						{Attributes: attribute.NewSet(append(
+							baseAttrs,
+							otelconv.SDKExporterOperationDuration{}.AttrErrorType("*errors.joinError"),
+							otelconv.SDKExporterOperationDuration{}.AttrHTTPResponseStatusCode(200),
+						)...)},
+					},
+					Temporality: 0x1,
+				},
+			},
+		},
+	}
+
+	require.Len(t, got.ScopeMetrics, 1)
+	opt := []metricdatatest.Option{
+		metricdatatest.IgnoreTimestamp(),
+		metricdatatest.IgnoreExemplars(),
+		metricdatatest.IgnoreValue(),
+	}
+	metricdatatest.AssertEqual(t, want, got.ScopeMetrics[0], opt...)
+}
+
+func BenchmarkExporterExportLogs(b *testing.B) {
+	const n = 10
+
+	run := func(b *testing.B) {
+		factory := newFactory(b)
+		client, coll, _ := factory(nil)
+		b.Cleanup(func() {
+			//nolint:usetesting // required to avoid getting a canceled context at cleanup.
+			assert.NoError(b, coll.srv.Shutdown(context.Background()))
+		})
+
+		stubs := make([]*lpb.ResourceLogs, n)
+		for i := range stubs {
+			stubs[i] = &lpb.ResourceLogs{
+				Resource: &rpb.Resource{
+					Attributes: []*cpb.KeyValue{kvSrvName, kvSrvVer},
+				},
+				ScopeLogs: []*lpb.ScopeLogs{
+					{
+						Scope:      scope,
+						LogRecords: logRecords[:(i+1)%(len(logRecords)+1)],
+						SchemaUrl:  semconv.SchemaURL,
+					},
+				},
+				SchemaUrl: semconv.SchemaURL,
+			}
+		}
+
+		b.ReportAllocs()
+		b.ResetTimer()
+
+		var err error
+		for b.Loop() {
+			err = client.UploadLogs(b.Context(), stubs)
+		}
+		_ = err
+	}
+
+	b.Run("Observability", func(b *testing.B) {
+		b.Setenv("OTEL_GO_X_OBSERVABILITY", "true")
+		run(b)
+	})
+
+	b.Run("NoObservability", run)
+}