From a615403f7d5f518db598f99f1e8c21ca84004fa5 Mon Sep 17 00:00:00 2001 From: MartinDotNet Date: Wed, 19 Jun 2024 17:04:42 +0000 Subject: [PATCH] Working example --- otel-dotnet-lambda-extension.sln | 9 + sample/src/Program.cs | 41 +---- sample/src/sample.csproj | 3 + .../ExtensionClient.cs | 164 ++++++++++++++++++ .../LambdaExtensionSetup.cs | 16 ++ .../OtelLambdaExtensionService.cs | 50 ++++++ .../PracticalOtel.LambdaFlusher.csproj | 14 ++ 7 files changed, 260 insertions(+), 37 deletions(-) create mode 100644 src/PracticalOtel.LambdaFlusher/ExtensionClient.cs create mode 100644 src/PracticalOtel.LambdaFlusher/LambdaExtensionSetup.cs create mode 100644 src/PracticalOtel.LambdaFlusher/OtelLambdaExtensionService.cs create mode 100644 src/PracticalOtel.LambdaFlusher/PracticalOtel.LambdaFlusher.csproj diff --git a/otel-dotnet-lambda-extension.sln b/otel-dotnet-lambda-extension.sln index f986e70..f6cf567 100644 --- a/otel-dotnet-lambda-extension.sln +++ b/otel-dotnet-lambda-extension.sln @@ -7,6 +7,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sample", "sample", "{67C330 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "sample", "sample\src\sample.csproj", "{EFCDD6DB-1DD2-4B5A-A888-58FB1E1DBD88}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9AAD1C69-7A79-4C26-83FB-567B753CD9BA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PracticalOtel.LambdaFlusher", "src\PracticalOtel.LambdaFlusher\PracticalOtel.LambdaFlusher.csproj", "{7E6BC80E-6EA4-4394-BAC8-FAA12FE541FF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -20,8 +24,13 @@ Global {EFCDD6DB-1DD2-4B5A-A888-58FB1E1DBD88}.Debug|Any CPU.Build.0 = Debug|Any CPU {EFCDD6DB-1DD2-4B5A-A888-58FB1E1DBD88}.Release|Any CPU.ActiveCfg = Release|Any CPU {EFCDD6DB-1DD2-4B5A-A888-58FB1E1DBD88}.Release|Any CPU.Build.0 = Release|Any CPU + {7E6BC80E-6EA4-4394-BAC8-FAA12FE541FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E6BC80E-6EA4-4394-BAC8-FAA12FE541FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E6BC80E-6EA4-4394-BAC8-FAA12FE541FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E6BC80E-6EA4-4394-BAC8-FAA12FE541FF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {EFCDD6DB-1DD2-4B5A-A888-58FB1E1DBD88} = {67C330B0-6434-42B3-8F2B-42235D3030AE} + {7E6BC80E-6EA4-4394-BAC8-FAA12FE541FF} = {9AAD1C69-7A79-4C26-83FB-567B753CD9BA} EndGlobalSection EndGlobal diff --git a/sample/src/Program.cs b/sample/src/Program.cs index 4beef6a..918e74f 100644 --- a/sample/src/Program.cs +++ b/sample/src/Program.cs @@ -1,7 +1,4 @@ -using System.Diagnostics; -using System.Diagnostics.Tracing; using OpenTelemetry; -using OpenTelemetry.Exporter; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -13,59 +10,29 @@ // Add AWS Lambda support. When application is run in Lambda Kestrel is swapped out as the web server with Amazon.Lambda.AspNetCoreServer. This // package will act as the webserver translating request and responses between the Lambda event source and ASP.NET Core. builder.Services.AddAWSLambdaHosting(LambdaEventSource.HttpApi); -builder.Services.AddSingleton(); builder.Services.AddOpenTelemetry() .ConfigureResource(resource => resource .AddService(builder.Environment.ApplicationName) ) - .WithTracing(tracingOptions => + .WithTracing(tracingOptions => tracingOptions.AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() ) + .AddLambdaExtension() .UseOtlpExporter(); var app = builder.Build(); -var tracerProvider = app.Services.GetRequiredService(); -ActivityListener listener = new() -{ - ShouldListenTo = _ => true, - Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData, - ActivityStopped = activity => - { - Console.WriteLine("Activity stopped: " + activity.Source.Name + " " + activity.DisplayName + " " + activity.Duration); - tracerProvider.ForceFlush(); - } -}; - -ActivitySource.AddActivityListener(listener); - -var openTelemetryDebugLogger = app.Services.GetRequiredService(); - app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); -app.MapGet("/", () => { +app.MapGet("/", () => +{ Thread.Sleep(1000); return "Welcome to running ASP.NET Core Minimal API on AWS Lambda"; }); app.Run(); - - -public class ConsoleOpenTelemetryListener : EventListener -{ - protected override void OnEventSourceCreated(EventSource eventSource) - { - if (eventSource.Name.StartsWith("OpenTelemetry")) - EnableEvents(eventSource, EventLevel.Error); - } - - protected override void OnEventWritten(EventWrittenEventArgs eventData) - { - Console.WriteLine(string.Format(eventData.Message, eventData.Payload?.Select(p => p?.ToString())?.ToArray())); - } -} \ No newline at end of file diff --git a/sample/src/sample.csproj b/sample/src/sample.csproj index 367f8ec..18bbf69 100644 --- a/sample/src/sample.csproj +++ b/sample/src/sample.csproj @@ -17,4 +17,7 @@ + + + \ No newline at end of file diff --git a/src/PracticalOtel.LambdaFlusher/ExtensionClient.cs b/src/PracticalOtel.LambdaFlusher/ExtensionClient.cs new file mode 100644 index 0000000..a6c5b75 --- /dev/null +++ b/src/PracticalOtel.LambdaFlusher/ExtensionClient.cs @@ -0,0 +1,164 @@ +using System.Text; +using Microsoft.Extensions.Logging; + +/// +/// Lambda Extension API client +/// +internal class ExtensionClient : IDisposable +{ + #region HTTP header key names + + /// + /// HTTP header that is used to register a new extension name with Extension API + /// + private const string LambdaExtensionNameHeader = "Lambda-Extension-Name"; + + /// + /// HTTP header used to provide extension registration id + /// + /// + /// Registration endpoint reply will have this header value with a new id, assigned to this extension by the API. + /// All other endpoints will expect HTTP calls to have id header attached to all requests. + /// + private const string LambdaExtensionIdHeader = "Lambda-Extension-Identifier"; + + /// + /// HTTP header to report Lambda Extension error type string. + /// + /// + /// This header is used to report additional error details for Init and Shutdown errors. + /// + private const string LambdaExtensionFunctionErrorTypeHeader = "Lambda-Extension-Function-Error-Type"; + + #endregion + + #region Environment variable names + + /// + /// Environment variable that holds server name and port number for Extension API endpoints + /// + private const string LambdaRuntimeApiAddress = "AWS_LAMBDA_RUNTIME_API"; + + #endregion + + #region Instance properties + + /// + /// Extension id, which is assigned to this extension after the registration + /// + public string? Id { get; private set; } + + #endregion + + #region Constructor and readonly variables + + /// + /// Http client instance + /// + /// This is an IDisposable object that must be properly disposed of, + /// thus implements interface too. + private readonly HttpClient httpClient = new HttpClient(); + + /// + /// Extension name, calculated from the current executing assembly name + /// + private readonly string _extensionName; + private readonly ILogger _logger; + + /// + /// Extension registration URL + /// + private readonly Uri registerUrl; + + /// + /// Next event long poll URL + /// + private readonly Uri nextUrl; + + /// + /// Constructor + /// + public ExtensionClient(string extensionName, ILogger logger) + { + _extensionName = extensionName ?? throw new ArgumentNullException(nameof(extensionName), "Extension name cannot be null"); + _logger = logger; + this.httpClient.Timeout = Timeout.InfiniteTimeSpan; + var apiUri = new UriBuilder(Environment.GetEnvironmentVariable(LambdaRuntimeApiAddress)!).Uri; + var basePath = "2020-01-01/extension"; + + // Calculate all Extension API endpoints' URLs + this.registerUrl = new Uri(apiUri, $"{basePath}/register"); + this.nextUrl = new Uri(apiUri, $"{basePath}/event/next"); + } + + #endregion + + #region Private methods + + /// + /// Register extension with Extension API + /// + /// Event types to by notified with + /// Awaitable void + /// This method is expected to be called just once when extension is being registered with the Extension API. + public async Task RegisterExtensionAsync() + { + using var scope = OpenTelemetry.SuppressInstrumentationScope.Begin(); + + const string payload = @"{ ""events"": [""INVOKE""] }"; + + using var content = new StringContent(payload, Encoding.UTF8, "application/json"); + content.Headers.Add(LambdaExtensionNameHeader, _extensionName); + + using var response = await this.httpClient.PostAsync(this.registerUrl, content); + + // if POST call didn't succeed + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Error response received for registration request: {response}", await response.Content.ReadAsStringAsync()); + response.EnsureSuccessStatusCode(); + } + + this.Id = response.Headers.GetValues(LambdaExtensionIdHeader).FirstOrDefault(); + if (string.IsNullOrEmpty(this.Id)) + { + throw new ApplicationException("Extension API register call didn't return a valid identifier."); + } + + this.httpClient.DefaultRequestHeaders.Add(LambdaExtensionIdHeader, this.Id); + } + + /// + /// Long poll for the next event from Extension API + /// + /// Awaitable tuple having event type and event details fields + /// It is important to have httpClient.Timeout set to some value, that is longer than any expected wait time, + /// otherwise HttpClient will throw an exception when getting the next event details from the server. + public async Task GetNextAsync() + { + using var scope = OpenTelemetry.SuppressInstrumentationScope.Begin(); + var response = await this.httpClient.GetAsync(this.nextUrl); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Error response received for {url}: {response}", this.nextUrl.PathAndQuery, await response.Content.ReadAsStringAsync()); + response.EnsureSuccessStatusCode(); + } + Console.WriteLine("Received event: " + await response.Content.ReadAsStringAsync()); + } + + #endregion + + #region IDisposable implementation + + /// + /// Dispose of instance Disposable variables + /// + public void Dispose() + { + // Quick and dirty implementation to propagate Dispose call to HttpClient instance + ((IDisposable)httpClient).Dispose(); + } + + #endregion +} diff --git a/src/PracticalOtel.LambdaFlusher/LambdaExtensionSetup.cs b/src/PracticalOtel.LambdaFlusher/LambdaExtensionSetup.cs new file mode 100644 index 0000000..ceb4b91 --- /dev/null +++ b/src/PracticalOtel.LambdaFlusher/LambdaExtensionSetup.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using PracticalOtel.LambdaFlusher; + +namespace OpenTelemetry; + +public static class LambdaExtensionSetup +{ + public static OpenTelemetryBuilder AddLambdaExtension(this OpenTelemetryBuilder builder) + { + builder.Services.AddHostedService(); + builder.Services.AddSingleton(sp => + new ExtensionClient("OtelLambdaExtensionService", sp.GetRequiredService>())); + return builder; + } +} diff --git a/src/PracticalOtel.LambdaFlusher/OtelLambdaExtensionService.cs b/src/PracticalOtel.LambdaFlusher/OtelLambdaExtensionService.cs new file mode 100644 index 0000000..e7a198c --- /dev/null +++ b/src/PracticalOtel.LambdaFlusher/OtelLambdaExtensionService.cs @@ -0,0 +1,50 @@ +using System.Diagnostics; +using System.Threading.Channels; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry.Trace; + +namespace PracticalOtel.LambdaFlusher; + +internal class OtelLambdaExtensionService : BackgroundService +{ + private readonly ExtensionClient _extensionClient; + private readonly TracerProvider _tracerProvider; + private readonly ILogger _logger; + + private static readonly Channel _channel = Channel.CreateUnbounded(); + + private readonly ActivityListener _listener = new() + { + ShouldListenTo = source => source.Name == "Microsoft.AspNetCore", + //ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData, + ActivityStopped = activity => + { + Console.WriteLine("Activity stopped: " + activity.Source.Name + " " + activity.DisplayName + " " + activity.Duration); + _channel.Writer.WriteAsync(activity); + } + }; + + public OtelLambdaExtensionService(ExtensionClient extensionClient, TracerProvider tracerProvider, ILogger logger) + { + _extensionClient = extensionClient; + _tracerProvider = tracerProvider; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + ActivitySource.AddActivityListener(_listener); + await _extensionClient.RegisterExtensionAsync(); + + + while(true) + { + await _extensionClient.GetNextAsync(); + await _channel.Reader.WaitToReadAsync(); + _tracerProvider.ForceFlush(); + } + } +} + diff --git a/src/PracticalOtel.LambdaFlusher/PracticalOtel.LambdaFlusher.csproj b/src/PracticalOtel.LambdaFlusher/PracticalOtel.LambdaFlusher.csproj new file mode 100644 index 0000000..34cd084 --- /dev/null +++ b/src/PracticalOtel.LambdaFlusher/PracticalOtel.LambdaFlusher.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + +