diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cc352f44..f8d1a4f91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Add experimental self-observability span metrics in `go.opentelemetry.io/otel/sdk/trace`. Check the `go.opentelemetry.io/otel/sdk/trace/internal/x` package documentation for more information. (#7027) - Add native histogram exemplar support in `go.opentelemetry.io/otel/exporters/prometheus`. (#6772) +- Add experimental self-observability log metrics in `go.opentelemetry.io/otel/sdk/log`. + Check the `go.opentelemetry.io/otel/sdk/log/internal/x` package documentation for more information. (#7121) ### Changed diff --git a/sdk/log/doc.go b/sdk/log/doc.go index 78935de63..a27834a5b 100644 --- a/sdk/log/doc.go +++ b/sdk/log/doc.go @@ -30,6 +30,9 @@ should be used to describe the unique runtime environment instrumented code is being run on. That way when multiple instances of the code are collected at a single endpoint their origin is decipherable. +See [go.opentelemetry.io/otel/sdk/log/internal/x] for information about +the experimental features. + See [go.opentelemetry.io/otel/log] for more information about the OpenTelemetry Logs API. */ diff --git a/sdk/log/go.mod b/sdk/log/go.mod index a74e2ee0c..571a77710 100644 --- a/sdk/log/go.mod +++ b/sdk/log/go.mod @@ -9,7 +9,9 @@ require ( github.com/stretchr/testify v1.10.0 go.opentelemetry.io/otel v1.37.0 go.opentelemetry.io/otel/log v0.13.0 + go.opentelemetry.io/otel/metric v1.37.0 go.opentelemetry.io/otel/sdk v1.37.0 + go.opentelemetry.io/otel/sdk/metric v1.37.0 go.opentelemetry.io/otel/trace v1.37.0 ) @@ -18,7 +20,6 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect golang.org/x/sys v0.34.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/sdk/log/internal/x/README.md b/sdk/log/internal/x/README.md new file mode 100644 index 000000000..83e9e7b4c --- /dev/null +++ b/sdk/log/internal/x/README.md @@ -0,0 +1,34 @@ +# Experimental Features + +The Logs SDK contains features that have not yet stabilized in the OpenTelemetry specification. +These features are added to the OpenTelemetry Go Logs SDK prior to stabilization in the specification so that users can start experimenting with them and provide feedback. + +These feature may change in backwards incompatible ways as feedback is applied. +See the [Compatibility and Stability](#compatibility-and-stability) section for more information. + +## Features + +- [Self-Observability](#self-observability) + +### Self-Observability + +The Logs SDK provides a self-observability feature that allows you to monitor the SDK itself. + +To opt-in, set the environment variable `OTEL_GO_X_SELF_OBSERVABILITY` to `true`. + +When enabled, the SDK will create the following metrics using the global `MeterProvider`: + +- `otel.sdk.log.created` + +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. diff --git a/sdk/log/internal/x/x.go b/sdk/log/internal/x/x.go new file mode 100644 index 000000000..5f01b275d --- /dev/null +++ b/sdk/log/internal/x/x.go @@ -0,0 +1,63 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package x documents experimental features for [go.opentelemetry.io/otel/sdk/log]. +package x // import "go.opentelemetry.io/otel/sdk/log/internal/x" + +import ( + "os" + "strings" +) + +// SelfObservability is an experimental feature flag that determines if SDK +// self-observability metrics are enabled. +// +// To enable this feature set the OTEL_GO_X_SELF_OBSERVABILITY environment variable +// to the case-insensitive string value of "true" (i.e. "True" and "TRUE" +// will also enable this). +var SelfObservability = newFeature("SELF_OBSERVABILITY", func(v string) (string, bool) { + if strings.EqualFold(v, "true") { + return v, true + } + return "", false +}) + +// 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 { + key 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_" + return Feature[T]{ + key: envKeyRoot + suffix, + parse: parse, + } +} + +// Key returns the environment variable key that needs to be set to enable the +// feature. +func (f Feature[T]) Key() string { return f.key } + +// 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. + vRaw := os.Getenv(f.key) + if vRaw == "" { + return v, ok + } + return f.parse(vRaw) +} + +// Enabled reports whether the feature is enabled. +func (f Feature[T]) Enabled() bool { + _, ok := f.Lookup() + return ok +} diff --git a/sdk/log/internal/x/x_test.go b/sdk/log/internal/x/x_test.go new file mode 100644 index 000000000..15124ca91 --- /dev/null +++ b/sdk/log/internal/x/x_test.go @@ -0,0 +1,59 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package x + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSelfObservability(t *testing.T) { + const key = "OTEL_GO_X_SELF_OBSERVABILITY" + require.Equal(t, key, SelfObservability.Key()) + + t.Run("100", run(setenv(key, "100"), assertDisabled(SelfObservability))) + t.Run("true", run(setenv(key, "true"), assertEnabled(SelfObservability, "true"))) + t.Run("True", run(setenv(key, "True"), assertEnabled(SelfObservability, "True"))) + t.Run("false", run(setenv(key, "false"), assertDisabled(SelfObservability))) + t.Run("empty", run(assertDisabled(SelfObservability))) +} + +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") + } +} diff --git a/sdk/log/logger.go b/sdk/log/logger.go index d3acd0562..8d8867678 100644 --- a/sdk/log/logger.go +++ b/sdk/log/logger.go @@ -10,7 +10,12 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/log" "go.opentelemetry.io/otel/log/embedded" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/sdk" "go.opentelemetry.io/otel/sdk/instrumentation" + "go.opentelemetry.io/otel/sdk/log/internal/x" + semconv "go.opentelemetry.io/otel/semconv/v1.36.0" + "go.opentelemetry.io/otel/semconv/v1.36.0/otelconv" "go.opentelemetry.io/otel/trace" ) @@ -24,13 +29,35 @@ type logger struct { provider *LoggerProvider instrumentationScope instrumentation.Scope + + selfObservabilityEnabled bool + logCreatedMetric otelconv.SDKLogCreated } func newLogger(p *LoggerProvider, scope instrumentation.Scope) *logger { - return &logger{ + l := &logger{ provider: p, instrumentationScope: scope, } + l.initSelfObservability() + return l +} + +func (l *logger) initSelfObservability() { + if !x.SelfObservability.Enabled() { + return + } + + l.selfObservabilityEnabled = true + mp := otel.GetMeterProvider() + m := mp.Meter("go.opentelemetry.io/otel/sdk/log", + metric.WithInstrumentationVersion(sdk.Version()), + metric.WithSchemaURL(semconv.SchemaURL)) + + var err error + if l.logCreatedMetric, err = otelconv.NewSDKLogCreated(m); err != nil { + otel.Handle(err) + } } func (l *logger) Emit(ctx context.Context, r log.Record) { @@ -96,6 +123,9 @@ func (l *logger) newRecord(ctx context.Context, r log.Record) Record { attributeCountLimit: l.provider.attributeCountLimit, allowDupKeys: l.provider.allowDupKeys, } + if l.selfObservabilityEnabled { + l.logCreatedMetric.Add(ctx, 1) + } // This field SHOULD be set once the event is observed by OpenTelemetry. if newRecord.observedTimestamp.IsZero() { diff --git a/sdk/log/logger_test.go b/sdk/log/logger_test.go index fd5e8f73b..e5a247e85 100644 --- a/sdk/log/logger_test.go +++ b/sdk/log/logger_test.go @@ -6,15 +6,24 @@ package log // import "go.opentelemetry.io/otel/sdk/log" import ( "context" "errors" + "strconv" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/log" + "go.opentelemetry.io/otel/sdk" "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" "go.opentelemetry.io/otel/sdk/resource" + semconv "go.opentelemetry.io/otel/semconv/v1.36.0" + "go.opentelemetry.io/otel/semconv/v1.36.0/otelconv" "go.opentelemetry.io/otel/trace" ) @@ -348,3 +357,68 @@ func TestLoggerEnabled(t *testing.T) { }) } } + +func TestLoggerSelfObservability(t *testing.T) { + testCases := []struct { + name string + selfObservabilityEnabled bool + records []log.Record + wantLogRecordCount int64 + }{ + { + name: "Disabled", + selfObservabilityEnabled: false, + records: []log.Record{{}, {}}, + wantLogRecordCount: 0, + }, + { + name: "Enabled", + selfObservabilityEnabled: true, + records: []log.Record{{}, {}, {}, {}, {}}, + wantLogRecordCount: 5, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("OTEL_GO_X_SELF_OBSERVABILITY", strconv.FormatBool(tc.selfObservabilityEnabled)) + prev := otel.GetMeterProvider() + defer otel.SetMeterProvider(prev) + r := metric.NewManualReader() + mp := metric.NewMeterProvider(metric.WithReader(r)) + otel.SetMeterProvider(mp) + l := newLogger(NewLoggerProvider(), instrumentation.Scope{}) + + for _, record := range tc.records { + l.Emit(context.Background(), record) + } + + gotMetrics := new(metricdata.ResourceMetrics) + assert.NoError(t, r.Collect(context.Background(), gotMetrics)) + if tc.wantLogRecordCount == 0 { + assert.Empty(t, gotMetrics.ScopeMetrics) + return + } + + require.Len(t, gotMetrics.ScopeMetrics, 1) + sm := gotMetrics.ScopeMetrics[0] + assert.Equal(t, instrumentation.Scope{ + Name: "go.opentelemetry.io/otel/sdk/log", + Version: sdk.Version(), + SchemaURL: semconv.SchemaURL, + }, sm.Scope) + + wantMetric := metricdata.Metrics{ + Name: otelconv.SDKLogCreated{}.Name(), + Description: otelconv.SDKLogCreated{}.Description(), + Unit: otelconv.SDKLogCreated{}.Unit(), + Data: metricdata.Sum[int64]{ + DataPoints: []metricdata.DataPoint[int64]{{Value: tc.wantLogRecordCount}}, + Temporality: metricdata.CumulativeTemporality, + IsMonotonic: true, + }, + } + metricdatatest.AssertEqual(t, wantMetric, sm.Metrics[0], metricdatatest.IgnoreTimestamp()) + }) + } +}