diff --git a/AspNetCore.Examples.OpenTelemetry.Api/AspNetCore.Examples.OpenTelemetry.Api.csproj b/AspNetCore.Examples.OpenTelemetry.Api/AspNetCore.Examples.OpenTelemetry.Api.csproj index 746ded4..68b458b 100644 --- a/AspNetCore.Examples.OpenTelemetry.Api/AspNetCore.Examples.OpenTelemetry.Api.csproj +++ b/AspNetCore.Examples.OpenTelemetry.Api/AspNetCore.Examples.OpenTelemetry.Api.csproj @@ -4,21 +4,16 @@ net9.0 enable enable - Linux - ..\docker-compose.dcproj - - - - - - + + + diff --git a/AspNetCore.Examples.OpenTelemetry.Api/AspNetCore.Examples.OpenTelemetry.Api.http b/AspNetCore.Examples.OpenTelemetry.Api/AspNetCore.Examples.OpenTelemetry.Api.http new file mode 100644 index 0000000..95bedb2 --- /dev/null +++ b/AspNetCore.Examples.OpenTelemetry.Api/AspNetCore.Examples.OpenTelemetry.Api.http @@ -0,0 +1,6 @@ +@AspNetCore.Examples.OpenTelemetry.Api_HostAddress = http://localhost:5250 + +GET {{AspNetCore.Examples.OpenTelemetry.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/AspNetCore.Examples.OpenTelemetry.Api/Extensions/OpenApiExtensions.cs b/AspNetCore.Examples.OpenTelemetry.Api/Extensions/OpenApiExtensions.cs deleted file mode 100644 index 25adc8a..0000000 --- a/AspNetCore.Examples.OpenTelemetry.Api/Extensions/OpenApiExtensions.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Microsoft.AspNetCore.OpenApi; -using Microsoft.OpenApi.Any; -using Microsoft.OpenApi.Models; -using Swashbuckle.AspNetCore.SwaggerUI; - -namespace AspNetCore.Examples.OpenTelemetry.Api.Extensions; - -public static class OpenApiExtensions -{ - private const string DocumentName = "v1"; - private const string Pattern = "/openapi/{documentName}.json"; - private const string Url = "/openapi/v1.json"; - - public static IServiceCollection AddCustomOpenApi(this IServiceCollection services) - { - return services.AddOpenApi(DocumentName, options => - { - options.AddSchemaTransformer(); - }); - } - - public static IEndpointRouteBuilder MapCustomOpenApi(this IEndpointRouteBuilder endpoints) - { - endpoints.MapOpenApi(Pattern); - return endpoints; - } - - public static IApplicationBuilder UseCustomOpenApiUI(this IApplicationBuilder app) - { - return app.UseSwaggerUI(options => - { - var hostingEnv = app.ApplicationServices.GetRequiredService(); - options.ConfigObject.Urls = [ - new UrlDescriptor { Name = $"{hostingEnv.ApplicationName} v1", Url = Url }, - ]; - }); - } - - private class EnumAsStringSchemaTransformer : IOpenApiSchemaTransformer - { - public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) - { - if (!context.JsonTypeInfo.Type.IsEnum) - return Task.CompletedTask; - - var enumType = context.JsonTypeInfo.Type; - - schema.Type = "string"; - - var defaultIntValue = (schema.Default as OpenApiInteger)?.Value; - var defaultEnumValue = Enum.ToObject(enumType, defaultIntValue ?? 0); - schema.Default = new OpenApiString(defaultEnumValue.ToString()); - - schema.Enum = Enum.GetNames(enumType) - .Select(name => (IOpenApiAny)new OpenApiString(name)) - .ToArray(); - - return Task.CompletedTask; - } - } -} diff --git a/AspNetCore.Examples.OpenTelemetry.Api/Program.cs b/AspNetCore.Examples.OpenTelemetry.Api/Program.cs index 5e80191..b6c6adc 100644 --- a/AspNetCore.Examples.OpenTelemetry.Api/Program.cs +++ b/AspNetCore.Examples.OpenTelemetry.Api/Program.cs @@ -1,11 +1,11 @@ -using AspNetCore.Examples.OpenTelemetry.Api.Extensions; -using AspNetCore.Examples.OpenTelemetry.Api.SampleTelemetry; +using AspNetCore.Examples.OpenTelemetry.Api.WeatherForecast; +using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); - -builder.Services.AddCustomOpenApi(); + +builder.Services.AddOpenApi(); if (builder.Environment.IsDevelopment()) { @@ -18,18 +18,17 @@ })); } -builder.Services.AddSampleTelemetry(); - +builder.Services.AddWeatherForecast(); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseCors(); - app.MapCustomOpenApi(); - app.UseCustomOpenApiUI(); + app.MapOpenApi(); + app.MapScalarApiReference(); } -app.MapSampleTelemetryEndpoints(); +app.MapWeatherForecast(); app.Run(); diff --git a/AspNetCore.Examples.OpenTelemetry.Api/Properties/launchSettings.json b/AspNetCore.Examples.OpenTelemetry.Api/Properties/launchSettings.json index 922f6c7..5ff3d16 100644 --- a/AspNetCore.Examples.OpenTelemetry.Api/Properties/launchSettings.json +++ b/AspNetCore.Examples.OpenTelemetry.Api/Properties/launchSettings.json @@ -1,21 +1,15 @@ -{ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { "http": { "commandName": "Project", + "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "swagger", + "launchUrl": "scalar/v1", + "applicationUrl": "http://localhost:5250", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" - }, - "dotnetRunMessages": true, - "applicationUrl": "http://localhost:8080" - }, - "Docker": { - "commandName": "Docker", - "launchBrowser": true, - "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger", - "publishAllPorts": true + } } - }, - "$schema": "https://json.schemastore.org/launchsettings.json" -} \ No newline at end of file + } +} diff --git a/AspNetCore.Examples.OpenTelemetry.Api/SampleTelemetry/SampleTelemetryEndpoints.cs b/AspNetCore.Examples.OpenTelemetry.Api/SampleTelemetry/SampleTelemetryEndpoints.cs deleted file mode 100644 index 7a522a9..0000000 --- a/AspNetCore.Examples.OpenTelemetry.Api/SampleTelemetry/SampleTelemetryEndpoints.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Diagnostics; -using System.Diagnostics.Metrics; - -namespace AspNetCore.Examples.OpenTelemetry.Api.SampleTelemetry; - -public static class SampleTelemetryEndpoints -{ - public static IServiceCollection AddSampleTelemetry(this IServiceCollection services) - { - services - .AddSingleton() - .AddOpenTelemetry() - .WithTracing(t => t.AddSource(Telemetry.ActivitySourceName)) - .WithMetrics(m => m.AddMeter(Telemetry.MeterName)); - return services; - } - - public static IEndpointRouteBuilder MapSampleTelemetryEndpoints(this IEndpointRouteBuilder builder) - { - builder.MapGet("telemetry/traces", async (Telemetry telemetry, int durationMs = 0, string? tag = null) => - { - var tags = new Dictionary() { { "tag", tag } }; - using var trace = telemetry.ActivitySource.StartActivity(name: "sample_activity", kind: ActivityKind.Internal, tags: tags); - - await Task.Delay(durationMs); - - return Results.Ok(); - }); - - builder.MapGet("telemetry/metrics", (Telemetry telemetry, int value = 0, string? tag = null) => - { - var tags = new Dictionary() { { "tag", tag } }.ToArray(); - - telemetry.Histogram.Record(value, tags); - telemetry.Counter.Add(value, tags); - telemetry.Gauge.Record(value, tags); - telemetry.UpDownCounter.Add(value, tags); - - return Results.Ok(); - }); - - - builder.MapGet("telemetry/logs", (Telemetry telemetry, LogLevel level = LogLevel.Information, string? attributeValue = null) => - { - telemetry.Logger.Log(level, "Sample log message with attribute value: \"{attribute}\"", attributeValue); - - return Results.Ok(); - }); - - return builder; - } - - private sealed class Telemetry : IDisposable - { - public const string LoggerName = "sample_logger"; - public const string MeterName = "sample_meter"; - public const string ActivitySourceName = "sample_source"; - public readonly ILogger Logger; - public readonly ActivitySource ActivitySource; - public readonly Meter Meter; - public readonly Histogram Histogram; - public readonly Counter Counter; - public readonly Gauge Gauge; - public readonly UpDownCounter UpDownCounter; - - public Telemetry(ILoggerFactory loggerFactory, IMeterFactory meterFactory) - { - Logger = loggerFactory.CreateLogger(LoggerName); - ActivitySource = new ActivitySource(ActivitySourceName, version: "1.0"); - Meter = meterFactory.Create(MeterName); - Histogram = Meter.CreateHistogram(name: "sample_histogram", unit: "Units", description: "Sample Histogram description"); - Counter = Meter.CreateCounter(name: "sample_counter", unit: "Units", description: "Sample Counter description"); - Gauge = Meter.CreateGauge(name: "sample_gauge", unit: "Units", description: "Sample Gauge description"); - UpDownCounter = Meter.CreateUpDownCounter(name: "sample_up_down_counter", unit: "Units", description: "Sample Up Down Counter description"); - } - - public void Dispose() - { - ActivitySource.Dispose(); - Meter.Dispose(); - } - } -} \ No newline at end of file diff --git a/AspNetCore.Examples.OpenTelemetry.Api/WeatherForecast/WeatherForecast.cs b/AspNetCore.Examples.OpenTelemetry.Api/WeatherForecast/WeatherForecast.cs new file mode 100644 index 0000000..38feabb --- /dev/null +++ b/AspNetCore.Examples.OpenTelemetry.Api/WeatherForecast/WeatherForecast.cs @@ -0,0 +1,6 @@ +namespace AspNetCore.Examples.OpenTelemetry.Api.WeatherForecast; + +internal record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/AspNetCore.Examples.OpenTelemetry.Api/WeatherForecast/WeatherForecastExtensions.cs b/AspNetCore.Examples.OpenTelemetry.Api/WeatherForecast/WeatherForecastExtensions.cs new file mode 100644 index 0000000..296a1ac --- /dev/null +++ b/AspNetCore.Examples.OpenTelemetry.Api/WeatherForecast/WeatherForecastExtensions.cs @@ -0,0 +1,46 @@ +using System.Diagnostics; + +namespace AspNetCore.Examples.OpenTelemetry.Api.WeatherForecast; + +internal static class WeatherForecastExtensions +{ + public static IServiceCollection AddWeatherForecast(this IServiceCollection services) + { + services.AddTelemetry(); + return services; + } + + public static IEndpointRouteBuilder MapWeatherForecast(this IEndpointRouteBuilder endpoints) + { + var summaries = new[] + { + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + }; + + endpoints.MapGet("/weatherforecast", (IWeatherForecastTelemetry telemetry) => + { + using var _ = telemetry.ActivitySource.StartActivity(name: "sample_activity", kind: ActivityKind.Internal); + telemetry.Logger.LogInformation("Requesting weather forecast"); + + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + + telemetry.Logger.LogInformation("Weather forecast calculated: {@forecast}", forecast); + foreach (var item in forecast) + { + telemetry.TemperatureC.Record(item.TemperatureC); + } + + return forecast; + }) + .WithName("GetWeatherForecast"); + + return endpoints; + } +} diff --git a/AspNetCore.Examples.OpenTelemetry.Api/WeatherForecast/WeatherForecastTelemetry.cs b/AspNetCore.Examples.OpenTelemetry.Api/WeatherForecast/WeatherForecastTelemetry.cs new file mode 100644 index 0000000..7415eec --- /dev/null +++ b/AspNetCore.Examples.OpenTelemetry.Api/WeatherForecast/WeatherForecastTelemetry.cs @@ -0,0 +1,20 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace AspNetCore.Examples.OpenTelemetry.Api.WeatherForecast; + +internal interface IWeatherForecastTelemetry : ITelemetry +{ + Histogram TemperatureC { get; } +} + +internal class WeatherForecastTelemetry : Telemetry, IWeatherForecastTelemetry +{ + public WeatherForecastTelemetry(ILoggerFactory loggerFactory, IMeterFactory meterFactory) + : base(loggerFactory, meterFactory) + { + TemperatureC = Meter.CreateHistogram("temperature", unit: "ºC"); + } + + public Histogram TemperatureC { get; } +} diff --git a/AspNetCore.Examples.OpenTelemetry.Api/appsettings.Development.json b/AspNetCore.Examples.OpenTelemetry.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/AspNetCore.Examples.OpenTelemetry.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/AspNetCore.Examples.OpenTelemetry.Api/appsettings.json b/AspNetCore.Examples.OpenTelemetry.Api/appsettings.json index cc7acd2..10f68b8 100644 --- a/AspNetCore.Examples.OpenTelemetry.Api/appsettings.json +++ b/AspNetCore.Examples.OpenTelemetry.Api/appsettings.json @@ -2,8 +2,7 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "AspNetCore.Examples.OpenTelemetry.Api.Controllers.TelemetryController": "Trace" + "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*" diff --git a/AspNetCore.Examples.OpenTelemetry.AspireHost/AspNetCore.Examples.OpenTelemetry.AspireHost.csproj b/AspNetCore.Examples.OpenTelemetry.AspireHost/AspNetCore.Examples.OpenTelemetry.AspireHost.csproj index 984dcb2..65b1d67 100644 --- a/AspNetCore.Examples.OpenTelemetry.AspireHost/AspNetCore.Examples.OpenTelemetry.AspireHost.csproj +++ b/AspNetCore.Examples.OpenTelemetry.AspireHost/AspNetCore.Examples.OpenTelemetry.AspireHost.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/AspNetCore.Examples.OpenTelemetry.ServiceDefaults/AspNetCore.Examples.OpenTelemetry.ServiceDefaults.csproj b/AspNetCore.Examples.OpenTelemetry.ServiceDefaults/AspNetCore.Examples.OpenTelemetry.ServiceDefaults.csproj index 08c916f..81b8b2b 100644 --- a/AspNetCore.Examples.OpenTelemetry.ServiceDefaults/AspNetCore.Examples.OpenTelemetry.ServiceDefaults.csproj +++ b/AspNetCore.Examples.OpenTelemetry.ServiceDefaults/AspNetCore.Examples.OpenTelemetry.ServiceDefaults.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 enable enable true @@ -10,8 +10,8 @@ - - + + diff --git a/AspNetCore.Examples.OpenTelemetry.ServiceDefaults/Extensions.cs b/AspNetCore.Examples.OpenTelemetry.ServiceDefaults/Extensions.cs index 4bac5e2..5d5b872 100644 --- a/AspNetCore.Examples.OpenTelemetry.ServiceDefaults/Extensions.cs +++ b/AspNetCore.Examples.OpenTelemetry.ServiceDefaults/Extensions.cs @@ -7,112 +7,111 @@ using OpenTelemetry.Metrics; using OpenTelemetry.Trace; -namespace Microsoft.Extensions.Hosting +namespace Microsoft.Extensions.Hosting; + +// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions { - // Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. - // This project should be referenced by each service project in your solution. - // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults - public static class Extensions + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) { - public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) - { - builder.ConfigureOpenTelemetry(); + builder.ConfigureOpenTelemetry(); - builder.AddDefaultHealthChecks(); + builder.AddDefaultHealthChecks(); - builder.Services.AddServiceDiscovery(); + builder.Services.AddServiceDiscovery(); - builder.Services.ConfigureHttpClientDefaults(http => - { - // Turn on resilience by default - http.AddStandardResilienceHandler(); + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); - // Turn on service discovery by default - http.AddServiceDiscovery(); - }); + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); - // Uncomment the following to restrict the allowed schemes for service discovery. - // builder.Services.Configure(options => - // { - // options.AllowedSchemes = ["https"]; - // }); + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); - return builder; - } + return builder; + } - public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => { - builder.Logging.AddOpenTelemetry(logging => + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; + tracing.AddAspNetCoreInstrumentation() + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); }); - builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - metrics.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation(); - }) - .WithTracing(tracing => - { - tracing.AddAspNetCoreInstrumentation() - // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) - //.AddGrpcClientInstrumentation() - .AddHttpClientInstrumentation(); - }); - - builder.AddOpenTelemetryExporters(); - - return builder; - } + builder.AddOpenTelemetryExporters(); - private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) - { - var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - - if (useOtlpExporter) - { - builder.Services.AddOpenTelemetry().UseOtlpExporter(); - } + return builder; + } - // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) - //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) - //{ - // builder.Services.AddOpenTelemetry() - // .UseAzureMonitor(); - //} + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - return builder; + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); } - public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) - { - builder.Services.AddHealthChecks() - // Add a default liveness check to ensure app is responsive - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} - return builder; - } + return builder; + } - public static WebApplication MapDefaultEndpoints(this WebApplication app) - { - // Adding health checks endpoints to applications in non-development environments has security implications. - // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. - if (app.Environment.IsDevelopment()) - { - // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks("/health"); + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks("/alive", new HealthCheckOptions - { - Predicate = r => r.Tags.Contains("live") - }); - } + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); - return app; + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); } + + return app; } } diff --git a/AspNetCore.Examples.OpenTelemetry.TelemetryExtensions/AspNetCore.Examples.OpenTelemetry.TelemetryExtensions.csproj b/AspNetCore.Examples.OpenTelemetry.TelemetryExtensions/AspNetCore.Examples.OpenTelemetry.TelemetryExtensions.csproj new file mode 100644 index 0000000..32c49c6 --- /dev/null +++ b/AspNetCore.Examples.OpenTelemetry.TelemetryExtensions/AspNetCore.Examples.OpenTelemetry.TelemetryExtensions.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + + + + + + + + diff --git a/AspNetCore.Examples.OpenTelemetry.TelemetryExtensions/Extensions.cs b/AspNetCore.Examples.OpenTelemetry.TelemetryExtensions/Extensions.cs new file mode 100644 index 0000000..24506f8 --- /dev/null +++ b/AspNetCore.Examples.OpenTelemetry.TelemetryExtensions/Extensions.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace Microsoft.Extensions.Hosting; + +public static class Extensions +{ + private static readonly Type TelemetryType = typeof(Telemetry<>); + private static readonly Type ITelemetryType = typeof(ITelemetry<>); + + public static IServiceCollection AddTelemetry(this IServiceCollection services) + where TService : class + where TImplementation : class, TService + { + services.AddSingleton(); + services.AddTelemetryInternal(services => services.GetRequiredService()); + return services; + } + + public static IServiceCollection AddTelemetry(this IServiceCollection services) + where TService : class + { + services.AddSingleton(); + services.AddTelemetryInternal(services => services.GetRequiredService()); + return services; + } + + private static IServiceCollection AddTelemetryInternal(this IServiceCollection services, Func implementationFactory) + where TService : class + { + var implementationType = typeof(TService); + if (implementationType.TryGetBaseTelemetryType(out var baseTelemetryType)) + { + services.AddSingleton(baseTelemetryType, implementationFactory); + var categoryName = baseTelemetryType + .GetField(nameof(Telemetry.CategoryName), BindingFlags.Static | BindingFlags.NonPublic)? + .GetValue(obj: null) as string; + if (categoryName is not null) + { + services.AddOpenTelemetry() + .WithTracing(t => t.AddSource(categoryName)) + .WithMetrics(m => m.AddMeter(categoryName)); + } + } + foreach (var telemetryInterfaceType in implementationType.GetTelemetryInterfaceTypes()) + { + services.AddSingleton(telemetryInterfaceType, implementationFactory); + } + + return services; + } + + + private static bool TryGetBaseTelemetryType(this Type implementationType, [MaybeNullWhen(returnValue: false)] out Type baseTelemetryType) + { + baseTelemetryType = null; + var type = implementationType; + while (type != null && type != typeof(object)) + { + if (type.IsGenericType && type.GetGenericTypeDefinition() == TelemetryType) + { + baseTelemetryType = type; + return true; + } + type = type.BaseType; + } + return false; + } + + private static IEnumerable GetTelemetryInterfaceTypes(this Type implementationType) + { + return implementationType.GetInterfaces() + .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == ITelemetryType); + } +} diff --git a/AspNetCore.Examples.OpenTelemetry.TelemetryExtensions/ITelemetry.cs b/AspNetCore.Examples.OpenTelemetry.TelemetryExtensions/ITelemetry.cs new file mode 100644 index 0000000..237e650 --- /dev/null +++ b/AspNetCore.Examples.OpenTelemetry.TelemetryExtensions/ITelemetry.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.Logging; +using System.Diagnostics.Metrics; + +namespace System.Diagnostics; + +public interface ITelemetry +{ + ILogger Logger { get; } + ActivitySource ActivitySource { get; } + Meter Meter { get; } +} \ No newline at end of file diff --git a/AspNetCore.Examples.OpenTelemetry.TelemetryExtensions/Telemetry.cs b/AspNetCore.Examples.OpenTelemetry.TelemetryExtensions/Telemetry.cs new file mode 100644 index 0000000..ea7f39d --- /dev/null +++ b/AspNetCore.Examples.OpenTelemetry.TelemetryExtensions/Telemetry.cs @@ -0,0 +1,51 @@ +using AspNetCore.Examples.OpenTelemetry.TelemetryExtensions; +using Microsoft.Extensions.Logging; +using System.Diagnostics.Metrics; + +namespace System.Diagnostics; + +public class Telemetry : ITelemetry +{ + private bool disposedValue; + + internal static readonly string CategoryName = TypeNameHelper.GetTypeDisplayName(typeof(TCategoryName), includeGenericParameters: false, nestedTypeDelimiter: '.'); + + public Telemetry(ILoggerFactory loggerFactory, IMeterFactory meterFactory) + { + Logger = loggerFactory.CreateLogger(); + ActivitySource = new ActivitySource(CategoryName, Version); + Meter = meterFactory.Create(new MeterOptions(CategoryName) + { + Version = Version, + Scope = Scope, + Tags = Tags, + }); + } + + public ILogger Logger { get; } + public ActivitySource ActivitySource { get; } + public Meter Meter { get; } + + protected virtual string? Version { get; } + protected virtual object? Scope { get; } + protected virtual IEnumerable>? Tags { get; } + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + ActivitySource.Dispose(); + Meter.Dispose(); + } + disposedValue = true; + } + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/AspNetCore.Examples.OpenTelemetry.TelemetryExtensions/TypeNameHelper.cs b/AspNetCore.Examples.OpenTelemetry.TelemetryExtensions/TypeNameHelper.cs new file mode 100644 index 0000000..4b87acd --- /dev/null +++ b/AspNetCore.Examples.OpenTelemetry.TelemetryExtensions/TypeNameHelper.cs @@ -0,0 +1,198 @@ + +// https://github.com/dotnet/runtime/blob/main/src/libraries/Common/src/Extensions/TypeNameHelper/TypeNameHelper.cs +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace AspNetCore.Examples.OpenTelemetry.TelemetryExtensions; + +internal static class TypeNameHelper +{ + private const char DefaultNestedTypeDelimiter = '+'; + + private static readonly Dictionary _builtInTypeNames = new Dictionary + { + { typeof(void), "void" }, + { typeof(bool), "bool" }, + { typeof(byte), "byte" }, + { typeof(char), "char" }, + { typeof(decimal), "decimal" }, + { typeof(double), "double" }, + { typeof(float), "float" }, + { typeof(int), "int" }, + { typeof(long), "long" }, + { typeof(object), "object" }, + { typeof(sbyte), "sbyte" }, + { typeof(short), "short" }, + { typeof(string), "string" }, + { typeof(uint), "uint" }, + { typeof(ulong), "ulong" }, + { typeof(ushort), "ushort" } + }; + + [return: NotNullIfNotNull(nameof(item))] + public static string? GetTypeDisplayName(object? item, bool fullName = true) + { + return item == null ? null : GetTypeDisplayName(item.GetType(), fullName); + } + + /// + /// Pretty print a type name. + /// + /// The . + /// true to print a fully qualified name. + /// true to include generic parameter names. + /// true to include generic parameters. + /// Character to use as a delimiter in nested type names + /// The pretty printed type name. + public static string GetTypeDisplayName(Type type, bool fullName = true, bool includeGenericParameterNames = false, bool includeGenericParameters = true, char nestedTypeDelimiter = DefaultNestedTypeDelimiter) + { + StringBuilder? builder = null; + string? name = ProcessType(ref builder, type, new DisplayNameOptions(fullName, includeGenericParameterNames, includeGenericParameters, nestedTypeDelimiter)); + return name ?? builder?.ToString() ?? string.Empty; + } + + private static string? ProcessType(ref StringBuilder? builder, Type type, in DisplayNameOptions options) + { + if (type.IsGenericType) + { + Type[] genericArguments = type.GetGenericArguments(); + builder ??= new StringBuilder(); + ProcessGenericType(builder, type, genericArguments, genericArguments.Length, options); + } + else if (type.IsArray) + { + builder ??= new StringBuilder(); + ProcessArrayType(builder, type, options); + } + else if (_builtInTypeNames.TryGetValue(type, out string? builtInName)) + { + if (builder is null) return builtInName; + + builder.Append(builtInName); + } + else if (type.IsGenericParameter) + { + if (options.IncludeGenericParameterNames) + { + if (builder is null) return type.Name; + + builder.Append(type.Name); + } + } + else + { + string name = options.FullName ? type.FullName! : type.Name; + + if (builder is null) + { + if (options.NestedTypeDelimiter != DefaultNestedTypeDelimiter) + { + return name.Replace(DefaultNestedTypeDelimiter, options.NestedTypeDelimiter); + } + + return name; + } + + builder.Append(name); + if (options.NestedTypeDelimiter != DefaultNestedTypeDelimiter) + { + builder.Replace(DefaultNestedTypeDelimiter, options.NestedTypeDelimiter, builder.Length - name.Length, name.Length); + } + } + + return null; + } + + private static void ProcessArrayType(StringBuilder builder, Type type, in DisplayNameOptions options) + { + Type innerType = type; + while (innerType.IsArray) + { + innerType = innerType.GetElementType()!; + } + + ProcessType(ref builder!, innerType, options); + + while (type.IsArray) + { + builder.Append('['); + builder.Append(',', type.GetArrayRank() - 1); + builder.Append(']'); + type = type.GetElementType()!; + } + } + + private static void ProcessGenericType(StringBuilder builder, Type type, Type[] genericArguments, int length, in DisplayNameOptions options) + { + int offset = 0; + if (type.IsNested) + { + offset = type.DeclaringType!.GetGenericArguments().Length; + } + + if (options.FullName) + { + if (type.IsNested) + { + ProcessGenericType(builder, type.DeclaringType!, genericArguments, offset, options); + builder.Append(options.NestedTypeDelimiter); + } + else if (!string.IsNullOrEmpty(type.Namespace)) + { + builder.Append(type.Namespace); + builder.Append('.'); + } + } + + int genericPartIndex = type.Name.IndexOf('`'); + if (genericPartIndex <= 0) + { + builder.Append(type.Name); + return; + } + + builder.Append(type.Name, 0, genericPartIndex); + + if (options.IncludeGenericParameters) + { + builder.Append('<'); + for (int i = offset; i < length; i++) + { + ProcessType(ref builder!, genericArguments[i], options); + if (i + 1 == length) + { + continue; + } + + builder.Append(','); + if (options.IncludeGenericParameterNames || !genericArguments[i + 1].IsGenericParameter) + { + builder.Append(' '); + } + } + builder.Append('>'); + } + } + + private readonly struct DisplayNameOptions + { + public DisplayNameOptions(bool fullName, bool includeGenericParameterNames, bool includeGenericParameters, char nestedTypeDelimiter) + { + FullName = fullName; + IncludeGenericParameters = includeGenericParameters; + IncludeGenericParameterNames = includeGenericParameterNames; + NestedTypeDelimiter = nestedTypeDelimiter; + } + + public bool FullName { get; } + + public bool IncludeGenericParameters { get; } + + public bool IncludeGenericParameterNames { get; } + + public char NestedTypeDelimiter { get; } + } +} \ No newline at end of file diff --git a/AspNetCore.Examples.OpenTelemetry.sln b/AspNetCore.Examples.OpenTelemetry.sln index 9555358..fbb046d 100644 --- a/AspNetCore.Examples.OpenTelemetry.sln +++ b/AspNetCore.Examples.OpenTelemetry.sln @@ -14,6 +14,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCore.Examples.OpenTel EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCore.Examples.OpenTelemetry.ServiceDefaults", "AspNetCore.Examples.OpenTelemetry.ServiceDefaults\AspNetCore.Examples.OpenTelemetry.ServiceDefaults.csproj", "{48B7BA98-6E70-4D59-8F5A-B18C6B8586ED}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCore.Examples.OpenTelemetry.TelemetryExtensions", "AspNetCore.Examples.OpenTelemetry.TelemetryExtensions\AspNetCore.Examples.OpenTelemetry.TelemetryExtensions.csproj", "{7C67218A-4B33-4CB4-B12B-AF02E4E51EB6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -32,6 +34,10 @@ Global {48B7BA98-6E70-4D59-8F5A-B18C6B8586ED}.Debug|Any CPU.Build.0 = Debug|Any CPU {48B7BA98-6E70-4D59-8F5A-B18C6B8586ED}.Release|Any CPU.ActiveCfg = Release|Any CPU {48B7BA98-6E70-4D59-8F5A-B18C6B8586ED}.Release|Any CPU.Build.0 = Release|Any CPU + {7C67218A-4B33-4CB4-B12B-AF02E4E51EB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C67218A-4B33-4CB4-B12B-AF02E4E51EB6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C67218A-4B33-4CB4-B12B-AF02E4E51EB6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C67218A-4B33-4CB4-B12B-AF02E4E51EB6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 7af22e0..4d395af 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,16 @@ This project serves as an example of how to monitor an ASP.Net Core application It makes use of the following technologies and projects: -- [.NET 8.0](https://dotnet.microsoft.com/download/dotnet/8.0) -- [ASP.NET Core 8.0](https://learn.microsoft.com/aspnet/core/?view=aspnetcore-8.0) -- [Swashbuckle](https://learn.microsoft.com/aspnet/core/tutorials/getting-started-with-swashbuckle?view=aspnetcore-8.0) -- [Docker](https://docs.docker.com/) -- [Docker compose](https://docs.docker.com/compose/) +- [.NET 9.0](https://dotnet.microsoft.com/download/dotnet/9.0) +- [ASP.NET Core 9.0](https://learn.microsoft.com/aspnet/core/?view=aspnetcore-9.0) +- [ScalaR](https://github.com/ScalaR/ScalaR) - [OpenTelemetry](https://opentelemetry.io/) -- [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) to receive the OpenTelemetry data from the application and distribute it to the proper backends -- [Grafana Tempo](https://grafana.com/oss/tempo/) as the tracing backend -- [Prometheus](https://prometheus.io/) as the metrics backend -- [Grafana Loki](https://grafana.com/oss/loki/) as the logs backend -- [Grafana](https://grafana.com/) as the observability UI +- [.NET Aspire](https://learn.microsoft.com/dotnet/aspire/get-started/aspire-overview) # How to run -Simply run the docker compose project from visual studio, or from a terminal by executing `docker compose up` in the root folder. +Simply run the AspireHost project from visual studio, or from a terminal by executing `dotnet run` in the Aspire project folder. -Access the web api at http://localhost:8080/swagger and try to create traces, metrics and logs by calling the corresponding endpoints. +Access the web api [Scalar UI](http://localhost:5250/scalar/v1) +Call the weather forecast endpoint to register some telemetry data. -Access grafana at http://localhost:3001 and check that the [traces](http://localhost:3001/explore?orgId=1&left=%7B%22datasource%22:%22tempo%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22datasource%22:%7B%22type%22:%22tempo%22,%22uid%22:%22tempo%22%7D,%22queryType%22:%22nativeSearch%22,%22limit%22:20,%22serviceName%22:%22AspNetCore.Examples.OpenTelemetry.Api%22%7D%5D,%22range%22:%7B%22from%22:%22now-1h%22,%22to%22:%22now%22%7D%7D), [metrics](http://localhost:3001/explore?orgId=1&left=%7B%22datasource%22:%22prometheus%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%7Bexported_job%3D%5C%22AspNetCore.Examples.OpenTelemetry.Api%5C%22%7D%22,%22range%22:true,%22instant%22:true,%22datasource%22:%7B%22type%22:%22prometheus%22,%22uid%22:%22prometheus%22%7D,%22editorMode%22:%22builder%22%7D%5D,%22range%22:%7B%22from%22:%22now-1h%22,%22to%22:%22now%22%7D%7D) and [logs](http://localhost:3001/explore?orgId=1&left=%7B%22datasource%22:%22loki%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%7Bjob%3D%5C%22AspNetCore.Examples.OpenTelemetry.Api%5C%22%7D%20%7C%3D%20%60%60%22,%22queryType%22:%22range%22,%22datasource%22:%7B%22type%22:%22loki%22,%22uid%22:%22loki%22%7D,%22editorMode%22:%22builder%22%7D%5D,%22range%22:%7B%22from%22:%22now-1h%22,%22to%22:%22now%22%7D%7D) have been registered. \ No newline at end of file +Access Aspire dashboard at http://localhost:15154, and check that the [traces](http://localhost:15154/traces), [metrics](http://localhost:15154/metrics) and [logs](http://localhost:15154/structuredlogs) have been registered.