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.



Fix (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)