Setting up better logging in Azure Functions
We have been using Azure Functions for years. Being able to easily deploy and run code on both Azure App Services and real serverless has been a killer feature for all of our asynchronous jobs and services. Unfortunately, the logging approach provided as part of the default template is not ideal. In this post, I'll introduce you to the first steps we take in all of our existing and new function apps to improve logging.
A quick note about the Azure Functions runtime. There are currently two ways of developing functions. In-process and Out-of-process (also known as Isolated Functions). This guide is for In-process functions, which is what you'll get if you select the normal template. I may write a guide for Isolated Functions at some time.
When creating a new Function app through Visual Studio or the command line, you'll get a function generated looking like this:
public class Function1
{
[FunctionName("Function1")]
public void Run([TimerTrigger("0 */5 * * * *")]TimerInfo myTimer, ILogger log)
{
log.LogInformation($"C# Timer trigger function executed at: {DateTime.Now}");
}
}
Notice the second parameter for the Run
method which is an ILogger
that you probably already know from other project types. The logger is from Microsoft.Extensions.Logging
and provides methods for logging everything from Trace
to Fatal
messages. Logging can be specified in the host.json
file:
{
"version": "2.0",
"logging": {
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"excludedTypes": "Request"
},
"enableLiveMetricsFilters": true
}
}
}
Unfortunately, Application Insights (AI) is the only supported logger when configuring logging this way. So unless you are using AI, you will need an alternative way to set up logging. Luckily, Microsoft provides a package named Microsoft.Azure.Functions.Extensions
that allows for a better setup experience looking more along the lines of an ASP.NET Core application. You can delete the applicationInsights
object from host.json
and install the NuGet package:
dotnet add package Microsoft.Azure.Functions.Extensions
Then add a new file to the project named Startup.cs
with the following code:
using Microsoft.Azure.Functions.Extensions.DependencyInjection;
[assembly: FunctionsStartup(typeof(FunctionApp1.Startup))]
namespace FunctionApp1
{
public class Startup : FunctionsStartup
{
public override void Configure(IFunctionsHostBuilder builder)
{
}
}
}
Let's go through the code. The line starting with [assembly
will add an attribute to the assembly, telling the Functions runtime to invoke the Startup
class when the Function app launches. You do this by providing the FunctionStartup
attribute with the type of the class. The Startup
class needs to extend FunctionsStartup
class and implement the abstract Configure
method. The IFunctionsHostBuilder
interface provides you with a set of methods that should look familiar if you have worked with dependency injection in ASP.NET Core and other modern frameworks.
Logging providers can be added to the builder
. Start by installing the Microsoft.Extensions.Logging.Console
package:
dotnet add package Microsoft.Extensions.Logging.Console
And add the following logging configuration code:
public override void Configure(IFunctionsHostBuilder builder)
{
builder.Services.AddLogging(logging =>
{
logging.AddConsole();
});
}
When launching the function app, you will see that an information message is logged to the console when the timed function executes. To be fair, the logging configuration above doesn't really bring anything new to the table, since the function host already added console logging to the ILogger
provided in the Run
method. But unlike the limited possibilities in the host.json
file, we now have access to the full range of possibilities on the ILoggingBuilder
interface like setting up logging to a file, database, elmah.io, or other cloud-logging platforms.
When working with logging we usually want to set a category on the ILogger
. This can be done using a string or using generics. The function host doesn't support changing the type of the log
parameter from ILogger
to ILogger<Function1>
. Let's do a bit of refactoring to make this possible:
public class Function1
{
private readonly ILogger<Function1> logger;
public Function1(ILogger<Function1> logger)
{
this.logger = logger;
}
[FunctionName("Function1")]
public void Run([TimerTrigger("0 */5 * * * *")]TimerInfo myTimer)
{
logger.LogInformation($"C# Timer trigger function executed at: {DateTime.Now}");
}
}
Getting hold of the logger has been changed from method-injection to constructor-injection which will let us change the type. When using the generic version of the ILogger
, Microsoft.Extensions.Logging will use the category name FunctionApp1.Funtion1
for the instance of the ILogger
. With this category name in place, we can implement log filtering in the Startup.cs
file:
builder.Services.AddLogging(logging =>
{
logging.AddConsole();
logging.AddFilter("FunctionApp1.Function1", LogLevel.Error);
});
In this example, only log messages with a log level of Error
or higher will be logged. Log levels can also be specified in the host.json
file if you prefer that (most people do):
{
"version": "2.0",
"logging": {
"logLevel": {
"default": "Information",
"FunctionApp1.Function1": "Error"
}
}
}
The configuration will use Information
for the default minimum level and Error
for log messages with the FunctionApp1.Function1
. This means that all of the info log messages written by the Functions host will be logged, but only Error
and Fatal
messages written inside our function will be logged.
As in ASP.NET Core, you can also inject the ILoggerFactory
for even more flexibility:
public class Function1
{
private readonly ILogger<Function1> logger;
private readonly ILogger debug;
public Function1(ILoggerFactory loggerFactory)
{
this.logger = loggerFactory.CreateLogger<Function1>();
this.debug = loggerFactory.CreateLogger("Debug");
}
// ...
}
As shown in the code examples above, logging from Azure Functions can be extended to support exactly the same possibilities as provided by ASP.NET Core.
Configuration
Most logging initialization needs some form of external configuration. In this section, I have tried to include most of the challenges I can think of in relation to configuration in Function apps.
Getting configuration values
You sometimes want to move part of the configuration from hardcoded in C# to a configuration file. Unlike most other modern .NET apps that use an appsettings.json
file, Azure Functions uses a local.settings.json
file. You can still pull configuration values from the file, though. The configuration is specified in the Values
object in the local.settings.json
file:
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "dotnet",
"Hello": "World" // ⬅️ This is a custom setting
}
}
To pull the value of the Hello
property, include the following code in the Startup.cs
file:
public override void Configure(IFunctionsHostBuilder builder)
{
var config = builder.GetContext().Configuration;
var hello = config["Hello"];
// ...
}
Using an appsettings.json file
As mentioned in the previous section, Azure Functions uses a local.settings.json
file over an appsettings.json
file. If you prefer to have your custom configuration in an appsettings.json
file you can still do that with a bit of magic in the Startup.cs
file:
public override void ConfigureAppConfiguration(IFunctionsConfigurationBuilder builder)
{
base.ConfigureAppConfiguration(builder);
var ctx = builder.GetContext();
builder.ConfigurationBuilder
.AddJsonFile(Path.Combine(ctx.ApplicationRootPath, "appsettings.json"));
}
Next, add a new JSON file named appsettings.json
with the following content:
{
"Foo": "Bar"
}
It's important to right-click the file and select Properties. In the Copy to Output Directory property select Copy if newer. If you forget this part, the file is not copied to the output directory when building the function app.
Properties from the appsettings.json
file can be fetched just as we did with properties in the local.settings.json
file:
public override void Configure(IFunctionsHostBuilder builder)
{
var configuration = builder.GetContext().Configuration;
var hello = configuration["Hello"]; // ⬅️ This is from local.settings.json
var foo = configuration["Foo"]; // ⬅️ This is from appsettings.json
// ...
}
Using environment variables
Environment variables can be included just as we included the appsettings.json
file. Environment variables are often needed unless you commit and push the local.settings.json
file (it is git ignored as a default).
To include configuration from environment variables, add the following code to the ConfigureAppConfiguration
method from the previous section:
public override void ConfigureAppConfiguration(IFunctionsConfigurationBuilder builder)
{
base.ConfigureAppConfiguration(builder);
var ctx = builder.GetContext();
builder.ConfigurationBuilder
.AddJsonFile(Path.Combine(ctx.ApplicationRootPath, "appsettings.json"))
.AddEnvironmentVariables(); // ⬅️ This is where the magic happens
}