diff --git a/docs/otel.md b/docs/otel.md new file mode 100644 index 000000000..940a4009a --- /dev/null +++ b/docs/otel.md @@ -0,0 +1,80 @@ +# OpenTelemetry Integration + +The Firebird ADO.NET provider includes built-in support for [OpenTelemetry](https://opentelemetry.io/) distributed tracing and metrics using the native .NET `System.Diagnostics` APIs. No dependency on the OpenTelemetry SDK is required in the provider itself — your application opts in by configuring the appropriate listeners. + +## Distributed Tracing + +The provider emits `Activity` spans for database command execution using `System.Diagnostics.ActivitySource`. + +### Enabling Traces + +```csharp +using OpenTelemetry.Trace; + +var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddSource(FirebirdSql.Data.FirebirdClient.FbTelemetry.ActivitySourceName) + .AddConsoleExporter() // or any other exporter + .Build(); +``` + +The `ActivitySource` name is `"FirebirdSql.Data"`, also available as the constant `FbTelemetry.ActivitySourceName`. + +### Span Attributes + +Spans follow the [OTel Database Client Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/database/database-spans/): + +| Attribute | Description | +|-----------|-------------| +| `db.system.name` | Always `"firebirdsql"` | +| `db.namespace` | The database name from the connection | +| `db.operation.name` | The SQL verb (`SELECT`, `INSERT`, etc.) or `EXECUTE PROCEDURE` | +| `db.collection.name` | The table name (for `TableDirect` commands) | +| `db.stored_procedure.name` | The stored procedure name (for `StoredProcedure` commands) | +| `db.query.summary` | A low-cardinality summary of the operation | +| `db.query.text` | The full SQL text (**opt-in**, see below) | +| `db.query.parameter.*` | Parameter values (**opt-in**, see below) | +| `server.address` | The database server hostname | +| `server.port` | The database server port (only when non-default, i.e. != 3050) | +| `error.type` | The SQLSTATE code (for `FbException`) or exception type name | + +### Opt-In Sensitive Attributes + +By default, `db.query.text` and `db.query.parameter.*` are **not** collected, as they may contain sensitive data. Enable them explicitly: + +```csharp +using FirebirdSql.Data.Logging; + +// Enable SQL text in traces +FbLogManager.EnableQueryTextTracing(); + +// Enable parameter values in traces (and logs) +FbLogManager.EnableParameterLogging(); +``` + +## Metrics + +The provider emits metrics via `System.Diagnostics.Metrics.Meter`. + +### Enabling Metrics + +```csharp +using OpenTelemetry.Metrics; + +var meterProvider = Sdk.CreateMeterProviderBuilder() + .AddMeter(FirebirdSql.Data.FirebirdClient.FbTelemetry.MeterName) + .AddConsoleExporter() // or any other exporter + .Build(); +``` + +The `Meter` name is `"FirebirdSql.Data"`, also available as the constant `FbTelemetry.MeterName`. + +### Available Metrics + +Metrics follow the [OTel Database Client Metrics Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/database/database-metrics/): + +| Metric | Type | Unit | Description | +|--------|------|------|-------------| +| `db.client.operation.duration` | Histogram | `s` | Duration of database client operations | +| `db.client.connection.create_time` | Histogram | `s` | Time to create a new connection | +| `db.client.connection.count` | ObservableUpDownCounter | `{connection}` | Current connection count by state (`idle`/`used`) | +| `db.client.connection.max` | ObservableUpDownCounter | `{connection}` | Maximum number of open connections allowed | diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FbTransactionTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/FbTransactionTests.cs index d3a7d9908..144375b74 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/FbTransactionTests.cs +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FbTransactionTests.cs @@ -139,4 +139,22 @@ public async Task SnapshotAtNumber() } } } + + [Test] + public async Task CanGetTransactionId() + { + if (!EnsureServerVersionAtLeast(new Version(2, 5, 0, 0))) + return; + + await using (var transaction1 = await Connection.BeginTransactionAsync()) + { + var idFromInfo = await new FbTransactionInfo(transaction1).GetTransactionIdAsync(); + Assert.NotZero(idFromInfo); + + var command = new FbCommand("SELECT current_transaction FROM rdb$database", Connection, transaction1); + var idFromSql = await command.ExecuteScalarAsync(); + + Assert.AreEqual(idFromInfo, idFromSql); + } + } } diff --git a/src/FirebirdSql.Data.FirebirdClient/Common/IscHelper.cs b/src/FirebirdSql.Data.FirebirdClient/Common/IscHelper.cs index 578895f7a..3cf67ac76 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Common/IscHelper.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Common/IscHelper.cs @@ -206,6 +206,10 @@ public static List ParseTransactionInfo(byte[] buffer, Charset charset) case IscCodes.isc_info_error: throw FbException.Create("Received error response."); + case IscCodes.isc_info_tra_id: + info.Add(VaxInteger(buffer, pos, length)); + break; + case IscCodes.fb_info_tra_snapshot_number: info.Add(VaxInteger(buffer, pos, length)); break; diff --git a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbCommand.cs b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbCommand.cs index 551c6ea64..66bff831d 100644 --- a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbCommand.cs +++ b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbCommand.cs @@ -20,11 +20,14 @@ using System.ComponentModel; using System.Data; using System.Data.Common; +using System.Diagnostics; using System.Text; using System.Threading; using System.Threading.Tasks; using FirebirdSql.Data.Common; using FirebirdSql.Data.Logging; +using FirebirdSql.Data.Metrics; +using FirebirdSql.Data.Trace; using Microsoft.Extensions.Logging; namespace FirebirdSql.Data.FirebirdClient; @@ -50,6 +53,8 @@ public sealed class FbCommand : DbCommand, IFbPreparedCommand, IDescriptorFiller private int? _commandTimeout; private int _fetchSize; private Type[] _expectedColumnTypes; + private Activity _currentActivity; + private long _startedAtTicks; #endregion @@ -1064,7 +1069,10 @@ internal void Release() _statement.Dispose2(); _statement = null; } + + TraceCommandStop(); } + Task IFbPreparedCommand.ReleaseAsync(CancellationToken cancellationToken) => ReleaseAsync(cancellationToken); internal async Task ReleaseAsync(CancellationToken cancellationToken = default) { @@ -1082,6 +1090,8 @@ internal async Task ReleaseAsync(CancellationToken cancellationToken = default) await _statement.Dispose2Async(cancellationToken).ConfigureAwait(false); _statement = null; } + + TraceCommandStop(); } void IFbPreparedCommand.TransactionCompleted() => TransactionCompleted(); @@ -1302,6 +1312,48 @@ private async ValueTask UpdateParameterValuesAsync(Descriptor descriptor, Cancel #endregion + #region Tracing + + private void TraceCommandStart() + { + Debug.Assert(_currentActivity == null); + if (FbActivitySource.Source.HasListeners()) + _currentActivity = FbActivitySource.CommandStart(this); + + _startedAtTicks = FbMetricsStore.CommandStart(); + } + + private void TraceCommandStop() + { + Debug.Assert(_startedAtTicks > 0 || _currentActivity == null, "TraceCommandStop called without TraceCommandStart"); + + if (_currentActivity != null) + { + // Do not set status to Ok: https://opentelemetry.io/docs/concepts/signals/traces/#span-status + _currentActivity.Dispose(); + _currentActivity = null; + } + + FbMetricsStore.CommandStop(_startedAtTicks, Connection); + _startedAtTicks = 0; + } + + private void TraceCommandException(Exception e) + { + Debug.Assert(_startedAtTicks > 0 || _currentActivity == null, "TraceCommandException called without TraceCommandStart"); + + if (_currentActivity != null) + { + FbActivitySource.CommandException(_currentActivity, e); + _currentActivity = null; + } + + FbMetricsStore.CommandStop(_startedAtTicks, Connection); + _startedAtTicks = 0; + } + + #endregion Tracing + #region Private Methods private void Prepare(bool returnsSet) @@ -1446,57 +1498,73 @@ private async Task PrepareAsync(bool returnsSet, CancellationToken cancellationT private void ExecuteCommand(CommandBehavior behavior, bool returnsSet) { LogMessages.CommandExecution(Log, this); + TraceCommandStart(); + try + { + Prepare(returnsSet); - Prepare(returnsSet); + if ((behavior & CommandBehavior.SequentialAccess) == CommandBehavior.SequentialAccess || + (behavior & CommandBehavior.SingleResult) == CommandBehavior.SingleResult || + (behavior & CommandBehavior.SingleRow) == CommandBehavior.SingleRow || + (behavior & CommandBehavior.CloseConnection) == CommandBehavior.CloseConnection || + behavior == CommandBehavior.Default) + { + // Set the fetch size + _statement.FetchSize = _fetchSize; - if ((behavior & CommandBehavior.SequentialAccess) == CommandBehavior.SequentialAccess || - (behavior & CommandBehavior.SingleResult) == CommandBehavior.SingleResult || - (behavior & CommandBehavior.SingleRow) == CommandBehavior.SingleRow || - (behavior & CommandBehavior.CloseConnection) == CommandBehavior.CloseConnection || - behavior == CommandBehavior.Default) - { - // Set the fetch size - _statement.FetchSize = _fetchSize; + // Set if it's needed the Records Affected information + _statement.ReturnRecordsAffected = _connection.ConnectionOptions.ReturnRecordsAffected; - // Set if it's needed the Records Affected information - _statement.ReturnRecordsAffected = _connection.ConnectionOptions.ReturnRecordsAffected; + // Validate input parameter count + if (_namedParameters.Count > 0 && !HasParameters) + { + throw FbException.Create("Must declare command parameters."); + } - // Validate input parameter count - if (_namedParameters.Count > 0 && !HasParameters) - { - throw FbException.Create("Must declare command parameters."); + // Execute + _statement.Execute(CommandTimeout * 1000, this); } - - // Execute - _statement.Execute(CommandTimeout * 1000, this); + } + catch (Exception e) + { + TraceCommandException(e); + throw; } } private async Task ExecuteCommandAsync(CommandBehavior behavior, bool returnsSet, CancellationToken cancellationToken = default) { LogMessages.CommandExecution(Log, this); + TraceCommandStart(); + try + { + await PrepareAsync(returnsSet, cancellationToken).ConfigureAwait(false); - await PrepareAsync(returnsSet, cancellationToken).ConfigureAwait(false); + if ((behavior & CommandBehavior.SequentialAccess) == CommandBehavior.SequentialAccess || + (behavior & CommandBehavior.SingleResult) == CommandBehavior.SingleResult || + (behavior & CommandBehavior.SingleRow) == CommandBehavior.SingleRow || + (behavior & CommandBehavior.CloseConnection) == CommandBehavior.CloseConnection || + behavior == CommandBehavior.Default) + { + // Set the fetch size + _statement.FetchSize = _fetchSize; - if ((behavior & CommandBehavior.SequentialAccess) == CommandBehavior.SequentialAccess || - (behavior & CommandBehavior.SingleResult) == CommandBehavior.SingleResult || - (behavior & CommandBehavior.SingleRow) == CommandBehavior.SingleRow || - (behavior & CommandBehavior.CloseConnection) == CommandBehavior.CloseConnection || - behavior == CommandBehavior.Default) - { - // Set the fetch size - _statement.FetchSize = _fetchSize; + // Set if it's needed the Records Affected information + _statement.ReturnRecordsAffected = _connection.ConnectionOptions.ReturnRecordsAffected; - // Set if it's needed the Records Affected information - _statement.ReturnRecordsAffected = _connection.ConnectionOptions.ReturnRecordsAffected; + // Validate input parameter count + if (_namedParameters.Count > 0 && !HasParameters) + { + throw FbException.Create("Must declare command parameters."); + } - // Validate input parameter count - if (_namedParameters.Count > 0 && !HasParameters) - { - throw FbException.Create("Must declare command parameters."); + // Execute + await _statement.ExecuteAsync(CommandTimeout * 1000, this, cancellationToken).ConfigureAwait(false); } - - // Execute - await _statement.ExecuteAsync(CommandTimeout * 1000, this, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + TraceCommandException(e); + throw; } } diff --git a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnection.cs b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnection.cs index 0a75039aa..b1a71223b 100644 --- a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnection.cs +++ b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnection.cs @@ -16,6 +16,7 @@ //$Authors = Carlos Guzman Alvarez, Jiri Cincura (jiri@cincura.net) using System; +using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Data.Common; @@ -23,6 +24,7 @@ using System.Threading.Tasks; using FirebirdSql.Data.Common; using FirebirdSql.Data.Logging; +using FirebirdSql.Data.Metrics; using Microsoft.Extensions.Logging; namespace FirebirdSql.Data.FirebirdClient; @@ -190,6 +192,13 @@ public override string ConnectionString _options = new ConnectionString(value); _options.Validate(); _connectionString = value; + + MetricsConnectionAttributes = [ + new("db.system.name", "firebirdsql"), + new("db.namespace", _options.Database), + new("server.address", _options.DataSource), + new("server.port", _options.Port), + ]; } } } @@ -270,6 +279,8 @@ internal bool IsClosed get { return _state == ConnectionState.Closed; } } + internal KeyValuePair[] MetricsConnectionAttributes = []; + #endregion #region Protected Properties @@ -524,6 +535,7 @@ public override async Task ChangeDatabaseAsync(string databaseName, Cancellation public override void Open() { LogMessages.ConnectionOpening(Log, this); + var startedAtTicks = FbMetricsStore.ConnectionOpening(); if (string.IsNullOrEmpty(_connectionString)) { @@ -616,10 +628,13 @@ public override void Open() } LogMessages.ConnectionOpened(Log, this); + FbMetricsStore.ConnectionOpened(startedAtTicks, this._options.NormalizedConnectionString); } + public override async Task OpenAsync(CancellationToken cancellationToken) { LogMessages.ConnectionOpening(Log, this); + var startedAtTicks = FbMetricsStore.ConnectionOpening(); if (string.IsNullOrEmpty(_connectionString)) { @@ -712,6 +727,7 @@ public override async Task OpenAsync(CancellationToken cancellationToken) } LogMessages.ConnectionOpened(Log, this); + FbMetricsStore.ConnectionOpened(startedAtTicks, this._options.NormalizedConnectionString); } public override void Close() diff --git a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnectionPoolManager.cs b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnectionPoolManager.cs index 59924b468..bc80ebd8a 100644 --- a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnectionPoolManager.cs +++ b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbConnectionPoolManager.cs @@ -167,6 +167,10 @@ static long GetTicks() var ticks = Environment.TickCount; return ticks + -(long)int.MinValue; } + + internal int AvailableCount => _available.Count; + internal int BusyCount => _busy.Count; + internal int MaxSize => _connectionString.MaxPoolSize; } int _disposed; @@ -220,6 +224,9 @@ internal void ClearPool(ConnectionString connectionString) } } + internal IEnumerable<(string poolName, int idleCount, int busyCount, int maxSize)> GetMetrics() => + _pools.Select(kvp => (kvp.Key, kvp.Value.AvailableCount, kvp.Value.BusyCount, kvp.Value.MaxSize)); + public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 1) diff --git a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbTransactionInfo.cs b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbTransactionInfo.cs index df3cb6192..41c928474 100644 --- a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbTransactionInfo.cs +++ b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbTransactionInfo.cs @@ -33,6 +33,15 @@ public sealed class FbTransactionInfo #region Methods + public long GetTransactionId() + { + return GetValue(IscCodes.isc_info_tra_id); + } + public Task GetTransactionIdAsync(CancellationToken cancellationToken = default) + { + return GetValueAsync(IscCodes.isc_info_tra_id, cancellationToken); + } + public long GetTransactionSnapshotNumber() { return GetValue(IscCodes.fb_info_tra_snapshot_number); diff --git a/src/FirebirdSql.Data.FirebirdClient/FirebirdSql.Data.FirebirdClient.csproj b/src/FirebirdSql.Data.FirebirdClient/FirebirdSql.Data.FirebirdClient.csproj index a040e0613..0ebf0442b 100644 --- a/src/FirebirdSql.Data.FirebirdClient/FirebirdSql.Data.FirebirdClient.csproj +++ b/src/FirebirdSql.Data.FirebirdClient/FirebirdSql.Data.FirebirdClient.csproj @@ -52,6 +52,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/FirebirdSql.Data.FirebirdClient/Logging/FbLogManager.cs b/src/FirebirdSql.Data.FirebirdClient/Logging/FbLogManager.cs index e82cfd8fd..7349b1f2f 100644 --- a/src/FirebirdSql.Data.FirebirdClient/Logging/FbLogManager.cs +++ b/src/FirebirdSql.Data.FirebirdClient/Logging/FbLogManager.cs @@ -23,6 +23,7 @@ namespace FirebirdSql.Data.Logging; public static class FbLogManager { public static bool IsParameterLoggingEnabled { get; private set; } = false; + public static bool IsQueryTextTracingEnabled { get; private set; } = false; private static ILoggerFactory LoggerFactory = NullLoggerFactory.Instance; @@ -32,6 +33,9 @@ public static void UseLoggerFactory(ILoggerFactory loggerFactory) => public static void EnableParameterLogging(bool enable = true) => IsParameterLoggingEnabled = enable; + public static void EnableQueryTextTracing(bool enable = true) => + IsQueryTextTracingEnabled = enable; + internal static ILogger CreateLogger() => LoggerFactory.CreateLogger(); } diff --git a/src/FirebirdSql.Data.FirebirdClient/Metrics/FbMetricsStore.cs b/src/FirebirdSql.Data.FirebirdClient/Metrics/FbMetricsStore.cs new file mode 100644 index 000000000..31b1f5a20 --- /dev/null +++ b/src/FirebirdSql.Data.FirebirdClient/Metrics/FbMetricsStore.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Reflection; +using FirebirdSql.Data.FirebirdClient; +using FirebirdSql.Data.Trace; + +namespace FirebirdSql.Data.Metrics +{ + internal static class FbMetricsStore + { + private const string ConnectionPoolNameAttributeName = "db.client.connection.pool.name"; + private const string ConnectionStateAttributeName = "db.client.connection.state"; + private const string ConnectionStateIdleValue = "idle"; + private const string ConnectionStateUsedValue = "used"; + + static readonly string Version = typeof(FbMetricsStore).Assembly.GetCustomAttribute()?.InformationalVersion ?? "0.0.0"; + + internal static readonly Meter Source = new(FbTelemetry.MeterName, Version); + + static readonly Histogram OperationDuration; + static readonly Histogram ConnectionCreateTime; + + static FbMetricsStore() + { +#if NET9_0_OR_GREATER + var durationAdvice = new InstrumentAdvice + { + HistogramBucketBoundaries = [0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], + }; +#endif + + OperationDuration = Source.CreateHistogram( + "db.client.operation.duration", + unit: "s", + description: "Duration of database client operations." +#if NET9_0_OR_GREATER + , advice: durationAdvice +#endif + ); + + Source.CreateObservableUpDownCounter( + "db.client.connection.count", + GetConnectionCount, + unit: "{connection}", + description: "The number of connections that are currently in state described by the 'state' attribute." + ); + + Source.CreateObservableUpDownCounter( + "db.client.connection.max", + GetConnectionMax, + unit: "{connection}", + description: "The maximum number of open connections allowed." + ); + + ConnectionCreateTime = Source.CreateHistogram( + "db.client.connection.create_time", + unit: "s", + description: "The time it took to create a new connection." +#if NET9_0_OR_GREATER + , advice: durationAdvice +#endif + ); + + // db.client.connection.wait_time + // The time it took to obtain an open connection from the pool + + // db.client.connection.use_time + // The time between borrowing a connection and returning it to the pool + } + + internal static long CommandStart() => Stopwatch.GetTimestamp(); + + internal static void CommandStop(long startedAtTicks, FbConnection connection) + { + if (OperationDuration.Enabled && startedAtTicks > 0) + { + var elapsed = Stopwatch.GetElapsedTime(startedAtTicks); + + OperationDuration.Record(elapsed.TotalSeconds, connection.MetricsConnectionAttributes); + } + } + + internal static long ConnectionOpening() => Stopwatch.GetTimestamp(); + + internal static void ConnectionOpened(long startedAtTicks, string poolName) + { + if (ConnectionCreateTime.Enabled && startedAtTicks > 0) + { + var elapsed = Stopwatch.GetElapsedTime(startedAtTicks); + + ConnectionCreateTime.Record(elapsed.TotalSeconds, [new(ConnectionPoolNameAttributeName, poolName)]); + } + } + + static IEnumerable> GetConnectionCount() => + FbConnectionPoolManager.Instance.GetMetrics() + .SelectMany(m => new List> + { + new( + m.idleCount, + new(ConnectionPoolNameAttributeName, m.poolName), + new(ConnectionStateAttributeName, ConnectionStateIdleValue) + ), + + new( + m.busyCount, + new(ConnectionPoolNameAttributeName, m.poolName), + new(ConnectionStateAttributeName, ConnectionStateUsedValue) + ), + }); + + static IEnumerable> GetConnectionMax() => + FbConnectionPoolManager.Instance.GetMetrics() + .Select(m => new Measurement( + m.maxSize, + [new(ConnectionPoolNameAttributeName, m.poolName)] + )); + } +} diff --git a/src/FirebirdSql.Data.FirebirdClient/Trace/FbActivitySource.cs b/src/FirebirdSql.Data.FirebirdClient/Trace/FbActivitySource.cs new file mode 100644 index 000000000..95a938499 --- /dev/null +++ b/src/FirebirdSql.Data.FirebirdClient/Trace/FbActivitySource.cs @@ -0,0 +1,137 @@ +using System; +using System.ComponentModel; +using System.Data; +using System.Diagnostics; +using System.Reflection; +using FirebirdSql.Data.Common; +using FirebirdSql.Data.FirebirdClient; +using FirebirdSql.Data.Logging; + +namespace FirebirdSql.Data.Trace +{ + internal static class FbActivitySource + { + static readonly string Version = typeof(FbActivitySource).Assembly.GetCustomAttribute()?.InformationalVersion ?? "0.0.0"; + + internal static readonly ActivitySource Source = new(FbTelemetry.ActivitySourceName, Version); + + internal static Activity CommandStart(FbCommand command) + { + var dbName = command.Connection.Database; + + string dbOperationName = null; + string dbCollectionName = null; + string activityName; + + switch (command.CommandType) + { + case CommandType.StoredProcedure: + dbOperationName = "EXECUTE PROCEDURE"; + activityName = $"{dbOperationName} {command.CommandText}"; + break; + + case CommandType.TableDirect: + dbOperationName = "SELECT"; + dbCollectionName = command.CommandText; + activityName = $"{dbOperationName} {dbCollectionName}"; + break; + + case CommandType.Text: + dbOperationName = ExtractSqlVerb(command.CommandText); + activityName = dbOperationName ?? dbName; + break; + + default: + throw new InvalidEnumArgumentException($"Invalid value for 'System.Data.CommandType' ({(int)command.CommandType})."); + } + + var activity = Source.StartActivity(activityName, ActivityKind.Client); + if (activity is not { IsAllDataRequested: true }) + return activity; + + activity.SetTag("db.system.name", "firebirdsql"); + + if (dbCollectionName != null) + { + activity.SetTag("db.collection.name", dbCollectionName); + } + + if (dbName != null) + { + activity.SetTag("db.namespace", dbName); + } + + if (dbOperationName != null) + { + activity.SetTag("db.operation.name", dbOperationName); + } + + activity.SetTag("db.query.summary", activityName); + + if (command.CommandType == CommandType.StoredProcedure) + { + activity.SetTag("db.stored_procedure.name", command.CommandText); + } + + if (FbLogManager.IsQueryTextTracingEnabled) + { + activity.SetTag("db.query.text", command.CommandText); + } + + if (command.Connection.DataSource != null) + { + activity.SetTag("server.address", command.Connection.DataSource); + } + + var port = command.Connection.ConnectionOptions.Port; + if (port != ConnectionString.DefaultValuePortNumber) + { + activity.SetTag("server.port", port); + } + + if (FbLogManager.IsParameterLoggingEnabled) + { + foreach (FbParameter p in command.Parameters) + { + activity.SetTag($"db.query.parameter.{p.ParameterName}", p.InternalValue == DBNull.Value ? null : p.InternalValue); + } + } + + return activity; + } + + internal static void CommandException(Activity activity, Exception exception) + { + activity.AddEvent( + new("exception", tags: new() + { + { "exception.message", exception.Message }, + { "exception.type", exception.GetType().FullName }, + { "exception.stacktrace", exception.ToString() }, + }) + ); + + string errorDescription = exception is FbException fbException + ? fbException.SQLSTATE + : exception.Message; + + activity.SetTag("error.type", exception is FbException fbEx + ? fbEx.SQLSTATE ?? exception.GetType().FullName + : exception.GetType().FullName); + + activity.SetStatus(ActivityStatusCode.Error, errorDescription); + activity.Dispose(); + } + + static string ExtractSqlVerb(string sql) + { + if (string.IsNullOrEmpty(sql)) + return null; + var span = sql.AsSpan().TrimStart(); + var spaceIndex = span.IndexOfAny([' ', '\t', '\n', '\r']); + if (spaceIndex <= 0) + return span.Length > 0 ? span.ToString().ToUpperInvariant() : null; + return span.Slice(0, spaceIndex).ToString().ToUpperInvariant(); + } + } +} diff --git a/src/FirebirdSql.Data.FirebirdClient/Trace/FbTelemetry.cs b/src/FirebirdSql.Data.FirebirdClient/Trace/FbTelemetry.cs new file mode 100644 index 000000000..3410a1a7c --- /dev/null +++ b/src/FirebirdSql.Data.FirebirdClient/Trace/FbTelemetry.cs @@ -0,0 +1,41 @@ +/* + * The contents of this file are subject to the Initial + * Developer's Public License Version 1.0 (the "License"); + * you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * https://github.com/FirebirdSQL/NETProvider/raw/master/license.txt. + * + * Software distributed under the License is distributed on + * an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, either + * express or implied. See the License for the specific + * language governing rights and limitations under the License. + * + * All Rights Reserved. + */ + +namespace FirebirdSql.Data.FirebirdClient; + +/// +/// Provides the names of the ActivitySource and Meter used by the Firebird ADO.NET provider +/// for OpenTelemetry integration. +/// +/// +/// To subscribe to Firebird traces, use: +/// builder.AddSource(FbTelemetry.ActivitySourceName) +/// To subscribe to Firebird metrics, use: +/// builder.AddMeter(FbTelemetry.MeterName) +/// +public static class FbTelemetry +{ + /// + /// The name of the used for distributed tracing. + /// Use with TracerProviderBuilder.AddSource(). + /// + public const string ActivitySourceName = "FirebirdSql.Data"; + + /// + /// The name of the used for metrics. + /// Use with MeterProviderBuilder.AddMeter(). + /// + public const string MeterName = "FirebirdSql.Data"; +}