diff --git a/Directory.Build.props b/Directory.Build.props index 905d563de5..da9f230fce 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,6 +8,7 @@ MIT $(MSBuildThisFileDirectory)/src/Shared/ $(MSBuildThisFileDirectory)/tests/Shared/ + $(MSBuildThisFileDirectory)/src/Vendoring/ $(PackageIconFullPath) diff --git a/Directory.Packages.props b/Directory.Packages.props index c14ddfc084..4f86d4cd8c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -113,7 +113,6 @@ - @@ -143,4 +142,4 @@ - \ No newline at end of file + diff --git a/THIRD-PARTY-NOTICES.TXT b/THIRD-PARTY-NOTICES.TXT index d33d10864f..671b0fed04 100644 --- a/THIRD-PARTY-NOTICES.TXT +++ b/THIRD-PARTY-NOTICES.TXT @@ -57,3 +57,20 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +License notice for OpenTelemetry +---------------------------------------------------------------------------------------------- + +Copyright The OpenTelemetry Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/Components/Aspire.Microsoft.Data.SqlClient/Aspire.Microsoft.Data.SqlClient.csproj b/src/Components/Aspire.Microsoft.Data.SqlClient/Aspire.Microsoft.Data.SqlClient.csproj index aa88db8e37..b65ab77d88 100644 --- a/src/Components/Aspire.Microsoft.Data.SqlClient/Aspire.Microsoft.Data.SqlClient.csproj +++ b/src/Components/Aspire.Microsoft.Data.SqlClient/Aspire.Microsoft.Data.SqlClient.csproj @@ -15,15 +15,19 @@ + + + + + - + - diff --git a/src/Components/Aspire.Microsoft.EntityFrameworkCore.SqlServer/Aspire.Microsoft.EntityFrameworkCore.SqlServer.csproj b/src/Components/Aspire.Microsoft.EntityFrameworkCore.SqlServer/Aspire.Microsoft.EntityFrameworkCore.SqlServer.csproj index 2e31191058..754cfa9da7 100644 --- a/src/Components/Aspire.Microsoft.EntityFrameworkCore.SqlServer/Aspire.Microsoft.EntityFrameworkCore.SqlServer.csproj +++ b/src/Components/Aspire.Microsoft.EntityFrameworkCore.SqlServer/Aspire.Microsoft.EntityFrameworkCore.SqlServer.csproj @@ -16,13 +16,17 @@ + + + + + - diff --git a/src/Vendoring/.editorconfig b/src/Vendoring/.editorconfig new file mode 100644 index 0000000000..495740bf14 --- /dev/null +++ b/src/Vendoring/.editorconfig @@ -0,0 +1,17 @@ +[*.{cs,vb}] + +# CA2007: Consider calling ConfigureAwait on the awaited task +dotnet_diagnostic.CA2007.severity = silent + +# IDE1006: Required naming style +dotnet_diagnostic.IDE1006.severity = silent + +# IDE0028: Use collection initializers +dotnet_diagnostic.IDE0028.severity = silent + +# CA1852: Seal internal types +dotnet_diagnostic.CA1852.severity = silent + +# IDE0060: Remove unused parameters +dotnet_diagnostic.IDE0060.severity = silent + diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlActivitySourceHelper.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlActivitySourceHelper.cs new file mode 100644 index 0000000000..1c83e1e581 --- /dev/null +++ b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlActivitySourceHelper.cs @@ -0,0 +1,30 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable disable + +using System.Diagnostics; +using System.Reflection; +using OpenTelemetry.Trace; + +namespace OpenTelemetry.Instrumentation.SqlClient.Implementation; + +/// +/// Helper class to hold common properties used by both SqlClientDiagnosticListener on .NET Core +/// and SqlEventSourceListener on .NET Framework. +/// +internal sealed class SqlActivitySourceHelper +{ + public const string MicrosoftSqlServerDatabaseSystemName = "mssql"; + + public static readonly AssemblyName AssemblyName = typeof(SqlActivitySourceHelper).Assembly.GetName(); + public static readonly string ActivitySourceName = AssemblyName.Name; + public static readonly Version Version = AssemblyName.Version; + public static readonly ActivitySource ActivitySource = new(ActivitySourceName, Version.ToString()); + public static readonly string ActivityName = ActivitySourceName + ".Execute"; + + public static readonly IEnumerable> CreationTags = new[] + { + new KeyValuePair(SemanticConventions.AttributeDbSystem, MicrosoftSqlServerDatabaseSystemName), + }; +} diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlClientDiagnosticListener.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlClientDiagnosticListener.cs new file mode 100644 index 0000000000..ad42830bb0 --- /dev/null +++ b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlClientDiagnosticListener.cs @@ -0,0 +1,206 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable disable + +#if !NETFRAMEWORK +using System.Data; +using System.Diagnostics; +using OpenTelemetry.Trace; +#if NET6_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif + +namespace OpenTelemetry.Instrumentation.SqlClient.Implementation; + +#if NET6_0_OR_GREATER +[RequiresUnreferencedCode(SqlClientInstrumentation.SqlClientTrimmingUnsupportedMessage)] +#endif +internal sealed class SqlClientDiagnosticListener : ListenerHandler +{ + public const string SqlDataBeforeExecuteCommand = "System.Data.SqlClient.WriteCommandBefore"; + public const string SqlMicrosoftBeforeExecuteCommand = "Microsoft.Data.SqlClient.WriteCommandBefore"; + + public const string SqlDataAfterExecuteCommand = "System.Data.SqlClient.WriteCommandAfter"; + public const string SqlMicrosoftAfterExecuteCommand = "Microsoft.Data.SqlClient.WriteCommandAfter"; + + public const string SqlDataWriteCommandError = "System.Data.SqlClient.WriteCommandError"; + public const string SqlMicrosoftWriteCommandError = "Microsoft.Data.SqlClient.WriteCommandError"; + + private readonly PropertyFetcher commandFetcher = new("Command"); + private readonly PropertyFetcher connectionFetcher = new("Connection"); + private readonly PropertyFetcher dataSourceFetcher = new("DataSource"); + private readonly PropertyFetcher databaseFetcher = new("Database"); + private readonly PropertyFetcher commandTypeFetcher = new("CommandType"); + private readonly PropertyFetcher commandTextFetcher = new("CommandText"); + private readonly PropertyFetcher exceptionFetcher = new("Exception"); + private readonly SqlClientTraceInstrumentationOptions options; + + public SqlClientDiagnosticListener(string sourceName, SqlClientTraceInstrumentationOptions options) + : base(sourceName) + { + this.options = options ?? new SqlClientTraceInstrumentationOptions(); + } + + public override bool SupportsNullActivity => true; + + public override void OnEventWritten(string name, object payload) + { + var activity = Activity.Current; + switch (name) + { + case SqlDataBeforeExecuteCommand: + case SqlMicrosoftBeforeExecuteCommand: + { + // SqlClient does not create an Activity. So the activity coming in here will be null or the root span. + activity = SqlActivitySourceHelper.ActivitySource.StartActivity( + SqlActivitySourceHelper.ActivityName, + ActivityKind.Client, + default(ActivityContext), + SqlActivitySourceHelper.CreationTags); + + if (activity == null) + { + // There is no listener or it decided not to sample the current request. + return; + } + + _ = this.commandFetcher.TryFetch(payload, out var command); + if (command == null) + { + SqlClientInstrumentationEventSource.Log.NullPayload(nameof(SqlClientDiagnosticListener), name); + activity.Stop(); + return; + } + + if (activity.IsAllDataRequested) + { + try + { + if (this.options.Filter?.Invoke(command) == false) + { + SqlClientInstrumentationEventSource.Log.CommandIsFilteredOut(activity.OperationName); + activity.IsAllDataRequested = false; + activity.ActivityTraceFlags &= ~ActivityTraceFlags.Recorded; + return; + } + } + catch (Exception ex) + { + SqlClientInstrumentationEventSource.Log.CommandFilterException(ex); + activity.IsAllDataRequested = false; + activity.ActivityTraceFlags &= ~ActivityTraceFlags.Recorded; + return; + } + + _ = this.connectionFetcher.TryFetch(command, out var connection); + _ = this.databaseFetcher.TryFetch(connection, out var database); + + activity.DisplayName = (string)database; + + _ = this.dataSourceFetcher.TryFetch(connection, out var dataSource); + _ = this.commandTextFetcher.TryFetch(command, out var commandText); + + activity.SetTag(SemanticConventions.AttributeDbName, (string)database); + + this.options.AddConnectionLevelDetailsToActivity((string)dataSource, activity); + + if (this.commandTypeFetcher.TryFetch(command, out CommandType commandType)) + { + switch (commandType) + { + case CommandType.StoredProcedure: + if (this.options.SetDbStatementForStoredProcedure) + { + activity.SetTag(SemanticConventions.AttributeDbStatement, (string)commandText); + } + + break; + + case CommandType.Text: + if (this.options.SetDbStatementForText) + { + activity.SetTag(SemanticConventions.AttributeDbStatement, (string)commandText); + } + + break; + + case CommandType.TableDirect: + break; + } + } + + try + { + this.options.Enrich?.Invoke(activity, "OnCustom", command); + } + catch (Exception ex) + { + SqlClientInstrumentationEventSource.Log.EnrichmentException(ex); + } + } + } + + break; + case SqlDataAfterExecuteCommand: + case SqlMicrosoftAfterExecuteCommand: + { + if (activity == null) + { + SqlClientInstrumentationEventSource.Log.NullActivity(name); + return; + } + + if (activity.Source != SqlActivitySourceHelper.ActivitySource) + { + return; + } + + activity.Stop(); + } + + break; + case SqlDataWriteCommandError: + case SqlMicrosoftWriteCommandError: + { + if (activity == null) + { + SqlClientInstrumentationEventSource.Log.NullActivity(name); + return; + } + + if (activity.Source != SqlActivitySourceHelper.ActivitySource) + { + return; + } + + try + { + if (activity.IsAllDataRequested) + { + if (this.exceptionFetcher.TryFetch(payload, out Exception exception) && exception != null) + { + activity.SetStatus(ActivityStatusCode.Error, exception.Message); + + if (this.options.RecordException) + { + activity.RecordException(exception); + } + } + else + { + SqlClientInstrumentationEventSource.Log.NullPayload(nameof(SqlClientDiagnosticListener), name); + } + } + } + finally + { + activity.Stop(); + } + } + + break; + } + } +} +#endif diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlClientInstrumentationEventSource.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlClientInstrumentationEventSource.cs new file mode 100644 index 0000000000..ccd86471d7 --- /dev/null +++ b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlClientInstrumentationEventSource.cs @@ -0,0 +1,85 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics.Tracing; +using OpenTelemetry.Internal; + +namespace OpenTelemetry.Instrumentation.SqlClient.Implementation; + +/// +/// EventSource events emitted from the project. +/// +[EventSource(Name = "OpenTelemetry-Instrumentation-SqlClient")] +internal sealed class SqlClientInstrumentationEventSource : EventSource +{ + public static SqlClientInstrumentationEventSource Log = new(); + + [NonEvent] + public void UnknownErrorProcessingEvent(string handlerName, string eventName, Exception ex) + { + if (this.IsEnabled(EventLevel.Error, EventKeywords.All)) + { + this.UnknownErrorProcessingEvent(handlerName, eventName, ex.ToInvariantString()); + } + } + + [Event(1, Message = "Unknown error processing event '{1}' from handler '{0}', Exception: {2}", Level = EventLevel.Error)] + public void UnknownErrorProcessingEvent(string handlerName, string eventName, string ex) + { + this.WriteEvent(1, handlerName, eventName, ex); + } + + [Event(2, Message = "Current Activity is NULL in the '{0}' callback. Span will not be recorded.", Level = EventLevel.Warning)] + public void NullActivity(string eventName) + { + this.WriteEvent(2, eventName); + } + + [Event(3, Message = "Payload is NULL in event '{1}' from handler '{0}', span will not be recorded.", Level = EventLevel.Warning)] + public void NullPayload(string handlerName, string eventName) + { + this.WriteEvent(3, handlerName, eventName); + } + + [Event(4, Message = "Payload is invalid in event '{1}' from handler '{0}', span will not be recorded.", Level = EventLevel.Warning)] + public void InvalidPayload(string handlerName, string eventName) + { + this.WriteEvent(4, handlerName, eventName); + } + + [NonEvent] + public void EnrichmentException(Exception ex) + { + if (this.IsEnabled(EventLevel.Error, EventKeywords.All)) + { + this.EnrichmentException(ex.ToInvariantString()); + } + } + + [Event(5, Message = "Enrichment threw exception. Exception {0}.", Level = EventLevel.Error)] + public void EnrichmentException(string exception) + { + this.WriteEvent(5, exception); + } + + [Event(6, Message = "Command is filtered out. Activity {0}", Level = EventLevel.Verbose)] + public void CommandIsFilteredOut(string activityName) + { + this.WriteEvent(6, activityName); + } + + [NonEvent] + public void CommandFilterException(Exception ex) + { + if (this.IsEnabled(EventLevel.Error, EventKeywords.All)) + { + this.CommandFilterException(ex.ToInvariantString()); + } + } + + [Event(7, Message = "Command filter threw exception. Command will not be collected. Exception {0}.", Level = EventLevel.Error)] + public void CommandFilterException(string exception) + { + this.WriteEvent(7, exception); + } +} diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlEventSourceListener.netfx.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlEventSourceListener.netfx.cs new file mode 100644 index 0000000000..111e4878d3 --- /dev/null +++ b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/Implementation/SqlEventSourceListener.netfx.cs @@ -0,0 +1,191 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NETFRAMEWORK +using System.Diagnostics; +using System.Diagnostics.Tracing; +using OpenTelemetry.Trace; + +namespace OpenTelemetry.Instrumentation.SqlClient.Implementation; + +/// +/// On .NET Framework, neither System.Data.SqlClient nor Microsoft.Data.SqlClient emit DiagnosticSource events. +/// Instead they use EventSource: +/// For System.Data.SqlClient see: reference source. +/// For Microsoft.Data.SqlClient see: SqlClientEventSource. +/// +/// We hook into these event sources and process their BeginExecute/EndExecute events. +/// +/// +/// Note that before version 2.0.0, Microsoft.Data.SqlClient used +/// "Microsoft-AdoNet-SystemData" (same as System.Data.SqlClient), but since +/// 2.0.0 has switched to "Microsoft.Data.SqlClient.EventSource". +/// +internal sealed class SqlEventSourceListener : EventListener +{ + internal const string AdoNetEventSourceName = "Microsoft-AdoNet-SystemData"; + internal const string MdsEventSourceName = "Microsoft.Data.SqlClient.EventSource"; + + internal const int BeginExecuteEventId = 1; + internal const int EndExecuteEventId = 2; + + private readonly SqlClientTraceInstrumentationOptions options; + private EventSource adoNetEventSource; + private EventSource mdsEventSource; + + public SqlEventSourceListener(SqlClientTraceInstrumentationOptions options = null) + { + this.options = options ?? new SqlClientTraceInstrumentationOptions(); + } + + public override void Dispose() + { + if (this.adoNetEventSource != null) + { + this.DisableEvents(this.adoNetEventSource); + } + + if (this.mdsEventSource != null) + { + this.DisableEvents(this.mdsEventSource); + } + + base.Dispose(); + } + + protected override void OnEventSourceCreated(EventSource eventSource) + { + if (eventSource?.Name.StartsWith(AdoNetEventSourceName, StringComparison.Ordinal) == true) + { + this.adoNetEventSource = eventSource; + this.EnableEvents(eventSource, EventLevel.Informational, EventKeywords.All); + } + else if (eventSource?.Name.StartsWith(MdsEventSourceName, StringComparison.Ordinal) == true) + { + this.mdsEventSource = eventSource; + this.EnableEvents(eventSource, EventLevel.Informational, EventKeywords.All); + } + + base.OnEventSourceCreated(eventSource); + } + + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + try + { + if (eventData.EventId == BeginExecuteEventId) + { + this.OnBeginExecute(eventData); + } + else if (eventData.EventId == EndExecuteEventId) + { + this.OnEndExecute(eventData); + } + } + catch (Exception exc) + { + SqlClientInstrumentationEventSource.Log.UnknownErrorProcessingEvent(nameof(SqlEventSourceListener), nameof(this.OnEventWritten), exc); + } + } + + private void OnBeginExecute(EventWrittenEventArgs eventData) + { + /* + Expected payload: + [0] -> ObjectId + [1] -> DataSource + [2] -> Database + [3] -> CommandText + + Note: + - For "Microsoft-AdoNet-SystemData" v1.0: [3] CommandText = CommandType == CommandType.StoredProcedure ? CommandText : string.Empty; (so it is set for only StoredProcedure command types) + (https://github.com/dotnet/SqlClient/blob/v1.0.19239.1/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlCommand.cs#L6369) + - For "Microsoft-AdoNet-SystemData" v1.1: [3] CommandText = sqlCommand.CommandText (so it is set for all command types) + (https://github.com/dotnet/SqlClient/blob/v1.1.0/src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlCommand.cs#L7459) + - For "Microsoft.Data.SqlClient.EventSource" v2.0+: [3] CommandText = sqlCommand.CommandText (so it is set for all command types). + (https://github.com/dotnet/SqlClient/blob/f4568ce68da21db3fe88c0e72e1287368aaa1dc8/src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/SqlCommand.cs#L6641) + */ + + if ((eventData?.Payload?.Count ?? 0) < 4) + { + SqlClientInstrumentationEventSource.Log.InvalidPayload(nameof(SqlEventSourceListener), nameof(this.OnBeginExecute)); + return; + } + + var activity = SqlActivitySourceHelper.ActivitySource.StartActivity( + SqlActivitySourceHelper.ActivityName, + ActivityKind.Client, + default(ActivityContext), + SqlActivitySourceHelper.CreationTags); + + if (activity == null) + { + // There is no listener or it decided not to sample the current request. + return; + } + + string databaseName = (string)eventData.Payload[2]; + + activity.DisplayName = databaseName; + + if (activity.IsAllDataRequested) + { + activity.SetTag(SemanticConventions.AttributeDbName, databaseName); + + this.options.AddConnectionLevelDetailsToActivity((string)eventData.Payload[1], activity); + + string commandText = (string)eventData.Payload[3]; + if (!string.IsNullOrEmpty(commandText) && this.options.SetDbStatementForText) + { + activity.SetTag(SemanticConventions.AttributeDbStatement, commandText); + } + } + } + + private void OnEndExecute(EventWrittenEventArgs eventData) + { + /* + Expected payload: + [0] -> ObjectId + [1] -> CompositeState bitmask (0b001 -> successFlag, 0b010 -> isSqlExceptionFlag , 0b100 -> synchronousFlag) + [2] -> SqlExceptionNumber + */ + + if ((eventData?.Payload?.Count ?? 0) < 3) + { + SqlClientInstrumentationEventSource.Log.InvalidPayload(nameof(SqlEventSourceListener), nameof(this.OnEndExecute)); + return; + } + + var activity = Activity.Current; + if (activity?.Source != SqlActivitySourceHelper.ActivitySource) + { + return; + } + + try + { + if (activity.IsAllDataRequested) + { + int compositeState = (int)eventData.Payload[1]; + if ((compositeState & 0b001) != 0b001) + { + if ((compositeState & 0b010) == 0b010) + { + var errorText = $"SqlExceptionNumber {eventData.Payload[2]} thrown."; + activity.SetStatus(ActivityStatusCode.Error, errorText); + } + else + { + activity.SetStatus(ActivityStatusCode.Error, "Unknown Sql failure."); + } + } + } + } + finally + { + activity.Stop(); + } + } +} +#endif diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/SqlClientInstrumentation.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/SqlClientInstrumentation.cs new file mode 100644 index 0000000000..3ba553e269 --- /dev/null +++ b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/SqlClientInstrumentation.cs @@ -0,0 +1,72 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable disable + +#if NET6_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +using OpenTelemetry.Instrumentation.SqlClient.Implementation; +#endif + +namespace OpenTelemetry.Instrumentation.SqlClient; + +/// +/// SqlClient instrumentation. +/// +internal sealed class SqlClientInstrumentation : IDisposable +{ + internal const string SqlClientDiagnosticListenerName = "SqlClientDiagnosticListener"; +#if NET6_0_OR_GREATER + internal const string SqlClientTrimmingUnsupportedMessage = "Trimming is not yet supported with SqlClient instrumentation."; +#endif +#if NETFRAMEWORK + private readonly SqlEventSourceListener sqlEventSourceListener; +#else + private static readonly HashSet DiagnosticSourceEvents = new() + { + "System.Data.SqlClient.WriteCommandBefore", + "Microsoft.Data.SqlClient.WriteCommandBefore", + "System.Data.SqlClient.WriteCommandAfter", + "Microsoft.Data.SqlClient.WriteCommandAfter", + "System.Data.SqlClient.WriteCommandError", + "Microsoft.Data.SqlClient.WriteCommandError", + }; + + private readonly Func isEnabled = (eventName, _, _) + => DiagnosticSourceEvents.Contains(eventName); + + private readonly DiagnosticSourceSubscriber diagnosticSourceSubscriber; +#endif + + /// + /// Initializes a new instance of the class. + /// + /// Configuration options for sql instrumentation. +#if NET6_0_OR_GREATER + [RequiresUnreferencedCode(SqlClientTrimmingUnsupportedMessage)] +#endif + public SqlClientInstrumentation( + SqlClientTraceInstrumentationOptions options = null) + { +#if NETFRAMEWORK + this.sqlEventSourceListener = new SqlEventSourceListener(options); +#else + this.diagnosticSourceSubscriber = new DiagnosticSourceSubscriber( + name => new SqlClientDiagnosticListener(name, options), + listener => listener.Name == SqlClientDiagnosticListenerName, + this.isEnabled, + SqlClientInstrumentationEventSource.Log.UnknownErrorProcessingEvent); + this.diagnosticSourceSubscriber.Subscribe(); +#endif + } + + /// + public void Dispose() + { +#if NETFRAMEWORK + this.sqlEventSourceListener?.Dispose(); +#else + this.diagnosticSourceSubscriber?.Dispose(); +#endif + } +} diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/SqlClientTraceInstrumentationOptions.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/SqlClientTraceInstrumentationOptions.cs new file mode 100644 index 0000000000..7f62149b14 --- /dev/null +++ b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/SqlClientTraceInstrumentationOptions.cs @@ -0,0 +1,303 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 +#nullable disable + +using System.Collections.Concurrent; +using System.Data; +using System.Diagnostics; +using System.Text.RegularExpressions; +using OpenTelemetry.Trace; + +namespace OpenTelemetry.Instrumentation.SqlClient; + +/// +/// Options for . +/// +/// +/// For help and examples see: . +/// +internal class SqlClientTraceInstrumentationOptions +{ + /* + * Match... + * protocol[ ]:[ ]serverName + * serverName + * serverName[ ]\[ ]instanceName + * serverName[ ],[ ]port + * serverName[ ]\[ ]instanceName[ ],[ ]port + * + * [ ] can be any number of white-space, SQL allows it for some reason. + * + * Optional "protocol" can be "tcp", "lpc" (shared memory), or "np" (named pipes). See: + * https://docs.microsoft.com/troubleshoot/sql/connect/use-server-name-parameter-connection-string, and + * https://docs.microsoft.com/dotnet/api/system.data.sqlclient.sqlconnection.connectionstring?view=dotnet-plat-ext-5.0 + * + * In case of named pipes the Data Source string can take form of: + * np:serverName\instanceName, or + * np:\\serverName\pipe\pipeName, or + * np:\\serverName\pipe\MSSQL$instanceName\pipeName - in this case a separate regex (see NamedPipeRegex below) + * is used to extract instanceName + */ + private static readonly Regex DataSourceRegex = new("^(.*\\s*:\\s*\\\\{0,2})?(.*?)\\s*(?:[\\\\,]|$)\\s*(.*?)\\s*(?:,|$)\\s*(.*)$", RegexOptions.Compiled); + + /// + /// In a Data Source string like "np:\\serverName\pipe\MSSQL$instanceName\pipeName" match the + /// "pipe\MSSQL$instanceName" segment to extract instanceName if it is available. + /// + /// + /// + /// + private static readonly Regex NamedPipeRegex = new("pipe\\\\MSSQL\\$(.*?)\\\\", RegexOptions.Compiled); + + private static readonly ConcurrentDictionary ConnectionDetailCache = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets or sets a value indicating whether or not the should add the names of commands as the tag. Default + /// value: . + /// + /// + /// SetDbStatementForStoredProcedure is only supported on .NET + /// and .NET Core runtimes. + /// + public bool SetDbStatementForStoredProcedure { get; set; } = true; + + /// + /// Gets or sets a value indicating whether or not the should add the text of commands as + /// the tag. + /// Default value: . + /// + /// + /// + /// WARNING: SetDbStatementForText will capture the raw + /// CommandText. Make sure your CommandText property never + /// contains any sensitive data. + /// + /// SetDbStatementForText is supported on all runtimes. + /// + /// On .NET and .NET Core SetDbStatementForText only applies to + /// SqlCommands with . + /// On .NET Framework SetDbStatementForText applies to all + /// SqlCommands regardless of . + /// + /// When using System.Data.SqlClient use + /// SetDbStatementForText to capture StoredProcedure command + /// names. + /// When using Microsoft.Data.SqlClient use + /// SetDbStatementForText to capture Text, StoredProcedure, and all + /// other command text. + /// + /// + /// + /// + public bool SetDbStatementForText { get; set; } + + /// + /// Gets or sets a value indicating whether or not the should parse the DataSource on a + /// SqlConnection into server name, instance name, and/or port + /// connection-level attribute tags. Default value: . + /// + /// + /// + /// EnableConnectionLevelAttributes is supported on all runtimes. + /// + /// + /// The default behavior is to set the SqlConnection DataSource as the tag. + /// If enabled, SqlConnection DataSource will be parsed and the server name will be sent as the + /// or tag, + /// the instance name will be sent as the tag, + /// and the port will be sent as the tag if it is not 1433 (the default port). + /// + /// + public bool EnableConnectionLevelAttributes { get; set; } + + /// + /// Gets or sets an action to enrich an with the + /// raw SqlCommand object. + /// + /// + /// Enrich is only executed on .NET and .NET Core + /// runtimes. + /// The parameters passed to the enrich action are: + /// + /// The being enriched. + /// The name of the event. Currently only "OnCustom" is + /// used but more events may be added in the future. + /// The raw SqlCommand object from which additional + /// information can be extracted to enrich the . + /// + /// + public Action Enrich { get; set; } + + /// + /// Gets or sets a filter function that determines whether or not to + /// collect telemetry about a command. + /// + /// + /// Filter is only executed on .NET and .NET Core + /// runtimes. + /// Notes: + /// + /// The first parameter passed to the filter function is the raw + /// SqlCommand object for the command being executed. + /// The return value for the filter function is interpreted as: + /// + /// If filter returns , the command is + /// collected. + /// If filter returns or throws an + /// exception the command is NOT collected. + /// + /// + /// + public Func Filter { get; set; } + + /// + /// Gets or sets a value indicating whether the exception will be + /// recorded as or not. Default value: . + /// + /// + /// RecordException is only supported on .NET and .NET Core + /// runtimes. + /// For specification details see: . + /// + public bool RecordException { get; set; } + + internal static SqlConnectionDetails ParseDataSource(string dataSource) + { + Match match = DataSourceRegex.Match(dataSource); + + string serverHostName = match.Groups[2].Value; + string serverIpAddress = null; + + string instanceName; + + var uriHostNameType = Uri.CheckHostName(serverHostName); + if (uriHostNameType == UriHostNameType.IPv4 || uriHostNameType == UriHostNameType.IPv6) + { + serverIpAddress = serverHostName; + serverHostName = null; + } + + string maybeProtocol = match.Groups[1].Value; + bool isNamedPipe = maybeProtocol.Length > 0 && + maybeProtocol.StartsWith("np", StringComparison.OrdinalIgnoreCase); + + if (isNamedPipe) + { + string pipeName = match.Groups[3].Value; + if (pipeName.Length > 0) + { + var namedInstancePipeMatch = NamedPipeRegex.Match(pipeName); + if (namedInstancePipeMatch.Success) + { + instanceName = namedInstancePipeMatch.Groups[1].Value; + return new SqlConnectionDetails + { + ServerHostName = serverHostName, + ServerIpAddress = serverIpAddress, + InstanceName = instanceName, + Port = null, + }; + } + } + + return new SqlConnectionDetails + { + ServerHostName = serverHostName, + ServerIpAddress = serverIpAddress, + InstanceName = null, + Port = null, + }; + } + + string port; + if (match.Groups[4].Length > 0) + { + instanceName = match.Groups[3].Value; + port = match.Groups[4].Value; + if (port == "1433") + { + port = null; + } + } + else if (int.TryParse(match.Groups[3].Value, out int parsedPort)) + { + port = parsedPort == 1433 ? null : match.Groups[3].Value; + instanceName = null; + } + else + { + instanceName = match.Groups[3].Value; + + if (string.IsNullOrEmpty(instanceName)) + { + instanceName = null; + } + + port = null; + } + + return new SqlConnectionDetails + { + ServerHostName = serverHostName, + ServerIpAddress = serverIpAddress, + InstanceName = instanceName, + Port = port, + }; + } + + internal void AddConnectionLevelDetailsToActivity(string dataSource, Activity sqlActivity) + { + if (!this.EnableConnectionLevelAttributes) + { + sqlActivity.SetTag(SemanticConventions.AttributePeerService, dataSource); + } + else + { + if (!ConnectionDetailCache.TryGetValue(dataSource, out SqlConnectionDetails connectionDetails)) + { + connectionDetails = ParseDataSource(dataSource); + ConnectionDetailCache.TryAdd(dataSource, connectionDetails); + } + + if (!string.IsNullOrEmpty(connectionDetails.InstanceName)) + { + sqlActivity.SetTag(SemanticConventions.AttributeDbMsSqlInstanceName, connectionDetails.InstanceName); + } + + if (!string.IsNullOrEmpty(connectionDetails.ServerHostName)) + { + sqlActivity.SetTag(SemanticConventions.AttributeServerAddress, connectionDetails.ServerHostName); + } + else + { + sqlActivity.SetTag(SemanticConventions.AttributeServerSocketAddress, connectionDetails.ServerIpAddress); + } + + if (!string.IsNullOrEmpty(connectionDetails.Port)) + { + // TODO: Should we continue to emit this if the default port (1433) is being used? + sqlActivity.SetTag(SemanticConventions.AttributeServerPort, connectionDetails.Port); + } + } + } + + internal sealed class SqlConnectionDetails + { + public string ServerHostName { get; set; } + + public string ServerIpAddress { get; set; } + + public string InstanceName { get; set; } + + public string Port { get; set; } + } +} diff --git a/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/TracerProviderBuilderExtensions.cs b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/TracerProviderBuilderExtensions.cs new file mode 100644 index 0000000000..c316d65080 --- /dev/null +++ b/src/Vendoring/OpenTelemetry.Instrumentation.SqlClient/TracerProviderBuilderExtensions.cs @@ -0,0 +1,82 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 +#nullable disable + +#if NET6_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenTelemetry.Instrumentation.SqlClient; +using OpenTelemetry.Instrumentation.SqlClient.Implementation; +using OpenTelemetry.Internal; + +namespace OpenTelemetry.Trace; + +/// +/// Extension methods to simplify registering of dependency instrumentation. +/// +internal static class TracerProviderBuilderExtensions +{ + /// + /// Enables SqlClient instrumentation. + /// + /// being configured. + /// The instance of to chain the calls. +#if NET6_0_OR_GREATER + [RequiresUnreferencedCode(SqlClientInstrumentation.SqlClientTrimmingUnsupportedMessage)] +#endif + public static TracerProviderBuilder AddSqlClientInstrumentation(this TracerProviderBuilder builder) + => AddSqlClientInstrumentation(builder, name: null, configureSqlClientTraceInstrumentationOptions: null); + + /// + /// Enables SqlClient instrumentation. + /// + /// being configured. + /// Callback action for configuring . + /// The instance of to chain the calls. +#if NET6_0_OR_GREATER + [RequiresUnreferencedCode(SqlClientInstrumentation.SqlClientTrimmingUnsupportedMessage)] +#endif + public static TracerProviderBuilder AddSqlClientInstrumentation( + this TracerProviderBuilder builder, + Action configureSqlClientTraceInstrumentationOptions) + => AddSqlClientInstrumentation(builder, name: null, configureSqlClientTraceInstrumentationOptions); + + /// + /// Enables SqlClient instrumentation. + /// + /// being configured. + /// Name which is used when retrieving options. + /// Callback action for configuring . + /// The instance of to chain the calls. +#if NET6_0_OR_GREATER + [RequiresUnreferencedCode(SqlClientInstrumentation.SqlClientTrimmingUnsupportedMessage)] +#endif + + public static TracerProviderBuilder AddSqlClientInstrumentation( + this TracerProviderBuilder builder, + string name, + Action configureSqlClientTraceInstrumentationOptions) + { + Guard.ThrowIfNull(builder); + + name ??= Options.DefaultName; + + if (configureSqlClientTraceInstrumentationOptions != null) + { + builder.ConfigureServices(services => services.Configure(name, configureSqlClientTraceInstrumentationOptions)); + } + + builder.AddInstrumentation(sp => + { + var sqlOptions = sp.GetRequiredService>().Get(name); + + return new SqlClientInstrumentation(sqlOptions); + }); + + builder.AddSource(SqlActivitySourceHelper.ActivitySourceName); + + return builder; + } +} diff --git a/src/Vendoring/OpenTelemetry.Shared/DiagnosticSourceInstrumentation/DiagnosticSourceListener.cs b/src/Vendoring/OpenTelemetry.Shared/DiagnosticSourceInstrumentation/DiagnosticSourceListener.cs new file mode 100644 index 0000000000..53d7e990d2 --- /dev/null +++ b/src/Vendoring/OpenTelemetry.Shared/DiagnosticSourceInstrumentation/DiagnosticSourceListener.cs @@ -0,0 +1,49 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable disable + +using System.Diagnostics; +using OpenTelemetry.Internal; + +namespace OpenTelemetry.Instrumentation; + +internal sealed class DiagnosticSourceListener : IObserver> +{ + private readonly ListenerHandler handler; + + private readonly Action logUnknownException; + + public DiagnosticSourceListener(ListenerHandler handler, Action logUnknownException) + { + Guard.ThrowIfNull(handler); + + this.handler = handler; + this.logUnknownException = logUnknownException; + } + + public void OnCompleted() + { + } + + public void OnError(Exception error) + { + } + + public void OnNext(KeyValuePair value) + { + if (!this.handler.SupportsNullActivity && Activity.Current == null) + { + return; + } + + try + { + this.handler.OnEventWritten(value.Key, value.Value); + } + catch (Exception ex) + { + this.logUnknownException?.Invoke(this.handler?.SourceName, value.Key, ex); + } + } +} diff --git a/src/Vendoring/OpenTelemetry.Shared/DiagnosticSourceInstrumentation/DiagnosticSourceSubscriber.cs b/src/Vendoring/OpenTelemetry.Shared/DiagnosticSourceInstrumentation/DiagnosticSourceSubscriber.cs new file mode 100644 index 0000000000..49528fea47 --- /dev/null +++ b/src/Vendoring/OpenTelemetry.Shared/DiagnosticSourceInstrumentation/DiagnosticSourceSubscriber.cs @@ -0,0 +1,105 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable disable + +using System.Diagnostics; +using OpenTelemetry.Internal; + +namespace OpenTelemetry.Instrumentation; + +internal sealed class DiagnosticSourceSubscriber : IDisposable, IObserver +{ + private readonly List listenerSubscriptions; + private readonly Func handlerFactory; + private readonly Func diagnosticSourceFilter; + private readonly Func isEnabledFilter; + private readonly Action logUnknownException; + private long disposed; + private IDisposable allSourcesSubscription; + + public DiagnosticSourceSubscriber( + ListenerHandler handler, + Func isEnabledFilter, + Action logUnknownException) + : this(_ => handler, value => handler.SourceName == value.Name, isEnabledFilter, logUnknownException) + { + } + + public DiagnosticSourceSubscriber( + Func handlerFactory, + Func diagnosticSourceFilter, + Func isEnabledFilter, + Action logUnknownException) + { + Guard.ThrowIfNull(handlerFactory); + + this.listenerSubscriptions = new List(); + this.handlerFactory = handlerFactory; + this.diagnosticSourceFilter = diagnosticSourceFilter; + this.isEnabledFilter = isEnabledFilter; + this.logUnknownException = logUnknownException; + } + + public void Subscribe() + { + if (this.allSourcesSubscription == null) + { + this.allSourcesSubscription = DiagnosticListener.AllListeners.Subscribe(this); + } + } + + public void OnNext(DiagnosticListener value) + { + if ((Interlocked.Read(ref this.disposed) == 0) && + this.diagnosticSourceFilter(value)) + { + var handler = this.handlerFactory(value.Name); + var listener = new DiagnosticSourceListener(handler, this.logUnknownException); + var subscription = this.isEnabledFilter == null ? + value.Subscribe(listener) : + value.Subscribe(listener, this.isEnabledFilter); + + lock (this.listenerSubscriptions) + { + this.listenerSubscriptions.Add(subscription); + } + } + } + + public void OnCompleted() + { + } + + public void OnError(Exception error) + { + } + + /// + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (Interlocked.CompareExchange(ref this.disposed, 1, 0) == 1) + { + return; + } + + lock (this.listenerSubscriptions) + { + foreach (var listenerSubscription in this.listenerSubscriptions) + { + listenerSubscription?.Dispose(); + } + + this.listenerSubscriptions.Clear(); + } + + this.allSourcesSubscription?.Dispose(); + this.allSourcesSubscription = null; + } +} diff --git a/src/Vendoring/OpenTelemetry.Shared/DiagnosticSourceInstrumentation/ListenerHandler.cs b/src/Vendoring/OpenTelemetry.Shared/DiagnosticSourceInstrumentation/ListenerHandler.cs new file mode 100644 index 0000000000..98c55107a6 --- /dev/null +++ b/src/Vendoring/OpenTelemetry.Shared/DiagnosticSourceInstrumentation/ListenerHandler.cs @@ -0,0 +1,40 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; + +namespace OpenTelemetry.Instrumentation; + +/// +/// ListenerHandler base class. +/// +internal abstract class ListenerHandler +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the . + public ListenerHandler(string sourceName) + { + this.SourceName = sourceName; + } + + /// + /// Gets the name of the . + /// + public string SourceName { get; } + + /// + /// Gets a value indicating whether the supports NULL . + /// + public virtual bool SupportsNullActivity { get; } + + /// + /// Method called for an event which does not have 'Start', 'Stop' or 'Exception' as suffix. + /// + /// Custom name. + /// An object that represent the value being passed as a payload for the event. + public virtual void OnEventWritten(string name, object payload) + { + } +} diff --git a/src/Vendoring/OpenTelemetry.Shared/DiagnosticSourceInstrumentation/PropertyFetcher.cs b/src/Vendoring/OpenTelemetry.Shared/DiagnosticSourceInstrumentation/PropertyFetcher.cs new file mode 100644 index 0000000000..a2ac797d8a --- /dev/null +++ b/src/Vendoring/OpenTelemetry.Shared/DiagnosticSourceInstrumentation/PropertyFetcher.cs @@ -0,0 +1,218 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable enable + +#if NETSTANDARD2_1_0_OR_GREATER || NET6_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif +using System.Reflection; + +namespace OpenTelemetry.Instrumentation; + +/// +/// PropertyFetcher fetches a property from an object. +/// +/// The type of the property being fetched. +internal sealed class PropertyFetcher +{ +#if NET6_0_OR_GREATER + private const string TrimCompatibilityMessage = "PropertyFetcher is used to access properties on objects dynamically by design and cannot be made trim compatible."; +#endif + private readonly string propertyName; + private PropertyFetch? innerFetcher; + + /// + /// Initializes a new instance of the class. + /// + /// Property name to fetch. + public PropertyFetcher(string propertyName) + { + this.propertyName = propertyName; + } + + public int NumberOfInnerFetchers => this.innerFetcher == null + ? 0 + : 1 + this.innerFetcher.NumberOfInnerFetchers; + + /// + /// Try to fetch the property from the object. + /// + /// Object to be fetched. + /// Fetched value. + /// if the property was fetched. +#if NET6_0_OR_GREATER + [RequiresUnreferencedCode(TrimCompatibilityMessage)] +#endif + public bool TryFetch( +#if NETSTANDARD2_1_0_OR_GREATER || NET6_0_OR_GREATER + [NotNullWhen(true)] +#endif + object? obj, + out T? value) + { + var innerFetcher = this.innerFetcher; + if (innerFetcher is null) + { + return TryFetchRare(obj, this.propertyName, ref this.innerFetcher, out value); + } + + return innerFetcher.TryFetch(obj, out value); + } + +#if NET6_0_OR_GREATER + [RequiresUnreferencedCode(TrimCompatibilityMessage)] +#endif + private static bool TryFetchRare(object? obj, string propertyName, ref PropertyFetch? destination, out T? value) + { + if (obj is null) + { + value = default; + return false; + } + + var fetcher = PropertyFetch.Create(obj.GetType().GetTypeInfo(), propertyName); + + if (fetcher is null) + { + value = default; + return false; + } + + destination = fetcher; + + return fetcher.TryFetch(obj, out value); + } + + // see https://github.com/dotnet/corefx/blob/master/src/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/DiagnosticSourceEventSource.cs +#if NET6_0_OR_GREATER + [RequiresUnreferencedCode(TrimCompatibilityMessage)] +#endif + private abstract class PropertyFetch + { + public abstract int NumberOfInnerFetchers { get; } + + public static PropertyFetch? Create(TypeInfo type, string propertyName) + { + var property = type.DeclaredProperties.FirstOrDefault(p => string.Equals(p.Name, propertyName, StringComparison.OrdinalIgnoreCase)) ?? type.GetProperty(propertyName); + return CreateFetcherForProperty(property); + + static PropertyFetch? CreateFetcherForProperty(PropertyInfo? propertyInfo) + { + if (propertyInfo == null || !typeof(T).IsAssignableFrom(propertyInfo.PropertyType)) + { + // returns null and wait for a valid payload to arrive. + return null; + } + + var declaringType = propertyInfo.DeclaringType; + if (declaringType!.IsValueType) + { + throw new NotSupportedException( + $"Type: {declaringType.FullName} is a value type. PropertyFetcher can only operate on reference payload types."); + } + + if (declaringType == typeof(object)) + { + // TODO: REMOVE this if branch when .NET 7 is out of support. + // This branch is never executed and is only needed for .NET 7 AOT-compiler at trimming stage; i.e., + // this is not needed in .NET 8, because the compiler is improved and call into MakeGenericMethod will be AOT-compatible. + // It is used to force the AOT compiler to create an instantiation of the method with a reference type. + // The code for that instantiation can then be reused at runtime to create instantiation over any other reference. + return CreateInstantiated(propertyInfo); + } + else + { + return DynamicInstantiationHelper(declaringType, propertyInfo); + } + + // Separated as a local function to be able to target the suppression to just this call. + // IL3050 was generated here because of the call to MakeGenericType, which is problematic in AOT if one of the type parameters is a value type; + // because the compiler might need to generate code specific to that type. + // If the type parameter is a reference type, there will be no problem; because the generated code can be shared among all reference type instantiations. +#if NET6_0_OR_GREATER + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "The code guarantees that all the generic parameters are reference types.")] +#endif + static PropertyFetch? DynamicInstantiationHelper(Type declaringType, PropertyInfo propertyInfo) + { + return (PropertyFetch?)typeof(PropertyFetch) + .GetMethod(nameof(CreateInstantiated), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(declaringType) // This is validated in the earlier call chain to be a reference type. + .Invoke(null, new object[] { propertyInfo })!; + } + } + } + + public abstract bool TryFetch( +#if NETSTANDARD2_1_0_OR_GREATER || NET6_0_OR_GREATER + [NotNullWhen(true)] +#endif + object? obj, + out T? value); + + // Goal: make PropertyFetcher AOT-compatible. + // AOT compiler can't guarantee correctness when call into MakeGenericType or MakeGenericMethod + // if one of the generic parameters is a value type (reference types are OK.) + // For PropertyFetcher, the decision was made to only support reference type payloads, i.e.: + // the object from which to get the property value MUST be a reference type. + // Create generics with the declared object type as a generic parameter is OK, but we need the return type + // of the property to be a value type (on top of reference types.) + // Normally, we would have a helper class like `PropertyFetchInstantiated` that takes 2 generic parameters, + // the declared object type, and the type of the property value. + // But that would mean calling MakeGenericType, with value type parameters which AOT won't support. + // + // As a workaround, Generic instantiation was split into: + // 1. The object type comes from the PropertyFetcher generic parameter. + // Compiler supports it even if it is a value type; the type is known statically during compilation + // since PropertyFetcher is used with it. + // 2. Then, the declared object type is passed as a generic parameter to a generic method on PropertyFetcher (or nested type.) + // Therefore, calling into MakeGenericMethod will only require specifying one parameter - the declared object type. + // The declared object type is guaranteed to be a reference type (throw on value type.) Thus, MakeGenericMethod is AOT compatible. + private static PropertyFetch CreateInstantiated(PropertyInfo propertyInfo) + where TDeclaredObject : class + => new PropertyFetchInstantiated(propertyInfo); + +#if NET6_0_OR_GREATER + [RequiresUnreferencedCode(TrimCompatibilityMessage)] +#endif + private sealed class PropertyFetchInstantiated : PropertyFetch + where TDeclaredObject : class + { + private readonly string propertyName; + private readonly Func propertyFetch; + private PropertyFetch? innerFetcher; + + public PropertyFetchInstantiated(PropertyInfo property) + { + this.propertyName = property.Name; + this.propertyFetch = (Func)property.GetMethod!.CreateDelegate(typeof(Func)); + } + + public override int NumberOfInnerFetchers => this.innerFetcher == null + ? 0 + : 1 + this.innerFetcher.NumberOfInnerFetchers; + + public override bool TryFetch( +#if NETSTANDARD2_1_0_OR_GREATER || NET6_0_OR_GREATER + [NotNullWhen(true)] +#endif + object? obj, + out T? value) + { + if (obj is TDeclaredObject o) + { + value = this.propertyFetch(o); + return true; + } + + var innerFetcher = this.innerFetcher; + if (innerFetcher is null) + { + return TryFetchRare(obj, this.propertyName, ref this.innerFetcher, out value); + } + + return innerFetcher.TryFetch(obj, out value); + } + } + } +} diff --git a/src/Vendoring/OpenTelemetry.Shared/ExceptionExtensions.cs b/src/Vendoring/OpenTelemetry.Shared/ExceptionExtensions.cs new file mode 100644 index 0000000000..9070b59c20 --- /dev/null +++ b/src/Vendoring/OpenTelemetry.Shared/ExceptionExtensions.cs @@ -0,0 +1,32 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable enable + +using System.Globalization; + +namespace OpenTelemetry.Internal; + +internal static class ExceptionExtensions +{ + /// + /// Returns a culture-independent string representation of the given object, + /// appropriate for diagnostics tracing. + /// + /// Exception to convert to string. + /// Exception as string with no culture. + public static string ToInvariantString(this Exception exception) + { + var originalUICulture = Thread.CurrentThread.CurrentUICulture; + + try + { + Thread.CurrentThread.CurrentUICulture = CultureInfo.InvariantCulture; + return exception.ToString(); + } + finally + { + Thread.CurrentThread.CurrentUICulture = originalUICulture; + } + } +} diff --git a/src/Vendoring/OpenTelemetry.Shared/Guard.cs b/src/Vendoring/OpenTelemetry.Shared/Guard.cs new file mode 100644 index 0000000000..e768d67604 --- /dev/null +++ b/src/Vendoring/OpenTelemetry.Shared/Guard.cs @@ -0,0 +1,203 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable enable + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Runtime.CompilerServices; + +#pragma warning disable SA1402 // File may only contain a single type +#pragma warning disable SA1403 // File may only contain a single namespace +#pragma warning disable SA1649 // File name should match first type name + +#if !NET6_0_OR_GREATER +namespace System.Runtime.CompilerServices +{ + /// Allows capturing of the expressions passed to a method. + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] + internal sealed class CallerArgumentExpressionAttribute : Attribute + { + public CallerArgumentExpressionAttribute(string parameterName) + { + this.ParameterName = parameterName; + } + + public string ParameterName { get; } + } +} +#endif + +#if !NET6_0_OR_GREATER && !NETSTANDARD2_1_OR_GREATER +namespace System.Diagnostics.CodeAnalysis +{ + /// Specifies that an output is not even if + /// the corresponding type allows it. Specifies that an input argument was + /// not when the call returns. + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.ReturnValue, Inherited = false)] + internal sealed class NotNullAttribute : Attribute + { + } +} +#endif + +#pragma warning disable IDE0161 // Convert to file-scoped namespace +namespace OpenTelemetry.Internal +{ + /// + /// Methods for guarding against exception throwing values. + /// + internal static class Guard + { + /// + /// Throw an exception if the value is null. + /// + /// The value to check. + /// The parameter name to use in the thrown exception. + [DebuggerHidden] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ThrowIfNull([NotNull] object? value, [CallerArgumentExpression("value")] string? paramName = null) + { + if (value is null) + { + throw new ArgumentNullException(paramName, "Must not be null"); + } + } + + /// + /// Throw an exception if the value is null or empty. + /// + /// The value to check. + /// The parameter name to use in the thrown exception. + [DebuggerHidden] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ThrowIfNullOrEmpty([NotNull] string? value, [CallerArgumentExpression("value")] string? paramName = null) +#pragma warning disable CS8777 // Parameter must have a non-null value when exiting. + { + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentException("Must not be null or empty", paramName); + } + } +#pragma warning restore CS8777 // Parameter must have a non-null value when exiting. + + /// + /// Throw an exception if the value is null or whitespace. + /// + /// The value to check. + /// The parameter name to use in the thrown exception. + [DebuggerHidden] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ThrowIfNullOrWhitespace([NotNull] string? value, [CallerArgumentExpression("value")] string? paramName = null) +#pragma warning disable CS8777 // Parameter must have a non-null value when exiting. + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Must not be null or whitespace", paramName); + } + } +#pragma warning restore CS8777 // Parameter must have a non-null value when exiting. + + /// + /// Throw an exception if the value is zero. + /// + /// The value to check. + /// The message to use in the thrown exception. + /// The parameter name to use in the thrown exception. + [DebuggerHidden] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ThrowIfZero(int value, string message = "Must not be zero", [CallerArgumentExpression("value")] string? paramName = null) + { + if (value == 0) + { + throw new ArgumentException(message, paramName); + } + } + + /// + /// Throw an exception if the value is not considered a valid timeout. + /// + /// The value to check. + /// The parameter name to use in the thrown exception. + [DebuggerHidden] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ThrowIfInvalidTimeout(int value, [CallerArgumentExpression("value")] string? paramName = null) + { + ThrowIfOutOfRange(value, paramName, min: Timeout.Infinite, message: $"Must be non-negative or '{nameof(Timeout)}.{nameof(Timeout.Infinite)}'"); + } + + /// + /// Throw an exception if the value is not within the given range. + /// + /// The value to check. + /// The parameter name to use in the thrown exception. + /// The inclusive lower bound. + /// The inclusive upper bound. + /// The name of the lower bound. + /// The name of the upper bound. + /// An optional custom message to use in the thrown exception. + [DebuggerHidden] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ThrowIfOutOfRange(int value, [CallerArgumentExpression("value")] string? paramName = null, int min = int.MinValue, int max = int.MaxValue, string? minName = null, string? maxName = null, string? message = null) + { + Range(value, paramName, min, max, minName, maxName, message); + } + + /// + /// Throw an exception if the value is not within the given range. + /// + /// The value to check. + /// The parameter name to use in the thrown exception. + /// The inclusive lower bound. + /// The inclusive upper bound. + /// The name of the lower bound. + /// The name of the upper bound. + /// An optional custom message to use in the thrown exception. + [DebuggerHidden] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void ThrowIfOutOfRange(double value, [CallerArgumentExpression("value")] string? paramName = null, double min = double.MinValue, double max = double.MaxValue, string? minName = null, string? maxName = null, string? message = null) + { + Range(value, paramName, min, max, minName, maxName, message); + } + + /// + /// Throw an exception if the value is not of the expected type. + /// + /// The value to check. + /// The parameter name to use in the thrown exception. + /// The type attempted to convert to. + /// The value casted to the specified type. + [DebuggerHidden] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T ThrowIfNotOfType([NotNull] object? value, [CallerArgumentExpression("value")] string? paramName = null) + { + if (value is not T result) + { + throw new InvalidCastException($"Cannot cast '{paramName}' from '{value?.GetType().ToString() ?? "null"}' to '{typeof(T)}'"); + } + + return result; + } + + [DebuggerHidden] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void Range(T value, string? paramName, T min, T max, string? minName, string? maxName, string? message) + where T : IComparable + { + if (value.CompareTo(min) < 0 || value.CompareTo(max) > 0) + { + var minMessage = minName != null ? $": {minName}" : string.Empty; + var maxMessage = maxName != null ? $": {maxName}" : string.Empty; + var exMessage = message ?? string.Format( + CultureInfo.InvariantCulture, + "Must be in the range: [{0}{1}, {2}{3}]", + min, + minMessage, + max, + maxMessage); + throw new ArgumentOutOfRangeException(paramName, value, exMessage); + } + } + } +} diff --git a/src/Vendoring/OpenTelemetry.Shared/SemanticConventions.cs b/src/Vendoring/OpenTelemetry.Shared/SemanticConventions.cs new file mode 100644 index 0000000000..8628b79d23 --- /dev/null +++ b/src/Vendoring/OpenTelemetry.Shared/SemanticConventions.cs @@ -0,0 +1,121 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#nullable enable + +namespace OpenTelemetry.Trace; + +/// +/// Constants for semantic attribute names outlined by the OpenTelemetry specifications. +/// and +/// . +/// +internal static class SemanticConventions +{ + // The set of constants matches the specification as of this commit. + // https://github.com/open-telemetry/semantic-conventions/blob/main/docs/general/trace.md + // https://github.com/open-telemetry/semantic-conventions/blob/main/docs/exceptions/exceptions-spans.md + public const string AttributeNetTransport = "net.transport"; + public const string AttributeNetPeerIp = "net.peer.ip"; + public const string AttributeNetPeerPort = "net.peer.port"; + public const string AttributeNetPeerName = "net.peer.name"; + public const string AttributeNetHostIp = "net.host.ip"; + public const string AttributeNetHostPort = "net.host.port"; + public const string AttributeNetHostName = "net.host.name"; + + public const string AttributeEnduserId = "enduser.id"; + public const string AttributeEnduserRole = "enduser.role"; + public const string AttributeEnduserScope = "enduser.scope"; + + public const string AttributePeerService = "peer.service"; + + public const string AttributeHttpMethod = "http.method"; + public const string AttributeHttpUrl = "http.url"; + public const string AttributeHttpTarget = "http.target"; + public const string AttributeHttpHost = "http.host"; + public const string AttributeHttpScheme = "http.scheme"; + public const string AttributeHttpStatusCode = "http.status_code"; + public const string AttributeHttpStatusText = "http.status_text"; + public const string AttributeHttpFlavor = "http.flavor"; + public const string AttributeHttpServerName = "http.server_name"; + public const string AttributeHttpRoute = "http.route"; + public const string AttributeHttpClientIP = "http.client_ip"; + public const string AttributeHttpUserAgent = "http.user_agent"; + public const string AttributeHttpRequestContentLength = "http.request_content_length"; + public const string AttributeHttpRequestContentLengthUncompressed = "http.request_content_length_uncompressed"; + public const string AttributeHttpResponseContentLength = "http.response_content_length"; + public const string AttributeHttpResponseContentLengthUncompressed = "http.response_content_length_uncompressed"; + + public const string AttributeDbSystem = "db.system"; + public const string AttributeDbConnectionString = "db.connection_string"; + public const string AttributeDbUser = "db.user"; + public const string AttributeDbMsSqlInstanceName = "db.mssql.instance_name"; + public const string AttributeDbJdbcDriverClassName = "db.jdbc.driver_classname"; + public const string AttributeDbName = "db.name"; + public const string AttributeDbStatement = "db.statement"; + public const string AttributeDbOperation = "db.operation"; + public const string AttributeDbInstance = "db.instance"; + public const string AttributeDbUrl = "db.url"; + public const string AttributeDbCassandraKeyspace = "db.cassandra.keyspace"; + public const string AttributeDbHBaseNamespace = "db.hbase.namespace"; + public const string AttributeDbRedisDatabaseIndex = "db.redis.database_index"; + public const string AttributeDbMongoDbCollection = "db.mongodb.collection"; + + public const string AttributeRpcSystem = "rpc.system"; + public const string AttributeRpcService = "rpc.service"; + public const string AttributeRpcMethod = "rpc.method"; + public const string AttributeRpcGrpcStatusCode = "rpc.grpc.status_code"; + + public const string AttributeMessageType = "message.type"; + public const string AttributeMessageId = "message.id"; + public const string AttributeMessageCompressedSize = "message.compressed_size"; + public const string AttributeMessageUncompressedSize = "message.uncompressed_size"; + + public const string AttributeFaasTrigger = "faas.trigger"; + public const string AttributeFaasExecution = "faas.execution"; + public const string AttributeFaasDocumentCollection = "faas.document.collection"; + public const string AttributeFaasDocumentOperation = "faas.document.operation"; + public const string AttributeFaasDocumentTime = "faas.document.time"; + public const string AttributeFaasDocumentName = "faas.document.name"; + public const string AttributeFaasTime = "faas.time"; + public const string AttributeFaasCron = "faas.cron"; + + public const string AttributeMessagingSystem = "messaging.system"; + public const string AttributeMessagingDestination = "messaging.destination"; + public const string AttributeMessagingDestinationKind = "messaging.destination_kind"; + public const string AttributeMessagingTempDestination = "messaging.temp_destination"; + public const string AttributeMessagingProtocol = "messaging.protocol"; + public const string AttributeMessagingProtocolVersion = "messaging.protocol_version"; + public const string AttributeMessagingUrl = "messaging.url"; + public const string AttributeMessagingMessageId = "messaging.message_id"; + public const string AttributeMessagingConversationId = "messaging.conversation_id"; + public const string AttributeMessagingPayloadSize = "messaging.message_payload_size_bytes"; + public const string AttributeMessagingPayloadCompressedSize = "messaging.message_payload_compressed_size_bytes"; + public const string AttributeMessagingOperation = "messaging.operation"; + + public const string AttributeExceptionEventName = "exception"; + public const string AttributeExceptionType = "exception.type"; + public const string AttributeExceptionMessage = "exception.message"; + public const string AttributeExceptionStacktrace = "exception.stacktrace"; + public const string AttributeErrorType = "error.type"; + + // v1.21.0 + // https://github.com/open-telemetry/semantic-conventions/blob/v1.21.0/docs/http/http-spans.md + // https://github.com/open-telemetry/semantic-conventions/blob/v1.21.0/docs/database/database-spans.md + // https://github.com/open-telemetry/semantic-conventions/blob/v1.21.0/docs/rpc/rpc-spans.md + public const string AttributeClientAddress = "client.address"; + public const string AttributeClientPort = "client.port"; + public const string AttributeHttpRequestMethod = "http.request.method"; // replaces: "http.method" (AttributeHttpMethod) + public const string AttributeHttpResponseStatusCode = "http.response.status_code"; // replaces: "http.status_code" (AttributeHttpStatusCode) + public const string AttributeNetworkProtocolVersion = "network.protocol.version"; // replaces: "http.flavor" (AttributeHttpFlavor) + public const string AttributeNetworkProtocolName = "network.protocol.name"; + public const string AttributeServerAddress = "server.address"; // replaces: "net.host.name" (AttributeNetHostName) and "net.peer.name" (AttributeNetPeerName) + public const string AttributeServerPort = "server.port"; // replaces: "net.host.port" (AttributeNetHostPort) and "net.peer.port" (AttributeNetPeerPort) + public const string AttributeServerSocketAddress = "server.socket.address"; // replaces: "net.peer.ip" (AttributeNetPeerIp) + public const string AttributeUrlFull = "url.full"; // replaces: "http.url" (AttributeHttpUrl) + public const string AttributeUrlPath = "url.path"; // replaces: "http.target" (AttributeHttpTarget) + public const string AttributeUrlScheme = "url.scheme"; // replaces: "http.scheme" (AttributeHttpScheme) + public const string AttributeUrlQuery = "url.query"; + public const string AttributeUserAgentOriginal = "user_agent.original"; // replaces: "http.user_agent" (AttributeHttpUserAgent) + public const string AttributeHttpRequestMethodOriginal = "http.request.method_original"; +} diff --git a/src/Vendoring/README.md b/src/Vendoring/README.md new file mode 100644 index 0000000000..5316257037 --- /dev/null +++ b/src/Vendoring/README.md @@ -0,0 +1,36 @@ +# Vendoring code sync instructions + +## OpenTelemetry.Shared + +```console +git clone https://github.com/open-telemetry/opentelemetry-dotnet.git +git fetch --tags +git checkout tags/Instrumentation.SqlClient-1.7.0-beta.1 +``` + +### Instructions + +- Copy required files from `src/Shared`: + - `DiagnosticSourceInstrumentation\*.cs` + - `ExceptionExtensions.cs` + - `Guard.cs` + - `SemanticConventions.cs` + +## OpenTelemetry.Instrumentation.SqlClient + +```console +git clone https://github.com/open-telemetry/opentelemetry-dotnet.git +git fetch --tags +git checkout tags/Instrumentation.SqlClient-1.7.0-beta.1 +``` + +### Instructions + +- Copy files from `src/OpenTelemetry.Instrumentation.SqlClient`: + - `**\*.cs` + +### Customizations + +- Add `#nullable disable` in files that require it. +- Change all `public` classes to `internal`. +- Update `src/Vendoring/.editorconfig` with the required exemptions.