// Copyright Epic Games, Inc. All Rights Reserved. using System.Diagnostics; using System.Diagnostics.Metrics; using EpicGames.Horde.Utilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OpenTelemetry.Exporter; using OpenTelemetry.Logs; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; using Serilog.Core; namespace HordeAgent.Utility; /// /// Serilog event enricher attaching trace and span ID for Datadog using current System.Diagnostics.Activity /// public class OpenTelemetryDatadogLogEnricher : ILogEventEnricher { /// public void Enrich(Serilog.Events.LogEvent logEvent, ILogEventPropertyFactory propertyFactory) { if (Activity.Current != null) { string stringTraceId = Activity.Current.TraceId.ToString(); string stringSpanId = Activity.Current.SpanId.ToString(); string ddTraceId = Convert.ToUInt64(stringTraceId.Substring(16), 16).ToString(); string ddSpanId = Convert.ToUInt64(stringSpanId, 16).ToString(); logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("dd.trace_id", ddTraceId)); logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("dd.span_id", ddSpanId)); } } } /// /// Helper for configuring OpenTelemetry /// public static class OpenTelemetryHelper { private static ResourceBuilder? s_resourceBuilder; /// /// Name of resource name attribute in Datadog /// Some traces use this for prettier display inside their UI /// public const string DatadogResourceAttribute = "resource.name"; /// /// Name of default Horde tracer (aka activity source) /// public const string HordeName = "Horde"; /// /// List of all source names configured in this class. /// They are needed at startup when initializing OpenTelemetry /// public static string[] SourceNames => new[] { HordeName }; /// /// Default tracer used in Horde /// Prefer dependency-injected tracer over this static member. /// public static readonly Tracer Horde = TracerProvider.Default.GetTracer(HordeName); /// /// Configure OpenTelemetry in Horde and ASP.NET /// /// Service collection for DI /// Current server settings public static void Configure(IServiceCollection services, OpenTelemetrySettings settings) { // Always configure tracers/meters as they are used in the codebase even when OpenTelemetry is not configured services.AddSingleton(Horde); services.AddSingleton(OpenTelemetryMeters.Horde); if (!settings.Enabled) { return; } services.AddOpenTelemetry() .WithTracing(builder => ConfigureTracing(builder, settings)) .WithMetrics(builder => ConfigureMetrics(builder, settings)); } private static void ConfigureTracing(TracerProviderBuilder builder, OpenTelemetrySettings settings) { void DatadogHttpRequestEnricher(Activity activity, HttpRequestMessage message) { activity.SetTag("service.name", settings.ServiceName + "-http-client"); activity.SetTag("operation.name", "http.request"); string url = $"{message.Method} {message.Headers.Host}{message.RequestUri?.LocalPath}"; activity.DisplayName = url; activity.SetTag("resource.name", url); } builder .AddSource(SourceNames) .AddHttpClientInstrumentation(options => { if (settings.EnableDatadogCompatibility) { options.EnrichWithHttpRequestMessage = DatadogHttpRequestEnricher; } }) .AddGrpcClientInstrumentation(options => { if (settings.EnableDatadogCompatibility) { options.EnrichWithHttpRequestMessage = DatadogHttpRequestEnricher; } }) .SetResourceBuilder(GetResourceBuilder(settings)); if (settings.EnableConsoleExporter) { builder.AddConsoleExporter(); } foreach ((string name, OpenTelemetryProtocolExporterSettings exporterSettings) in settings.ProtocolExporters) { builder.AddOtlpExporter(name, exporter => { exporter.Endpoint = exporterSettings.Endpoint!; exporter.Protocol = Enum.TryParse(exporterSettings.Protocol, true, out OtlpExportProtocol protocol) ? protocol : OtlpExportProtocol.Grpc; }); } } private static void ConfigureMetrics(MeterProviderBuilder builder, OpenTelemetrySettings settings) { builder.AddMeter(OpenTelemetryMeters.MeterNames); builder.AddHttpClientInstrumentation(); if (settings.EnableConsoleExporter) { builder.AddConsoleExporter(); } foreach ((string name, OpenTelemetryProtocolExporterSettings exporterSettings) in settings.ProtocolExporters) { builder.AddOtlpExporter(name, exporter => { exporter.Endpoint = exporterSettings.Endpoint!; exporter.Protocol = Enum.TryParse(exporterSettings.Protocol, true, out OtlpExportProtocol protocol) ? protocol : OtlpExportProtocol.Grpc; }); } } /// /// Configure .NET logging with OpenTelemetry /// /// Logging builder to modify /// Current server settings public static void ConfigureLogging(ILoggingBuilder builder, OpenTelemetrySettings settings) { if (!settings.Enabled) { return; } builder.AddOpenTelemetry(options => { options.IncludeScopes = true; options.IncludeFormattedMessage = true; options.ParseStateValues = true; options.SetResourceBuilder(GetResourceBuilder(settings)); if (settings.EnableConsoleExporter) { options.AddConsoleExporter(); } }); } private static ResourceBuilder GetResourceBuilder(OpenTelemetrySettings settings) { if (s_resourceBuilder != null) { return s_resourceBuilder; } List> attributes = settings.Attributes.Select(x => new KeyValuePair(x.Key, x.Value)).ToList(); s_resourceBuilder = ResourceBuilder.CreateDefault() .AddService(settings.ServiceName, serviceNamespace: settings.ServiceNamespace, serviceVersion: settings.ServiceVersion) .AddAttributes(attributes) .AddTelemetrySdk() .AddEnvironmentVariableDetector(); return s_resourceBuilder; } } /// /// Static initialization of all available OpenTelemetry meters /// public static class OpenTelemetryMeters { /// /// Name of default Horde meter /// public const string HordeName = "Horde"; /// /// List of all source names configured in this class. /// They are needed at startup when initializing OpenTelemetry /// public static string[] MeterNames => new[] { HordeName }; /// /// Default meter used in Horde /// public static readonly Meter Horde = new(HordeName); }