diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index eeb0f2b4..6dcb0980 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -18,12 +18,13 @@ - - - - - - + + + + + + + diff --git a/src/FillInTheTextBot.Api/DI/ConfigurationRegistration.cs b/src/FillInTheTextBot.Api/DI/ConfigurationRegistration.cs index 97602b58..a07a2864 100644 --- a/src/FillInTheTextBot.Api/DI/ConfigurationRegistration.cs +++ b/src/FillInTheTextBot.Api/DI/ConfigurationRegistration.cs @@ -1,15 +1,12 @@ using FillInTheTextBot.Services.Configuration; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace FillInTheTextBot.Api.DI { internal static class ConfigurationRegistration { - internal static void AddAppConfiguration(this IServiceCollection services, IConfiguration appConfiguration) + internal static void AddAppConfiguration(this IServiceCollection services, AppConfiguration configuration) { - var configuration = appConfiguration.GetSection($"{nameof(AppConfiguration)}").Get(); - services.AddSingleton(configuration); services.AddSingleton(configuration.HttpLog); services.AddSingleton(configuration.Redis); diff --git a/src/FillInTheTextBot.Api/DI/ExternalServicesRegistration.cs b/src/FillInTheTextBot.Api/DI/ExternalServicesRegistration.cs index 49a3d798..1a2810f7 100644 --- a/src/FillInTheTextBot.Api/DI/ExternalServicesRegistration.cs +++ b/src/FillInTheTextBot.Api/DI/ExternalServicesRegistration.cs @@ -1,21 +1,13 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Reflection; using FillInTheTextBot.Services.Configuration; using Google.Apis.Auth.OAuth2; using Google.Cloud.Dialogflow.V2; using GranSteL.Helpers.Redis; using GranSteL.Tools.ScopeSelector; using Grpc.Auth; -using Jaeger; -using Jaeger.Reporters; -using Jaeger.Samplers; -using Jaeger.Senders.Thrift; -using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; -using OpenTracing; -using OpenTracing.Util; using StackExchange.Redis; namespace FillInTheTextBot.Api.DI @@ -27,7 +19,6 @@ internal static void AddExternalServices(this IServiceCollection services) services.AddSingleton(RegisterSessionsClientScopes); services.AddSingleton(RegisterContextsClientScopes); services.AddSingleton(RegisterRedisClient); - services.AddSingleton(RegisterTracer); services.AddSingleton(RegisterCacheService); } @@ -147,32 +138,6 @@ private static IDatabase RegisterRedisClient(IServiceProvider provider) return dataBase; } - private static ITracer RegisterTracer(IServiceProvider provider) - { - var env = provider.GetService(); - // TODO: get config as parameter - var configuration = provider.GetService(); - - var serviceName = env.ApplicationName; - var fullVersion = Assembly.GetExecutingAssembly().GetName().Version; - - var version = $"{fullVersion?.Major}.{fullVersion?.Minor}.{fullVersion?.Build}"; - - var sampler = new ConstSampler(true); - var reporter = new RemoteReporter.Builder() - .WithSender(new UdpSender(configuration.Host, configuration.Port, 0)) - .Build(); - - var tracer = new Tracer.Builder(serviceName) - .WithSampler(sampler) - .WithReporter(reporter) - .WithTag("Version", version) - .Build(); - - GlobalTracer.Register(tracer); - return tracer; - } - private static IRedisCacheService RegisterCacheService(IServiceProvider provider) { var configuration = provider.GetService(); diff --git a/src/FillInTheTextBot.Api/DI/TelemetryRegistration.cs b/src/FillInTheTextBot.Api/DI/TelemetryRegistration.cs new file mode 100644 index 00000000..5e98699d --- /dev/null +++ b/src/FillInTheTextBot.Api/DI/TelemetryRegistration.cs @@ -0,0 +1,69 @@ +using System; +using System.Reflection; +using FillInTheTextBot.Services; +using FillInTheTextBot.Services.Configuration; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace FillInTheTextBot.Api.DI +{ + internal static class TelemetryRegistration + { + private const int DefaultOtlpPort = 4317; + + internal static void AddTelemetry(this IServiceCollection services, TracingConfiguration tracing) + { + var assemblyName = Assembly.GetExecutingAssembly().GetName(); + var version = assemblyName.Version?.ToString(3); + + var otlpEndpoint = GetOtlpEndpoint(tracing); + + services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService(assemblyName.Name, serviceVersion: version)) + .WithTracing(builder => + { + builder + // Без AddSource активности из Tracing создаются, но не экспортируются + .AddSource(Tracing.ActivitySourceName) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation(); + + if (otlpEndpoint is not null) + { + builder.AddOtlpExporter(options => options.Endpoint = otlpEndpoint); + } + }) + .WithMetrics(builder => builder + .AddMeter(MetricsCollector.MeterName) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddPrometheusExporter()); + } + + /// + /// Адрес OTLP-коллектора. Трейсинг включается только явным флагом Enabled — + /// иначе экспортёр не добавляется, чтобы не долбиться в несуществующий адрес. + /// + private static Uri GetOtlpEndpoint(TracingConfiguration tracing) + { + if (tracing is not { Enabled: true }) + { + return null; + } + + if (string.IsNullOrWhiteSpace(tracing.Host)) + { + throw new InvalidOperationException( + $"{nameof(TracingConfiguration)}.{nameof(TracingConfiguration.Host)} обязателен, " + + $"когда трейсинг включён ({nameof(TracingConfiguration.Enabled)} = true)."); + } + + var port = tracing.Port is > 0 ? tracing.Port.Value : DefaultOtlpPort; + + return new UriBuilder(Uri.UriSchemeHttp, tracing.Host, port).Uri; + } + } +} diff --git a/src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj b/src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj index 5462fab1..36d52f09 100644 --- a/src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj +++ b/src/FillInTheTextBot.Api/FillInTheTextBot.Api.csproj @@ -8,12 +8,14 @@ - - - - + + + + + + diff --git a/src/FillInTheTextBot.Api/Startup.cs b/src/FillInTheTextBot.Api/Startup.cs index 60ad0696..19d26b99 100644 --- a/src/FillInTheTextBot.Api/Startup.cs +++ b/src/FillInTheTextBot.Api/Startup.cs @@ -1,5 +1,4 @@ using FillInTheTextBot.Api.Middleware; -using FillInTheTextBot.Services; using FillInTheTextBot.Services.Configuration; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; @@ -7,7 +6,6 @@ using System; using System.Linq; using FillInTheTextBot.Api.DI; -using Prometheus; namespace FillInTheTextBot.Api { @@ -24,22 +22,24 @@ public Startup(IConfiguration configuration) // ReSharper disable once UnusedMember.Global public void ConfigureServices(IServiceCollection services) { + var appConfiguration = _configuration.GetSection(nameof(AppConfiguration)).Get(); + services .AddMvc() .AddNewtonsoftJson(); - services.AddOpenTracing(); + services.AddTelemetry(appConfiguration.Tracing); + services.AddHttpLogging(o => { o.LoggingFields = Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.All; }); - services.AddAppConfiguration(_configuration); + services.AddAppConfiguration(appConfiguration); services.AddInternalServices(); services.AddExternalServices(); } - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. // ReSharper disable once UnusedMember.Global public void Configure(IApplicationBuilder app, AppConfiguration configuration) @@ -47,8 +47,6 @@ public void Configure(IApplicationBuilder app, AppConfiguration configuration) app.UseMiddleware(); app.UseRouting(); - app.UseHttpMetrics(); - app.UseGrpcMetrics(); if (configuration.HttpLog.Enabled) { @@ -62,7 +60,7 @@ public void Configure(IApplicationBuilder app, AppConfiguration configuration) app.UseEndpoints(e => { e.MapControllers(); - e.MapMetrics(); + e.MapPrometheusScrapingEndpoint(); }); } } diff --git a/src/FillInTheTextBot.Api/appsettings.json b/src/FillInTheTextBot.Api/appsettings.json index fc18875b..35b45ecb 100644 --- a/src/FillInTheTextBot.Api/appsettings.json +++ b/src/FillInTheTextBot.Api/appsettings.json @@ -28,8 +28,9 @@ "KeyPrefix": "" }, "Tracing": { + "Enabled": false, "Host": "", - "Port": "" + "Port": "" }, "Conversation":{ "ResetContextWords": [ diff --git a/src/FillInTheTextBot.Messengers/MessengerService.cs b/src/FillInTheTextBot.Messengers/MessengerService.cs index bd2c0d4b..a5c6f79f 100644 --- a/src/FillInTheTextBot.Messengers/MessengerService.cs +++ b/src/FillInTheTextBot.Messengers/MessengerService.cs @@ -42,9 +42,9 @@ public virtual async Task ProcessIncomingAsync(TInput input) request = Before(input); } - using (Tracing.Trace(s => s - .WithTag(nameof(request.UserHash), request.UserHash) - .WithTag(nameof(request.SessionId), request.SessionId))) + using (Tracing.Trace(a => a + .SetTag(nameof(request.UserHash), request.UserHash) + .SetTag(nameof(request.SessionId), request.SessionId))) { var contexts = GetContexts(request); request.RequiredContexts.AddRange(contexts); diff --git a/src/FillInTheTextBot.Services/Configuration/TracingConfiguration.cs b/src/FillInTheTextBot.Services/Configuration/TracingConfiguration.cs index 84a19121..eeb9b517 100644 --- a/src/FillInTheTextBot.Services/Configuration/TracingConfiguration.cs +++ b/src/FillInTheTextBot.Services/Configuration/TracingConfiguration.cs @@ -2,8 +2,10 @@ { public class TracingConfiguration : Configuration { + public bool Enabled { get; set; } + public string Host { get; set; } - public int Port { get; set; } + public int? Port { get; set; } } } \ No newline at end of file diff --git a/src/FillInTheTextBot.Services/DialogflowService.cs b/src/FillInTheTextBot.Services/DialogflowService.cs index 6a8af60a..be3395e9 100644 --- a/src/FillInTheTextBot.Services/DialogflowService.cs +++ b/src/FillInTheTextBot.Services/DialogflowService.cs @@ -100,7 +100,7 @@ public Task SetContextAsync(string sessionId, string scopeKey, string contextNam private async Task GetResponseInternalAsync(InternalModels.Request request, SessionsClient client, ScopeContext context) { - using (Tracing.Trace(s => s.WithTag(nameof(context.ScopeId), context.ScopeId), "Get response from Dialogflow")) + using (Tracing.Trace(a => a.SetTag(nameof(context.ScopeId), context.ScopeId), "Get response from Dialogflow")) { MetricsCollector.Increment("dialogflow_DetectIntent_scope", context.ScopeId); diff --git a/src/FillInTheTextBot.Services/FillInTheTextBot.Services.csproj b/src/FillInTheTextBot.Services/FillInTheTextBot.Services.csproj index ba72e4b7..bb8a7d29 100644 --- a/src/FillInTheTextBot.Services/FillInTheTextBot.Services.csproj +++ b/src/FillInTheTextBot.Services/FillInTheTextBot.Services.csproj @@ -11,8 +11,6 @@ - - diff --git a/src/FillInTheTextBot.Services/MetricsCollector.cs b/src/FillInTheTextBot.Services/MetricsCollector.cs index 18e8e2bd..ede8e38e 100644 --- a/src/FillInTheTextBot.Services/MetricsCollector.cs +++ b/src/FillInTheTextBot.Services/MetricsCollector.cs @@ -1,19 +1,56 @@ -using Prometheus; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.Metrics; -namespace FillInTheTextBot.Services; - -public static class MetricsCollector +namespace FillInTheTextBot.Services { - private static readonly Gauge Metrics; - - static MetricsCollector() + public static class MetricsCollector { - Metrics = Prometheus.Metrics - .CreateGauge("metrics", "Custom metrics", "metric_name", "parameter"); - } + /// + /// Имя счётчика. Его нужно передать в AddMeter при настройке OpenTelemetry. + /// + public const string MeterName = Telemetry.ScopeName; - public static void Increment(string key, string value) - { - Metrics.WithLabels(key, value).Inc(); + private const string MetricName = "metrics"; + + private const string MetricNameLabel = "metric_name"; + private const string ParameterLabel = "parameter"; + + private static readonly Meter Meter; + + /// + /// Значения по комбинациям меток. Хранятся в памяти, потому что метрика отдаётся + /// как gauge — см. комментарий ниже. + /// + private static readonly ConcurrentDictionary<(string Key, string Value), long> Values = new(); + + static MetricsCollector() + { + Meter = new Meter(MeterName); + + // Раньше метрика собиралась prometheus-net как Gauge с именем "metrics" и метками + // metric_name/parameter, на которую опираются существующие дашборды и алерты. + // Counter в OpenTelemetry экспортировался бы как "metrics_total", поэтому здесь + // ObservableGauge: он отдаёт то же имя и те же метки. Смысл у значения при этом + // счётчиковый — только растёт. Переименование в честный counter сломает дашборды, + // поэтому делать его нужно отдельно и осознанно. + Meter.CreateObservableGauge(MetricName, GetMeasurements, description: "Custom metrics"); + } + + public static void Increment(string key, string value) + { + Values.AddOrUpdate((key, value), 1, (_, current) => current + 1); + } + + private static IEnumerable> GetMeasurements() + { + foreach (var pair in Values) + { + yield return new Measurement( + pair.Value, + new KeyValuePair(MetricNameLabel, pair.Key.Key), + new KeyValuePair(ParameterLabel, pair.Key.Value)); + } + } } -} \ No newline at end of file +} diff --git a/src/FillInTheTextBot.Services/Telemetry.cs b/src/FillInTheTextBot.Services/Telemetry.cs new file mode 100644 index 00000000..f5f8ccd3 --- /dev/null +++ b/src/FillInTheTextBot.Services/Telemetry.cs @@ -0,0 +1,12 @@ +namespace FillInTheTextBot.Services +{ + /// + /// Общие константы телеметрии. Единое имя, под которым приложение публикует + /// активности (ActivitySource) и метрики (Meter) — в OpenTelemetry это + /// instrumentation scope (otel_scope_name). + /// + public static class Telemetry + { + public const string ScopeName = "FillInTheTextBot"; + } +} diff --git a/src/FillInTheTextBot.Services/Tracing.cs b/src/FillInTheTextBot.Services/Tracing.cs index 6642556f..026b9384 100644 --- a/src/FillInTheTextBot.Services/Tracing.cs +++ b/src/FillInTheTextBot.Services/Tracing.cs @@ -1,21 +1,35 @@ using System; +using System.Diagnostics; using System.Runtime.CompilerServices; -using OpenTracing; -using OpenTracing.Util; namespace FillInTheTextBot.Services { public static class Tracing { - public static IScope Trace(Action spanBuilderAction = null, string operationName = null, [CallerMemberName] string caller = null) - { - var spanBuilder = GlobalTracer.Instance.BuildSpan(operationName ?? caller); + /// + /// Имя источника активностей. Его нужно передать в AddSource при настройке + /// OpenTelemetry, иначе активности будут создаваться, но никуда не уедут. + /// + public const string ActivitySourceName = Telemetry.ScopeName; + + private static readonly ActivitySource ActivitySource = new(ActivitySourceName); - spanBuilderAction?.Invoke(spanBuilder); + /// + /// Открывает активность. Если слушателей нет (юнит-тесты, отключённый экспорт), + /// StartActivity возвращает null — using с null работает штатно, а действие + /// над активностью не вызывается. + /// + public static Activity Trace(Action activityAction = null, string operationName = null, + [CallerMemberName] string caller = null) + { + var activity = ActivitySource.StartActivity(operationName ?? caller); - var scope = spanBuilder.StartActive(true); + if (activity is not null) + { + activityAction?.Invoke(activity); + } - return scope; + return activity; } } -} \ No newline at end of file +}