diff --git a/src/OpenTelemetry.Instrumentation.ElasticsearchClient/CHANGELOG.md b/src/OpenTelemetry.Instrumentation.ElasticsearchClient/CHANGELOG.md index e97e1d38b8..a0eb85fceb 100644 --- a/src/OpenTelemetry.Instrumentation.ElasticsearchClient/CHANGELOG.md +++ b/src/OpenTelemetry.Instrumentation.ElasticsearchClient/CHANGELOG.md @@ -2,6 +2,42 @@ ## Unreleased +* Updated the database semantic conventions to + [v1.43.0](https://github.com/open-telemetry/semantic-conventions/blob/v1.43.0/docs/db/elasticsearch.md) + of the Semantic Conventions for Elasticsearch client operations. The new + attributes can be opted in to by setting the `OTEL_SEMCONV_STABILITY_OPT_IN` + environment variable. This allows for a transition period for users to + experiment with the new semantic conventions and adapt as necessary. The + environment variable supports the following values: + * `database` - emit the new, frozen (proposed for stable) database attributes, + and stop emitting the old experimental database attributes that the + instrumentation emitted previously. + * `database/dup` - emit both the old and the frozen (proposed for stable) + database attributes, allowing for a more seamless transition. + * The default behavior (in the absence of one of these values) is to continue + emitting the same database semantic conventions that were emitted in + the previous version. + * Note: this option will be removed after the new database semantic + conventions are marked stable. At which time this instrumentation can receive + a stable release, and the old database semantic conventions will no longer be + supported. Refer to the + [specification](https://github.com/open-telemetry/semantic-conventions/blob/v1.43.0/docs/db/elasticsearch.md) + for more information. + + When the new attributes are emitted: + * `net.peer.ip` and `net.peer.name` are replaced by `server.address`. + * `net.peer.port` is replaced by `server.port`. + * `db.system` is replaced by `db.system.name`. + * `db.name` is replaced by `db.collection.name`. + * `db.method` is replaced by `http.request.method`. + * `db.statement` is replaced by `db.query.text`. + * `http.status_code` is replaced by `db.response.status_code`. + * `error.type` is emitted when the operation fails. + * `db.operation.name` is emitted, derived from the request URL. + * The span name is `{db.operation.name} {db.collection.name}`. + + ([#4635](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/4635)) + ## 1.16.0-beta.1 Released 2026-Jul-09 diff --git a/src/OpenTelemetry.Instrumentation.ElasticsearchClient/ElasticsearchClientInstrumentationOptions.cs b/src/OpenTelemetry.Instrumentation.ElasticsearchClient/ElasticsearchClientInstrumentationOptions.cs index a5bf693622..d3101ff11a 100644 --- a/src/OpenTelemetry.Instrumentation.ElasticsearchClient/ElasticsearchClientInstrumentationOptions.cs +++ b/src/OpenTelemetry.Instrumentation.ElasticsearchClient/ElasticsearchClientInstrumentationOptions.cs @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 using System.Diagnostics; +using Microsoft.Extensions.Configuration; +using static OpenTelemetry.Internal.DatabaseSemanticConventionHelper; namespace OpenTelemetry.Instrumentation.ElasticsearchClient; @@ -10,6 +12,21 @@ namespace OpenTelemetry.Instrumentation.ElasticsearchClient; /// public class ElasticsearchClientInstrumentationOptions { + /// + /// Initializes a new instance of the class. + /// + public ElasticsearchClientInstrumentationOptions() + : this(new ConfigurationBuilder().AddEnvironmentVariables().Build()) + { + } + + internal ElasticsearchClientInstrumentationOptions(IConfiguration configuration) + { + var databaseSemanticConvention = GetSemanticConventionOptIn(configuration); + this.EmitOldAttributes = databaseSemanticConvention.HasFlag(DatabaseSemanticConvention.Old); + this.EmitNewAttributes = databaseSemanticConvention.HasFlag(DatabaseSemanticConvention.New); + } + /// /// Gets or sets a value indicating whether down stream instrumentation (HttpClient) is suppressed (disabled). /// @@ -41,4 +58,14 @@ public class ElasticsearchClientInstrumentationOptions /// The type of this object depends on the event, which is given by the above parameter. /// public Action? Enrich { get; set; } + + /// + /// Gets or sets a value indicating whether the old database attributes should be emitted. + /// + internal bool EmitOldAttributes { get; set; } + + /// + /// Gets or sets a value indicating whether the new database attributes should be emitted. + /// + internal bool EmitNewAttributes { get; set; } } diff --git a/src/OpenTelemetry.Instrumentation.ElasticsearchClient/Implementation/ElasticsearchRequestPipelineDiagnosticListener.cs b/src/OpenTelemetry.Instrumentation.ElasticsearchClient/Implementation/ElasticsearchRequestPipelineDiagnosticListener.cs index 5a59f81d7a..357f20e6e4 100644 --- a/src/OpenTelemetry.Instrumentation.ElasticsearchClient/Implementation/ElasticsearchRequestPipelineDiagnosticListener.cs +++ b/src/OpenTelemetry.Instrumentation.ElasticsearchClient/Implementation/ElasticsearchRequestPipelineDiagnosticListener.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.Globalization; #if NETFRAMEWORK using System.Net.Http; #endif @@ -27,15 +28,58 @@ internal partial class ElasticsearchRequestPipelineDiagnosticListener : Listener #pragma warning disable IDE0370 // Suppression is unnecessary internal static readonly string ActivitySourceName = AssemblyName.Name!; #pragma warning restore IDE0370 // Suppression is unnecessary - internal static readonly ActivitySource ActivitySource = new(ActivitySourceName, Assembly.GetPackageVersion()); + + internal static readonly Version SemanticConventionsVersion = new(1, 23, 0); + internal static readonly ActivitySource ActivitySource = ActivitySourceFactory.Create(SemanticConventionsVersion); private const string RequestRegexPattern = @"\n# Request:\r?\n(\{.*)\n# Response"; + private static readonly Version SemanticConventionsVersionNew = new(1, 43, 0); + private static readonly ActivitySource ActivitySourceNew = ActivitySourceFactory.Create(SemanticConventionsVersionNew); + + private static readonly ActivitySource ActivitySourceBoth = ActivitySourceFactory.Create(null); + #if !NET private static readonly Regex ParseRequest = new(RequestRegexPattern, RegexOptions.Compiled | RegexOptions.Singleline); #endif private static readonly ConcurrentDictionary MethodNameCache = new(); + // Some Elasticsearch endpoints are namespaced with a fixed, low-cardinality sub-action, e.g. "_cat/aliases" or + // "_eql/search". Unlike e.g. "_snapshot/{repository}" or "_tasks/{task_id}", where the segment following the + // namespace is a user-supplied resource identifier, these sub-actions are drawn from a small known vocabulary + // and are safe to combine into `db.operation.name` (e.g. "cat.aliases") without risking high cardinality. + private static readonly Dictionary> NamespacedSubActions = new(StringComparer.Ordinal) + { + ["cat"] = new(StringComparer.Ordinal) + { + "aliases", + "allocation", + "component_templates", + "count", + "fielddata", + "health", + "indices", + "master", + "ml", + "nodeattrs", + "nodes", + "pending_tasks", + "plugins", + "recovery", + "repositories", + "segments", + "shards", + "snapshots", + "tasks", + "templates", + "thread_pool", + "transforms", + }, + ["eql"] = new(StringComparer.Ordinal) { "search" }, + ["graph"] = new(StringComparer.Ordinal) { "explore" }, + ["sql"] = new(StringComparer.Ordinal) { "close", "delete_async", "get_async", "get_async_status", "translate" }, + }; + private readonly ElasticsearchClientInstrumentationOptions options; private readonly MultiTypePropertyFetcher uriFetcher = new("Uri"); private readonly MultiTypePropertyFetcher methodFetcher = new("Method"); @@ -68,14 +112,25 @@ public override void OnEventWritten(string name, object? payload) } } - private static string GetDisplayName(Activity activity, object? method, string? elasticType = null) + private static string GetDisplayName(Activity activity, object? method, string? elasticType, string? dbOperationName, bool useNewSpanNameFormat) { switch (activity.OperationName) { case "Ping": return "Elasticsearch Ping"; + case "CallElasticsearch" when method != null: { + if (useNewSpanNameFormat) + { + // Per the stable database semantic conventions, the span name is + // "{db.operation.name} {target}", falling back to just the target or operation name + // when the other is not available. + return dbOperationName == null + ? elasticType ?? method.ToString()! + : elasticType == null ? dbOperationName : $"{dbOperationName} {elasticType}"; + } + var methodName = MethodNameCache.GetOrAdd(method, $"Elasticsearch {method}"); return elasticType == null ? methodName : $"{methodName} {elasticType}"; } @@ -127,6 +182,67 @@ private static string GetDisplayName(Activity activity, object? method, string? return elasticType; } + private static string? GetDbOperationName(Uri uri, object? method) + { + // The `_doc`/`_create` endpoints map to different logical operations depending on the HTTP method used. + var segments = uri.Segments; + string? endpoint = null; + string? @namespace = null; + + for (var i = 1; i < segments.Length; i++) + { + var segment = Uri.UnescapeDataString(segments[i]).TrimEnd('/'); + + if (segment.Length == 0) + { + continue; + } + + if (segment[0] != '_') + { + // Only combine with the following segment when it is a known, fixed sub-action for the current + // namespace (see NamespacedSubActions); otherwise, it is an index/id/other identifier and is + // ignored to avoid introducing unbounded cardinality into `db.operation.name`. + if (endpoint != null && + @namespace == null && + NamespacedSubActions.TryGetValue(endpoint, out var subActions) && + subActions.Contains(segment)) + { + @namespace = endpoint; + endpoint = segment; + } + + continue; + } + + endpoint = segment.Substring(1); + @namespace = null; + } + + if (endpoint == null) + { + return null; + } + + var methodName = method?.ToString(); + + if (string.Equals(endpoint, "doc", StringComparison.Ordinal) || + string.Equals(endpoint, "create", StringComparison.Ordinal)) + { + endpoint = methodName?.ToUpperInvariant() switch + { + "GET" => "get", + "HEAD" => "exists", + "PUT" => "index", + "POST" => "create", + "DELETE" => "delete", + _ => endpoint, + }; + } + + return @namespace == null ? endpoint : $"{@namespace}.{endpoint}"; + } + #if NET [GeneratedRegex(RequestRegexPattern, RegexOptions.Singleline)] private static partial Regex RequestParser(); @@ -195,7 +311,15 @@ private void OnStartActivity(Activity activity, object? payload) // remove sensitive information like user and password information uri = UriHelper.ScrubUserInfo(uri); - ActivityInstrumentationHelper.SetActivitySourceProperty(activity, ActivitySource); + var emitOldAttributes = this.options.EmitOldAttributes; + var emitNewAttributes = this.options.EmitNewAttributes; + + var activitySource = + emitOldAttributes && emitNewAttributes ? ActivitySourceBoth : + emitNewAttributes ? ActivitySourceNew : + ActivitySource; + + ActivityInstrumentationHelper.SetActivitySourceProperty(activity, activitySource); ActivityInstrumentationHelper.SetKindProperty(activity, ActivityKind.Client); var method = this.methodFetcher.Fetch(payload); @@ -206,32 +330,86 @@ private void OnStartActivity(Activity activity, object? payload) } var elasticIndex = GetElasticIndex(uri); - activity.DisplayName = GetDisplayName(activity, method, elasticIndex); - activity.SetTag(SemanticConventions.AttributeDbSystem, DatabaseSystemName); + var dbOperationName = emitNewAttributes ? GetDbOperationName(uri, method) : null; + activity.DisplayName = GetDisplayName(activity, method, elasticIndex, dbOperationName, emitNewAttributes && !emitOldAttributes); + + if (emitOldAttributes) + { + activity.SetTag(SemanticConventions.AttributeDbSystem, DatabaseSystemName); + } + + if (emitNewAttributes) + { + activity.SetTag(SemanticConventions.AttributeDbSystemName, DatabaseSystemName); + + if (dbOperationName != null) + { + activity.SetTag(SemanticConventions.AttributeDbOperationName, dbOperationName); + } + } if (elasticIndex != null) { - activity.SetTag(SemanticConventions.AttributeDbName, elasticIndex); + if (emitOldAttributes) + { + activity.SetTag(SemanticConventions.AttributeDbName, elasticIndex); + } + + if (emitNewAttributes) + { + activity.SetTag(SemanticConventions.AttributeDbCollectionName, elasticIndex); + } } var uriHostNameType = Uri.CheckHostName(uri.Host); - if (uriHostNameType is UriHostNameType.IPv4 or UriHostNameType.IPv6) + var isIpAddress = uriHostNameType is UriHostNameType.IPv4 or UriHostNameType.IPv6; + + if (emitOldAttributes) { - activity.SetTag(SemanticConventions.AttributeNetPeerIp, uri.Host); + activity.SetTag(isIpAddress ? SemanticConventions.AttributeNetPeerIp : SemanticConventions.AttributeNetPeerName, uri.Host); } - else + + if (emitNewAttributes) { - activity.SetTag(SemanticConventions.AttributeNetPeerName, uri.Host); + activity.SetTag(SemanticConventions.AttributeServerAddress, uri.Host); + + if (isIpAddress) + { + activity.SetTag(SemanticConventions.AttributeNetworkPeerAddress, uri.Host); + } } if (uri.Port > 0) { - activity.SetTag(SemanticConventions.AttributeNetPeerPort, uri.Port); + if (emitOldAttributes) + { + activity.SetTag(SemanticConventions.AttributeNetPeerPort, uri.Port); + } + + if (emitNewAttributes) + { + activity.SetTag(SemanticConventions.AttributeServerPort, uri.Port); + + if (isIpAddress) + { + activity.SetTag(SemanticConventions.AttributeNetworkPeerPort, uri.Port); + } + } } if (method != null) { - activity.SetTag(AttributeDbMethod, method.ToString()); + var methodName = method.ToString(); + + if (emitOldAttributes) + { + activity.SetTag(AttributeDbMethod, methodName); + } + + if (emitNewAttributes) + { + activity.SetTag(SemanticConventions.AttributeHttpRequestMethod, methodName); + } } activity.SetTag(SemanticConventions.AttributeUrlFull, uri.OriginalString); @@ -251,12 +429,24 @@ private void OnStopActivity(Activity activity, object? payload) { if (activity.IsAllDataRequested) { + var emitOldAttributes = this.options.EmitOldAttributes; + var emitNewAttributes = this.options.EmitNewAttributes; + var statusCode = this.httpStatusFetcher.Fetch(payload); - activity.SetStatus(SpanHelper.ResolveActivityStatusForHttpStatusCode(activity.Kind, statusCode.GetValueOrDefault())); + var activityStatus = SpanHelper.ResolveActivityStatusForHttpStatusCode(activity.Kind, statusCode.GetValueOrDefault()); + activity.SetStatus(activityStatus); if (statusCode.HasValue) { - activity.SetTag(SemanticConventions.AttributeHttpStatusCode, (int)statusCode); + if (emitOldAttributes) + { + activity.SetTag(SemanticConventions.AttributeHttpStatusCode, (int)statusCode); + } + + if (emitNewAttributes) + { + activity.SetTag(SemanticConventions.AttributeDbResponseStatusCode, statusCode.Value.ToString(CultureInfo.InvariantCulture)); + } } var debugInformation = this.debugInformationFetcher.Fetch(payload); @@ -268,7 +458,15 @@ private void OnStopActivity(Activity activity, object? payload) dbStatement = dbStatement.Substring(0, this.options.MaxDbStatementLength); } - activity.SetTag(SemanticConventions.AttributeDbStatement, dbStatement); + if (emitOldAttributes) + { + activity.SetTag(SemanticConventions.AttributeDbStatement, dbStatement); + } + + if (emitNewAttributes) + { + activity.SetTag(SemanticConventions.AttributeDbQueryText, dbStatement); + } } var originalException = this.originalExceptionFetcher.Fetch(payload); @@ -276,6 +474,11 @@ private void OnStopActivity(Activity activity, object? payload) { activity.SetCustomProperty(ExceptionCustomPropertyName, originalException); + if (emitNewAttributes) + { + activity.SetTag(SemanticConventions.AttributeErrorType, originalException.GetType().FullName); + } + var failureReason = this.failureReasonFetcher.Fetch(originalException); if (failureReason != null) { @@ -303,6 +506,11 @@ private void OnStopActivity(Activity activity, object? payload) } } } + else if (emitNewAttributes && activityStatus == ActivityStatusCode.Error && statusCode.HasValue) + { + // No exception was thrown, but the response indicates an error (4xx/5xx). + activity.SetTag(SemanticConventions.AttributeErrorType, statusCode.Value.ToString(CultureInfo.InvariantCulture)); + } try { diff --git a/src/OpenTelemetry.Instrumentation.ElasticsearchClient/OpenTelemetry.Instrumentation.ElasticsearchClient.csproj b/src/OpenTelemetry.Instrumentation.ElasticsearchClient/OpenTelemetry.Instrumentation.ElasticsearchClient.csproj index 95f5bf0341..45b2299b54 100644 --- a/src/OpenTelemetry.Instrumentation.ElasticsearchClient/OpenTelemetry.Instrumentation.ElasticsearchClient.csproj +++ b/src/OpenTelemetry.Instrumentation.ElasticsearchClient/OpenTelemetry.Instrumentation.ElasticsearchClient.csproj @@ -27,14 +27,18 @@ + + + - + + diff --git a/src/OpenTelemetry.Instrumentation.ElasticsearchClient/README.md b/src/OpenTelemetry.Instrumentation.ElasticsearchClient/README.md index 93df8b5bd0..840a516d70 100644 --- a/src/OpenTelemetry.Instrumentation.ElasticsearchClient/README.md +++ b/src/OpenTelemetry.Instrumentation.ElasticsearchClient/README.md @@ -18,9 +18,9 @@ and collects traces about outgoing requests. > [!NOTE] > This component is based on the OpenTelemetry semantic conventions for -[metrics](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/db/elasticsearch.md) +[metrics](https://github.com/open-telemetry/semantic-conventions/blob/v1.43.0/docs/db/elasticsearch.md#metrics) and -[traces](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/db/elasticsearch.md). +[traces](https://github.com/open-telemetry/semantic-conventions/blob/v1.43.0/docs/db/elasticsearch.md#spans). These conventions are [Experimental](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/document-status.md), and hence, this package is a diff --git a/test/OpenTelemetry.Instrumentation.ElasticsearchClient.Tests/ElasticsearchClientInstrumentationOptionsTests.cs b/test/OpenTelemetry.Instrumentation.ElasticsearchClient.Tests/ElasticsearchClientInstrumentationOptionsTests.cs new file mode 100644 index 0000000000..47bb33eaaa --- /dev/null +++ b/test/OpenTelemetry.Instrumentation.ElasticsearchClient.Tests/ElasticsearchClientInstrumentationOptionsTests.cs @@ -0,0 +1,46 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Microsoft.Extensions.Configuration; +using OpenTelemetry.Internal; + +namespace OpenTelemetry.Instrumentation.ElasticsearchClient.Tests; + +public class ElasticsearchClientInstrumentationOptionsTests +{ + [Fact] + public void ShouldEmitOldAttributesWhenStabilityOptInIsNotSpecified() + { + var configuration = new ConfigurationBuilder().Build(); + var options = new ElasticsearchClientInstrumentationOptions(configuration); + + Assert.True(options.EmitOldAttributes); + Assert.False(options.EmitNewAttributes); + } + + [Fact] + public void ShouldEmitNewAttributesWhenStabilityOptInIsDatabase() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection([new(DatabaseSemanticConventionHelper.SemanticConventionOptInKeyName, "database")]) + .Build(); + + var options = new ElasticsearchClientInstrumentationOptions(configuration); + + Assert.False(options.EmitOldAttributes); + Assert.True(options.EmitNewAttributes); + } + + [Fact] + public void ShouldEmitBothAttributesWhenStabilityOptInIsDatabaseDup() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection([new(DatabaseSemanticConventionHelper.SemanticConventionOptInKeyName, "database/dup")]) + .Build(); + + var options = new ElasticsearchClientInstrumentationOptions(configuration); + + Assert.True(options.EmitOldAttributes); + Assert.True(options.EmitNewAttributes); + } +} diff --git a/test/OpenTelemetry.Instrumentation.ElasticsearchClient.Tests/ElasticsearchClientTests.cs b/test/OpenTelemetry.Instrumentation.ElasticsearchClient.Tests/ElasticsearchClientTests.cs index 855fb270e8..6d3a269086 100644 --- a/test/OpenTelemetry.Instrumentation.ElasticsearchClient.Tests/ElasticsearchClientTests.cs +++ b/test/OpenTelemetry.Instrumentation.ElasticsearchClient.Tests/ElasticsearchClientTests.cs @@ -44,9 +44,7 @@ public async Task CanCaptureGetById() Assert.Empty(failed); } - Assert.Single(exportedItems); - - var searchActivity = exportedItems[0]; + var searchActivity = Assert.Single(exportedItems); Assert.Equal(parent.TraceId, searchActivity.Context.TraceId); Assert.Equal(parent.SpanId, searchActivity.ParentSpanId); @@ -67,8 +65,6 @@ public async Task CanCaptureGetById() Assert.Contains("Successful (200) low level call", debugInfo); Assert.Equal(ActivityStatusCode.Unset, searchActivity.Status); - - // Assert.Equal(expectedResource, searchActivity.GetResource()); } [Fact] @@ -97,9 +93,7 @@ public async Task CanCaptureGetByIdNotFound() Assert.Empty(failed); } - Assert.Single(exportedItems); - - var searchActivity = exportedItems[0]; + var searchActivity = Assert.Single(exportedItems); Assert.Equal(parent.TraceId, searchActivity.Context.TraceId); Assert.Equal(parent.SpanId, searchActivity.ParentSpanId); @@ -120,8 +114,6 @@ public async Task CanCaptureGetByIdNotFound() Assert.Contains("Successful (404) low level call", debugInfo); Assert.Equal(ActivityStatusCode.Error, searchActivity.Status); - - // Assert.Equal(expectedResource, searchActivity.GetResource()); } [Fact] @@ -150,8 +142,7 @@ public async Task CanCaptureSearchCall() Assert.Empty(failed); } - Assert.Single(exportedItems); - var searchActivity = exportedItems[0]; + var searchActivity = Assert.Single(exportedItems); Assert.Equal(parent.TraceId, searchActivity.Context.TraceId); Assert.Equal(parent.SpanId, searchActivity.ParentSpanId); @@ -172,8 +163,6 @@ public async Task CanCaptureSearchCall() Assert.Contains("Successful (200) low level call", debugInfo); Assert.Equal(ActivityStatusCode.Unset, searchActivity.Status); - - // Assert.Equal(expectedResource, searchActivity.GetResource()); } [Fact] @@ -382,8 +371,7 @@ public async Task CanCaptureSearchCallWithDebugMode() Assert.Empty(failed); } - Assert.Single(exportedItems); - var searchActivity = exportedItems[0]; + var searchActivity = Assert.Single(exportedItems); Assert.Equal(parent.TraceId, searchActivity.Context.TraceId); Assert.Equal(parent.SpanId, searchActivity.ParentSpanId); @@ -404,8 +392,6 @@ public async Task CanCaptureSearchCallWithDebugMode() Assert.Contains("Successful (200) low level call", debugInfo); Assert.Equal(ActivityStatusCode.Unset, searchActivity.Status); - - // Assert.Equal(expectedResource, searchActivity.GetResource()); } [Fact] @@ -434,8 +420,7 @@ public async Task CanCaptureSearchCallWithParseAndFormatRequestOption() Assert.Empty(failed); } - Assert.Single(exportedItems); - var searchActivity = exportedItems[0]; + var searchActivity = Assert.Single(exportedItems); Assert.Equal(parent.TraceId, searchActivity.Context.TraceId); Assert.Equal(parent.SpanId, searchActivity.ParentSpanId); @@ -471,8 +456,6 @@ public async Task CanCaptureSearchCallWithParseAndFormatRequestOption() debugInfo.Replace("\r\n", "\n")); Assert.Equal(ActivityStatusCode.Unset, searchActivity.Status); - - // Assert.Equal(expectedResource, searchActivity.GetResource()); } [Fact] @@ -501,8 +484,7 @@ public async Task CanCaptureSearchCallWithoutDebugMode() Assert.Empty(failed); } - Assert.Single(exportedItems); - var searchActivity = exportedItems[0]; + var searchActivity = Assert.Single(exportedItems); Assert.Equal(parent.TraceId, searchActivity.Context.TraceId); Assert.Equal(parent.SpanId, searchActivity.ParentSpanId); @@ -523,8 +505,6 @@ public async Task CanCaptureSearchCallWithoutDebugMode() Assert.DoesNotContain("123", debugInfo); Assert.Equal(ActivityStatusCode.Unset, searchActivity.Status); - - // Assert.Equal(expectedResource, searchActivity.GetResource()); } [Fact] @@ -553,8 +533,7 @@ public async Task CanCaptureMultipleIndiceSearchCall() Assert.Empty(failed); } - Assert.Single(exportedItems); - var searchActivity = exportedItems[0]; + var searchActivity = Assert.Single(exportedItems); Assert.Equal(parent.TraceId, searchActivity.Context.TraceId); Assert.Equal(parent.SpanId, searchActivity.ParentSpanId); @@ -575,8 +554,6 @@ public async Task CanCaptureMultipleIndiceSearchCall() Assert.Contains("Successful (200) low level call", debugInfo); Assert.Equal(ActivityStatusCode.Unset, searchActivity.Status); - - // Assert.Equal(expectedResource, searchActivity.GetResource()); } [Fact] @@ -606,8 +583,7 @@ public async Task CanCaptureElasticsearchClientException() Assert.NotEmpty(failed); } - Assert.Single(exportedItems); - var searchActivity = exportedItems[0]; + var searchActivity = Assert.Single(exportedItems); Assert.Equal(parent.TraceId, searchActivity.Context.TraceId); Assert.Equal(parent.SpanId, searchActivity.ParentSpanId); @@ -628,8 +604,6 @@ public async Task CanCaptureElasticsearchClientException() Assert.Contains("Unsuccessful (500) low level call", debugInfo); Assert.Equal(ActivityStatusCode.Error, searchActivity.Status); - - // Assert.Equal(expectedResource, searchActivity.GetResource()); } [Fact] @@ -658,8 +632,7 @@ public async Task CanCaptureCatRequest() Assert.Empty(failed); } - Assert.Single(exportedItems); - var searchActivity = exportedItems[0]; + var searchActivity = Assert.Single(exportedItems); Assert.Equal(parent.TraceId, searchActivity.Context.TraceId); Assert.Equal(parent.SpanId, searchActivity.ParentSpanId); @@ -680,8 +653,6 @@ public async Task CanCaptureCatRequest() Assert.Contains("Successful (200) low level call", debugInfo); Assert.Equal(ActivityStatusCode.Unset, searchActivity.Status); - - // Assert.Equal(expectedResource, searchActivity.GetResource()); } [Fact] @@ -778,8 +749,7 @@ public async Task DbStatementIsNotDisplayedWhenSetDbStatementForRequestIsFalse() Assert.Empty(failed); } - Assert.Single(exportedItems); - var searchActivity = exportedItems[0]; + var searchActivity = Assert.Single(exportedItems); var tags = searchActivity.Tags.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); Assert.Null(searchActivity.GetTagValue(SemanticConventions.AttributeDbStatement)); @@ -811,8 +781,7 @@ public async Task DbStatementIsDisplayedWhenSetDbStatementForRequestIsUsingTheDe Assert.Empty(failed); } - Assert.Single(exportedItems); - var searchActivity = exportedItems[0]; + var searchActivity = Assert.Single(exportedItems); var tags = searchActivity.Tags.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); Assert.NotNull(searchActivity.GetTagValue(SemanticConventions.AttributeDbStatement)); @@ -845,12 +814,245 @@ public async Task ShouldRemoveSensitiveInformation() Assert.Empty(failed); } - Assert.Single(exportedItems); - var searchActivity = exportedItems[0]; + var searchActivity = Assert.Single(exportedItems); var dbUrl = (string?)searchActivity.GetTagValue(SemanticConventions.AttributeUrlFull); Assert.DoesNotContain("sensitive", dbUrl); Assert.Contains("REDACTED:REDACTED", dbUrl); } + + [Fact] + public async Task EmitsNewSemanticConventionsWhenOptedIn() + { + var exportedItems = new List(); + + var parent = new Activity("parent").Start(); + + var client = new ElasticClient(new ConnectionSettings(new InMemoryConnection()).DefaultIndex("customer")); + + using (Sdk.CreateTracerProviderBuilder() + .SetSampler(new AlwaysOnSampler()) + .AddElasticsearchClientInstrumentation(o => + { + o.EmitOldAttributes = false; + o.EmitNewAttributes = true; + }) + .AddInMemoryExporter(exportedItems) + .Build()) + { + var response = await client.GetAsync("123"); + + Assert.NotNull(response); + Assert.True(response.ApiCall.Success); + } + + var searchActivity = Assert.Single(exportedItems); + + // New attributes are emitted. + Assert.Equal("elasticsearch", searchActivity.GetTagValue(SemanticConventions.AttributeDbSystemName)); + Assert.Equal("customer", searchActivity.GetTagValue(SemanticConventions.AttributeDbCollectionName)); + Assert.Equal("get", searchActivity.GetTagValue(SemanticConventions.AttributeDbOperationName)); + Assert.Equal("localhost", searchActivity.GetTagValue(SemanticConventions.AttributeServerAddress)); + Assert.Equal(9200, searchActivity.GetTagValue(SemanticConventions.AttributeServerPort)); + Assert.Equal("GET", searchActivity.GetTagValue(SemanticConventions.AttributeHttpRequestMethod)); + Assert.NotNull(searchActivity.GetTagValue(SemanticConventions.AttributeUrlFull)); + Assert.NotNull(searchActivity.GetTagValue(SemanticConventions.AttributeDbQueryText)); + + // Span name follows the stable convention: "{db.operation.name} {db.collection.name}". + Assert.Equal("get customer", searchActivity.DisplayName); + + // localhost is a hostname (not an IP), so network.peer.* are not set. + Assert.Null(searchActivity.GetTagValue(SemanticConventions.AttributeNetworkPeerAddress)); + Assert.Null(searchActivity.GetTagValue(SemanticConventions.AttributeNetworkPeerPort)); + + // Old attributes are not emitted. + Assert.Null(searchActivity.GetTagValue(SemanticConventions.AttributeDbSystem)); + Assert.Null(searchActivity.GetTagValue(SemanticConventions.AttributeDbName)); + Assert.Null(searchActivity.GetTagValue(SemanticConventions.AttributeNetPeerName)); + Assert.Null(searchActivity.GetTagValue(SemanticConventions.AttributeNetPeerIp)); + Assert.Null(searchActivity.GetTagValue(SemanticConventions.AttributeNetPeerPort)); + Assert.Null(searchActivity.GetTagValue("db.method")); + Assert.Null(searchActivity.GetTagValue(SemanticConventions.AttributeDbStatement)); + Assert.Null(searchActivity.GetTagValue(SemanticConventions.AttributeHttpStatusCode)); + } + + [Fact] + public async Task EmitsBothSemanticConventionsWhenOptedIn() + { + var exportedItems = new List(); + + var parent = new Activity("parent").Start(); + + var client = new ElasticClient(new ConnectionSettings(new InMemoryConnection()).DefaultIndex("customer")); + + using (Sdk.CreateTracerProviderBuilder() + .SetSampler(new AlwaysOnSampler()) + .AddElasticsearchClientInstrumentation(o => + { + o.EmitOldAttributes = true; + o.EmitNewAttributes = true; + }) + .AddInMemoryExporter(exportedItems) + .Build()) + { + var response = await client.GetAsync("123"); + + Assert.NotNull(response); + Assert.True(response.ApiCall.Success); + } + + var searchActivity = Assert.Single(exportedItems); + + // Old attributes. + Assert.Equal("elasticsearch", searchActivity.GetTagValue(SemanticConventions.AttributeDbSystem)); + Assert.Equal("customer", searchActivity.GetTagValue(SemanticConventions.AttributeDbName)); + Assert.Equal("localhost", searchActivity.GetTagValue(SemanticConventions.AttributeNetPeerName)); + Assert.Equal(9200, searchActivity.GetTagValue(SemanticConventions.AttributeNetPeerPort)); + Assert.Equal("GET", searchActivity.GetTagValue("db.method")); + Assert.NotNull(searchActivity.GetTagValue(SemanticConventions.AttributeDbStatement)); + + // New attributes. + Assert.Equal("elasticsearch", searchActivity.GetTagValue(SemanticConventions.AttributeDbSystemName)); + Assert.Equal("customer", searchActivity.GetTagValue(SemanticConventions.AttributeDbCollectionName)); + Assert.Equal("get", searchActivity.GetTagValue(SemanticConventions.AttributeDbOperationName)); + Assert.Equal("localhost", searchActivity.GetTagValue(SemanticConventions.AttributeServerAddress)); + Assert.Equal(9200, searchActivity.GetTagValue(SemanticConventions.AttributeServerPort)); + Assert.Equal("GET", searchActivity.GetTagValue(SemanticConventions.AttributeHttpRequestMethod)); + Assert.NotNull(searchActivity.GetTagValue(SemanticConventions.AttributeDbQueryText)); + + // Span name retains the legacy format while old attributes are still being emitted. + Assert.Equal("Elasticsearch GET customer", searchActivity.DisplayName); + } + + [Fact] + public async Task EmitsNetworkPeerAddressForIpAddressWhenNewSemanticConventionsOptedIn() + { + var exportedItems = new List(); + + var parent = new Activity("parent").Start(); + + var client = new ElasticClient(new ConnectionSettings( + new SingleNodeConnectionPool(new Uri("http://127.0.0.1:9200")), new InMemoryConnection()).DefaultIndex("customer")); + + using (Sdk.CreateTracerProviderBuilder() + .SetSampler(new AlwaysOnSampler()) + .AddElasticsearchClientInstrumentation(o => + { + o.EmitOldAttributes = false; + o.EmitNewAttributes = true; + }) + .AddInMemoryExporter(exportedItems) + .Build()) + { + var response = await client.GetAsync("123"); + + Assert.NotNull(response); + Assert.True(response.ApiCall.Success); + } + + var searchActivity = Assert.Single(exportedItems); + + Assert.Equal("127.0.0.1", searchActivity.GetTagValue(SemanticConventions.AttributeServerAddress)); + Assert.Equal(9200, searchActivity.GetTagValue(SemanticConventions.AttributeServerPort)); + Assert.Equal("127.0.0.1", searchActivity.GetTagValue(SemanticConventions.AttributeNetworkPeerAddress)); + Assert.Equal(9200, searchActivity.GetTagValue(SemanticConventions.AttributeNetworkPeerPort)); + } + + [Fact] + public async Task EmitsErrorTypeFromStatusCodeWhenNewSemanticConventionsOptedIn() + { + var exportedItems = new List(); + + var parent = new Activity("parent").Start(); + + var client = new ElasticClient(new ConnectionSettings(new InMemoryConnection(null, statusCode: 404)).DefaultIndex("customer")); + + using (Sdk.CreateTracerProviderBuilder() + .SetSampler(new AlwaysOnSampler()) + .AddElasticsearchClientInstrumentation(o => + { + o.EmitOldAttributes = false; + o.EmitNewAttributes = true; + }) + .AddInMemoryExporter(exportedItems) + .Build()) + { + var response = await client.GetAsync("123"); + + Assert.NotNull(response); + } + + var searchActivity = Assert.Single(exportedItems); + + Assert.Equal(ActivityStatusCode.Error, searchActivity.Status); + Assert.Equal("404", searchActivity.GetTagValue(SemanticConventions.AttributeDbResponseStatusCode)); + Assert.Equal("404", searchActivity.GetTagValue(SemanticConventions.AttributeErrorType)); + } + + [Fact] + public async Task EmitsErrorTypeFromExceptionWhenNewSemanticConventionsOptedIn() + { + var exportedItems = new List(); + + var parent = new Activity("parent").Start(); + + var connection = new InMemoryConnection(Encoding.UTF8.GetBytes("{}"), statusCode: 500, exception: new ElasticsearchClientException("Boom")); + var client = new ElasticClient(new ConnectionSettings(connection).DefaultIndex("customer").EnableDebugMode()); + + using (Sdk.CreateTracerProviderBuilder() + .SetSampler(new AlwaysOnSampler()) + .AddElasticsearchClientInstrumentation(o => + { + o.EmitOldAttributes = false; + o.EmitNewAttributes = true; + }) + .AddInMemoryExporter(exportedItems) + .Build()) + { + var searchResponse = await client.SearchAsync(s => s.Query(q => q.Bool(b => b.Must(m => m.Term(f => f.Id, "123"))))); + Assert.NotNull(searchResponse); + Assert.False(searchResponse.ApiCall.Success); + + var expectedErrorType = searchResponse.ApiCall.OriginalException?.GetType().FullName; + Assert.NotNull(expectedErrorType); + + var searchActivity = Assert.Single(exportedItems); + + Assert.Equal(ActivityStatusCode.Error, searchActivity.Status); + Assert.Equal("500", searchActivity.GetTagValue(SemanticConventions.AttributeDbResponseStatusCode)); + Assert.Equal(expectedErrorType, searchActivity.GetTagValue(SemanticConventions.AttributeErrorType)); + } + } + + [Fact] + public async Task EmitsDbOperationNameForSearchWhenNewSemanticConventionsOptedIn() + { + var exportedItems = new List(); + + var parent = new Activity("parent").Start(); + + var client = new ElasticClient(new ConnectionSettings(new InMemoryConnection()).DefaultIndex("customer")); + + using (Sdk.CreateTracerProviderBuilder() + .SetSampler(new AlwaysOnSampler()) + .AddElasticsearchClientInstrumentation(o => + { + o.EmitOldAttributes = false; + o.EmitNewAttributes = true; + }) + .AddInMemoryExporter(exportedItems) + .Build()) + { + var response = await client.SearchAsync(s => s.Query(q => q.Bool(b => b.Must(m => m.Term(f => f.Id, "123"))))); + + Assert.NotNull(response); + Assert.True(response.ApiCall.Success); + } + + var searchActivity = Assert.Single(exportedItems); + + Assert.Equal("search", searchActivity.GetTagValue(SemanticConventions.AttributeDbOperationName)); + Assert.Equal("search customer", searchActivity.DisplayName); + } }