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.

Setting up better logging in Azure Functions

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
}

elmah.io: Error logging and Uptime Monitoring for your web apps

This blog post is brought to you by elmah.io. elmah.io is error logging, uptime monitoring, deployment tracking, and service heartbeats for your .NET and JavaScript applications. Stop relying on your users to notify you when something is wrong or dig through hundreds of megabytes of log files spread across servers. With elmah.io, we store all of your log messages, notify you through popular channels like email, Slack, and Microsoft Teams, and help you fix errors fast.

See how we can help you monitor your website for crashes Monitor your website