One challenge we hit recently was how to build our dotnetcore lambda functions in a consistent way – in particular how would we approach logging.
A pattern we’ve adopted is to write the core functionality for our functions so that it’s as easy to run from a console app as it is from a lambda. The lambda can then be considered only as the entry point to the functionality.
Serverless Dependency injection
I am sure there are different schools of thought here, should you use a container within a serverless function or not? For this post the design assumes you do make use of the Microsoft DependencyInjection libraries.
Setting up your projects
Based on the design mentioned above, ie you can run from functionality as easily from a Console App as you can a lambda, I often setup the following projects:
- Project.ActualFunctionality (e.g. SnsDemo.Publisher)
- Project.ActualFunctionality.ConsoleApp (e.g. SnsDemo.Publisher.ConsoleApp)
- Project.ActualFunctionality.Lambda (e.g. SnsDemo.Publisher.Lambda)
The actual functionality lives in the top project and is shared with both other projects. Dependency injection, and AWS profiles are used to run the functionality locally.
The actual functionality
Let’s assume the functionality for your function does something simple like pushing messages into an SQS queue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
public class SqsSender { private readonly IAmazonSQS _amazonSQS; private readonly ILogger<SqsSender> _logger; public SqsSender(IAmazonSQS amazonSQS, ILogger<SqsSender> logger) { _amazonSQS = amazonSQS; _logger = logger; } public void SendMessage() { var message = new SendMessageRequest { QueueUrl = "...", }; message.MessageBody = $"Message {Guid.NewGuid()}"; _amazonSQS.SendMessageAsync(message).Wait(); _logger.LogInformation("_logger Messages sent"); Console.WriteLine("Console Message(s) sent"); } } |
The console app version
It’s pretty simple to get DI working in a dotnetcore console app
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
static void Main(string[] args) { IConfiguration config = new ConfigurationBuilder() .AddJsonFile("appsettings.json", true, true) .Build(); var serviceProvider = new ServiceCollection() .AddSingleton(config) .AddSingleton<SqsSender>() .AddLogging(a => { a.AddConsole(); }) .AddAWSService<IAmazonSQS>() .BuildServiceProvider(); serviceProvider.GetService<SqsSender>().SendMessage(); } |
The lambda version
This looks very similar to the console version
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public string FunctionHandler(object input, ILambdaContext context) { IConfiguration config = new ConfigurationBuilder() .AddJsonFile("lambdasettings.json", true, true) .Build(); var serviceProvider = new ServiceCollection() .AddSingleton(config) .AddSingleton<SqsSender>() .AddLogging(a => a.AddProvider(new CustomLambdaLogProvider(context.Logger))) .AddAWSService<IAmazonSQS>() .BuildServiceProvider(); serviceProvider.GetService<SqsSender>().SendMessage(); return "..."; } |
The really interesting bit to take note of is: .AddLogging(a => a.AddProvider(new CustomLambdaLogProvider(context.Logger)))
In the actual functionality we can log in many ways:
1 2 3 |
_logger.LogInformation("_logger Messages sent"); Console.WriteLine("Console Message(s) sent"); |
To make things lambda agnostic I’d argue injecting ILogger<Type> and then _logger.LogInformation(“_logger Messages sent”); is the preferred option.
Customizing the logger
It’s simple to customize the dotnetcore logging framework – for this demo I setup 2 things. The CustomLambdaLogProvider and the CustomLambdaLogger.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
internal class CustomLambdaLogProvider : ILoggerProvider { private readonly ILambdaLogger _logger; private readonly ConcurrentDictionary<string, CustomLambdaLogger> _loggers = new ConcurrentDictionary<string, CustomLambdaLogger>(); public CustomLambdaLogProvider(ILambdaLogger logger) { _logger = logger; } public ILogger CreateLogger(string categoryName) { return _loggers.GetOrAdd(categoryName, a => new CustomLambdaLogger(a, _logger)); } public void Dispose() { _loggers.Clear(); } } |
And finally a basic version of the actual logger:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
internal class CustomLambdaLogger : ILogger { private string _categoryName; private ILambdaLogger _lambdaLogger; public CustomLambdaLogger(string categoryName, ILambdaLogger lambdaLogger) { _categoryName = categoryName; _lambdaLogger = lambdaLogger; } public IDisposable BeginScope<TState>(TState state) { return null; } public bool IsEnabled(LogLevel logLevel) { //todo - add logic around filtering log messages if desired return true; } public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter) { if (!IsEnabled(logLevel)) { return; } _lambdaLogger.LogLine($"{logLevel.ToString()} - {_categoryName} - {formatter(state, exception)}"); } } |
Summary
The aim here is to keep your application code agnostic to where it runs. Using dependency injection we can share core logic between any ‘runner’ e.g. Lambda functions, Azure functions, Console App’s – you name it.
With some small tweaks to the lambda logging calls you can ensure the OTB lambda logger is still used under the hood, but your implementation code can make use of injecting things like ILogger<T> wherever needed 🙂