You've already forked opentelemetry-go
							
							
				mirror of
				https://github.com/open-telemetry/opentelemetry-go.git
				synced 2025-10-31 00:07:40 +02:00 
			
		
		
		
	feat: logs SDK observability - otlploggrpc exporter metrics (#7353)
This PR adds support for experimental metrics in `otlploggrpc`
- `otel.sdk.exporter.log.inflight`
- `otel.sdk.exporter.log.exported`
- `otel.sdk.exporter.operation.duration`
References:
-  #7084 
-  https://github.com/open-telemetry/opentelemetry-go/issues/7019
- [Follow
guidelines](a5dcd68ebb/CONTRIBUTING.md (encapsulation)).
-----
```txt
goos: darwin
goarch: arm64
pkg: go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc
cpu: Apple M3
                                   │ disabled.txt │          enabled.txt          │
                                   │    sec/op    │   sec/op     vs base          │
ExporterExportLogs/Observability-8    681.5µ ± 3%   684.3µ ± 6%  ~ (p=0.315 n=10)
                                   │ disabled.txt │          enabled.txt           │
                                   │     B/op     │     B/op      vs base          │
ExporterExportLogs/Observability-8   672.8Ki ± 0%   673.6Ki ± 1%  ~ (p=0.247 n=10)
                                   │ disabled.txt │            enabled.txt             │
                                   │  allocs/op   │  allocs/op   vs base               │
ExporterExportLogs/Observability-8    9.224k ± 0%   9.232k ± 0%  +0.09% (p=0.000 n=10)
```
-----
```txt
goos: darwin
goarch: arm64
pkg: go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal/observ
cpu: Apple M3
                                         │  bench.txt  │
                                         │   sec/op    │
InstrumentationExportLogs/NoError-8        162.6n ± 3%
InstrumentationExportLogs/PartialError-8   705.5n ± 5%
InstrumentationExportLogs/FullError-8      592.1n ± 1%
geomean                                    408.0n
                                         │ bench.txt  │
                                         │    B/op    │
InstrumentationExportLogs/NoError-8        152.0 ± 0%
InstrumentationExportLogs/PartialError-8   697.0 ± 0%
InstrumentationExportLogs/FullError-8      616.0 ± 0%
geomean                                    402.6
                                         │ bench.txt  │
                                         │ allocs/op  │
InstrumentationExportLogs/NoError-8        3.000 ± 0%
InstrumentationExportLogs/PartialError-8   10.00 ± 0%
InstrumentationExportLogs/FullError-8      8.000 ± 0%
geomean                                    6.214
```
-----
```txt
pkg: go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal/observ
cpu: Apple M3
                                 │ parse_target.txt │
                                 │      sec/op      │
ParseTarget/HostName-8                 38.00n ± ∞ ¹
ParseTarget/HostPort-8                 51.33n ± ∞ ¹
ParseTarget/IPv4WithoutPort-8          44.74n ± ∞ ¹
ParseTarget/IPv4WithPort-8             62.56n ± ∞ ¹
ParseTarget/IPv6Bare-8                 94.89n ± ∞ ¹
ParseTarget/IPv6Bracket-8              93.78n ± ∞ ¹
ParseTarget/IPv6WithPort-8             57.57n ± ∞ ¹
ParseTarget/UnixSocket-8               8.329n ± ∞ ¹
ParseTarget/UnixAbstractSocket-8       9.082n ± ∞ ¹
ParseTarget/Passthrough-8              58.06n ± ∞ ¹
geomean                                40.64n
¹ need >= 6 samples for confidence interval at level 0.95
                                 │ parse_target.txt │
                                 │       B/op       │
ParseTarget/HostName-8                  48.00 ± ∞ ¹
ParseTarget/HostPort-8                  48.00 ± ∞ ¹
ParseTarget/IPv4WithoutPort-8           16.00 ± ∞ ¹
ParseTarget/IPv4WithPort-8              48.00 ± ∞ ¹
ParseTarget/IPv6Bare-8                  16.00 ± ∞ ¹
ParseTarget/IPv6Bracket-8               16.00 ± ∞ ¹
ParseTarget/IPv6WithPort-8              48.00 ± ∞ ¹
ParseTarget/UnixSocket-8                0.000 ± ∞ ¹
ParseTarget/UnixAbstractSocket-8        0.000 ± ∞ ¹
ParseTarget/Passthrough-8               48.00 ± ∞ ¹
geomean                                           ²
¹ need >= 6 samples for confidence interval at level 0.95
² summaries must be >0 to compute geomean
                                 │ parse_target.txt │
                                 │    allocs/op     │
ParseTarget/HostName-8                  1.000 ± ∞ ¹
ParseTarget/HostPort-8                  1.000 ± ∞ ¹
ParseTarget/IPv4WithoutPort-8           1.000 ± ∞ ¹
ParseTarget/IPv4WithPort-8              1.000 ± ∞ ¹
ParseTarget/IPv6Bare-8                  1.000 ± ∞ ¹
ParseTarget/IPv6Bracket-8               1.000 ± ∞ ¹
ParseTarget/IPv6WithPort-8              1.000 ± ∞ ¹
ParseTarget/UnixSocket-8                0.000 ± ∞ ¹
ParseTarget/UnixAbstractSocket-8        0.000 ± ∞ ¹
ParseTarget/Passthrough-8               1.000 ± ∞ ¹
geomean                                           ²
¹ need >= 6 samples for confidence interval at level 0.95
² summaries must be >0 to compute geomean
```
---------
Co-authored-by: Tyler Yahn <MrAlias@users.noreply.github.com>
			
			
This commit is contained in:
		| @@ -16,6 +16,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm | ||||
| - Greatly reduce the cost of recording metrics in `go.opentelemetry.io/otel/sdk/metric` using hashing for map keys. (#7175) | ||||
| - Add experimental observability for the prometheus exporter in `go.opentelemetry.io/otel/exporters/prometheus`. | ||||
|   Check the `go.opentelemetry.io/otel/exporters/prometheus/internal/x` package documentation for more information. (#7345) | ||||
| - Add experimental observability metrics in `go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc`. (#7353) | ||||
|  | ||||
| ### Fixed | ||||
|  | ||||
|   | ||||
| @@ -6,7 +6,7 @@ package otlploggrpc // import "go.opentelemetry.io/otel/exporters/otlp/otlplog/o | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"sync/atomic" | ||||
| 	"time" | ||||
|  | ||||
| 	collogpb "go.opentelemetry.io/proto/otlp/collector/logs/v1" | ||||
| @@ -21,6 +21,8 @@ import ( | ||||
| 	"google.golang.org/grpc/metadata" | ||||
| 	"google.golang.org/grpc/status" | ||||
|  | ||||
| 	"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal" | ||||
| 	"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal/observ" | ||||
| 	"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal/retry" | ||||
| ) | ||||
|  | ||||
| @@ -37,6 +39,8 @@ type client struct { | ||||
| 	ourConn bool | ||||
| 	conn    *grpc.ClientConn | ||||
| 	lsc     collogpb.LogsServiceClient | ||||
|  | ||||
| 	instrumentation *observ.Instrumentation | ||||
| } | ||||
|  | ||||
| // Used for testing. | ||||
| @@ -71,7 +75,18 @@ func newClient(cfg config) (*client, error) { | ||||
|  | ||||
| 	c.lsc = collogpb.NewLogsServiceClient(c.conn) | ||||
|  | ||||
| 	return c, nil | ||||
| 	var err error | ||||
| 	id := nextExporterID() | ||||
| 	c.instrumentation, err = observ.NewInstrumentation(id, c.conn.CanonicalTarget()) | ||||
| 	return c, err | ||||
| } | ||||
|  | ||||
| var exporterN atomic.Int64 | ||||
|  | ||||
| // nextExporterID returns the next unique ID for an exporter. | ||||
| func nextExporterID() int64 { | ||||
| 	const inc = 1 | ||||
| 	return exporterN.Add(inc) - inc | ||||
| } | ||||
|  | ||||
| func newGRPCDialOptions(cfg config) []grpc.DialOption { | ||||
| @@ -131,6 +146,14 @@ func (c *client) UploadLogs(ctx context.Context, rl []*logpb.ResourceLogs) (uplo | ||||
| 	ctx, cancel := c.exportContext(ctx) | ||||
| 	defer cancel() | ||||
|  | ||||
| 	count := int64(len(rl)) | ||||
| 	if c.instrumentation != nil { | ||||
| 		eo := c.instrumentation.ExportLogs(ctx, count) | ||||
| 		defer func() { | ||||
| 			eo.End(uploadErr) | ||||
| 		}() | ||||
| 	} | ||||
|  | ||||
| 	return errors.Join(uploadErr, c.requestFunc(ctx, func(ctx context.Context) error { | ||||
| 		resp, err := c.lsc.Export(ctx, &collogpb.ExportLogsServiceRequest{ | ||||
| 			ResourceLogs: rl, | ||||
| @@ -139,7 +162,7 @@ func (c *client) UploadLogs(ctx context.Context, rl []*logpb.ResourceLogs) (uplo | ||||
| 			msg := resp.PartialSuccess.GetErrorMessage() | ||||
| 			n := resp.PartialSuccess.GetRejectedLogRecords() | ||||
| 			if n != 0 || msg != "" { | ||||
| 				err := errPartial{msg: msg, n: n} | ||||
| 				err := internal.LogPartialSuccessError(n, msg) | ||||
| 				uploadErr = errors.Join(uploadErr, err) | ||||
| 			} | ||||
| 		} | ||||
| @@ -152,23 +175,6 @@ func (c *client) UploadLogs(ctx context.Context, rl []*logpb.ResourceLogs) (uplo | ||||
| 	})) | ||||
| } | ||||
|  | ||||
| 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 | ||||
| } | ||||
|  | ||||
| // Shutdown shuts down the client, freeing all resources. | ||||
| // | ||||
| // Any active connections to a remote endpoint are closed if they were created | ||||
|   | ||||
| @@ -5,6 +5,7 @@ package otlploggrpc // import "go.opentelemetry.io/otel/exporters/otlp/otlplog/o | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"net" | ||||
| 	"sync" | ||||
| 	"testing" | ||||
| @@ -27,8 +28,17 @@ import ( | ||||
| 	"google.golang.org/protobuf/proto" | ||||
| 	"google.golang.org/protobuf/types/known/durationpb" | ||||
|  | ||||
| 	"go.opentelemetry.io/otel" | ||||
| 	"go.opentelemetry.io/otel/attribute" | ||||
| 	"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal" | ||||
| 	"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/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 ( | ||||
| @@ -546,7 +556,7 @@ func TestClient(t *testing.T) { | ||||
| 		ctx := t.Context() | ||||
| 		client, _ := clientFactory(t, 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)) | ||||
| 	}) | ||||
| @@ -587,3 +597,694 @@ func TestConfig(t *testing.T) { | ||||
| 		assert.Equal(t, []string{headers[key]}, got[key]) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| // 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 TestClientObservability(t *testing.T) { | ||||
| 	testCases := []struct { | ||||
| 		name    string | ||||
| 		enabled bool | ||||
| 		test    func(t *testing.T, scopeMetrics func() metricdata.ScopeMetrics) | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:    "disable", | ||||
| 			enabled: false, | ||||
| 			test: func(t *testing.T, _ func() metricdata.ScopeMetrics) { | ||||
| 				client, _ := clientFactory(t, nil) | ||||
| 				assert.Empty(t, client.instrumentation) | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "upload success", | ||||
| 			enabled: true, | ||||
| 			test: func(t *testing.T, scopeMetrics func() metricdata.ScopeMetrics) { | ||||
| 				ctx := t.Context() | ||||
| 				client, coll := clientFactory(t, nil) | ||||
|  | ||||
| 				componentName := observ.GetComponentName(0) | ||||
| 				serverAddrAttrs := observ.ServerAddrAttrs(client.conn.CanonicalTarget()) | ||||
| 				wantMetrics := 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]{ | ||||
| 								Temporality: metricdata.CumulativeTemporality, | ||||
| 								DataPoints: []metricdata.DataPoint[int64]{ | ||||
| 									{ | ||||
| 										Attributes: attribute.NewSet( | ||||
| 											otelconv.SDKExporterLogInflight{}.AttrComponentName(componentName), | ||||
| 											otelconv.SDKExporterLogInflight{}.AttrComponentType( | ||||
| 												otelconv.ComponentTypeOtlpGRPCLogExporter, | ||||
| 											), | ||||
| 											serverAddrAttrs[0], | ||||
| 											serverAddrAttrs[1], | ||||
| 										), | ||||
| 										Value: 0, | ||||
| 									}, | ||||
| 								}, | ||||
| 							}, | ||||
| 						}, | ||||
| 						{ | ||||
| 							Name:        otelconv.SDKExporterLogExported{}.Name(), | ||||
| 							Description: otelconv.SDKExporterLogExported{}.Description(), | ||||
| 							Unit:        otelconv.SDKExporterLogExported{}.Unit(), | ||||
| 							Data: metricdata.Sum[int64]{ | ||||
| 								Temporality: metricdata.CumulativeTemporality, | ||||
| 								IsMonotonic: true, | ||||
| 								DataPoints: []metricdata.DataPoint[int64]{ | ||||
| 									{ | ||||
| 										Attributes: attribute.NewSet( | ||||
| 											otelconv.SDKExporterLogExported{}.AttrComponentName(componentName), | ||||
| 											otelconv.SDKExporterLogExported{}.AttrComponentType( | ||||
| 												otelconv.ComponentTypeOtlpGRPCLogExporter, | ||||
| 											), | ||||
| 											serverAddrAttrs[0], | ||||
| 											serverAddrAttrs[1], | ||||
| 										), | ||||
| 										Value: int64(len(resourceLogs)), | ||||
| 									}, | ||||
| 								}, | ||||
| 							}, | ||||
| 						}, | ||||
| 						{ | ||||
| 							Name:        otelconv.SDKExporterOperationDuration{}.Name(), | ||||
| 							Description: otelconv.SDKExporterOperationDuration{}.Description(), | ||||
| 							Unit:        otelconv.SDKExporterOperationDuration{}.Unit(), | ||||
| 							Data: metricdata.Histogram[float64]{ | ||||
| 								Temporality: metricdata.CumulativeTemporality, | ||||
| 								DataPoints: []metricdata.HistogramDataPoint[float64]{ | ||||
| 									{ | ||||
| 										Attributes: attribute.NewSet( | ||||
| 											otelconv.SDKExporterLogExported{}.AttrComponentName(componentName), | ||||
| 											otelconv.SDKExporterOperationDuration{}.AttrComponentType( | ||||
| 												otelconv.ComponentTypeOtlpGRPCLogExporter, | ||||
| 											), | ||||
| 											otelconv.SDKExporterOperationDuration{}.AttrRPCGRPCStatusCode( | ||||
| 												otelconv.RPCGRPCStatusCodeAttr( | ||||
| 													codes.OK, | ||||
| 												), | ||||
| 											), | ||||
| 											serverAddrAttrs[0], | ||||
| 											serverAddrAttrs[1], | ||||
| 										), | ||||
| 										Count: 1, | ||||
| 									}, | ||||
| 								}, | ||||
| 							}, | ||||
| 						}, | ||||
| 					}, | ||||
| 				} | ||||
|  | ||||
| 				require.NoError(t, client.UploadLogs(ctx, resourceLogs)) | ||||
| 				require.NoError(t, client.Shutdown(ctx)) | ||||
| 				got := coll.Collect().Dump() | ||||
| 				require.Len(t, got, 1, "upload of one ResourceLogs") | ||||
| 				diff := cmp.Diff(got[0], resourceLogs[0], cmp.Comparer(proto.Equal)) | ||||
| 				if diff != "" { | ||||
| 					t.Fatalf("unexpected ResourceLogs:\n%s", diff) | ||||
| 				} | ||||
|  | ||||
| 				assert.Equal(t, instrumentation.Scope{ | ||||
| 					Name:      observ.ScopeName, | ||||
| 					Version:   observ.Version, | ||||
| 					SchemaURL: semconv.SchemaURL, | ||||
| 				}, wantMetrics.Scope) | ||||
|  | ||||
| 				g := scopeMetrics() | ||||
| 				metricdatatest.AssertEqual(t, wantMetrics.Metrics[0], g.Metrics[0], metricdatatest.IgnoreTimestamp()) | ||||
| 				metricdatatest.AssertEqual(t, wantMetrics.Metrics[1], g.Metrics[1], metricdatatest.IgnoreTimestamp()) | ||||
| 				metricdatatest.AssertEqual( | ||||
| 					t, | ||||
| 					wantMetrics.Metrics[2], | ||||
| 					g.Metrics[2], | ||||
| 					metricdatatest.IgnoreTimestamp(), | ||||
| 					metricdatatest.IgnoreValue(), | ||||
| 				) | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "partial success", | ||||
| 			enabled: true, | ||||
| 			test: func(t *testing.T, scopeMetrics func() metricdata.ScopeMetrics) { | ||||
| 				const n, msg = 2, "bad data" | ||||
| 				rCh := make(chan exportResult, 1) | ||||
| 				rCh <- exportResult{ | ||||
| 					Response: &collogpb.ExportLogsServiceResponse{ | ||||
| 						PartialSuccess: &collogpb.ExportLogsPartialSuccess{ | ||||
| 							RejectedLogRecords: n, | ||||
| 							ErrorMessage:       msg, | ||||
| 						}, | ||||
| 					}, | ||||
| 				} | ||||
| 				ctx := t.Context() | ||||
| 				client, _ := clientFactory(t, rCh) | ||||
|  | ||||
| 				componentName := observ.GetComponentName(0) | ||||
| 				serverAddrAttrs := observ.ServerAddrAttrs(client.conn.CanonicalTarget()) | ||||
| 				var wantErr error | ||||
| 				wantErr = errors.Join(wantErr, internal.LogPartialSuccessError(n, msg)) | ||||
| 				wantMetrics := 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]{ | ||||
| 								Temporality: metricdata.CumulativeTemporality, | ||||
| 								DataPoints: []metricdata.DataPoint[int64]{ | ||||
| 									{ | ||||
| 										Attributes: attribute.NewSet( | ||||
| 											otelconv.SDKExporterLogInflight{}.AttrComponentName(componentName), | ||||
| 											otelconv.SDKExporterLogInflight{}.AttrComponentType( | ||||
| 												otelconv.ComponentTypeOtlpGRPCLogExporter, | ||||
| 											), | ||||
| 											serverAddrAttrs[0], | ||||
| 											serverAddrAttrs[1], | ||||
| 										), | ||||
|  | ||||
| 										Value: 0, | ||||
| 									}, | ||||
| 								}, | ||||
| 							}, | ||||
| 						}, | ||||
| 						{ | ||||
| 							Name:        otelconv.SDKExporterLogExported{}.Name(), | ||||
| 							Description: otelconv.SDKExporterLogExported{}.Description(), | ||||
| 							Unit:        otelconv.SDKExporterLogExported{}.Unit(), | ||||
| 							Data: metricdata.Sum[int64]{ | ||||
| 								Temporality: metricdata.CumulativeTemporality, | ||||
| 								IsMonotonic: true, | ||||
| 								DataPoints: []metricdata.DataPoint[int64]{ | ||||
| 									{ | ||||
| 										Attributes: attribute.NewSet( | ||||
| 											otelconv.SDKExporterLogExported{}.AttrComponentName(componentName), | ||||
| 											otelconv.SDKExporterLogExported{}.AttrComponentType( | ||||
| 												otelconv.ComponentTypeOtlpGRPCLogExporter, | ||||
| 											), | ||||
| 											serverAddrAttrs[0], | ||||
| 											serverAddrAttrs[1], | ||||
| 										), | ||||
| 										Value: 0, | ||||
| 									}, | ||||
| 									{ | ||||
| 										Attributes: attribute.NewSet( | ||||
| 											otelconv.SDKExporterLogExported{}.AttrComponentName(componentName), | ||||
| 											otelconv.SDKExporterLogExported{}.AttrComponentType( | ||||
| 												otelconv.ComponentTypeOtlpGRPCLogExporter, | ||||
| 											), | ||||
| 											serverAddrAttrs[0], | ||||
| 											serverAddrAttrs[1], | ||||
| 											semconv.ErrorType(wantErr), | ||||
| 										), | ||||
| 										Value: 1, | ||||
| 									}, | ||||
| 								}, | ||||
| 							}, | ||||
| 						}, | ||||
| 						{ | ||||
| 							Name:        otelconv.SDKExporterOperationDuration{}.Name(), | ||||
| 							Description: otelconv.SDKExporterOperationDuration{}.Description(), | ||||
| 							Unit:        otelconv.SDKExporterOperationDuration{}.Unit(), | ||||
| 							Data: metricdata.Histogram[float64]{ | ||||
| 								Temporality: metricdata.CumulativeTemporality, | ||||
| 								DataPoints: []metricdata.HistogramDataPoint[float64]{ | ||||
| 									{ | ||||
| 										Attributes: attribute.NewSet( | ||||
| 											otelconv.SDKExporterLogExported{}.AttrComponentName(componentName), | ||||
| 											otelconv.SDKExporterOperationDuration{}.AttrComponentType( | ||||
| 												otelconv.ComponentTypeOtlpGRPCLogExporter, | ||||
| 											), | ||||
| 											otelconv.SDKExporterOperationDuration{}.AttrRPCGRPCStatusCode( | ||||
| 												otelconv.RPCGRPCStatusCodeAttr( | ||||
| 													status.Code(wantErr), | ||||
| 												), | ||||
| 											), | ||||
| 											serverAddrAttrs[0], | ||||
| 											serverAddrAttrs[1], | ||||
| 											semconv.ErrorType(wantErr), | ||||
| 										), | ||||
| 										Count: 1, | ||||
| 									}, | ||||
| 								}, | ||||
| 							}, | ||||
| 						}, | ||||
| 					}, | ||||
| 				} | ||||
|  | ||||
| 				err := client.UploadLogs(ctx, resourceLogs) | ||||
| 				assert.ErrorContains(t, err, wantErr.Error()) | ||||
|  | ||||
| 				assert.Equal(t, instrumentation.Scope{ | ||||
| 					Name:      observ.ScopeName, | ||||
| 					Version:   observ.Version, | ||||
| 					SchemaURL: semconv.SchemaURL, | ||||
| 				}, wantMetrics.Scope) | ||||
|  | ||||
| 				g := scopeMetrics() | ||||
| 				metricdatatest.AssertEqual(t, wantMetrics.Metrics[0], g.Metrics[0], metricdatatest.IgnoreTimestamp()) | ||||
| 				metricdatatest.AssertEqual(t, wantMetrics.Metrics[1], g.Metrics[1], metricdatatest.IgnoreTimestamp()) | ||||
| 				metricdatatest.AssertEqual( | ||||
| 					t, | ||||
| 					wantMetrics.Metrics[2], | ||||
| 					g.Metrics[2], | ||||
| 					metricdatatest.IgnoreTimestamp(), | ||||
| 					metricdatatest.IgnoreValue(), | ||||
| 				) | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "upload failure", | ||||
| 			enabled: true, | ||||
| 			test: func(t *testing.T, scopeMetrics func() metricdata.ScopeMetrics) { | ||||
| 				err := status.Error(codes.InvalidArgument, "request contains invalid arguments") | ||||
| 				var wantErr error | ||||
| 				wantErr = errors.Join(wantErr, err) | ||||
|  | ||||
| 				wantErrTypeAttr := semconv.ErrorType(wantErr) | ||||
| 				wantGRPCStatusCodeAttr := otelconv.RPCGRPCStatusCodeAttr(codes.InvalidArgument) | ||||
| 				rCh := make(chan exportResult, 1) | ||||
| 				rCh <- exportResult{ | ||||
| 					Err: err, | ||||
| 				} | ||||
| 				ctx := t.Context() | ||||
| 				client, _ := clientFactory(t, rCh) | ||||
| 				uploadErr := client.UploadLogs(ctx, resourceLogs) | ||||
| 				assert.ErrorContains(t, uploadErr, "request contains invalid arguments") | ||||
|  | ||||
| 				componentName := observ.GetComponentName(0) | ||||
|  | ||||
| 				serverAddrAttrs := observ.ServerAddrAttrs(client.conn.CanonicalTarget()) | ||||
| 				wantMetrics := 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]{ | ||||
| 								Temporality: metricdata.CumulativeTemporality, | ||||
| 								DataPoints: []metricdata.DataPoint[int64]{ | ||||
| 									{ | ||||
| 										Attributes: attribute.NewSet( | ||||
| 											otelconv.SDKExporterLogInflight{}.AttrComponentName(componentName), | ||||
| 											otelconv.SDKExporterLogInflight{}.AttrComponentType( | ||||
| 												otelconv.ComponentTypeOtlpGRPCLogExporter, | ||||
| 											), | ||||
| 											serverAddrAttrs[0], | ||||
| 											serverAddrAttrs[1], | ||||
| 										), | ||||
| 										Value: 0, | ||||
| 									}, | ||||
| 								}, | ||||
| 							}, | ||||
| 						}, | ||||
| 						{ | ||||
| 							Name:        otelconv.SDKExporterLogExported{}.Name(), | ||||
| 							Description: otelconv.SDKExporterLogExported{}.Description(), | ||||
| 							Unit:        otelconv.SDKExporterLogExported{}.Unit(), | ||||
| 							Data: metricdata.Sum[int64]{ | ||||
| 								Temporality: metricdata.CumulativeTemporality, | ||||
| 								IsMonotonic: true, | ||||
| 								DataPoints: []metricdata.DataPoint[int64]{ | ||||
| 									{ | ||||
| 										Attributes: attribute.NewSet( | ||||
| 											otelconv.SDKExporterLogExported{}.AttrComponentName(componentName), | ||||
| 											otelconv.SDKExporterLogExported{}.AttrComponentType( | ||||
| 												otelconv.ComponentTypeOtlpGRPCLogExporter, | ||||
| 											), | ||||
| 											serverAddrAttrs[0], | ||||
| 											serverAddrAttrs[1], | ||||
| 										), | ||||
| 										Value: 0, | ||||
| 									}, | ||||
| 									{ | ||||
| 										Attributes: attribute.NewSet( | ||||
| 											otelconv.SDKExporterLogExported{}.AttrComponentName(componentName), | ||||
| 											otelconv.SDKExporterLogExported{}.AttrComponentType( | ||||
| 												otelconv.ComponentTypeOtlpGRPCLogExporter, | ||||
| 											), | ||||
| 											serverAddrAttrs[0], | ||||
| 											serverAddrAttrs[1], | ||||
| 											wantErrTypeAttr, | ||||
| 										), | ||||
| 										Value: 1, | ||||
| 									}, | ||||
| 								}, | ||||
| 							}, | ||||
| 						}, | ||||
| 						{ | ||||
| 							Name:        otelconv.SDKExporterOperationDuration{}.Name(), | ||||
| 							Description: otelconv.SDKExporterOperationDuration{}.Description(), | ||||
| 							Unit:        otelconv.SDKExporterOperationDuration{}.Unit(), | ||||
| 							Data: metricdata.Histogram[float64]{ | ||||
| 								Temporality: metricdata.CumulativeTemporality, | ||||
| 								DataPoints: []metricdata.HistogramDataPoint[float64]{ | ||||
| 									{ | ||||
| 										Attributes: attribute.NewSet( | ||||
| 											otelconv.SDKExporterLogExported{}.AttrComponentName(componentName), | ||||
| 											otelconv.SDKExporterOperationDuration{}.AttrComponentType( | ||||
| 												otelconv.ComponentTypeOtlpGRPCLogExporter, | ||||
| 											), | ||||
| 											otelconv.SDKExporterOperationDuration{}.AttrRPCGRPCStatusCode( | ||||
| 												wantGRPCStatusCodeAttr, | ||||
| 											), | ||||
| 											serverAddrAttrs[0], | ||||
| 											serverAddrAttrs[1], | ||||
| 											wantErrTypeAttr, | ||||
| 										), | ||||
| 										Count: 1, | ||||
| 									}, | ||||
| 								}, | ||||
| 							}, | ||||
| 						}, | ||||
| 					}, | ||||
| 				} | ||||
| 				g := scopeMetrics() | ||||
| 				assert.Equal(t, instrumentation.Scope{ | ||||
| 					Name:      observ.ScopeName, | ||||
| 					Version:   observ.Version, | ||||
| 					SchemaURL: semconv.SchemaURL, | ||||
| 				}, wantMetrics.Scope) | ||||
|  | ||||
| 				metricdatatest.AssertEqual(t, wantMetrics.Metrics[0], g.Metrics[0], metricdatatest.IgnoreTimestamp()) | ||||
| 				metricdatatest.AssertEqual(t, wantMetrics.Metrics[1], g.Metrics[1], metricdatatest.IgnoreTimestamp()) | ||||
| 				metricdatatest.AssertEqual( | ||||
| 					t, | ||||
| 					wantMetrics.Metrics[2], | ||||
| 					g.Metrics[2], | ||||
| 					metricdatatest.IgnoreTimestamp(), | ||||
| 					metricdatatest.IgnoreValue(), | ||||
| 				) | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
| 	for _, tc := range testCases { | ||||
| 		t.Run(tc.name, func(t *testing.T) { | ||||
| 			if tc.enabled { | ||||
| 				t.Setenv("OTEL_GO_X_OBSERVABILITY", "true") | ||||
|  | ||||
| 				// Reset component name counter for each test. | ||||
| 				_ = SetExporterID(0) | ||||
| 			} | ||||
| 			prev := otel.GetMeterProvider() | ||||
| 			t.Cleanup(func() { | ||||
| 				otel.SetMeterProvider(prev) | ||||
| 			}) | ||||
| 			r := metric.NewManualReader() | ||||
| 			mp := metric.NewMeterProvider(metric.WithReader(r)) | ||||
| 			otel.SetMeterProvider(mp) | ||||
|  | ||||
| 			scopeMetrics := func() metricdata.ScopeMetrics { | ||||
| 				var got metricdata.ResourceMetrics | ||||
| 				err := r.Collect(t.Context(), &got) | ||||
| 				require.NoError(t, err) | ||||
| 				require.Len(t, got.ScopeMetrics, 1) | ||||
| 				return got.ScopeMetrics[0] | ||||
| 			} | ||||
| 			tc.test(t, scopeMetrics) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestClientObservabilityWithRetry(t *testing.T) { | ||||
| 	t.Setenv("OTEL_GO_X_OBSERVABILITY", "true") | ||||
|  | ||||
| 	_ = SetExporterID(0) | ||||
| 	prev := otel.GetMeterProvider() | ||||
| 	t.Cleanup(func() { | ||||
| 		otel.SetMeterProvider(prev) | ||||
| 	}) | ||||
|  | ||||
| 	r := metric.NewManualReader() | ||||
| 	mp := metric.NewMeterProvider(metric.WithReader(r)) | ||||
| 	otel.SetMeterProvider(mp) | ||||
|  | ||||
| 	scopeMetrics := func() metricdata.ScopeMetrics { | ||||
| 		var got metricdata.ResourceMetrics | ||||
| 		err := r.Collect(t.Context(), &got) | ||||
| 		require.NoError(t, err) | ||||
| 		require.Len(t, got.ScopeMetrics, 1) | ||||
| 		return got.ScopeMetrics[0] | ||||
| 	} | ||||
|  | ||||
| 	rCh := make(chan exportResult, 2) | ||||
| 	rCh <- exportResult{ | ||||
| 		Err: status.Error(codes.Unavailable, "service temporarily unavailable"), | ||||
| 	} | ||||
| 	const n, msg = 1, "some logs rejected" | ||||
| 	rCh <- exportResult{ | ||||
| 		Response: &collogpb.ExportLogsServiceResponse{ | ||||
| 			PartialSuccess: &collogpb.ExportLogsPartialSuccess{ | ||||
| 				RejectedLogRecords: n, | ||||
| 				ErrorMessage:       msg, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	ctx := t.Context() | ||||
| 	client, _ := clientFactory(t, rCh) | ||||
|  | ||||
| 	componentName := observ.GetComponentName(0) | ||||
|  | ||||
| 	serverAddrAttrs := observ.ServerAddrAttrs(client.conn.CanonicalTarget()) | ||||
| 	var wantErr error | ||||
| 	wantErr = errors.Join(wantErr, internal.LogPartialSuccessError(n, msg)) | ||||
|  | ||||
| 	wantMetrics := 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]{ | ||||
| 					Temporality: metricdata.CumulativeTemporality, | ||||
| 					DataPoints: []metricdata.DataPoint[int64]{ | ||||
| 						{ | ||||
| 							Attributes: attribute.NewSet( | ||||
| 								otelconv.SDKExporterLogInflight{}.AttrComponentName(componentName), | ||||
| 								otelconv.SDKExporterLogInflight{}.AttrComponentType( | ||||
| 									otelconv.ComponentTypeOtlpGRPCLogExporter, | ||||
| 								), | ||||
| 								serverAddrAttrs[0], | ||||
| 								serverAddrAttrs[1], | ||||
| 							), | ||||
| 							Value: 0, | ||||
| 						}, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			{ | ||||
| 				Name:        otelconv.SDKExporterLogExported{}.Name(), | ||||
| 				Description: otelconv.SDKExporterLogExported{}.Description(), | ||||
| 				Unit:        otelconv.SDKExporterLogExported{}.Unit(), | ||||
| 				Data: metricdata.Sum[int64]{ | ||||
| 					Temporality: metricdata.CumulativeTemporality, | ||||
| 					IsMonotonic: true, | ||||
| 					DataPoints: []metricdata.DataPoint[int64]{ | ||||
| 						{ | ||||
| 							Attributes: attribute.NewSet( | ||||
| 								otelconv.SDKExporterLogExported{}.AttrComponentName(componentName), | ||||
| 								otelconv.SDKExporterLogExported{}.AttrComponentType( | ||||
| 									otelconv.ComponentTypeOtlpGRPCLogExporter, | ||||
| 								), | ||||
| 								serverAddrAttrs[0], | ||||
| 								serverAddrAttrs[1], | ||||
| 							), | ||||
| 							Value: int64(len(resourceLogs)) - n, | ||||
| 						}, | ||||
| 						{ | ||||
| 							Attributes: attribute.NewSet( | ||||
| 								otelconv.SDKExporterLogExported{}.AttrComponentName(componentName), | ||||
| 								otelconv.SDKExporterLogExported{}.AttrComponentType( | ||||
| 									otelconv.ComponentTypeOtlpGRPCLogExporter, | ||||
| 								), | ||||
| 								serverAddrAttrs[0], | ||||
| 								serverAddrAttrs[1], | ||||
| 								semconv.ErrorType(wantErr), | ||||
| 							), | ||||
| 							Value: n, | ||||
| 						}, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			{ | ||||
| 				Name:        otelconv.SDKExporterOperationDuration{}.Name(), | ||||
| 				Description: otelconv.SDKExporterOperationDuration{}.Description(), | ||||
| 				Unit:        otelconv.SDKExporterOperationDuration{}.Unit(), | ||||
| 				Data: metricdata.Histogram[float64]{ | ||||
| 					Temporality: metricdata.CumulativeTemporality, | ||||
| 					DataPoints: []metricdata.HistogramDataPoint[float64]{ | ||||
| 						{ | ||||
| 							Attributes: attribute.NewSet( | ||||
| 								otelconv.SDKExporterLogExported{}.AttrComponentName(componentName), | ||||
| 								otelconv.SDKExporterOperationDuration{}.AttrComponentType( | ||||
| 									otelconv.ComponentTypeOtlpGRPCLogExporter, | ||||
| 								), | ||||
| 								otelconv.SDKExporterOperationDuration{}.AttrRPCGRPCStatusCode( | ||||
| 									otelconv.RPCGRPCStatusCodeAttr( | ||||
| 										status.Code(wantErr), | ||||
| 									), | ||||
| 								), | ||||
| 								serverAddrAttrs[0], | ||||
| 								serverAddrAttrs[1], | ||||
| 								semconv.ErrorType(wantErr), | ||||
| 							), | ||||
| 							Count: 1, | ||||
| 						}, | ||||
| 					}, | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	err := client.UploadLogs(ctx, resourceLogs) | ||||
| 	assert.ErrorContains(t, err, wantErr.Error()) | ||||
|  | ||||
| 	assert.Equal(t, instrumentation.Scope{ | ||||
| 		Name:      observ.ScopeName, | ||||
| 		Version:   observ.Version, | ||||
| 		SchemaURL: semconv.SchemaURL, | ||||
| 	}, wantMetrics.Scope) | ||||
|  | ||||
| 	g := scopeMetrics() | ||||
| 	metricdatatest.AssertEqual(t, wantMetrics.Metrics[0], g.Metrics[0], metricdatatest.IgnoreTimestamp()) | ||||
| 	metricdatatest.AssertEqual(t, wantMetrics.Metrics[1], g.Metrics[1], metricdatatest.IgnoreTimestamp()) | ||||
| 	metricdatatest.AssertEqual( | ||||
| 		t, | ||||
| 		wantMetrics.Metrics[2], | ||||
| 		g.Metrics[2], | ||||
| 		metricdatatest.IgnoreTimestamp(), | ||||
| 		metricdatatest.IgnoreValue(), | ||||
| 	) | ||||
| } | ||||
|  | ||||
| func BenchmarkExporterExportLogs(b *testing.B) { | ||||
| 	const logRecordsCount = 100 | ||||
|  | ||||
| 	run := func(b *testing.B) { | ||||
| 		coll, err := newGRPCCollector("", nil) | ||||
| 		require.NoError(b, err) | ||||
| 		b.Cleanup(func() { | ||||
| 			coll.srv.Stop() | ||||
| 		}) | ||||
|  | ||||
| 		ctx := b.Context() | ||||
| 		opts := []Option{ | ||||
| 			WithEndpoint(coll.listener.Addr().String()), | ||||
| 			WithInsecure(), | ||||
| 			WithTimeout(5 * time.Second), | ||||
| 		} | ||||
| 		exp, err := New(ctx, opts...) | ||||
| 		require.NoError(b, err) | ||||
| 		b.Cleanup(func() { | ||||
| 			//nolint:usetesting // required to avoid getting a canceled context at cleanup. | ||||
| 			assert.NoError(b, exp.Shutdown(context.Background())) | ||||
| 		}) | ||||
|  | ||||
| 		logs := make([]log.Record, logRecordsCount) | ||||
| 		now := time.Now() | ||||
| 		for i := range logs { | ||||
| 			logs[i].SetTimestamp(now) | ||||
| 			logs[i].SetObservedTimestamp(now) | ||||
| 		} | ||||
|  | ||||
| 		b.ReportAllocs() | ||||
| 		b.ResetTimer() | ||||
|  | ||||
| 		for b.Loop() { | ||||
| 			err := exp.Export(b.Context(), logs) | ||||
| 			require.NoError(b, err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	b.Run("Observability", func(b *testing.B) { | ||||
| 		b.Setenv("OTEL_GO_X_OBSERVABILITY", "true") | ||||
| 		run(b) | ||||
| 	}) | ||||
|  | ||||
| 	b.Run("NoObservability", func(b *testing.B) { | ||||
| 		b.Setenv("OTEL_GO_X_OBSERVABILITY", "false") | ||||
| 		run(b) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestNextExporterID(t *testing.T) { | ||||
| 	SetExporterID(0) | ||||
|  | ||||
| 	var expected int64 | ||||
| 	for range 10 { | ||||
| 		id := nextExporterID() | ||||
| 		if id != expected { | ||||
| 			t.Errorf("nextExporterID() = %d; want %d", id, expected) | ||||
| 		} | ||||
| 		expected++ | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestSetExporterID(t *testing.T) { | ||||
| 	SetExporterID(0) | ||||
|  | ||||
| 	prev := SetExporterID(42) | ||||
| 	if prev != 0 { | ||||
| 		t.Errorf("SetExporterID(42) returned %d; want 0", prev) | ||||
| 	} | ||||
|  | ||||
| 	id := nextExporterID() | ||||
| 	if id != 42 { | ||||
| 		t.Errorf("nextExporterID() = %d; want 42", id) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestNextExporterIDConcurrentSafe(t *testing.T) { | ||||
| 	SetExporterID(0) | ||||
|  | ||||
| 	const goroutines = 100 | ||||
| 	const increments = 10 | ||||
|  | ||||
| 	var wg sync.WaitGroup | ||||
| 	wg.Add(goroutines) | ||||
|  | ||||
| 	for range goroutines { | ||||
| 		go func() { | ||||
| 			defer wg.Done() | ||||
| 			for range increments { | ||||
| 				nextExporterID() | ||||
| 			} | ||||
| 		}() | ||||
| 	} | ||||
|  | ||||
| 	wg.Wait() | ||||
|  | ||||
| 	expected := int64(goroutines * increments) | ||||
| 	if id := nextExporterID(); id != expected { | ||||
| 		t.Errorf("nextExporterID() = %d; want %d", id, expected) | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -11,6 +11,8 @@ import ( | ||||
| 	"sync/atomic" | ||||
| 	"testing" | ||||
|  | ||||
| 	"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| 	collogpb "go.opentelemetry.io/proto/otlp/collector/logs/v1" | ||||
| @@ -214,7 +216,7 @@ func TestExporter(t *testing.T) { | ||||
| 		c, _ := clientFactory(t, rCh) | ||||
| 		e := newExporter(c) | ||||
|  | ||||
| 		assert.ErrorIs(t, e.Export(ctx, records), errPartial{}) | ||||
| 		assert.ErrorIs(t, e.Export(ctx, records), internal.PartialSuccess{}) | ||||
| 		assert.NoError(t, e.Export(ctx, records)) | ||||
| 		assert.NoError(t, e.Export(ctx, records)) | ||||
| 	}) | ||||
|   | ||||
| @@ -11,9 +11,11 @@ require ( | ||||
| 	github.com/stretchr/testify v1.11.1 | ||||
| 	go.opentelemetry.io/otel v1.38.0 | ||||
| 	go.opentelemetry.io/otel/log v0.14.0 | ||||
| 	go.opentelemetry.io/otel/metric v1.38.0 | ||||
| 	go.opentelemetry.io/otel/sdk v1.38.0 | ||||
| 	go.opentelemetry.io/otel/sdk/log v0.14.0 | ||||
| 	go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 | ||||
| 	go.opentelemetry.io/otel/sdk/metric v1.38.0 | ||||
| 	go.opentelemetry.io/otel/trace v1.38.0 | ||||
| 	go.opentelemetry.io/proto/otlp v1.8.0 | ||||
| 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 | ||||
| @@ -29,7 +31,6 @@ require ( | ||||
| 	github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect | ||||
| 	github.com/pmezard/go-difflib v1.0.0 // indirect | ||||
| 	go.opentelemetry.io/auto/sdk v1.2.1 // indirect | ||||
| 	go.opentelemetry.io/otel/metric v1.38.0 // indirect | ||||
| 	golang.org/x/net v0.44.0 // indirect | ||||
| 	golang.org/x/sys v0.36.0 // indirect | ||||
| 	golang.org/x/text v0.29.0 // indirect | ||||
|   | ||||
| @@ -5,6 +5,12 @@ | ||||
| // package. | ||||
| package internal // import "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal" | ||||
|  | ||||
| //go:generate gotmpl --body=../../../../../internal/shared/otlp/observ/target.go.tmpl "--data={ \"pkg\": \"observ\", \"pkg_path\": \"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal/observ\" }" --out=observ/target.go | ||||
| //go:generate gotmpl --body=../../../../../internal/shared/otlp/observ/target_test.go.tmpl "--data={ \"pkg\": \"observ\" }" --out=observ/target_test.go | ||||
|  | ||||
| //go:generate  gotmpl --body=../../../../../internal/shared/x/x.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc\" }"  --out=x/x.go | ||||
| //go:generate gotmpl --body=../../../../../internal/shared/x/x_test.go.tmpl "--data={}" --out=x/x_test.go | ||||
|  | ||||
| //go:generate gotmpl --body=../../../../../internal/shared/otlp/retry/retry.go.tmpl "--data={}" --out=retry/retry.go | ||||
| //go:generate gotmpl --body=../../../../../internal/shared/otlp/retry/retry_test.go.tmpl "--data={}" --out=retry/retry_test.go | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,310 @@ | ||||
| // Copyright The OpenTelemetry Authors | ||||
| // SPDX-License-Identifier: Apache-2.0 | ||||
|  | ||||
| // Package observ provides observability metrics for OTLP log exporters. | ||||
| // This is an experimental feature controlled by the x.Observability feature flag. | ||||
| package observ // import "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal/observ" | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"go.opentelemetry.io/otel/internal/global" | ||||
|  | ||||
| 	"google.golang.org/grpc/status" | ||||
|  | ||||
| 	"go.opentelemetry.io/otel" | ||||
| 	"go.opentelemetry.io/otel/attribute" | ||||
| 	"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal" | ||||
| 	"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal/x" | ||||
| 	"go.opentelemetry.io/otel/metric" | ||||
| 	semconv "go.opentelemetry.io/otel/semconv/v1.37.0" | ||||
| 	"go.opentelemetry.io/otel/semconv/v1.37.0/otelconv" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	// ScopeName is the unique name of the meter used for instrumentation. | ||||
| 	ScopeName = "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal/observ" | ||||
|  | ||||
| 	// Version is the current version of this instrumentation. | ||||
| 	// | ||||
| 	// This matches the version of the exporter. | ||||
| 	Version = internal.Version | ||||
| ) | ||||
|  | ||||
| var ( | ||||
| 	attrsPool = &sync.Pool{ | ||||
| 		New: func() any { | ||||
| 			const n = 1 /* component.name */ + | ||||
| 				1 /* component.type */ + | ||||
| 				1 /* server.addr */ + | ||||
| 				1 /* server.port */ + | ||||
| 				1 /* error.type */ + | ||||
| 				1 /* rpc.grpc.status_code */ | ||||
| 			s := make([]attribute.KeyValue, 0, n) | ||||
| 			// Return a pointer to a slice instead of a slice itself | ||||
| 			// to avoid allocations on every call. | ||||
| 			return &s | ||||
| 		}, | ||||
| 	} | ||||
| 	addOpPool = &sync.Pool{ | ||||
| 		New: func() any { | ||||
| 			const n = 1 // WithAttributeSet | ||||
| 			o := make([]metric.AddOption, 0, n) | ||||
| 			return &o | ||||
| 		}, | ||||
| 	} | ||||
| 	recordOptPool = &sync.Pool{ | ||||
| 		New: func() any { | ||||
| 			const n = 1 // WithAttributeSet | ||||
| 			o := make([]metric.RecordOption, 0, n) | ||||
| 			return &o | ||||
| 		}, | ||||
| 	} | ||||
| ) | ||||
|  | ||||
| func get[T any](p *sync.Pool) *[]T { return p.Get().(*[]T) } | ||||
| func put[T any](p *sync.Pool, s *[]T) { | ||||
| 	*s = (*s)[:0] | ||||
| 	p.Put(s) | ||||
| } | ||||
|  | ||||
| // GetComponentName returns the constant name for the exporter with the | ||||
| // provided id. | ||||
| func GetComponentName(id int64) string { | ||||
| 	return fmt.Sprintf("%s/%d", otelconv.ComponentTypeOtlpGRPCLogExporter, id) | ||||
| } | ||||
|  | ||||
| // getPresetAttrs builds the preset attributes for instrumentation. | ||||
| func getPresetAttrs(id int64, target string) []attribute.KeyValue { | ||||
| 	serverAttrs := ServerAddrAttrs(target) | ||||
| 	attrs := make([]attribute.KeyValue, 0, 2+len(serverAttrs)) | ||||
|  | ||||
| 	attrs = append( | ||||
| 		attrs, | ||||
| 		semconv.OTelComponentName(GetComponentName(id)), | ||||
| 		semconv.OTelComponentTypeOtlpGRPCLogExporter, | ||||
| 	) | ||||
| 	attrs = append(attrs, serverAttrs...) | ||||
|  | ||||
| 	return attrs | ||||
| } | ||||
|  | ||||
| // Instrumentation is experimental instrumentation for the exporter. | ||||
| type Instrumentation struct { | ||||
| 	logInflightMetric         metric.Int64UpDownCounter | ||||
| 	logExportedMetric         metric.Int64Counter | ||||
| 	logExportedDurationMetric metric.Float64Histogram | ||||
|  | ||||
| 	presetAttrs []attribute.KeyValue | ||||
| 	addOpt      metric.AddOption | ||||
| 	recOpt      metric.RecordOption | ||||
| } | ||||
|  | ||||
| // NewInstrumentation returns instrumentation for otlplog grpc exporter. | ||||
| func NewInstrumentation(id int64, target string) (*Instrumentation, error) { | ||||
| 	if !x.Observability.Enabled() { | ||||
| 		return nil, nil | ||||
| 	} | ||||
|  | ||||
| 	i := &Instrumentation{} | ||||
|  | ||||
| 	mp := otel.GetMeterProvider() | ||||
| 	m := mp.Meter( | ||||
| 		ScopeName, | ||||
| 		metric.WithInstrumentationVersion(Version), | ||||
| 		metric.WithSchemaURL(semconv.SchemaURL), | ||||
| 	) | ||||
|  | ||||
| 	var err error | ||||
|  | ||||
| 	logInflightMetric, e := otelconv.NewSDKExporterLogInflight(m) | ||||
| 	if e != nil { | ||||
| 		e = fmt.Errorf("failed to create log inflight metric: %w", e) | ||||
| 		err = errors.Join(err, e) | ||||
| 	} | ||||
| 	i.logInflightMetric = logInflightMetric.Inst() | ||||
|  | ||||
| 	logExportedMetric, e := otelconv.NewSDKExporterLogExported(m) | ||||
| 	if e != nil { | ||||
| 		e = fmt.Errorf("failed to create log exported metric: %w", e) | ||||
| 		err = errors.Join(err, e) | ||||
| 	} | ||||
| 	i.logExportedMetric = logExportedMetric.Inst() | ||||
|  | ||||
| 	logOpDurationMetric, e := otelconv.NewSDKExporterOperationDuration(m) | ||||
| 	if e != nil { | ||||
| 		e = fmt.Errorf("failed to create log operation duration metric: %w", e) | ||||
| 		err = errors.Join(err, e) | ||||
| 	} | ||||
| 	i.logExportedDurationMetric = logOpDurationMetric.Inst() | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	i.presetAttrs = getPresetAttrs(id, target) | ||||
|  | ||||
| 	i.addOpt = metric.WithAttributeSet(attribute.NewSet(i.presetAttrs...)) | ||||
| 	i.recOpt = metric.WithAttributeSet(attribute.NewSet(append( | ||||
| 		// Default to OK status code. | ||||
| 		[]attribute.KeyValue{semconv.RPCGRPCStatusCodeOk}, | ||||
| 		i.presetAttrs..., | ||||
| 	)...)) | ||||
| 	return i, nil | ||||
| } | ||||
|  | ||||
| // ExportLogs instruments the ExportLogs method of the exporter. It returns | ||||
| // an [ExportOp] that must have its [ExportOp.End] method called when the | ||||
| // ExportLogs method returns. | ||||
| func (i *Instrumentation) ExportLogs(ctx context.Context, count int64) ExportOp { | ||||
| 	start := time.Now() | ||||
| 	addOpt := get[metric.AddOption](addOpPool) | ||||
| 	defer put(addOpPool, addOpt) | ||||
|  | ||||
| 	*addOpt = append(*addOpt, i.addOpt) | ||||
|  | ||||
| 	i.logInflightMetric.Add(ctx, count, *addOpt...) | ||||
|  | ||||
| 	return ExportOp{ | ||||
| 		nLogs: count, | ||||
| 		ctx:   ctx, | ||||
| 		start: start, | ||||
| 		inst:  i, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // ExportOp tracks the operation being observed by [Instrumentation.ExportLogs]. | ||||
| type ExportOp struct { | ||||
| 	nLogs int64 | ||||
| 	ctx   context.Context | ||||
| 	start time.Time | ||||
|  | ||||
| 	inst *Instrumentation | ||||
| } | ||||
|  | ||||
| // End completes the observation of the operation being observed by a call to | ||||
| // [Instrumentation.ExportLogs]. | ||||
| // Any error that is encountered is provided as err. | ||||
| // | ||||
| // If err is not nil, all logs will be recorded as failures unless error is of | ||||
| // type [internal.PartialSuccess]. In the case of a PartialSuccess, the number | ||||
| // of successfully exported logs will be determined by inspecting the | ||||
| // RejectedItems field of the PartialSuccess. | ||||
| func (e ExportOp) End(err error) { | ||||
| 	addOpt := get[metric.AddOption](addOpPool) | ||||
| 	defer put(addOpPool, addOpt) | ||||
| 	*addOpt = append(*addOpt, e.inst.addOpt) | ||||
|  | ||||
| 	e.inst.logInflightMetric.Add(e.ctx, -e.nLogs, *addOpt...) | ||||
| 	success := successful(e.nLogs, err) | ||||
| 	e.inst.logExportedMetric.Add(e.ctx, success, *addOpt...) | ||||
|  | ||||
| 	if err != nil { | ||||
| 		// Add the error.type attribute to the attribute set. | ||||
| 		attrs := get[attribute.KeyValue](attrsPool) | ||||
| 		defer put(attrsPool, attrs) | ||||
| 		*attrs = append(*attrs, e.inst.presetAttrs...) | ||||
| 		*attrs = append(*attrs, semconv.ErrorType(err)) | ||||
|  | ||||
| 		o := metric.WithAttributeSet(attribute.NewSet(*attrs...)) | ||||
|  | ||||
| 		// Reset addOpt with new attribute set | ||||
| 		*addOpt = append((*addOpt)[:0], o) | ||||
|  | ||||
| 		e.inst.logExportedMetric.Add(e.ctx, e.nLogs-success, *addOpt...) | ||||
| 	} | ||||
|  | ||||
| 	recordOpt := get[metric.RecordOption](recordOptPool) | ||||
| 	defer put(recordOptPool, recordOpt) | ||||
| 	*recordOpt = append(*recordOpt, e.inst.recordOption(err)) | ||||
| 	e.inst.logExportedDurationMetric.Record(e.ctx, time.Since(e.start).Seconds(), *recordOpt...) | ||||
| } | ||||
|  | ||||
| func (i *Instrumentation) recordOption(err error) metric.RecordOption { | ||||
| 	if err == nil { | ||||
| 		return i.recOpt | ||||
| 	} | ||||
| 	attrs := get[attribute.KeyValue](attrsPool) | ||||
| 	defer put(attrsPool, attrs) | ||||
|  | ||||
| 	*attrs = append(*attrs, i.presetAttrs...) | ||||
| 	code := int64(status.Code(err)) | ||||
| 	*attrs = append( | ||||
| 		*attrs, | ||||
| 		semconv.RPCGRPCStatusCodeKey.Int64(code), | ||||
| 		semconv.ErrorType(err), | ||||
| 	) | ||||
|  | ||||
| 	return metric.WithAttributeSet(attribute.NewSet(*attrs...)) | ||||
| } | ||||
|  | ||||
| // successful returns the number of successfully exported logs out of the n | ||||
| // that were exported based on the provided error. | ||||
| // | ||||
| // If err is nil, n is returned. All logs were successfully exported. | ||||
| // | ||||
| // If err is not nil and not an [internal.PartialSuccess] error, 0 is returned. | ||||
| // It is assumed all logs failed to be exported. | ||||
| // | ||||
| // If err is an [internal.PartialSuccess] error, the number of successfully | ||||
| // exported logs is computed by subtracting the RejectedItems field from n. If | ||||
| // RejectedItems is negative, n is returned. If RejectedItems is greater than | ||||
| // n, 0 is returned. | ||||
| func successful(n int64, err error) int64 { | ||||
| 	if err == nil { | ||||
| 		return n // All logs successfully exported. | ||||
| 	} | ||||
| 	// Split rejection calculation so successful is inlineable. | ||||
| 	return n - rejectedCount(n, err) | ||||
| } | ||||
|  | ||||
| var errPool = sync.Pool{ | ||||
| 	New: func() any { | ||||
| 		return new(internal.PartialSuccess) | ||||
| 	}, | ||||
| } | ||||
|  | ||||
| // rejectedCount returns how many out of the n logs exporter were rejected based on | ||||
| // the provided non-nil err. | ||||
| func rejectedCount(n int64, err error) int64 { | ||||
| 	ps := errPool.Get().(*internal.PartialSuccess) | ||||
| 	defer errPool.Put(ps) | ||||
|  | ||||
| 	// check for partial success | ||||
| 	if errors.As(err, ps) { | ||||
| 		return min(max(ps.RejectedItems, 0), n) | ||||
| 	} | ||||
| 	// all logs exporter | ||||
| 	return n | ||||
| } | ||||
|  | ||||
| // ServerAddrAttrs is a function that extracts server address and port attributes | ||||
| // from a target string. | ||||
| func ServerAddrAttrs(target string) []attribute.KeyValue { | ||||
| 	addr, port, err := ParseCanonicalTarget(target) | ||||
| 	if err != nil || (addr == "" && port < 0) { | ||||
| 		if err != nil { | ||||
| 			global.Debug("failed to parse target", "target", target, "error", err) | ||||
| 		} | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	// Unix domain sockets: return only the path as server.address | ||||
| 	if port == -1 { | ||||
| 		return []attribute.KeyValue{semconv.ServerAddress(addr)} | ||||
| 	} | ||||
|  | ||||
| 	// For network addresses, only include port if it's valid (> 0) | ||||
| 	if port > 0 { | ||||
| 		return []attribute.KeyValue{ | ||||
| 			semconv.ServerAddress(addr), | ||||
| 			semconv.ServerPort(port), | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Port is 0 or invalid, only return address | ||||
| 	return []attribute.KeyValue{semconv.ServerAddress(addr)} | ||||
| } | ||||
| @@ -0,0 +1,329 @@ | ||||
| // Copyright The OpenTelemetry Authors | ||||
| // SPDX-License-Identifier: Apache-2.0 | ||||
|  | ||||
| package observ // import "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal/observ" | ||||
|  | ||||
| import ( | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| 	"google.golang.org/grpc/codes" | ||||
| 	"google.golang.org/grpc/status" | ||||
|  | ||||
| 	"go.opentelemetry.io/otel" | ||||
| 	"go.opentelemetry.io/otel/attribute" | ||||
| 	"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal" | ||||
| 	mapi "go.opentelemetry.io/otel/metric" | ||||
| 	"go.opentelemetry.io/otel/sdk/instrumentation" | ||||
| 	"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" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	ID     = 0 | ||||
| 	TARGET = "localhost:8080" | ||||
| ) | ||||
|  | ||||
| type errMeterProvider struct { | ||||
| 	mapi.MeterProvider | ||||
|  | ||||
| 	err error | ||||
| } | ||||
|  | ||||
| func (m *errMeterProvider) Meter(string, ...mapi.MeterOption) mapi.Meter { | ||||
| 	return &errMeter{err: m.err} | ||||
| } | ||||
|  | ||||
| type errMeter struct { | ||||
| 	mapi.Meter | ||||
|  | ||||
| 	err error | ||||
| } | ||||
|  | ||||
| func (m *errMeter) Int64UpDownCounter(string, ...mapi.Int64UpDownCounterOption) (mapi.Int64UpDownCounter, error) { | ||||
| 	return nil, m.err | ||||
| } | ||||
|  | ||||
| func (m *errMeter) Int64Counter(string, ...mapi.Int64CounterOption) (mapi.Int64Counter, error) { | ||||
| 	return nil, m.err | ||||
| } | ||||
|  | ||||
| func (m *errMeter) Float64Histogram(string, ...mapi.Float64HistogramOption) (mapi.Float64Histogram, error) { | ||||
| 	return nil, m.err | ||||
| } | ||||
|  | ||||
| func TestNewExporterMetrics(t *testing.T) { | ||||
| 	t.Setenv("OTEL_GO_X_OBSERVABILITY", "true") | ||||
|  | ||||
| 	t.Run("No Error", func(t *testing.T) { | ||||
| 		em, err := NewInstrumentation(ID, "dns:///example.com:42") | ||||
| 		require.NoError(t, err) | ||||
| 		assert.ElementsMatch(t, []attribute.KeyValue{ | ||||
| 			semconv.OTelComponentName(GetComponentName(ID)), | ||||
| 			semconv.OTelComponentTypeKey.String(string(otelconv.ComponentTypeOtlpGRPCLogExporter)), | ||||
| 			semconv.ServerAddress("example.com"), | ||||
| 			semconv.ServerPort(42), | ||||
| 		}, em.presetAttrs) | ||||
|  | ||||
| 		assert.NotNil(t, em.logInflightMetric, "logInflightMetric should be created") | ||||
| 		assert.NotNil(t, em.logExportedMetric, "logExportedMetric should be created") | ||||
| 		assert.NotNil(t, em.logExportedDurationMetric, "logExportedDurationMetric should be created") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("Error", func(t *testing.T) { | ||||
| 		orig := otel.GetMeterProvider() | ||||
| 		t.Cleanup(func() { otel.SetMeterProvider(orig) }) | ||||
| 		mp := &errMeterProvider{err: assert.AnError} | ||||
| 		otel.SetMeterProvider(mp) | ||||
|  | ||||
| 		_, err := NewInstrumentation(ID, "dns:///:8080") | ||||
| 		require.ErrorIs(t, err, assert.AnError, "new instrument errors") | ||||
|  | ||||
| 		assert.ErrorContains(t, err, "inflight metric") | ||||
| 		assert.ErrorContains(t, err, "log exported metric") | ||||
| 		assert.ErrorContains(t, err, "operation duration metric") | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestServerAddrAttrs(t *testing.T) { | ||||
| 	testcases := []struct { | ||||
| 		name   string | ||||
| 		target string | ||||
| 		want   []attribute.KeyValue | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:   "Unix socket", | ||||
| 			target: "unix:///tmp/grpc.sock", | ||||
| 			want:   []attribute.KeyValue{semconv.ServerAddress("/tmp/grpc.sock")}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:   "DNS with port", | ||||
| 			target: "dns:///localhost:8080", | ||||
| 			want:   []attribute.KeyValue{semconv.ServerAddress("localhost"), semconv.ServerPort(8080)}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:   "Dns with endpoint host:port", | ||||
| 			target: "dns://8.8.8.8/example.com:4", | ||||
| 			want:   []attribute.KeyValue{semconv.ServerAddress("example.com"), semconv.ServerPort(4)}, | ||||
| 		}, | ||||
| 	} | ||||
| 	for _, tc := range testcases { | ||||
| 		t.Run(tc.name, func(t *testing.T) { | ||||
| 			attrs := ServerAddrAttrs(tc.target) | ||||
| 			assert.Equal(t, tc.want, attrs) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func set(err error) attribute.Set { | ||||
| 	attrs := []attribute.KeyValue{ | ||||
| 		semconv.OTelComponentName(GetComponentName(ID)), | ||||
| 		semconv.OTelComponentTypeKey.String(string(otelconv.ComponentTypeOtlpGRPCLogExporter)), | ||||
| 	} | ||||
| 	attrs = append(attrs, ServerAddrAttrs(TARGET)...) | ||||
| 	if err != nil { | ||||
| 		attrs = append(attrs, semconv.ErrorType(err)) | ||||
| 	} | ||||
| 	return attribute.NewSet(attrs...) | ||||
| } | ||||
|  | ||||
| func logInflightMetrics() metricdata.Metrics { | ||||
| 	m := otelconv.SDKExporterLogInflight{} | ||||
| 	return metricdata.Metrics{ | ||||
| 		Name:        m.Name(), | ||||
| 		Description: m.Description(), | ||||
| 		Unit:        m.Unit(), | ||||
| 		Data: metricdata.Sum[int64]{ | ||||
| 			Temporality: metricdata.CumulativeTemporality, | ||||
| 			DataPoints: []metricdata.DataPoint[int64]{ | ||||
| 				{Attributes: set(nil), Value: 0}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func logExportedMetrics(success, total int64, err error) metricdata.Metrics { | ||||
| 	dp := []metricdata.DataPoint[int64]{ | ||||
| 		{Attributes: set(nil), Value: success}, | ||||
| 	} | ||||
|  | ||||
| 	if err != nil { | ||||
| 		dp = append(dp, metricdata.DataPoint[int64]{ | ||||
| 			Attributes: set(err), | ||||
| 			Value:      total - success, | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	m := otelconv.SDKExporterLogExported{} | ||||
| 	return metricdata.Metrics{ | ||||
| 		Name:        m.Name(), | ||||
| 		Description: m.Description(), | ||||
| 		Unit:        m.Unit(), | ||||
| 		Data: metricdata.Sum[int64]{ | ||||
| 			Temporality: metricdata.CumulativeTemporality, | ||||
| 			IsMonotonic: true, | ||||
| 			DataPoints:  dp, | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func logOperationDurationMetrics(err error, code codes.Code) metricdata.Metrics { | ||||
| 	attrs := []attribute.KeyValue{ | ||||
| 		semconv.OTelComponentName(GetComponentName(ID)), | ||||
| 		semconv.OTelComponentTypeKey.String(string(otelconv.ComponentTypeOtlpGRPCLogExporter)), | ||||
| 		semconv.RPCGRPCStatusCodeKey.Int64(int64(code)), | ||||
| 	} | ||||
| 	attrs = append(attrs, ServerAddrAttrs(TARGET)...) | ||||
| 	if err != nil { | ||||
| 		attrs = append(attrs, semconv.ErrorType(err)) | ||||
| 	} | ||||
|  | ||||
| 	m := otelconv.SDKExporterOperationDuration{} | ||||
| 	return metricdata.Metrics{ | ||||
| 		Name:        m.Name(), | ||||
| 		Description: m.Description(), | ||||
| 		Unit:        m.Unit(), | ||||
| 		Data: metricdata.Histogram[float64]{ | ||||
| 			Temporality: metricdata.CumulativeTemporality, | ||||
| 			DataPoints: []metricdata.HistogramDataPoint[float64]{ | ||||
| 				{Attributes: attribute.NewSet(attrs...)}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func setup(t *testing.T) (*Instrumentation, func() metricdata.ScopeMetrics) { | ||||
| 	t.Helper() | ||||
| 	t.Setenv("OTEL_GO_X_OBSERVABILITY", "true") | ||||
| 	original := otel.GetMeterProvider() | ||||
| 	t.Cleanup(func() { | ||||
| 		otel.SetMeterProvider(original) | ||||
| 	}) | ||||
|  | ||||
| 	r := metric.NewManualReader() | ||||
| 	mp := metric.NewMeterProvider(metric.WithReader(r)) | ||||
| 	otel.SetMeterProvider(mp) | ||||
|  | ||||
| 	inst, err := NewInstrumentation(ID, TARGET) | ||||
| 	require.NoError(t, err) | ||||
| 	require.NotNil(t, inst) | ||||
|  | ||||
| 	return inst, func() metricdata.ScopeMetrics { | ||||
| 		var rm metricdata.ResourceMetrics | ||||
| 		require.NoError(t, r.Collect(t.Context(), &rm)) | ||||
| 		require.Len(t, rm.ScopeMetrics, 1) | ||||
| 		return rm.ScopeMetrics[0] | ||||
| 	} | ||||
| } | ||||
|  | ||||
| var Scope = instrumentation.Scope{ | ||||
| 	Name:      ScopeName, | ||||
| 	Version:   Version, | ||||
| 	SchemaURL: semconv.SchemaURL, | ||||
| } | ||||
|  | ||||
| func assertMetrics( | ||||
| 	t *testing.T, | ||||
| 	got metricdata.ScopeMetrics, | ||||
| 	spans int64, | ||||
| 	success int64, | ||||
| 	err error, | ||||
| 	code codes.Code, | ||||
| ) { | ||||
| 	t.Helper() | ||||
|  | ||||
| 	assert.Equal(t, Scope, got.Scope, "unexpected scope") | ||||
|  | ||||
| 	m := got.Metrics | ||||
| 	require.Len(t, m, 3, "expected 3 metrics") | ||||
|  | ||||
| 	o := metricdatatest.IgnoreTimestamp() | ||||
| 	want := logInflightMetrics() | ||||
| 	metricdatatest.AssertEqual(t, want, m[0], o) | ||||
|  | ||||
| 	want = logExportedMetrics(success, spans, err) | ||||
| 	metricdatatest.AssertEqual(t, want, m[1], o) | ||||
|  | ||||
| 	want = logOperationDurationMetrics(err, code) | ||||
| 	metricdatatest.AssertEqual(t, want, m[2], metricdatatest.IgnoreValue(), o) | ||||
| } | ||||
|  | ||||
| func TestInstrumentationExportLogs(t *testing.T) { | ||||
| 	inst, collect := setup(t) | ||||
| 	const n = 10 | ||||
| 	inst.ExportLogs(t.Context(), n).End(nil) | ||||
| 	assertMetrics(t, collect(), n, n, nil, codes.OK) | ||||
| } | ||||
|  | ||||
| func TestInstrumentationExportLogPartialErrors(t *testing.T) { | ||||
| 	inst, collect := setup(t) | ||||
| 	const n = 10 | ||||
| 	const success = 5 | ||||
|  | ||||
| 	err := internal.PartialSuccess{RejectedItems: success} | ||||
| 	inst.ExportLogs(t.Context(), n).End(err) | ||||
|  | ||||
| 	assertMetrics(t, collect(), n, success, err, status.Code(err)) | ||||
| } | ||||
|  | ||||
| func TestInstrumentationExportLogAllErrors(t *testing.T) { | ||||
| 	inst, collect := setup(t) | ||||
| 	const n = 10 | ||||
| 	const success = 0 | ||||
| 	inst.ExportLogs(t.Context(), n).End(assert.AnError) | ||||
|  | ||||
| 	assertMetrics(t, collect(), n, success, assert.AnError, status.Code(assert.AnError)) | ||||
| } | ||||
|  | ||||
| func TestInstrumentationExportLogsInvalidPartialErrored(t *testing.T) { | ||||
| 	inst, collect := setup(t) | ||||
| 	const n = 10 | ||||
| 	err := internal.PartialSuccess{RejectedItems: -5} | ||||
| 	inst.ExportLogs(t.Context(), n).End(err) | ||||
|  | ||||
| 	success := int64(n) | ||||
| 	assertMetrics(t, collect(), n, success, err, status.Code(err)) | ||||
|  | ||||
| 	err.RejectedItems = n + 5 | ||||
| 	inst.ExportLogs(t.Context(), n).End(err) | ||||
|  | ||||
| 	success += 0 | ||||
| 	assertMetrics(t, collect(), n+n, success, err, status.Code(err)) | ||||
| } | ||||
|  | ||||
| func BenchmarkInstrumentationExportLogs(b *testing.B) { | ||||
| 	setup := func(tb *testing.B) *Instrumentation { | ||||
| 		tb.Helper() | ||||
| 		tb.Setenv("OTEL_GO_X_OBSERVABILITY", "true") | ||||
| 		inst, err := NewInstrumentation(ID, TARGET) | ||||
| 		if err != nil { | ||||
| 			tb.Fatalf("failed to create instrumentation: %v", err) | ||||
| 		} | ||||
| 		return inst | ||||
| 	} | ||||
| 	run := func(err error) func(*testing.B) { | ||||
| 		return func(b *testing.B) { | ||||
| 			inst := setup(b) | ||||
| 			b.ReportAllocs() | ||||
| 			b.ResetTimer() | ||||
| 			for b.Loop() { | ||||
| 				inst.ExportLogs(b.Context(), 10).End(err) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	b.Run("NoError", run(nil)) | ||||
| 	b.Run("PartialError", run(&internal.PartialSuccess{RejectedItems: 6})) | ||||
| 	b.Run("FullError", run(assert.AnError)) | ||||
| } | ||||
|  | ||||
| func BenchmarkSetPresetAttrs(b *testing.B) { | ||||
| 	b.ResetTimer() | ||||
| 	b.ReportAllocs() | ||||
| 	for i := range b.N { | ||||
| 		getPresetAttrs(int64(i), "dns:///192.168.1.1:8080") | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										143
									
								
								exporters/otlp/otlplog/otlploggrpc/internal/observ/target.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								exporters/otlp/otlplog/otlploggrpc/internal/observ/target.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,143 @@ | ||||
| // Code generated by gotmpl. DO NOT MODIFY. | ||||
| // source: internal/shared/otlp/observ/target.go.tmpl | ||||
|  | ||||
| // Copyright The OpenTelemetry Authors | ||||
| // SPDX-License-Identifier: Apache-2.0 | ||||
|  | ||||
| package observ // import "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal/observ" | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net" | ||||
| 	"net/netip" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	schemeUnix         = "unix" | ||||
| 	schemeUnixAbstract = "unix-abstract" | ||||
| ) | ||||
|  | ||||
| // ParseCanonicalTarget parses a target string and returns the extracted host | ||||
| // (domain address or IP), the target port, or an error. | ||||
| // | ||||
| // If no port is specified, -1 is returned. | ||||
| // | ||||
| // If no host is specified, an empty string is returned. | ||||
| // | ||||
| // The target string is expected to always have the form | ||||
| // "<scheme>://[authority]/<endpoint>". For example: | ||||
| //   - "dns:///example.com:42" | ||||
| //   - "dns://8.8.8.8/example.com:42" | ||||
| //   - "unix:///path/to/socket" | ||||
| //   - "unix-abstract:///socket-name" | ||||
| //   - "passthrough:///192.34.2.1:42" | ||||
| // | ||||
| // The target is expected to come from the CanonicalTarget method of a gRPC | ||||
| // Client. | ||||
| func ParseCanonicalTarget(target string) (string, int, error) { | ||||
| 	const sep = "://" | ||||
|  | ||||
| 	// Find scheme. Do not allocate the string by using url.Parse. | ||||
| 	idx := strings.Index(target, sep) | ||||
| 	if idx == -1 { | ||||
| 		return "", -1, fmt.Errorf("invalid target %q: missing scheme", target) | ||||
| 	} | ||||
| 	scheme, endpoint := target[:idx], target[idx+len(sep):] | ||||
|  | ||||
| 	// Check for unix schemes. | ||||
| 	if scheme == schemeUnix || scheme == schemeUnixAbstract { | ||||
| 		return parseUnix(endpoint) | ||||
| 	} | ||||
|  | ||||
| 	// Strip leading slash and any authority. | ||||
| 	if i := strings.Index(endpoint, "/"); i != -1 { | ||||
| 		endpoint = endpoint[i+1:] | ||||
| 	} | ||||
|  | ||||
| 	// DNS, passthrough, and custom resolvers. | ||||
| 	return parseEndpoint(endpoint) | ||||
| } | ||||
|  | ||||
| // parseUnix parses unix socket targets. | ||||
| func parseUnix(endpoint string) (string, int, error) { | ||||
| 	// Format: unix[-abstract]://path | ||||
| 	// | ||||
| 	// We should have "/path" (empty authority) if valid. | ||||
| 	if len(endpoint) >= 1 && endpoint[0] == '/' { | ||||
| 		// Return the full path including leading slash. | ||||
| 		return endpoint, -1, nil | ||||
| 	} | ||||
|  | ||||
| 	// If there's no leading slash, it means there might be an authority | ||||
| 	// Check for authority case (should error): "authority/path" | ||||
| 	if slashIdx := strings.Index(endpoint, "/"); slashIdx > 0 { | ||||
| 		return "", -1, fmt.Errorf("invalid (non-empty) authority: %s", endpoint[:slashIdx]) | ||||
| 	} | ||||
|  | ||||
| 	return "", -1, errors.New("invalid unix target format") | ||||
| } | ||||
|  | ||||
| // parseEndpoint parses an endpoint from a gRPC target. | ||||
| // | ||||
| // It supports the following formats: | ||||
| //   - "host" | ||||
| //   - "host%zone" | ||||
| //   - "host:port" | ||||
| //   - "host%zone:port" | ||||
| //   - "ipv4" | ||||
| //   - "ipv4%zone" | ||||
| //   - "ipv4:port" | ||||
| //   - "ipv4%zone:port" | ||||
| //   - "ipv6" | ||||
| //   - "ipv6%zone" | ||||
| //   - "[ipv6]" | ||||
| //   - "[ipv6%zone]" | ||||
| //   - "[ipv6]:port" | ||||
| //   - "[ipv6%zone]:port" | ||||
| // | ||||
| // It returns the host or host%zone (domain address or IP), the port (or -1 if | ||||
| // not specified), or an error if the input is not a valid. | ||||
| func parseEndpoint(endpoint string) (string, int, error) { | ||||
| 	// First check if the endpoint is just an IP address. | ||||
| 	if ip := parseIP(endpoint); ip != "" { | ||||
| 		return ip, -1, nil | ||||
| 	} | ||||
|  | ||||
| 	// If there's no colon, there is no port (IPv6 with no port checked above). | ||||
| 	if !strings.Contains(endpoint, ":") { | ||||
| 		return endpoint, -1, nil | ||||
| 	} | ||||
|  | ||||
| 	host, portStr, err := net.SplitHostPort(endpoint) | ||||
| 	if err != nil { | ||||
| 		return "", -1, fmt.Errorf("invalid host:port %q: %w", endpoint, err) | ||||
| 	} | ||||
|  | ||||
| 	const base, bitSize = 10, 16 | ||||
| 	port16, err := strconv.ParseUint(portStr, base, bitSize) | ||||
| 	if err != nil { | ||||
| 		return "", -1, fmt.Errorf("invalid port %q: %w", portStr, err) | ||||
| 	} | ||||
| 	port := int(port16) // port is guaranteed to be in the range [0, 65535]. | ||||
|  | ||||
| 	return host, port, nil | ||||
| } | ||||
|  | ||||
| // parseIP attempts to parse the entire endpoint as an IP address. | ||||
| // It returns the normalized string form of the IP if successful, | ||||
| // or an empty string if parsing fails. | ||||
| func parseIP(ip string) string { | ||||
| 	// Strip leading and trailing brackets for IPv6 addresses. | ||||
| 	if len(ip) >= 2 && ip[0] == '[' && ip[len(ip)-1] == ']' { | ||||
| 		ip = ip[1 : len(ip)-1] | ||||
| 	} | ||||
| 	addr, err := netip.ParseAddr(ip) | ||||
| 	if err != nil { | ||||
| 		return "" | ||||
| 	} | ||||
| 	// Return the normalized string form of the IP. | ||||
| 	return addr.String() | ||||
| } | ||||
| @@ -0,0 +1,162 @@ | ||||
| // Code generated by gotmpl. DO NOT MODIFY. | ||||
| // source: internal/shared/otlp/observ/target_test.go.tmpl | ||||
|  | ||||
| // Copyright The OpenTelemetry Authors | ||||
| // SPDX-License-Identifier: Apache-2.0 | ||||
|  | ||||
| package observ | ||||
|  | ||||
| import "testing" | ||||
|  | ||||
| func TestParseTarget(t *testing.T) { | ||||
| 	// gRPC target naming is defined here: | ||||
| 	// https://github.com/grpc/grpc/blob/74232c6bd3c0f4bc35bad035dbeecf5cbc834a11/doc/naming.md | ||||
| 	// | ||||
| 	// The Go gRPC client only supports the "dns", "unix", "unix-abstract", and | ||||
| 	// "passthrough" schemes natively with "dns" being the default: | ||||
| 	// https://pkg.go.dev/google.golang.org/grpc@v1.75.1/internal/resolver | ||||
| 	// | ||||
| 	// Other schemes (e.g., "consul", "zk") are supported via custom resolvers | ||||
| 	// that can be registered with the gRPC resolver package. These custom | ||||
| 	// resolvers are still expected to follow the general target string format | ||||
| 	// when rendered with the CanonicalTarget method: | ||||
| 	// | ||||
| 	//   <scheme>://<authority>/<endpoint> | ||||
| 	// | ||||
| 	// All target strings in these tests are rendered with the | ||||
| 	// CanonicalTarget method. Therefore they all follow the above format. | ||||
| 	tests := []struct { | ||||
| 		target string | ||||
| 		host   string | ||||
| 		port   int | ||||
| 	}{ | ||||
| 		// DNS scheme: hostname and port. | ||||
| 		{target: "dns:///:8080", host: "", port: 8080}, | ||||
| 		{target: "dns:///example.com", host: "example.com", port: -1}, | ||||
| 		{target: "dns:///example.com%eth0", host: "example.com%eth0", port: -1}, | ||||
| 		{target: "dns:///example.com:42", host: "example.com", port: 42}, | ||||
| 		{target: "dns:///example.com%eth0:42", host: "example.com%eth0", port: 42}, | ||||
|  | ||||
| 		// DNS scheme: hostname and port with authority. | ||||
| 		{target: "dns://8.8.8.8/example.com", host: "example.com", port: -1}, | ||||
| 		{target: "dns://8.8.8.8/example.com%eth0", host: "example.com%eth0", port: -1}, | ||||
| 		{target: "dns://8.8.8.8/example.com:42", host: "example.com", port: 42}, | ||||
| 		{target: "dns://8.8.8.8/example.com%eth0:42", host: "example.com%eth0", port: 42}, | ||||
|  | ||||
| 		// DNS scheme: IPv4 address and port. | ||||
| 		{target: "dns:///192.168.1.1", host: "192.168.1.1", port: -1}, | ||||
| 		{target: "dns:///192.168.1.1%eth0", host: "192.168.1.1%eth0", port: -1}, | ||||
| 		{target: "dns:///192.168.1.1:8080", host: "192.168.1.1", port: 8080}, | ||||
| 		{target: "dns:///192.168.1.1%eth0:8080", host: "192.168.1.1%eth0", port: 8080}, | ||||
|  | ||||
| 		// DNS scheme: IPv6 address and port. | ||||
| 		{target: "dns:///2001:0db8:85a3:0000:0000:8a2e:0370:7334", host: "2001:db8:85a3::8a2e:370:7334", port: -1}, | ||||
| 		{target: "dns:///2001:db8:85a3:0:0:8a2e:370:7334", host: "2001:db8:85a3::8a2e:370:7334", port: -1}, | ||||
| 		{target: "dns:///2001:db8:85a3::8a2e:370:7334", host: "2001:db8:85a3::8a2e:370:7334", port: -1}, | ||||
| 		{target: "dns:///2001:db8:85a3::8a2e:370:7334%eth0", host: "2001:db8:85a3::8a2e:370:7334%eth0", port: -1}, | ||||
| 		{target: "dns:///[2001:db8:85a3::8a2e:370:7334]", host: "2001:db8:85a3::8a2e:370:7334", port: -1}, | ||||
| 		{target: "dns:///[2001:db8:85a3::8a2e:370:7334%eth0]", host: "2001:db8:85a3::8a2e:370:7334%eth0", port: -1}, | ||||
| 		{target: "dns:///[::1]:9090", host: "::1", port: 9090}, | ||||
| 		{target: "dns:///[::1%eth0]:9090", host: "::1%eth0", port: 9090}, | ||||
|  | ||||
| 		// Unix domain sockets. | ||||
| 		{target: "unix:///tmp/grpc.sock", host: "/tmp/grpc.sock", port: -1}, | ||||
| 		{target: "unix:///absolute_path", host: "/absolute_path", port: -1}, | ||||
|  | ||||
| 		// Unix domain socket in abstract namespace. | ||||
| 		{target: "unix-abstract:///abstract-socket-name", host: "/abstract-socket-name", port: -1}, | ||||
|  | ||||
| 		// International domain names. | ||||
| 		{target: "dns:///测试.example.com:8080", host: "测试.example.com", port: 8080}, | ||||
|  | ||||
| 		// Port edge cases. | ||||
| 		{target: "dns:///example.com:0", host: "example.com", port: 0}, | ||||
| 		{target: "dns:///example.com:65535", host: "example.com", port: 65535}, | ||||
|  | ||||
| 		// Case sensitivity. | ||||
| 		{target: "dns:///EXAMPLE.COM:8080", host: "EXAMPLE.COM", port: 8080}, | ||||
| 		{target: "dns:///Example.Com:8080", host: "Example.Com", port: 8080}, | ||||
|  | ||||
| 		// Custom and passthrough resolvers scheme | ||||
| 		{target: "passthrough:///localhost:50051", host: "localhost", port: 50051}, | ||||
| 		{target: "passthrough:///10.0.0.2:7777", host: "10.0.0.2", port: 7777}, | ||||
| 		{target: "consul:///my-service", host: "my-service", port: -1}, | ||||
| 		{target: "zk:///services/my-service", host: "services/my-service", port: -1}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		host, port, err := ParseCanonicalTarget(tt.target) | ||||
| 		if err != nil { | ||||
| 			t.Errorf("parseTarget(%q) unexpected error: %v", tt.target, err) | ||||
| 			continue | ||||
| 		} | ||||
| 		if host != tt.host { | ||||
| 			t.Errorf("parseTarget(%q) host = %q, want %q", tt.target, host, tt.host) | ||||
| 		} | ||||
| 		if port != tt.port { | ||||
| 			t.Errorf("parseTarget(%q) port = %d, want %d", tt.target, port, tt.port) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestParseTargetErrors(t *testing.T) { | ||||
| 	targets := []string{ | ||||
| 		"dns:///example.com:invalid",     // Non-numeric port in URL. | ||||
| 		"dns:///example.com:8080:9090",   // Multiple colons in port. | ||||
| 		"dns:///example.com:99999",       // Port out of range. | ||||
| 		"dns:///example.com:-1",          // Port out of range. | ||||
| 		"unix://localhost/sock",          // Non-empty authority for unix scheme. | ||||
| 		"unix:",                          // Empty unix scheme. | ||||
| 		"unix-abstract://",               // Empty unix-abstract scheme. | ||||
| 		"unix-abstract://authority/sock", // Non-empty authority for unix-abstract scheme. | ||||
| 		"contains-cont\roll-cha\rs",      // Invalid URL. | ||||
| 	} | ||||
|  | ||||
| 	for _, target := range targets { | ||||
| 		host, port, err := ParseCanonicalTarget(target) | ||||
| 		if err == nil { | ||||
| 			t.Errorf("parseTarget(%q) expected error, got nil", target) | ||||
| 		} | ||||
|  | ||||
| 		if host != "" { | ||||
| 			t.Errorf("parseTarget(%q) host = %q, want empty", target, host) | ||||
| 		} | ||||
|  | ||||
| 		if port != -1 { | ||||
| 			t.Errorf("parseTarget(%q) port = %d, want -1", target, port) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func BenchmarkParseTarget(b *testing.B) { | ||||
| 	benchmarks := []struct { | ||||
| 		name   string | ||||
| 		target string | ||||
| 	}{ | ||||
| 		{"HostName", "dns:///example.com"}, | ||||
| 		{"HostPort", "dns:///example.com:8080"}, | ||||
| 		{"IPv4WithoutPort", "dns:///192.168.1.1"}, | ||||
| 		{"IPv4WithPort", "dns:///192.168.1.1:8080"}, | ||||
| 		{"IPv6Bare", "dns:///2001:db8::1"}, | ||||
| 		{"IPv6Bracket", "dns:///[2001:db8::1]"}, | ||||
| 		{"IPv6WithPort", "dns:///[2001:db8::1]:8080"}, | ||||
| 		{"UnixSocket", "unix:///tmp/grpc.sock"}, | ||||
| 		{"UnixAbstractSocket", "unix-abstract:///abstract-socket-name"}, | ||||
| 		{"Passthrough", "passthrough:///localhost:50051"}, | ||||
| 	} | ||||
|  | ||||
| 	var ( | ||||
| 		host string | ||||
| 		port int | ||||
| 		err  error | ||||
| 	) | ||||
| 	for _, bm := range benchmarks { | ||||
| 		b.Run(bm.name, func(b *testing.B) { | ||||
| 			b.ReportAllocs() | ||||
| 			for b.Loop() { | ||||
| 				host, port, err = ParseCanonicalTarget(bm.target) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| 	_, _, _ = host, port, err | ||||
| } | ||||
| @@ -0,0 +1,43 @@ | ||||
| // Copyright The OpenTelemetry Authors | ||||
| // SPDX-License-Identifier: Apache-2.0 | ||||
|  | ||||
| package internal // import "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal" | ||||
|  | ||||
| import "fmt" | ||||
|  | ||||
| // PartialSuccess represents the underlying error for all handling | ||||
| // OTLP partial success messages.  Use `errors.Is(err, | ||||
| // PartialSuccess{})` to test whether an error passed to the OTel | ||||
| // error handler belongs to this category. | ||||
| type PartialSuccess struct { | ||||
| 	ErrorMessage  string | ||||
| 	RejectedItems int64 | ||||
| 	RejectedKind  string | ||||
| } | ||||
|  | ||||
| var _ error = PartialSuccess{} | ||||
|  | ||||
| // Error implements the error interface. | ||||
| func (ps PartialSuccess) Error() string { | ||||
| 	msg := ps.ErrorMessage | ||||
| 	if msg == "" { | ||||
| 		msg = "empty message" | ||||
| 	} | ||||
| 	return fmt.Sprintf("OTLP partial success: %s (%d %s rejected)", msg, ps.RejectedItems, ps.RejectedKind) | ||||
| } | ||||
|  | ||||
| // Is supports the errors.Is() interface. | ||||
| func (PartialSuccess) Is(err error) bool { | ||||
| 	_, ok := err.(PartialSuccess) | ||||
| 	return ok | ||||
| } | ||||
|  | ||||
| // LogPartialSuccessError returns an error describing a partial success | ||||
| // response for the log signal. | ||||
| func LogPartialSuccessError(itemsRejected int64, errorMessage string) error { | ||||
| 	return PartialSuccess{ | ||||
| 		ErrorMessage:  errorMessage, | ||||
| 		RejectedItems: itemsRejected, | ||||
| 		RejectedKind:  "logs", | ||||
| 	} | ||||
| } | ||||
| @@ -0,0 +1,34 @@ | ||||
| // Copyright The OpenTelemetry Authors | ||||
| // SPDX-License-Identifier: Apache-2.0 | ||||
|  | ||||
| package internal | ||||
|  | ||||
| import ( | ||||
| 	"strings" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func requireErrorString(t *testing.T, expect string, err error) { | ||||
| 	t.Helper() | ||||
| 	require.Error(t, err) | ||||
| 	require.ErrorIs(t, err, PartialSuccess{}) | ||||
|  | ||||
| 	const pfx = "OTLP partial success: " | ||||
|  | ||||
| 	msg := err.Error() | ||||
| 	require.True(t, strings.HasPrefix(msg, pfx)) | ||||
| 	require.Equal(t, expect, msg[len(pfx):]) | ||||
| } | ||||
|  | ||||
| func TestPartialSuccessFormat(t *testing.T) { | ||||
| 	requireErrorString(t, "empty message (0 logs rejected)", LogPartialSuccessError(0, "")) | ||||
| 	requireErrorString(t, "help help (0 logs rejected)", LogPartialSuccessError(0, "help help")) | ||||
| 	requireErrorString( | ||||
| 		t, | ||||
| 		"what happened (10 logs rejected)", | ||||
| 		LogPartialSuccessError(10, "what happened"), | ||||
| 	) | ||||
| 	requireErrorString(t, "what happened (15 logs rejected)", LogPartialSuccessError(15, "what happened")) | ||||
| } | ||||
							
								
								
									
										8
									
								
								exporters/otlp/otlplog/otlploggrpc/internal/version.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								exporters/otlp/otlplog/otlploggrpc/internal/version.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| // Copyright The OpenTelemetry Authors | ||||
| // SPDX-License-Identifier: Apache-2.0 | ||||
|  | ||||
| package internal // import "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal" | ||||
|  | ||||
| // Version is the current release version of the OpenTelemetry otlploggrpc | ||||
| // exporter in use. | ||||
| const Version = "0.14.0" | ||||
							
								
								
									
										36
									
								
								exporters/otlp/otlplog/otlploggrpc/internal/x/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								exporters/otlp/otlplog/otlploggrpc/internal/x/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| # Experimental Features | ||||
|  | ||||
| The `otlploggrpc` exporter contains features that have not yet stabilized in the OpenTelemetry specification. | ||||
| These features are added to the `otlploggrpc` exporter prior to stabilization in the specification so that users can start experimenting with them and provide feedback. | ||||
|  | ||||
| These features may change in backwards incompatible ways as feedback is applied. | ||||
| See the [Compatibility and Stability](#compatibility-and-stability) section for more information. | ||||
|  | ||||
| ## Features | ||||
|  | ||||
| - [Observability](#observability) | ||||
|  | ||||
| ### Observability | ||||
|  | ||||
| The `otlploggrpc` exporter can be configured to provide observability about itself using OpenTelemetry metrics. | ||||
|  | ||||
| To opt-in, set the environment variable `OTEL_GO_X_OBSERVABILITY` to `true`. | ||||
|  | ||||
| When enabled, the exporter will create the following metrics using the global `MeterProvider`: | ||||
|  | ||||
| - `otel.sdk.exporter.log.inflight` | ||||
| - `otel.sdk.exporter.log.exported` | ||||
| - `otel.sdk.exporter.operation.duration` | ||||
|  | ||||
| Please see the [Semantic conventions for OpenTelemetry SDK metrics] documentation for more details on these metrics. | ||||
|  | ||||
| [Semantic conventions for OpenTelemetry SDK metrics]: https://github.com/open-telemetry/semantic-conventions/blob/v1.36.0/docs/otel/sdk-metrics.md | ||||
|  | ||||
| ## Compatibility and Stability | ||||
|  | ||||
| Experimental features do not fall within the scope of the OpenTelemetry Go versioning and stability [policy](../../../../../../VERSIONING.md). | ||||
| These features may be removed or modified in successive version releases, including patch versions. | ||||
|  | ||||
| When an experimental feature is promoted to a stable feature, a migration path will be included in the changelog entry of the release. | ||||
| There is no guarantee that any environment variable feature flags that enabled the experimental feature will be supported by the stable version. | ||||
| If they are supported, they may be accompanied with a deprecation notice stating a timeline for the removal of that support. | ||||
							
								
								
									
										23
									
								
								exporters/otlp/otlplog/otlploggrpc/internal/x/features.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								exporters/otlp/otlplog/otlploggrpc/internal/x/features.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| // Copyright The OpenTelemetry Authors | ||||
| // SPDX-License-Identifier: Apache-2.0 | ||||
|  | ||||
| // Package x documents experimental features for [go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc]. | ||||
| package x // import "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal/x" | ||||
|  | ||||
| import "strings" | ||||
|  | ||||
| // Observability is an experimental feature flag that determines if exporter | ||||
| // observability metrics are enabled. | ||||
| // | ||||
| // To enable this feature set the OTEL_GO_X_OBSERVABILITY environment variable | ||||
| // to the case-insensitive string value of "true" (i.e. "True" and "TRUE" | ||||
| // will also enable this). | ||||
| var Observability = newFeature( | ||||
| 	[]string{"OBSERVABILITY"}, | ||||
| 	func(v string) (string, bool) { | ||||
| 		if strings.EqualFold(v, "true") { | ||||
| 			return v, true | ||||
| 		} | ||||
| 		return "", false | ||||
| 	}, | ||||
| ) | ||||
| @@ -0,0 +1,21 @@ | ||||
| // Copyright The OpenTelemetry Authors | ||||
| // SPDX-License-Identifier: Apache-2.0 | ||||
|  | ||||
| package x | ||||
|  | ||||
| import ( | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func TestObservability(t *testing.T) { | ||||
| 	const key = "OTEL_GO_X_OBSERVABILITY" | ||||
| 	require.Contains(t, Observability.Keys(), key) | ||||
|  | ||||
| 	t.Run("100", run(setenv(key, "100"), assertDisabled(Observability))) | ||||
| 	t.Run("true", run(setenv(key, "true"), assertEnabled(Observability, "true"))) | ||||
| 	t.Run("True", run(setenv(key, "True"), assertEnabled(Observability, "True"))) | ||||
| 	t.Run("false", run(setenv(key, "false"), assertDisabled(Observability))) | ||||
| 	t.Run("empty", run(assertDisabled(Observability))) | ||||
| } | ||||
							
								
								
									
										58
									
								
								exporters/otlp/otlplog/otlploggrpc/internal/x/x.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								exporters/otlp/otlplog/otlploggrpc/internal/x/x.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | ||||
| // Code generated by gotmpl. DO NOT MODIFY. | ||||
| // source: internal/shared/x/x.go.tmpl | ||||
|  | ||||
| // Copyright The OpenTelemetry Authors | ||||
| // SPDX-License-Identifier: Apache-2.0 | ||||
|  | ||||
| // Package x documents experimental features for [go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc]. | ||||
| package x // import "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc/internal/x" | ||||
|  | ||||
| import ( | ||||
| 	"os" | ||||
| ) | ||||
|  | ||||
| // Feature is an experimental feature control flag. It provides a uniform way | ||||
| // to interact with these feature flags and parse their values. | ||||
| type Feature[T any] struct { | ||||
| 	keys  []string | ||||
| 	parse func(v string) (T, bool) | ||||
| } | ||||
|  | ||||
| func newFeature[T any](suffix []string, parse func(string) (T, bool)) Feature[T] { | ||||
| 	const envKeyRoot = "OTEL_GO_X_" | ||||
| 	keys := make([]string, 0, len(suffix)) | ||||
| 	for _, s := range suffix { | ||||
| 		keys = append(keys, envKeyRoot+s) | ||||
| 	} | ||||
| 	return Feature[T]{ | ||||
| 		keys:  keys, | ||||
| 		parse: parse, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // Keys returns the environment variable keys that can be set to enable the | ||||
| // feature. | ||||
| func (f Feature[T]) Keys() []string { return f.keys } | ||||
|  | ||||
| // Lookup returns the user configured value for the feature and true if the | ||||
| // user has enabled the feature. Otherwise, if the feature is not enabled, a | ||||
| // zero-value and false are returned. | ||||
| func (f Feature[T]) Lookup() (v T, ok bool) { | ||||
| 	// https://github.com/open-telemetry/opentelemetry-specification/blob/62effed618589a0bec416a87e559c0a9d96289bb/specification/configuration/sdk-environment-variables.md#parsing-empty-value | ||||
| 	// | ||||
| 	// > The SDK MUST interpret an empty value of an environment variable the | ||||
| 	// > same way as when the variable is unset. | ||||
| 	for _, key := range f.keys { | ||||
| 		vRaw := os.Getenv(key) | ||||
| 		if vRaw != "" { | ||||
| 			return f.parse(vRaw) | ||||
| 		} | ||||
| 	} | ||||
| 	return v, ok | ||||
| } | ||||
|  | ||||
| // Enabled reports whether the feature is enabled. | ||||
| func (f Feature[T]) Enabled() bool { | ||||
| 	_, ok := f.Lookup() | ||||
| 	return ok | ||||
| } | ||||
							
								
								
									
										75
									
								
								exporters/otlp/otlplog/otlploggrpc/internal/x/x_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								exporters/otlp/otlplog/otlploggrpc/internal/x/x_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | ||||
| // Code generated by gotmpl. DO NOT MODIFY. | ||||
| // source: internal/shared/x/x_text.go.tmpl | ||||
|  | ||||
| // Copyright The OpenTelemetry Authors | ||||
| // SPDX-License-Identifier: Apache-2.0 | ||||
|  | ||||
| package x | ||||
|  | ||||
| import ( | ||||
| 	"strings" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	mockKey  = "OTEL_GO_X_MOCK_FEATURE" | ||||
| 	mockKey2 = "OTEL_GO_X_MOCK_FEATURE2" | ||||
| ) | ||||
|  | ||||
| var mockFeature = newFeature([]string{"MOCK_FEATURE", "MOCK_FEATURE2"}, func(v string) (string, bool) { | ||||
| 	if strings.EqualFold(v, "true") { | ||||
| 		return v, true | ||||
| 	} | ||||
| 	return "", false | ||||
| }) | ||||
|  | ||||
| func TestFeature(t *testing.T) { | ||||
| 	require.Contains(t, mockFeature.Keys(), mockKey) | ||||
| 	require.Contains(t, mockFeature.Keys(), mockKey2) | ||||
|  | ||||
| 	t.Run("100", run(setenv(mockKey, "100"), assertDisabled(mockFeature))) | ||||
| 	t.Run("true", run(setenv(mockKey, "true"), assertEnabled(mockFeature, "true"))) | ||||
| 	t.Run("True", run(setenv(mockKey, "True"), assertEnabled(mockFeature, "True"))) | ||||
| 	t.Run("false", run(setenv(mockKey, "false"), assertDisabled(mockFeature))) | ||||
| 	t.Run("empty", run(assertDisabled(mockFeature))) | ||||
| } | ||||
|  | ||||
| func run(steps ...func(*testing.T)) func(*testing.T) { | ||||
| 	return func(t *testing.T) { | ||||
| 		t.Helper() | ||||
| 		for _, step := range steps { | ||||
| 			step(t) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func setenv(k, v string) func(t *testing.T) { //nolint:unparam // This is a reusable test utility function. | ||||
| 	return func(t *testing.T) { t.Setenv(k, v) } | ||||
| } | ||||
|  | ||||
| func assertEnabled[T any](f Feature[T], want T) func(*testing.T) { | ||||
| 	return func(t *testing.T) { | ||||
| 		t.Helper() | ||||
| 		assert.True(t, f.Enabled(), "not enabled") | ||||
|  | ||||
| 		v, ok := f.Lookup() | ||||
| 		assert.True(t, ok, "Lookup state") | ||||
| 		assert.Equal(t, want, v, "Lookup value") | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func assertDisabled[T any](f Feature[T]) func(*testing.T) { | ||||
| 	var zero T | ||||
| 	return func(t *testing.T) { | ||||
| 		t.Helper() | ||||
|  | ||||
| 		assert.False(t, f.Enabled(), "enabled") | ||||
|  | ||||
| 		v, ok := f.Lookup() | ||||
| 		assert.False(t, ok, "Lookup state") | ||||
| 		assert.Equal(t, zero, v, "Lookup value") | ||||
| 	} | ||||
| } | ||||
| @@ -49,3 +49,7 @@ modules: | ||||
|   go.opentelemetry.io/otel/exporters/prometheus: | ||||
|     version-refs: | ||||
|       - ./exporters/prometheus/internal/version.go | ||||
|  | ||||
|   go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc: | ||||
|     version-refs: | ||||
|       - ./exporters/otlp/otlplog/otlploggrpc/internal/version.go | ||||
		Reference in New Issue
	
	Block a user