<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Marcel Michau - Code & Other Stuff]]></title><description><![CDATA[Marcel Michau - Code & Other Stuff]]></description><link>https://blog.marcelmichau.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 12 May 2026 00:05:18 GMT</lastBuildDate><atom:link href="https://blog.marcelmichau.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Filtering Telemetry in .NET]]></title><description><![CDATA[Background
Telemetry is crucial to observability, but too much telemetry can drown out important signals & can lead to noisy logs & spending more $$$ to store them in Azure Log Analytics, Splunk, DynaTrace, DataDog, etc.
This post details some guidel...]]></description><link>https://blog.marcelmichau.dev/filtering-telemetry-in-net</link><guid isPermaLink="true">https://blog.marcelmichau.dev/filtering-telemetry-in-net</guid><category><![CDATA[.NET]]></category><category><![CDATA[OpenTelemetry]]></category><category><![CDATA[Azure]]></category><category><![CDATA[logging]]></category><dc:creator><![CDATA[Marcel Michau]]></dc:creator><pubDate>Fri, 28 Feb 2025 10:02:05 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/stock/unsplash/Ko7PFAommGE/upload/0f3a61f7338e6631e34ea2ad50e1f1af.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-background">Background</h2>
<p>Telemetry is crucial to observability, but too much telemetry can drown out important signals &amp; can lead to noisy logs &amp; spending more $$$ to store them in Azure Log Analytics, Splunk, DynaTrace, DataDog, etc.</p>
<p>This post details some guidelines to reduce the amount of telemetry generated by a .NET app using OpenTelemetry. The guidance here is based on an ASP.NET Core Web API project using .NET Aspire deployed to Azure Container Apps, but most of the ideas are generally applicable.</p>
<h2 id="heading-1-disable-console-logging">1) Disable Console Logging</h2>
<p>By default, when using <a target="_blank" href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.hosting.host.createapplicationbuilder">Host.CreateApplicationBuilder</a> or <a target="_blank" href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.builder.webapplication.createbuilder">WebApplication.CreateBuilder</a> which most of the built-in .NET templates use, the <a target="_blank" href="https://learn.microsoft.com/en-us/dotnet/core/extensions/logging-providers#console">Console Logging Provider</a> is registered to output logs to <code>stdout</code>. When using Azure Container Apps &amp; you're a good observability citizen, you likely have configured Container Apps to <a target="_blank" href="https://learn.microsoft.com/en-us/azure/container-apps/log-monitoring?tabs=bash#console-logs">send console logs to Log Analytics</a>. When the app is also instrumented using OpenTelemetry &amp; configured to export logs to Application Insights/Azure Monitor - as is configured in the .NET Aspire <code>ServiceDefaults</code> project - these logs are duplicated as they are exported from both the Container App console logs as well as directly from your app to Application Insights &amp; ultimately into Log Analytics.</p>
<p>To address this duplication, add the following before adding the OpenTelemetry logger (<code>builder.Logging.AddOpenTelemetry()</code>):</p>
<pre><code class="lang-csharp">builder.Logging.ClearProviders();
</code></pre>
<p>This will remove the Console logging provider &amp; only export logs using OpenTelemetry. Be aware that this will prevent logs from being output to the console &amp; showing up in the Container App’s Log Stream, so errors which occur on startup before the OpenTelemetry logger is configured will be lost &amp; can make diagnosing container startup crashes more difficult.</p>
<p>In terms of other methods for collecting telemetry from Azure Container Apps specifically, it’s worthwhile to call out that there is currently a <a target="_blank" href="https://learn.microsoft.com/en-us/azure/container-apps/opentelemetry-agents">preview feature for using OpenTelemetry agents</a> in Container Apps.</p>
<h2 id="heading-2-filtering-traces">2) Filtering Traces</h2>
<p>A common requirement when it comes to filtering telemetry is excluding telemetry which is generated as the result of calling a health check endpoint. Usually, these health check signals add noise/cost &amp; are not all that useful/valuable.</p>
<p>OpenTelemetry <a target="_blank" href="https://github.com/open-telemetry/opentelemetry-dotnet/blob/main/docs/trace/customizing-the-sdk/README.md#processors--exporters">supports filtering Traces</a> by using a <code>Processor</code> on the <code>Activity</code> type. We can combine a Processor with <code>IHttpContextAccessor</code> to filter out traces which originate from known health check request paths by setting the Activity's <code>ActivityTraceFlags</code> to prevent the trace from being exported:</p>
<pre><code class="lang-csharp"><span class="hljs-function"><span class="hljs-keyword">internal</span> <span class="hljs-keyword">sealed</span> class <span class="hljs-title">HealthCheckTraceFilterProcessor</span>(<span class="hljs-params">IHttpContextAccessor httpContextAccessor</span>)
    : BaseProcessor&lt;Activity&gt;</span>
{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> IHttpContextAccessor _httpContextAccessor = httpContextAccessor;

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">override</span> <span class="hljs-keyword">void</span> <span class="hljs-title">OnEnd</span>(<span class="hljs-params">Activity activity</span>)</span>
    {
        <span class="hljs-keyword">if</span> (!OKtoSend(activity))
        {
            activity.ActivityTraceFlags &amp;= ~ActivityTraceFlags.Recorded;
        }
    }

    <span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-keyword">bool</span> <span class="hljs-title">OKtoSend</span>(<span class="hljs-params">Activity activity</span>)</span>
    {
        <span class="hljs-keyword">var</span> excludedHealthCheckPaths = <span class="hljs-keyword">new</span>[]
        {
            <span class="hljs-string">"/health/live"</span>,
            <span class="hljs-string">"/health/ready"</span>
        };

        <span class="hljs-keyword">return</span> !excludedHealthCheckPaths.Any(path =&gt;
            _httpContextAccessor.HttpContext?.Request.Path.Value?.Equals(path,
                StringComparison.OrdinalIgnoreCase) ?? <span class="hljs-literal">false</span>);
    }
}
</code></pre>
<p>And add the processor to the tracing configuration:</p>
<pre><code class="lang-csharp">builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton&lt;HealthCheckTraceFilterProcessor&gt;();

builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics =&gt;
    {
        ...
    })
    .WithTracing(tracing =&gt;
    {
        <span class="hljs-keyword">using</span> <span class="hljs-keyword">var</span> scope = sp.CreateScope();
        tracing.AddProcessor(scope.ServiceProvider.GetRequiredService&lt;HealthCheckTraceFilterProcessor&gt;());

        ...
    });
</code></pre>
<p>When the app uses a SQL database, it's common to have instrumentation for SQL queries using <a target="_blank" href="https://github.com/open-telemetry/opentelemetry-dotnet-contrib/blob/main/src/OpenTelemetry.Instrumentation.SqlClient/README.md">OpenTelemetry.Instrumentation.SqlClient</a>. This instrumentation is included when using .NET Aspire integrations such as the <a target="_blank" href="https://learn.microsoft.com/en-us/dotnet/aspire/database/sql-server-integration">.NET Aspire SQL Server integration</a> or the <a target="_blank" href="https://learn.microsoft.com/en-us/dotnet/aspire/database/sql-server-entity-framework-integration">.NET Aspire SQL Server Entity Framework Core integration</a>.</p>
<p>In one of our applications, we use <a target="_blank" href="https://masstransit.io/documentation/patterns/transactional-outbox">MassTransit with the Transactional Outbox</a> enabled &amp; this requires periodically polling specific tables in the database for messages to send to the broker. These SQL queries generate traces which are not useful unless they fail. We can use a combination of <strong>Enrichment</strong> &amp; a <code>Processor</code> to identify these traces &amp; filter them accordingly.</p>
<p>First, we need to disable the tracing configuration implemented by the .NET Aspire integration as we will be customising it ourselves (the example below is for the .NET Aspire SQL Server Entity Framework Core integration):</p>
<pre><code class="lang-csharp">builder.EnrichSqlServerDbContext&lt;AppDbContext&gt;(configure =&gt;
{
    configure.DisableTracing = <span class="hljs-literal">true</span>;
});
</code></pre>
<p>Secondly, when configuring tracing, the <code>OpenTelemetry.Instrumentation.SqlClient</code> package needs to be installed to allow customising the traces it generates. Then we enrich the traces to set a custom tag for queries to known MassTransit outbox tables:</p>
<pre><code class="lang-csharp">tracing.AddSource(builder.Environment.ApplicationName)
    ...
    ...
    .AddSqlClientInstrumentation(opt =&gt;
    {
        opt.Enrich = (Activity activity, <span class="hljs-keyword">string</span> eventName, <span class="hljs-keyword">object</span> rawObject) =&gt;
        {
            <span class="hljs-keyword">if</span> (!eventName.Equals(<span class="hljs-string">"OnCustom"</span>)) <span class="hljs-keyword">return</span>;
            <span class="hljs-keyword">if</span> (rawObject <span class="hljs-keyword">is</span> not SqlCommand cmd) <span class="hljs-keyword">return</span>;
            <span class="hljs-keyword">if</span> (cmd.CommandText.Contains(<span class="hljs-string">"InboxState"</span>) || cmd.CommandText.Contains(<span class="hljs-string">"OutboxState"</span>))
            {
                activity.SetTag(<span class="hljs-string">"masstransit.outbox.query"</span>, <span class="hljs-literal">true</span>);
            }
        }
        ;
    });
</code></pre>
<p>Then we can write a <code>Processor</code> which filters based on our custom tag:</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">internal</span> <span class="hljs-keyword">sealed</span> <span class="hljs-keyword">class</span> <span class="hljs-title">MassTransitOutboxDbOperationTraceFilterProcessor</span> : <span class="hljs-title">BaseProcessor</span>&lt;<span class="hljs-title">Activity</span>&gt;
{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">override</span> <span class="hljs-keyword">void</span> <span class="hljs-title">OnEnd</span>(<span class="hljs-params">Activity activity</span>)</span>
    {
        <span class="hljs-keyword">if</span> (activity.TagObjects.Any(kv =&gt; kv.Key == <span class="hljs-string">"masstransit.outbox.query"</span>))
            activity.ActivityTraceFlags &amp;= ~ActivityTraceFlags.Recorded;
    }
}
</code></pre>
<p>And lastly, add it to our tracing configuration:</p>
<pre><code class="lang-csharp">.WithTracing(tracing =&gt;
{
    tracing.AddProcessor(<span class="hljs-keyword">new</span> MassTransitOutboxDbOperationTraceFilterProcessor());
   ...
})
</code></pre>
<h2 id="heading-3-filtering-logs">3) Filtering Logs</h2>
<p>First, some background: OpenTelemetry does support filtering logs, but the mechanism for doing so is not as straightforward as trace filtering due to the way that OpenTelemetry integrates with the built-in <code>ILogger</code>. There are many GitHub issues (<a target="_blank" href="https://github.com/open-telemetry/opentelemetry-dotnet/issues/5924">1</a>, <a target="_blank" href="https://github.com/open-telemetry/opentelemetry-dotnet/issues/5250">2</a>, <a target="_blank" href="https://github.com/Azure/azure-sdk-for-net/issues/32276">3</a>) I referenced when going down this rabbit hole. Long story short, log filtering involves wrapping an OpenTelemetry exporter with a filtering <code>Processor</code> to prevent exporting the logs. This is what it would look like (taken from one of the above issues):</p>
<pre><code class="lang-csharp">    configure.AddProcessor(<span class="hljs-keyword">new</span> FilteringBatchLogRecordProcessor(ShouldUpload, exporter));

    <span class="hljs-keyword">internal</span> <span class="hljs-keyword">sealed</span> <span class="hljs-keyword">class</span> <span class="hljs-title">FilteringBatchLogRecordProcessor</span> : <span class="hljs-title">BatchLogRecordExportProcessor</span>
    {
        <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> Func&lt;LogRecord, <span class="hljs-keyword">bool</span>&gt; filter;

        <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">FilteringBatchLogRecordProcessor</span>(<span class="hljs-params">Func&lt;LogRecord, <span class="hljs-keyword">bool</span>&gt; filter, BaseExporter&lt;LogRecord&gt; exporter</span>)
            : <span class="hljs-title">base</span>(<span class="hljs-params">exporter</span>)</span>
        {
            <span class="hljs-keyword">this</span>.filter = filter;
        }

        <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">override</span> <span class="hljs-keyword">void</span> <span class="hljs-title">OnEnd</span>(<span class="hljs-params">LogRecord logRecord</span>)</span>
        {
            <span class="hljs-comment">// Call the underlying processor</span>
            <span class="hljs-comment">// only if the Filter returns true.</span>
            <span class="hljs-keyword">if</span> (<span class="hljs-keyword">this</span>.filter(logRecord))
            {
                <span class="hljs-keyword">base</span>.OnEnd(logRecord);
            }
        }
    }
</code></pre>
<p>Testing this approach in a .NET Aspire project, I could filter logs when exporting to the local OTLP Endpoint configured by .NET Aspire:</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">var</span> customOtlpExporter = <span class="hljs-keyword">new</span> OtlpLogExporter(<span class="hljs-keyword">new</span> OtlpExporterOptions
{
    Endpoint = <span class="hljs-keyword">new</span> Uri(builder.Configuration[<span class="hljs-string">"OTEL_EXPORTER_OTLP_ENDPOINT"</span>])
});

...

builder.Logging
    .AddOpenTelemetry(logging =&gt;
{
    logging.AddProcessor(<span class="hljs-keyword">new</span> FilteringBatchLogRecordProcessor((log) =&gt; <span class="hljs-literal">false</span>, customOtlpExporter));

    ...
});
</code></pre>
<p>The key thing to note in the above snippet is that in order to wrap an exporter with the filtering processor, one has to create an instance of an exporter - in this case the <code>OtlpLogExporter</code> to pass it to the <code>FilteringBatchLogRecordProcessor</code>. However, when trying to configure this for the Azure Monitor exporter, I found that it simply wasn't possible because one can't initialize a new <code>AzureMonitorLogExporter</code> because its constructor is <code>internal</code>. This was highlighted in one of the above GitHub issues &amp; in fact, a <a target="_blank" href="https://github.com/Azure/azure-sdk-for-net/pull/35568">pull request was created to make the constructor public</a> but was closed without reason.</p>
<p>With this setback, I decided to explore alternative filtering mechanisms to the native OpenTelemetry processors.</p>
<p>The built-in .NET <code>ILogger</code> is limited in terms of log filtering in that it can only filter based on log category. This makes it difficult to filter logs based on some other criteria such as request path or the content of the log message. There is currently an <a target="_blank" href="https://github.com/dotnet/runtime/issues/82465">open issue on GitHub</a> to extend <code>ILogger</code> to make this easier in future.</p>
<p>To support more advanced filtering capabilities, we can use <a target="_blank" href="https://serilog.net/">Serilog</a> in order to achieve our health check endpoint filtering use case.</p>
<p>Serilog has an <code>ILogEventFilter</code> interface which can be used to implement a filter which operates on health check requests:</p>
<pre><code class="lang-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> class <span class="hljs-title">HealthCheckLogsFilter</span>(<span class="hljs-params">IHttpContextAccessor httpContextAccessor</span>) : ILogEventFilter</span>
{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">bool</span> <span class="hljs-title">IsEnabled</span>(<span class="hljs-params">LogEvent logEvent</span>)</span>
    {
        <span class="hljs-keyword">if</span> (httpContextAccessor.HttpContext <span class="hljs-keyword">is</span> <span class="hljs-literal">null</span>)
            <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;

        <span class="hljs-keyword">var</span> healthCheckPaths = <span class="hljs-keyword">new</span>[]
        {
            <span class="hljs-string">"/health/live"</span>,
            <span class="hljs-string">"/health/ready"</span>
        };

        <span class="hljs-keyword">var</span> matchesHealthCheckPath = healthCheckPaths.Any(path =&gt;
            httpContextAccessor.HttpContext?.Request.Path.Value?.Equals(path,
                StringComparison.OrdinalIgnoreCase) ?? <span class="hljs-literal">true</span>);

        <span class="hljs-keyword">return</span> !(matchesHealthCheckPath &amp;&amp; logEvent.Level == LogEventLevel.Information);
    }
}
</code></pre>
<p>Which can be wired up to the Serilog log configuration:</p>
<pre><code class="lang-csharp">builder.Services.AddSingleton&lt;HealthCheckLogsFilter&gt;();

builder.Services.AddSerilog((services, lc) =&gt;
{
    ...
    lc.Filter.With(services.GetRequiredService&lt;HealthCheckLogsFilter&gt;())
    ...
});
</code></pre>
<p>The same MassTransit outbox SQL queries mentioned above also generate logs for each query in addition to traces. These logs all have the category <code>Microsoft.EntityFrameworkCore.Database.Command</code>, but we don't necessarily want to filter out all logs from this category as this will also exclude logs from other processes which we care about. To specifically target only these MassTransit query logs, we can use an <code>Excluding</code> log filter with Serilog.</p>
<p>Firstly, we create the exclusion method which checks for logs which contain the SQL operation on known MassTransit outbox tables &amp; only if the Log Level is <code>Information</code> so that when one of these queries throw an error, we don't filter them out:</p>
<pre><code class="lang-csharp"><span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">bool</span> <span class="hljs-title">MassTransitOutboxDbCommandLogExclusion</span>(<span class="hljs-params">LogEvent logEvent</span>)</span>
{
    <span class="hljs-keyword">const</span> <span class="hljs-keyword">string</span> logCategory = <span class="hljs-string">""</span><span class="hljs-string">"
                               "</span>Microsoft.EntityFrameworkCore.Database.Command<span class="hljs-string">"
                               "</span><span class="hljs-string">""</span>;

    <span class="hljs-keyword">var</span> sourceContext = logEvent.Properties.FirstOrDefault(l =&gt; l.Key == <span class="hljs-string">"SourceContext"</span>).Value.ToString();

    <span class="hljs-keyword">if</span> (sourceContext != logCategory) <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;

    <span class="hljs-keyword">var</span> commandText = logEvent.Properties.FirstOrDefault(l =&gt; l.Key == <span class="hljs-string">"commandText"</span>).Value?.ToString();

    <span class="hljs-keyword">if</span> (commandText <span class="hljs-keyword">is</span> <span class="hljs-literal">null</span>) <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;

    <span class="hljs-keyword">string</span>[] massTransitOutboxTableNames = [<span class="hljs-string">"InboxState"</span>, <span class="hljs-string">"OutboxState"</span>];

    <span class="hljs-keyword">var</span> isMassTransitDbCommand = !<span class="hljs-keyword">string</span>.IsNullOrWhiteSpace(commandText) &amp;&amp; massTransitOutboxTableNames.Any(commandText.Contains);

    <span class="hljs-keyword">return</span> isMassTransitDbCommand &amp;&amp; logEvent <span class="hljs-keyword">is</span> { Level: LogEventLevel.Information };
}
</code></pre>
<p>Then we register this method with Serilog using <code>.Filter.ByExcluding()</code>:</p>
<pre><code class="lang-csharp">builder.Services.AddSerilog((services, lc) =&gt;
{
    ...
    .Filter.ByExcluding(MassTransitOutboxDbCommandLogExclusion);

});
</code></pre>
<h2 id="heading-4-filtering-metrics">4) Filtering Metrics</h2>
<p>For some interesting history around metric filtering, <a target="_blank" href="https://github.com/open-telemetry/opentelemetry-dotnet-contrib/issues/1765">see this GitHub issue</a>.</p>
<p>To satisfy the requirement of filtering metrics for health check endpoints, <a target="_blank" href="https://github.com/dotnet/aspnetcore/pull/56036">this PR</a> introduced the capability to opt-out of metric collection on a per-endpoint basis.</p>
<p>This can be added to our health check endpoints to disable metrics:</p>
<pre><code class="lang-csharp">healthChecks.MapHealthChecks(<span class="hljs-string">"/health/ready"</span>)
    .DisableHttpMetrics();

healthChecks.MapHealthChecks(<span class="hljs-string">"/health/live"</span>, <span class="hljs-keyword">new</span> HealthCheckOptions
{
    Predicate = <span class="hljs-keyword">static</span> r =&gt; r.Tags.Contains(<span class="hljs-string">"live"</span>)
})
.DisableHttpMetrics();
</code></pre>
<p>It's important to note that the <code>DisableHttpMetrics()</code> method is only available in .NET 9 &amp; above.</p>
<h2 id="heading-other-considerations">Other Considerations</h2>
<p>During the wire-up of all these various filters, there were some finer points of configuration needed to address the following:</p>
<ol>
<li><p>Sending Serilog logs to the local OTEL endpoint when running on a local machine so that logs appear in the .NET Aspire dashboard.</p>
</li>
<li><p>Using the <code>Azure.Monitor.OpenTelemetry.Exporter</code> package instead of the <code>Azure.Monitor.OpenTelemetry.AspNetCore</code> package as the latter overrides some tracing &amp; logging configuration which un-does a lot of our hard work.</p>
</li>
</ol>
<p>I've enclosed the full configuration below to show the full setup of all the telemetry configuration:</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">class</span> <span class="hljs-title">TelemetryConfigurationExtensions</span>
{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> IHostApplicationBuilder <span class="hljs-title">AddTelemetryConfiguration</span>(<span class="hljs-params"><span class="hljs-keyword">this</span> IHostApplicationBuilder builder</span>)</span>
    {
        <span class="hljs-comment">// Required so that traces for health checks are not recorded</span>
        builder.Services.AddHttpContextAccessor();

        builder.Services.AddSingleton&lt;HealthCheckLogsFilter&gt;();
        builder.Services.AddSingleton&lt;HealthCheckTraceFilterProcessor&gt;();

        builder.Services.AddSerilog((services, lc) =&gt;
        {
            lc.MinimumLevel.Override(<span class="hljs-string">"Microsoft"</span>, LogEventLevel.Information)
                .MinimumLevel.Override(<span class="hljs-string">"Microsoft.AspNetCore"</span>, LogEventLevel.Warning)
                .Filter.With(services.GetRequiredService&lt;HealthCheckLogsFilter&gt;())
                .Enrich.FromLogContext()
                .Filter.ByExcluding(MassTransitOutboxDbCommandLogExclusion);

        }, writeToProviders: <span class="hljs-literal">true</span>);

        builder.Services.AddOpenTelemetry()
            .ConfigureResource(resourceBuilder =&gt;
            {
                resourceBuilder
                    .AddAzureContainerAppsDetector(); <span class="hljs-comment">// Used to set the Cloud Role Name and the Cloud Role Instance properties</span>
            })
            .WithMetrics(metrics =&gt;
            {
                metrics.AddAspNetCoreInstrumentation()
                    .AddHttpClientInstrumentation()
                    .AddRuntimeInstrumentation();
            })
            .WithTracing(tracing =&gt;
            {
                tracing.AddProcessor(<span class="hljs-keyword">new</span> MassTransitOutboxDbOperationTraceFilterProcessor());

                tracing.AddSource(builder.Environment.ApplicationName)
                    .AddAspNetCoreInstrumentation()
                    .AddHttpClientInstrumentation()
                    .AddSqlClientInstrumentation(opt =&gt;
                    {
                        opt.Enrich = SqlClientMassTransitOutboxQueryEnrichment;
                    });
            });

        <span class="hljs-keyword">var</span> applicationInsightsConnectionString = builder.Configuration[<span class="hljs-string">"APPLICATIONINSIGHTS_CONNECTION_STRING"</span>];

        builder.Services.ConfigureOpenTelemetryTracerProvider((sp, tracerProviderBuilder) =&gt;
        {
            <span class="hljs-keyword">using</span> <span class="hljs-keyword">var</span> scope = sp.CreateScope();

            tracerProviderBuilder.AddProcessor(scope.ServiceProvider.GetRequiredService&lt;HealthCheckTraceFilterProcessor&gt;());

            <span class="hljs-keyword">if</span> (builder.Environment.IsProduction())
            {
                tracerProviderBuilder.AddAzureMonitorTraceExporter(options =&gt;
                {
                    options.ConnectionString = applicationInsightsConnectionString;
                });
            }
        });

        builder.Services.ConfigureOpenTelemetryMeterProvider((_, meterProviderBuilder) =&gt;
        {
            <span class="hljs-keyword">if</span> (builder.Environment.IsProduction())
            {
                meterProviderBuilder.AddAzureMonitorMetricExporter(options =&gt;
                {
                    options.ConnectionString = applicationInsightsConnectionString;
                });
            }
        });

        <span class="hljs-comment">// Clear all existing logging providers so that logs are only sent to App Insights/Log Analytics</span>
        <span class="hljs-comment">// This prevents duplicate logs from logging to the console &amp; also being forwarded to Log Analytics</span>
        builder.Logging.ClearProviders();

        builder.Logging.AddOpenTelemetry(logging =&gt;
        {
            logging.IncludeFormattedMessage = <span class="hljs-literal">true</span>;
            logging.IncludeScopes = <span class="hljs-literal">true</span>;

            <span class="hljs-keyword">if</span> (builder.Environment.IsProduction())
            {
                logging.AddAzureMonitorLogExporter(x =&gt;
                {
                    x.ConnectionString = applicationInsightsConnectionString;
                });
            }
        });

        <span class="hljs-keyword">var</span> useOtlpExporter = !<span class="hljs-keyword">string</span>.IsNullOrWhiteSpace(builder.Configuration[<span class="hljs-string">"OTEL_EXPORTER_OTLP_ENDPOINT"</span>]);

        <span class="hljs-keyword">if</span> (useOtlpExporter)
        {
            builder.Services.AddOpenTelemetry()
                .UseOtlpExporter();
        }

        <span class="hljs-keyword">return</span> builder;
    }

    <span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">void</span> <span class="hljs-title">SqlClientMassTransitOutboxQueryEnrichment</span>(<span class="hljs-params">Activity activity, <span class="hljs-keyword">string</span> eventName, <span class="hljs-keyword">object</span> rawObject</span>)</span>
    {
        <span class="hljs-keyword">if</span> (!eventName.Equals(<span class="hljs-string">"OnCustom"</span>)) <span class="hljs-keyword">return</span>;
        <span class="hljs-keyword">if</span> (rawObject <span class="hljs-keyword">is</span> not SqlCommand cmd) <span class="hljs-keyword">return</span>;
        <span class="hljs-keyword">if</span> (cmd.CommandText.Contains(<span class="hljs-string">"InboxState"</span>) || cmd.CommandText.Contains(<span class="hljs-string">"OutboxState"</span>))
        {
            activity.SetTag(<span class="hljs-string">"masstransit.outbox.query"</span>, <span class="hljs-literal">true</span>);
        }
    }

    <span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">bool</span> <span class="hljs-title">MassTransitOutboxDbCommandLogExclusion</span>(<span class="hljs-params">LogEvent logEvent</span>)</span>
    {
        <span class="hljs-keyword">const</span> <span class="hljs-keyword">string</span> logCategory = <span class="hljs-string">""</span><span class="hljs-string">"
                                   "</span>Microsoft.EntityFrameworkCore.Database.Command<span class="hljs-string">"
                                   "</span><span class="hljs-string">""</span>;

        <span class="hljs-keyword">var</span> sourceContext = logEvent.Properties.FirstOrDefault(l =&gt; l.Key == <span class="hljs-string">"SourceContext"</span>).Value.ToString();

        <span class="hljs-keyword">if</span> (sourceContext != logCategory) <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;

        <span class="hljs-keyword">var</span> commandText = logEvent.Properties.FirstOrDefault(l =&gt; l.Key == <span class="hljs-string">"commandText"</span>).Value?.ToString();

        <span class="hljs-keyword">if</span> (commandText <span class="hljs-keyword">is</span> <span class="hljs-literal">null</span>) <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;

        <span class="hljs-keyword">string</span>[] massTransitOutboxTableNames = [<span class="hljs-string">"InboxState"</span>, <span class="hljs-string">"OutboxState"</span>];

        <span class="hljs-keyword">var</span> isMassTransitDbCommand = !<span class="hljs-keyword">string</span>.IsNullOrWhiteSpace(commandText) &amp;&amp; massTransitOutboxTableNames.Any(commandText.Contains);

        <span class="hljs-keyword">return</span> isMassTransitDbCommand &amp;&amp; logEvent <span class="hljs-keyword">is</span> { Level: LogEventLevel.Information };
    }
}

<span class="hljs-keyword">internal</span> <span class="hljs-keyword">sealed</span> <span class="hljs-keyword">class</span> <span class="hljs-title">MassTransitOutboxDbOperationTraceFilterProcessor</span> : <span class="hljs-title">BaseProcessor</span>&lt;<span class="hljs-title">Activity</span>&gt;
{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">override</span> <span class="hljs-keyword">void</span> <span class="hljs-title">OnEnd</span>(<span class="hljs-params">Activity activity</span>)</span>
    {
        <span class="hljs-keyword">if</span> (activity.TagObjects.Any(kv =&gt; kv.Key == <span class="hljs-string">"masstransit.outbox.query"</span>))
            activity.ActivityTraceFlags &amp;= ~ActivityTraceFlags.Recorded;
    }
}

<span class="hljs-function"><span class="hljs-keyword">internal</span> <span class="hljs-keyword">sealed</span> class <span class="hljs-title">HealthCheckTraceFilterProcessor</span>(<span class="hljs-params">IHttpContextAccessor httpContextAccessor</span>) : BaseProcessor&lt;Activity&gt;</span>
{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">override</span> <span class="hljs-keyword">void</span> <span class="hljs-title">OnEnd</span>(<span class="hljs-params">Activity activity</span>)</span>
    {
        <span class="hljs-keyword">var</span> excludedHealthCheckPaths = <span class="hljs-keyword">new</span>[]
        {
            <span class="hljs-string">"/health/live"</span>,
            <span class="hljs-string">"/health/ready"</span>
        };

        <span class="hljs-keyword">if</span> (excludedHealthCheckPaths.Any(path =&gt;
                httpContextAccessor.HttpContext?.Request.Path.Value?.Equals(path,
                    StringComparison.OrdinalIgnoreCase) ?? <span class="hljs-literal">false</span>))
        {
            activity.ActivityTraceFlags &amp;= ~ActivityTraceFlags.Recorded;
        }
    }
}

<span class="hljs-function"><span class="hljs-keyword">public</span> class <span class="hljs-title">HealthCheckLogsFilter</span>(<span class="hljs-params">IHttpContextAccessor httpContextAccessor</span>) : ILogEventFilter</span>
{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">bool</span> <span class="hljs-title">IsEnabled</span>(<span class="hljs-params">LogEvent logEvent</span>)</span>
    {
        <span class="hljs-keyword">if</span> (httpContextAccessor.HttpContext <span class="hljs-keyword">is</span> <span class="hljs-literal">null</span>)
            <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;

        <span class="hljs-keyword">var</span> healthCheckPaths = <span class="hljs-keyword">new</span>[]
        {
            <span class="hljs-string">"/health/live"</span>,
            <span class="hljs-string">"/health/ready"</span>
        };

        <span class="hljs-keyword">var</span> matchesHealthCheckPath = healthCheckPaths.Any(path =&gt;
            httpContextAccessor.HttpContext?.Request.Path.Value?.Equals(path,
                StringComparison.OrdinalIgnoreCase) ?? <span class="hljs-literal">true</span>);

        <span class="hljs-keyword">return</span> !(matchesHealthCheckPath &amp;&amp; logEvent.Level == LogEventLevel.Information);
    }
}
</code></pre>
<h2 id="heading-conclusion">Conclusion</h2>
<p>These techniques may not necessarily be the most optimal way to achieve the desired outcome, or quite the <em>OpenTelemetry-native</em> way, so if there are better approaches, please let me know!</p>
<p>OpenTelemetry is indeed enormously powerful &amp; quite customisable in how you can adapt it to your use case. Though it’s worth noting that there are some gotchas with how it integrates into the broader .NET ecosystem which may be unintuitive. This post documents some learnings in my telemetry journey &amp; hopefully these learnings can help cut down on unnecessary telemetry &amp; hopefully save some $$$ in the process.</p>
]]></content:encoded></item><item><title><![CDATA[Azure SQL - Create SQL User From Managed Identity]]></title><description><![CDATA[When using Managed Identities in Azure, a common requirement is creating a SQL user for your app's Managed Identity in your Azure SQL Database from some CI/CD pipeline. Sometimes apps need to talk to a database - who knew?
According to the documentat...]]></description><link>https://blog.marcelmichau.dev/azure-sql-create-sql-user-from-managed-identity</link><guid isPermaLink="true">https://blog.marcelmichau.dev/azure-sql-create-sql-user-from-managed-identity</guid><category><![CDATA[Azure]]></category><category><![CDATA[Azure SQL Database]]></category><category><![CDATA[Azure Managed Identities]]></category><category><![CDATA[Azure Pipelines]]></category><dc:creator><![CDATA[Marcel Michau]]></dc:creator><pubDate>Fri, 31 May 2024 07:14:36 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/stock/unsplash/oyXis2kALVg/upload/3dee44ee54543f169d68122b695fca53.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>When using Managed Identities in Azure, a common requirement is creating a SQL user for your app's Managed Identity in your Azure SQL Database from some CI/CD pipeline. Sometimes apps need to talk to a database - who knew?</p>
<p><a target="_blank" href="https://learn.microsoft.com/en-us/azure/azure-sql/database/authentication-azure-ad-user-assigned-managed-identity?view=azuresql#permissions">According to the documentation</a>, this requires that you grant the SQL Server identity additional permissions, which in turn can only be granted by either a <a target="_blank" href="https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#global-administrator">Global Administrator</a> or <a target="_blank" href="https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#global-administrator">Privileged Role Administrator</a>.</p>
<p>Once the SQL Server identity has these permissions, one can execute the following query to add the application's identity to the database:</p>
<pre><code class="lang-sql"><span class="hljs-comment">-- Create the user with details retrieved from Entra ID</span>
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">USER</span> [ManagedIdentityName] <span class="hljs-keyword">FROM</span> <span class="hljs-keyword">EXTERNAL</span> PROVIDER

<span class="hljs-comment">-- Assign roles to the new user</span>
<span class="hljs-keyword">ALTER</span> <span class="hljs-keyword">ROLE</span> db_datareader <span class="hljs-keyword">ADD</span> <span class="hljs-keyword">MEMBER</span> [ManagedIdentityName]
<span class="hljs-keyword">ALTER</span> <span class="hljs-keyword">ROLE</span> db_datawriter <span class="hljs-keyword">ADD</span> <span class="hljs-keyword">MEMBER</span> [ManagedIdentityName]
</code></pre>
<p>Common strategies for enabling this at scale are:</p>
<ol>
<li><p>Create a single User Assigned Managed Identity with the required permissions &amp; associate it to all SQL Servers</p>
</li>
<li><p>Create a dedicated Entra ID group for SQL identities &amp; grant it the <a target="_blank" href="https://learn.microsoft.com/en-us/azure/azure-sql/database/authentication-aad-directory-readers-role-tutorial?view=azuresql">Directory Readers</a> role</p>
</li>
</ol>
<p>There exists, however, an alternative approach which doesn't require any of the above pre-configuration.</p>
<p>Given the Client ID of your app's Managed Identity, you can use the following PowerShell to convert the client ID into the SQL Server's User SID format:</p>
<pre><code class="lang-powershell"><span class="hljs-variable">$managedIdentitySid</span> = <span class="hljs-string">"0x"</span> + [<span class="hljs-type">System.BitConverter</span>]::ToString(([<span class="hljs-type">guid</span>]<span class="hljs-variable">$managedIdentityClientId</span>).ToByteArray()).Replace(<span class="hljs-string">"-"</span>, <span class="hljs-string">""</span>)
</code></pre>
<p>And then add the user to the SQL Database as follows:</p>
<pre><code class="lang-sql"><span class="hljs-comment">-- Create the user using its SID - does not need to talk to Entra ID</span>
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">USER</span> [ManagedIdentityName] <span class="hljs-keyword">WITH</span> <span class="hljs-keyword">SID</span> = &lt;managedIdentitySid&gt;, <span class="hljs-keyword">TYPE</span> = E;

<span class="hljs-comment">-- Assign roles to the new user</span>
<span class="hljs-keyword">ALTER</span> <span class="hljs-keyword">ROLE</span> db_datareader <span class="hljs-keyword">ADD</span> <span class="hljs-keyword">MEMBER</span> [ManagedIdentityName]
<span class="hljs-keyword">ALTER</span> <span class="hljs-keyword">ROLE</span> db_datawriter <span class="hljs-keyword">ADD</span> <span class="hljs-keyword">MEMBER</span> [ManagedIdentityName]
</code></pre>
<p>Here is an example of an Azure Pipeline using the above approach:</p>
<pre><code class="lang-yaml"><span class="hljs-bullet">-</span> <span class="hljs-attr">task:</span> <span class="hljs-string">AzureCLI@2</span>
  <span class="hljs-attr">displayName:</span> <span class="hljs-string">'Get Managed Identity Client ID'</span>
  <span class="hljs-attr">inputs:</span>
    <span class="hljs-attr">azureSubscription:</span> <span class="hljs-string">${{</span> <span class="hljs-string">parameters.serviceConnection</span> <span class="hljs-string">}}</span>
    <span class="hljs-attr">scriptType:</span> <span class="hljs-string">'pscore'</span>
    <span class="hljs-attr">scriptLocation:</span> <span class="hljs-string">'inlineScript'</span>
    <span class="hljs-attr">inlineScript:</span> <span class="hljs-string">|
      $clientId = az identity show --name $(managedIdentityName) --resource-group $(resourceGroupName) --query clientId -o tsv
      echo "##vso[task.setvariable variable=managedIdentityClientId]$clientId"
</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">powershell:</span> <span class="hljs-string">|
    $clientId = '$(managedIdentityClientId)'
    $sid = "0x" + [System.BitConverter]::ToString(([guid]$clientId).ToByteArray()).Replace("-", "")
</span>
    <span class="hljs-string">Write-Host</span> <span class="hljs-string">"##vso[task.setvariable variable=managedIdentitySid]$sid"</span>
  <span class="hljs-attr">displayName:</span> <span class="hljs-string">Convert</span> <span class="hljs-string">Managed</span> <span class="hljs-string">Identity</span> <span class="hljs-string">Client</span> <span class="hljs-string">ID</span> <span class="hljs-string">to</span> <span class="hljs-string">SID</span>

<span class="hljs-comment"># The Azure DevOps pipeline Service Connection (Azure AD Service Principal) needs to be a SQL Server Administrator (AAD Group) or Database Owner</span>
<span class="hljs-comment"># The SqlAzureDacpacDeployment@1 task can only run on windows agents</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">task:</span> <span class="hljs-string">SqlAzureDacpacDeployment@1</span>
  <span class="hljs-attr">displayName:</span> <span class="hljs-string">'Add User + Roles for Managed Identity'</span>
  <span class="hljs-attr">inputs:</span>
    <span class="hljs-attr">azureSubscription:</span> <span class="hljs-string">${{</span> <span class="hljs-string">parameters.serviceConnection</span> <span class="hljs-string">}}</span>
    <span class="hljs-attr">AuthenticationType:</span> <span class="hljs-string">'servicePrincipal'</span>
    <span class="hljs-attr">ServerName:</span> <span class="hljs-string">'$(sqlServerName).database.windows.net'</span>
    <span class="hljs-attr">DatabaseName:</span> <span class="hljs-string">'$(sqlDatabaseName)'</span>
    <span class="hljs-attr">TaskNameSelector:</span> <span class="hljs-string">'InlineSqlTask'</span>
    <span class="hljs-attr">SqlInline:</span> <span class="hljs-string">|
      IF NOT EXISTS (SELECT [name] FROM [sys].[database_principals] WHERE [name] = '$(managedIdentityName)')
      CREATE USER [$(managedIdentityName)] WITH SID = $(managedIdentitySid), TYPE = E;
      GO
      ALTER ROLE [db_datareader] ADD MEMBER [$(managedIdentityName)];
      ALTER ROLE [db_datawriter] ADD MEMBER [$(managedIdentityName)];
      GO
</span>    <span class="hljs-attr">IpDetectionMethod:</span> <span class="hljs-string">'AutoDetect'</span>
</code></pre>
<p>I can't take credit for the PowerShell script, I found it by happenstance in <a target="_blank" href="https://stackoverflow.com/a/76996864">this StackOverflow answer</a> by <a target="_blank" href="https://github.com/CrazyTuna">Thomas Vercoutre</a>. Thomas you legend! 👏</p>
]]></content:encoded></item><item><title><![CDATA[Microsoft Entra ID - Call Protected APIs using Managed Identities]]></title><description><![CDATA[💡
UPDATE: As of 8 May 2025, the ability to configure an application to trust a managed identity is generally available - The below post documents a different mechanism to achieving a similar goal, though with some caveats. The official documentation...]]></description><link>https://blog.marcelmichau.dev/microsoft-entra-id-call-protected-apis-using-managed-identities</link><guid isPermaLink="true">https://blog.marcelmichau.dev/microsoft-entra-id-call-protected-apis-using-managed-identities</guid><category><![CDATA[Microsoft Entra]]></category><category><![CDATA[Azure]]></category><category><![CDATA[.NET]]></category><category><![CDATA[Azure Managed Identities]]></category><dc:creator><![CDATA[Marcel Michau]]></dc:creator><pubDate>Fri, 12 Apr 2024 08:11:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/stock/unsplash/BcjdbyKWquw/upload/8b5191877852342b0a22f30382df5d3f.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">UPDATE: As of 8 May 2025, the ability to <a target="_self" href="https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-config-app-trust-managed-identity?tabs=microsoft-entra-admin-center">configure an application to trust a managed identity</a> is generally available - The below post documents a different mechanism to achieving a similar goal, though with some caveats. The official documentation is the recommended approach &amp; this post is left here for posterity.</div>
</div>

<p><a target="_blank" href="https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview">Entra ID Managed Identities</a> are great. They enable authentication &amp; authorization to Azure resources without the need to store &amp; manage credentials.</p>
<p>This works well if you, say, have an Azure Container App which connects to an Azure SQL Database. It's possible to <a target="_blank" href="https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/tutorial-windows-vm-access-sql">use a managed identity to authenticate with the database</a> so that you don't need to <s>hardcode</s> explicitly configure a password in a connection string.</p>
<p>This is all well &amp; good when talking to Azure services - Azure SQL, Cosmos DB, Key Vault, etc. But when an app needs to talk to a custom API protected with Entra ID, the <a target="_blank" href="https://learn.microsoft.com/en-us/entra/identity-platform/scenario-web-api-call-api-app-registration#add-a-client-secret-or-certificate">documentation</a> states that one must use an App Registration along with a Client ID &amp; Secret/Certificate to acquire a token for the downstream API. This means that your app will need to store this secret/certificate somewhere &amp; have a process in place to rotate it when it inevitably expires. Not ideal when Managed Identities were created specifically to solve for this scenario.</p>
<p>For ASP.NET Core apps, the <a target="_blank" href="https://github.com/AzureAD/microsoft-identity-web">Microsoft Identity Web</a> library is the recommended way for interacting with the Microsoft Identity Platform to protect APIs with Entra ID, acquire access tokens &amp; a whole host of other auth-related stuff. The most common approach I've encountered in the wild for acquiring an access token on behalf of an app (as opposed to a user) is to use the <a target="_blank" href="https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow">Client Credentials flow</a> which relies on supplying a client ID along with a client secret (which is not recommended for production 🤫) or client certificate.</p>
<p>Fast-forward to February 2024, <a target="_blank" href="https://github.com/AzureAD/microsoft-identity-web/releases/tag/2.17.0">the 2.17.0 release of Microsoft Identity Web</a> included this new feature in the release notes:</p>
<blockquote>
<ul>
<li>Added support for Managed identity when calling a downstream API on behalf of the app. See <a target="_blank" href="https://github.com/AzureAD/microsoft-identity-web/wiki/calling-apis-with-managed-identity">Calling APIs with Managed Identity</a> and <a target="_blank" href="https://github.com/AzureAD/microsoft-identity-web/pull/2650">PR 2650</a>. For details see <a target="_blank" href="https://github.com/AzureAD/microsoft-identity-web/issues/2645">PR #2645</a></li>
</ul>
</blockquote>
<p>This was intriguing! Did this mean I could throw my secrets in the rubbish bin? Spoiler: Almost.</p>
<p>To get this to work, a few prerequisites need to be in place:</p>
<ol>
<li><p>The downstream API which is protected with Entra ID needs at least one <a target="_blank" href="https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-app-roles-in-apps">App Role</a> defined on the App Registration</p>
</li>
<li><p>The calling API should have either a System-Assigned Managed Identity or User-Assigned Managed Identity associated to the compute resource</p>
</li>
<li><p>The Managed Identity used by the calling API must be assigned to the App Role on the downstream API's App Registration by following the approach detailed <a target="_blank" href="https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-assign-app-role-managed-identity-cli">here</a> - There is no UI to do this in the Azure Portal at the time of writing &amp; hence must be done via Azure PowerShell or the Azure CLI</p>
</li>
</ol>
<p>You can confirm that step 3 was successful by navigating to the <strong>Microsoft Entra ID</strong> blade in the Azure Portal &gt; Enterprise Applications &gt; Switch the 'Application type' filter to <strong>Managed Identities</strong> &gt; select the app with the same name as your Managed Identity &gt; Permissions - note that when using a System-Assigned Managed Identity, the name of the Managed Identity will be the same as your compute resource name.</p>
<p>So given the following App Registration with its App Roles:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1712851917918/fd2f8357-10ed-4907-96fc-71f13027494d.png" alt class="image--center mx-auto" /></p>
<p>This is what the Managed Identity Enterprise App Permissions should look like:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1712851966725/96968389-3344-4b83-bb8b-821c24d79879.png" alt class="image--center mx-auto" /></p>
<p>This is not entirely different from how we used App Registrations to achieve this. The key difference between the App Registration setup &amp; the Managed Identity setup is that, when using Managed Identities, the App Role on the downstream API is assigned to the client app’s Managed Identity rather than to an App Registration. This means that an App Registration need not be created to represent the client app within the Microsoft Identity Platform, it can just use a Managed Identity. However, if the client app is, itself an API which needs to authenticate users or applications, then it will still require an App Registration of its own. A daemon application, for example, would not require an App Registration at all.</p>
<p>What changes from a code perspective? Let's have a look - The following example showcases an ASP.NET Core Web API (protected by Entra ID) which calls a downstream ASP.NET Core Web API (protected by Entra ID) on behalf of the app.</p>
<p>This example shows the configuration &amp; code using the Client Credentials flow to call a downstream API - in this case, the quintessential Weather API. For simplicity, the below code uses the <code>IDownstreamApi</code> helper interface for calling the API, which takes care of acquiring the token, attaching the Authorization header to the request, handling errors &amp; deserialising the response. There are also <a target="_blank" href="https://learn.microsoft.com/en-us/entra/identity-platform/scenario-web-api-call-api-call-api?tabs=aspnetcore">other options</a> which allow for more flexibility, such as the low-level <code>ITokenAcquisition</code> or <code>ITokenAcquirerFactory</code> interfaces, or the <code>IAuthorizationHeaderProvider</code> interface, depending on your use case or preference.</p>
<p><code>appsettings.json</code>:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"AzureAd"</span>: {
    <span class="hljs-attr">"Instance"</span>: <span class="hljs-string">"https://login.microsoftonline.com/"</span>,
    <span class="hljs-attr">"TenantId"</span>: <span class="hljs-string">"&lt;tenant id guid&gt;"</span>,
    <span class="hljs-attr">"ClientId"</span>: <span class="hljs-string">"&lt;app registration client id guid&gt;"</span>,
    <span class="hljs-attr">"ClientCredentials"</span>: [
      {
        <span class="hljs-attr">"SourceType"</span>: <span class="hljs-string">"ClientSecret"</span>,
        <span class="hljs-attr">"ClientSecret"</span>: <span class="hljs-string">"&lt;client secret&gt;"</span>
      }
    ]
  },
  <span class="hljs-attr">"DownstreamApi"</span>: {
    <span class="hljs-attr">"BaseUrl"</span>: <span class="hljs-string">"https://weather-api/"</span>,
    <span class="hljs-attr">"RelativePath"</span>: <span class="hljs-string">"WeatherForecast"</span>,
    <span class="hljs-attr">"RequestAppToken"</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">"Scopes"</span>: [ <span class="hljs-string">"api://&lt;downstream api name or app id&gt;/.default"</span> ]
  }
}
</code></pre>
<p><code>Program.cs</code>:</p>
<pre><code class="lang-csharp">builder.Services.AddMicrosoftIdentityWebApiAuthentication(builder.Configuration)
    .EnableTokenAcquisitionToCallDownstreamApi()
    .AddDownstreamApi(<span class="hljs-string">"WeatherApi"</span>, builder.Configuration.GetSection(<span class="hljs-string">"DownstreamApi"</span>))
    .AddInMemoryTokenCaches();
</code></pre>
<p>Controller:</p>
<pre><code class="lang-csharp">[<span class="hljs-meta">ApiController</span>]
[<span class="hljs-meta">Route(<span class="hljs-meta-string">"[controller]"</span>)</span>]
<span class="hljs-function"><span class="hljs-keyword">public</span> class <span class="hljs-title">WeatherForecastController</span>(<span class="hljs-params">
    ILogger&lt;WeatherForecastController&gt; logger,
    IDownstreamApi downstreamApi</span>) : ControllerBase</span>
{
    [<span class="hljs-meta">HttpGet(Name = <span class="hljs-meta-string">"GetWeatherForecast"</span>)</span>]
    <span class="hljs-keyword">public</span> <span class="hljs-keyword">async</span> Task&lt;IEnumerable&lt;WeatherForecast&gt;&gt; Get()
    {
        <span class="hljs-keyword">try</span>
        {
            <span class="hljs-keyword">var</span> results = <span class="hljs-keyword">await</span> downstreamApi.GetForAppAsync&lt;IEnumerable&lt;WeatherForecast&gt;&gt;(<span class="hljs-string">"WeatherApi"</span>);

            <span class="hljs-keyword">return</span> results ?? [];
        }
        <span class="hljs-keyword">catch</span> (Exception ex)
        {
            logger.LogError(ex, <span class="hljs-string">"Something went wrong"</span>);
            <span class="hljs-keyword">return</span> [];
        }
    }
}
</code></pre>
<p>To enable the Managed Identity flow, update the <code>appsettings.json</code> file to remove the <code>ClientCredentials</code> section from the <code>AzureAd</code> section &amp; add an <code>AcquireTokenOptions</code> section inside the <code>DownstreamApi</code> section as shown:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"AzureAd"</span>: {
    <span class="hljs-attr">"Instance"</span>: <span class="hljs-string">"https://login.microsoftonline.com/"</span>,
    <span class="hljs-attr">"TenantId"</span>: <span class="hljs-string">"&lt;tenant id guid&gt;"</span>,
    <span class="hljs-attr">"ClientId"</span>: <span class="hljs-string">"&lt;app registration client id guid&gt;"</span>
  },
  <span class="hljs-attr">"DownstreamApi"</span>: {
    <span class="hljs-attr">"BaseUrl"</span>: <span class="hljs-string">"https://weather-api/"</span>,
    <span class="hljs-attr">"RelativePath"</span>: <span class="hljs-string">"WeatherForecast"</span>,
    <span class="hljs-attr">"RequestAppToken"</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">"Scopes"</span>: [ <span class="hljs-string">"api://&lt;downstream api name or app id&gt;/.default"</span> ],
    <span class="hljs-attr">"AcquireTokenOptions"</span>: {
      <span class="hljs-attr">"ManagedIdentity"</span>: {
        <span class="hljs-attr">"UserAssignedClientId"</span>: <span class="hljs-string">"&lt;managed identity client id&gt;"</span>
      }
    }
  }
}
</code></pre>
<p>Note that when using a System-Assigned Managed Identity, the <code>UserAssignedClientId</code> property can be omitted, like so:</p>
<pre><code class="lang-json"><span class="hljs-string">"AcquireTokenOptions"</span>: {
  <span class="hljs-attr">"ManagedIdentity"</span>: {
  }
}
</code></pre>
<p>The controller code or <code>Program.cs</code> code does not need to be updated.</p>
<p>The above example holds true for API calling a downstream API, but <a target="_blank" href="https://github.com/AzureAD/microsoft-identity-web/wiki/worker%E2%80%90app%E2%80%90calling%E2%80%90downstream%E2%80%90apis">using a Worker service is also supported</a>, though the configuration differs slightly as the Worker service is a daemon app. There is also a super minimal <a target="_blank" href="https://github.com/AzureAD/microsoft-identity-web/wiki/calling-apis-with-managed-identity#daemon-app-example-with-managed-identity">Console app example</a> in the GitHub repo wiki.</p>
<p>One thing I should call out is that, while this approach is awesome &amp; prevents the need to store credentials &amp; manage their rotation, there are <a target="_blank" href="https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/managed-identity-best-practice-recommendations#limitation-of-using-managed-identities-for-authorization">some limitations to using Managed Identities for Authorization</a>. Most notably that Managed Identity tokens are cached by the Azure infrastructure for up to 24 hours with no currently supported way to force a refresh of the token. The docs state that this delay is applicable only when using Entra groups with Managed Identities &amp; recommend using a user-assigned managed identity with permissions applied directly to the identity, instead of adding to or removing managed identities from a Microsoft Entra group that has permissions. I have not tested this extensively, but I will be running some experiments to kick the tyres.</p>
<p>A crucial aspect of using Managed Identities for authorization is that <strong>Managed Identities only work in Azure</strong>. This might seem obvious, but it's worth noting as this means that this approach <em>will not work</em> for local development scenarios. If you want to call the protected API from your local machine, you will either need to:</p>
<ol>
<li><p>Create an App Registration with a Client ID &amp; Secret with the sole purpose of local development use - as well as using different config for local dev to use the Client Credentials flow</p>
</li>
<li><p>Mock the call to the protected API only for local development/running integration tests locally</p>
</li>
</ol>
<p>And that's why I said you can <em>almost</em> throw your secrets in the rubbish bin. Alas, there are no free lunches. 🙂</p>
<p>I'm sure this approach works for a broad set of use-cases such that we can start slowly chipping away at the number of secrets which need managing. I might be coming across as a secret-basher, but it's not like expired or leaked secrets ever caused anyone any trouble, right? 😉</p>
]]></content:encoded></item><item><title><![CDATA[Reduce Azure Log Analytics Cost by Tweaking Health Checks Logging]]></title><description><![CDATA[Open any book on building software & you will eventually spot a piece of wisdom resembling the following:

Observability is important

I agree with that statement. It's crucial to be able to ascertain that a system is functioning correctly, and when ...]]></description><link>https://blog.marcelmichau.dev/reduce-azure-log-analytics-cost-by-tweaking-health-checks-logging</link><guid isPermaLink="true">https://blog.marcelmichau.dev/reduce-azure-log-analytics-cost-by-tweaking-health-checks-logging</guid><category><![CDATA[observability]]></category><category><![CDATA[logging]]></category><category><![CDATA[Azure]]></category><category><![CDATA[.NET]]></category><dc:creator><![CDATA[Marcel Michau]]></dc:creator><pubDate>Thu, 31 Aug 2023 09:24:04 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/stock/unsplash/XoOjLGLavJU/upload/1059b76d7a4b7df6d48ac75218ddbac6.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Open any book on building software &amp; you will eventually spot a piece of wisdom resembling the following:</p>
<blockquote>
<p>Observability is important</p>
</blockquote>
<p>I agree with that statement. It's crucial to be able to ascertain that a system is functioning correctly, and when implemented well, it eases the troubleshooting process for when something inevitably goes awry.</p>
<p>Unfortunately, oftentimes the above statement gets translated into the following:</p>
<blockquote>
<p>Log <strong>everything</strong></p>
</blockquote>
<p>In a world with free infinite storage, I say go ahead. But that world &amp; our world are not the same.</p>
<p>Cloud providers are acutely aware that observability is important &amp; will reinforce that fact throughout their documentation as a best practice. They will also charge for that observability accordingly. Couple that with the <em>log-everything</em> mantra &amp; that is a recipe for a woefully melancholic invoice at the end of the month.</p>
<p>We experienced this melancholy recently with a simple ASP.NET Core Web API deployed to Azure Container Apps. The application had some supporting resources such as Azure SQL Database, Key Vault, App Configuration, Front Door, and because we strive to be good observability citizens, Application Insights &amp; Log Analytics.</p>
<p>We deployed the API into a development environment, left it running for a couple of days &amp; upon analysing our costs noticed the following:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1693406603084/15968f88-5d09-41df-8517-ee2771e94e96.png" alt class="image--center mx-auto" /></p>
<p>Upon this realisation, the question that came to mind was:</p>
<blockquote>
<p>"Why the **** is Log Analytics' cost so high?"</p>
</blockquote>
<p>Keep in mind that this was in a development environment with no real user traffic apart from us doing some initial tests to verify that the app was working. This was while the application was <em>just sitting there.</em> So, we started digging.</p>
<p>We checked the log categories that we were sending to Log Analytics to see if we could spot some offenders:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Category</td><td>Usage</td></tr>
</thead>
<tbody>
<tr>
<td>AppTraces</td><td>5,72 GB</td></tr>
<tr>
<td>AppRequests</td><td>690 MB</td></tr>
<tr>
<td>AppDependencies</td><td>460 MB</td></tr>
<tr>
<td>AzureDiagnostics</td><td>330 MB</td></tr>
<tr>
<td>AppPerformanceCounters</td><td>60 MB</td></tr>
<tr>
<td>AACHttpRequest</td><td>40 MB</td></tr>
<tr>
<td>AzureMetrics</td><td>20 MB</td></tr>
</tbody>
</table>
</div><p>That's a lot of <em>AppTraces</em>. Digging into what these <em>AppTraces</em> were, we determined that these were generated by another thing that is supposed to be good practice - <em>health checks.</em> A lot of health checks.</p>
<p>The API we deployed included two kinds of health checks: liveness and readiness. These terms are widely used in the Kubernetes world to determine whether an application is running (live) &amp; ready to receive requests (ready) &amp; the Kubernetes API uses these checks to determine if pods should be restarted and if traffic should be sent to them. In our API's case, the liveness check was a <em>/health/live</em> endpoint which returned a simple 200 OK response to indicate that the API was running, whereas the readiness check was a <em>/health/ready</em> endpoint which queried critical dependencies to check that all of them were happy &amp; healthy as well.</p>
<p>Digging into the logging for our API, a call to the <em>/health/live</em> endpoint generated the following logs:</p>
<pre><code class="lang-plaintext">[09:23:32 INF] Request starting HTTP/2 GET https://localhost:5001/health/live - -
[09:23:32 INF] Executing endpoint 'Health checks'
[09:23:32 INF] Executed endpoint 'Health checks'
[09:23:32 INF] HTTP GET /health/live responded 200 in 355.8161 ms
[09:23:32 INF] Request finished HTTP/2 GET https://localhost:5001/health/live - - - 200 - text/plain 417.8957ms
</code></pre>
<p>Calling the <em>/health/ready</em> endpoint (which is a more involved check) generated the following logs:</p>
<pre><code class="lang-plaintext">[09:23:45 INF] Request starting HTTP/2 GET https://localhost:5001/health/ready - -
[09:23:45 INF] Executing endpoint 'Health checks'
[09:23:45 INF] Start processing HTTP request GET https://login.microsoftonline.com/&lt;tenantId&gt;/v2.0/.well-known/openid-configuration
[09:23:45 INF] Sending HTTP request GET https://login.microsoftonline.com/&lt;tenantId&gt;/v2.0/.well-known/openid-configuration
[09:23:46 INF] Received HTTP response headers after 1102.9673ms - 200
[09:23:46 INF] End processing HTTP request after 1131.027ms - 200
[09:23:47 INF] Executed endpoint 'Health checks'
[09:23:47 INF] HTTP GET /health/ready responded 200 in 1286.3275 ms
[09:23:47 INF] Request finished HTTP/2 GET https://localhost:5001/health/ready - - - 200 - application/json 1297.1640ms
</code></pre>
<p>Keen eyes may notice that we're using <a target="_blank" href="https://serilog.net/">Serilog</a> which has been configured to use the <a target="_blank" href="https://github.com/serilog-contrib/serilog-sinks-applicationinsights">Serilog.Sinks.ApplicationInsights</a> package to send logs to Application Insights as <code>TraceTelemetry</code> which explains where all the <em>AppTraces</em> in Log Analytics came from.</p>
<p>That is a non-trivial amount of logging for something that is purely infrastructural. One could argue that generating all these logs for successful calls to health check endpoints is unnecessarily verbose. Of course, I don't expect everyone to share the same opinion. If these logs were collected &amp; analysed for response time trends or something similar, they would be necessary. I only really care when health checks fail and when they do, I would like to be notified that something blew up. It's not like I'm going to send myself PagerDuty alerts every five minutes stating that <em>nothing has blown up yet.</em> So, we came to a collective agreement that we didn't want to store these successful health check logs until we needed them.</p>
<p>Another question we asked was:</p>
<blockquote>
<p>"Why is the sheer volume of logs so high?"</p>
</blockquote>
<p>The answer to that question has some nuance to it.</p>
<p>Azure Container Apps has an awesome feature called <a target="_blank" href="https://learn.microsoft.com/en-us/azure/container-apps/health-probes">Health Probes</a>. These probes are configured on a container level. Container Apps can also scale out your app to multiple containers based on a <a target="_blank" href="https://learn.microsoft.com/en-us/azure/container-apps/scale-app?pivots=azure-cli#scale-rules">Scale Rule</a>.</p>
<p>See where I'm going with this?</p>
<p>Our API was scaled to three containers, each with its own liveness and readiness probes, configured to call their respective endpoints <em>every 10 seconds.</em> That's 14 log entries x 3 containers x 6 = 252 log entries per minute being sent to Log Analytics - just for health checks.</p>
<p>That was not the only contributing factor though. The API is protected by an Azure Front Door resource which has its own <a target="_blank" href="https://learn.microsoft.com/en-us/azure/frontdoor/health-probes">Health Probes feature</a> to check if the origin is healthy. In our case, this was configured to probe the backend API's <em>/health/live</em> endpoint every 30 seconds <em>on top of the built-in probes in Container Apps.</em></p>
<p>And that is why the volume of logs was so high.</p>
<p>With more wisdom gained, we set out on a path of optimisation. The first port of call was to implement the guidance from Andrew Lock's excellent blog post on <a target="_blank" href="https://andrewlock.net/using-serilog-aspnetcore-in-asp-net-core-3-excluding-health-check-endpoints-from-serilog-request-logging/">Excluding health check endpoints from Serilog request logging</a>. This involved setting the log level for log entries generated by the health check endpoints to <em>Verbose</em> by using a custom <code>GetLevel</code> function for the Serilog <code>UseSerilogRequestLogging</code> middleware as shown in <a target="_blank" href="https://github.com/andrewlock/blog-examples/blob/b492595ec28016e88ceee6385a57a0686b77d33e/SerilogRequestLogging/LogHelper.cs#L46">Andrew's example repo</a>.</p>
<p>The pertinent code is shown here for reference:</p>
<pre><code class="lang-csharp"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> Func&lt;HttpContext, <span class="hljs-keyword">double</span>, Exception, LogEventLevel&gt; <span class="hljs-title">GetLevel</span>(<span class="hljs-params">LogEventLevel traceLevel, <span class="hljs-keyword">params</span> <span class="hljs-keyword">string</span>[] traceEndpointNames</span>)</span>
{
    <span class="hljs-keyword">if</span> (traceEndpointNames <span class="hljs-keyword">is</span> <span class="hljs-literal">null</span> || traceEndpointNames.Length == <span class="hljs-number">0</span>)
    {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> ArgumentNullException(<span class="hljs-keyword">nameof</span>(traceEndpointNames));
    }

    <span class="hljs-keyword">return</span> (ctx, _, ex) =&gt; 
        IsError(ctx, ex) 
        ? LogEventLevel.Error
        : IsTraceEndpoint(ctx, traceEndpointNames)
            ? traceLevel
            : LogEventLevel.Information;
}

<span class="hljs-function"><span class="hljs-keyword">static</span> <span class="hljs-keyword">bool</span> <span class="hljs-title">IsError</span>(<span class="hljs-params">HttpContext ctx, Exception ex</span>)</span> 
    =&gt; ex != <span class="hljs-literal">null</span> || ctx.Response.StatusCode &gt; <span class="hljs-number">499</span>;

<span class="hljs-function"><span class="hljs-keyword">static</span> <span class="hljs-keyword">bool</span> <span class="hljs-title">IsTraceEndpoint</span>(<span class="hljs-params">HttpContext ctx, <span class="hljs-keyword">string</span>[] traceEndpoints</span>)</span>
{
    <span class="hljs-keyword">var</span> endpoint = ctx.GetEndpoint();
    <span class="hljs-keyword">if</span> (endpoint <span class="hljs-keyword">is</span> <span class="hljs-keyword">object</span>)
    {
        <span class="hljs-keyword">for</span> (<span class="hljs-keyword">var</span> i = <span class="hljs-number">0</span>; i &lt; traceEndpoints.Length; i++)
        {
            <span class="hljs-keyword">if</span> (<span class="hljs-keyword">string</span>.Equals(traceEndpoints[i], endpoint.DisplayName, StringComparison.OrdinalIgnoreCase))
            {
                <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;
            }
        }
    }
    <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;
}
</code></pre>
<p>This <code>GetLevel</code> method is wired up in <code>Startup.cs</code>/<code>Program.cs</code> as follows:</p>
<pre><code class="lang-csharp">app.UseSerilogRequestLogging(opts =&gt; {
    opts.EnrichDiagnosticContext = LogHelper.EnrichFromRequest;
    opts.GetLevel = LogHelper.GetLevel(LogEventLevel.Verbose, <span class="hljs-string">"Health checks"</span>);
});
</code></pre>
<p>Implementing the above resulted in the logs generated from health check endpoints to be reduced to:</p>
<p>That's right. Zero. Zip. Nada.</p>
<p>It's worthwhile to note that these logs are only filtered out when calls to the health check endpoints are successful. If an error happens to occur, the log level will be set to <em>Error</em> and those logs will still be emitted.</p>
<p>But we can do more!</p>
<p>The amount of <em>AppTraces</em> logs decreased, though there were some more low-hanging fruits in the form of <em>AppRequests</em> and <em>AppDependencies.</em> These log categories are sent by the <a target="_blank" href="https://learn.microsoft.com/en-us/azure/azure-monitor/app/asp-net-core">Application Insights SDK for ASP.NET Core</a> whenever requests reached our API &amp; when a dependency was called, respectively. Therefore, we were generating <em>AppRequests</em> telemetry for each call to the <em>/health/live</em> and <em>/health/ready</em> endpoints, as well as <em>AppDependencies</em> telemetry when the <em>/health/ready</em> health check pinged critical dependencies such as SQL Server, Microsoft Entra ID, Key Vault, etc.</p>
<p>These sets of telemetry also constituted a fair amount of data in Log Analytics:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1693468999886/9c39f9f0-0832-4656-9c37-62befa9e51f3.jpeg" alt class="image--center mx-auto" /></p>
<p>For these, we could not reach for Serilog as this was outside of its realm. Fortunately, the Application Insights SDK we used in the API project allows for <a target="_blank" href="https://learn.microsoft.com/en-us/azure/azure-monitor/app/api-filtering-sampling">filtering and preprocessing telemetry</a> using <code>ITelemetryProcessor</code>.</p>
<p>With this revelation in mind, we settled on the following:</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">HealthCheckRequestFilter</span> : <span class="hljs-title">ITelemetryProcessor</span>
{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> ITelemetryProcessor _next;
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> IHttpContextAccessor _httpContextAccessor;
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">HealthCheckRequestFilter</span>(<span class="hljs-params">ITelemetryProcessor next, IHttpContextAccessor httpContextAccessor</span>)</span>
    {
        _next = next ?? <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> ArgumentNullException(<span class="hljs-keyword">nameof</span>(next));
        _httpContextAccessor = httpContextAccessor ?? <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> ArgumentNullException(<span class="hljs-keyword">nameof</span>(httpContextAccessor));
    }
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">Process</span>(<span class="hljs-params">ITelemetry item</span>)</span>
    {
        <span class="hljs-keyword">var</span> excludedHealthCheckPaths = <span class="hljs-keyword">new</span>[]
        {
            <span class="hljs-string">"/health/live"</span>,
            <span class="hljs-string">"/health/ready"</span>
        };
        <span class="hljs-keyword">if</span> (excludedHealthCheckPaths.Any(path =&gt;
                _httpContextAccessor.HttpContext?.Request.Path.Value?.Equals(path,
                    StringComparison.OrdinalIgnoreCase) ?? <span class="hljs-literal">false</span>)) <span class="hljs-keyword">return</span>;
        _next. Process(item);
    }
}
</code></pre>
<p>The <code>HealthCheckRequestFilter</code> uses <code>IHttpContextAccessor</code> to determine if the incoming request path matches one of our predefined health check endpoints, and if it does, we tell the Application Insights SDK to skip processing the telemetry.</p>
<p>Then the filter is wired up in <code>Startup.cs</code>/<code>Program.cs:</code></p>
<pre><code class="lang-csharp">services.AddApplicationInsightsTelemetryProcessor&lt;HealthCheckRequestFilter&gt;();
</code></pre>
<p>After deploying this change, graphs trended downwards:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1693469052172/6d65e743-1f3e-44af-9046-88451a12130f.jpeg" alt class="image--center mx-auto" /></p>
<p>We managed to reduce our daily log ingestion from ±1.1GB to under 50MB. This resulted in cost savings by cutting our Log Analytics cost from a touch over $3/day to 25c/day.</p>
<p><img src="https://media0.giphy.com/media/l41lIkTqv4NTHPktO/giphy.gif?cid=ecf05e47j07m2zvmcyv34coioe6j0n3ui17xvnrbzb2w76xe&amp;ep=v1_gifs_search&amp;rid=giphy.gif&amp;ct=g" alt="Seinfeld gif. Jerry sits with his feet on a table and a cigar in his mouth, smiling and nodding as Julia Louis-Dreyfus counts out cash in front of him, looking annoyed." class="image--center mx-auto" /></p>
<p>Moral of the story: checking if your app is healthy can come at a cost. Especially if those checks are performed often. But it doesn't have to be - some considered optimisation can bring down your cloud bill without sacrificing best practices.</p>
<p>Through this effort, we gained a better understanding of exactly how our application generates logs, the triggers for these logs, and the various mechanisms to fine-tune exactly which logs get stored.</p>
<p>I hope this post leads to some cash savings on a couple of invoices. 🙂</p>
]]></content:encoded></item><item><title><![CDATA[Taming Startup.cs in ASP.NET Core]]></title><description><![CDATA[Photo by Safar Safarov on Unsplash

Overview
There are plenty of ASP.NET Core projects out on the internet. All of them have one thing in common. The Startup.cs file. There's also another thing that a lot of these Startup.cs files have in common - th...]]></description><link>https://blog.marcelmichau.dev/taming-startupcs-in-aspnet-core</link><guid isPermaLink="true">https://blog.marcelmichau.dev/taming-startupcs-in-aspnet-core</guid><category><![CDATA[C#]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[dotnet]]></category><dc:creator><![CDATA[Marcel Michau]]></dc:creator><pubDate>Wed, 21 Jul 2021 10:54:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1626864730209/pcMFTdkug.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Photo by <a target="_blank" href="https://unsplash.com/@codestorm?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText">Safar Safarov</a> on <a target="_blank" href="https://unsplash.com/s/photos/code?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText">Unsplash</a></p>
<hr />
<h2 id="overview">Overview</h2>
<p>There are plenty of ASP.NET Core projects out on the internet. All of them have one thing in common. The <code>Startup.cs</code> file. There's also another thing that a lot of these <code>Startup.cs</code> files have in common - they are <strong>HUGE</strong>.</p>
<h2 id="the-problem">The Problem</h2>
<p>For those unfamiliar with <code>Startup.cs</code>, this is the class that wires up <em>pretty much everything</em> in ASP.NET Core. It's where the request pipeline, dependency injection container, configuration &amp; others get configured. The official docs can be found <a target="_blank" href="https://docs.microsoft.com/en-us/aspnet/core/fundamentals/startup">here</a>. The issue with being responsible for wiring up <em>pretty much everything</em> is that this class tends to grow quite large as more things need to be configured. This problem is compounded with plenty of documentation &amp; tutorials suggesting that, to wire up [<em>insert feature here</em>], all you need to do is drop [<em>insert code snippet here</em>] into <code>Startup.cs</code> and you're off to the races. And the file just keeps on growing...</p>
<p>This causes a few things:</p>
<ul>
<li>The <code>Startup</code> class becomes difficult to navigate because there's a lot happening in it</li>
<li>The <code>Startup</code> class violates the Single Responsibility Principle (SRP) because it no longer has a single reason to change</li>
<li>It hurts maintainability because there's a high chance of merge conflicts if multiple developers make changes to the <code>Startup</code> class simultaneously</li>
</ul>
<h2 id="the-problem-in-practice">The Problem in Practice</h2>
<p>Let's take the following <code>Startup.cs</code> template as a starting point, taken from the .NET Core Web API template (comments removed for brevity):</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">Startup</span>
{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">Startup</span>(<span class="hljs-params">IConfiguration configuration</span>)</span>
    {
        Configuration = configuration;
    }

    <span class="hljs-keyword">public</span> IConfiguration Configuration { <span class="hljs-keyword">get</span>; }

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">ConfigureServices</span>(<span class="hljs-params">IServiceCollection services</span>)</span>
    {
        services.AddControllers();
    }

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">Configure</span>(<span class="hljs-params">IApplicationBuilder app, IWebHostEnvironment env</span>)</span>
    {
        <span class="hljs-keyword">if</span> (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        app.UseHttpsRedirection();

        app.UseRouting();

        app.UseAuthorization();

        app.UseEndpoints(endpoints =&gt;
        {
            endpoints.MapControllers();
        });
    }
}
</code></pre>
<p>Awesome, clean &amp; simple. For now.</p>
<p>Now let's add our first feature. Any application with reasonable complexity is going to need to store some data somewhere. So, let's add Entity Framework Core &amp; wire it up with SQL Server. As per the docs, we add the following to <code>ConfigureServices</code>:</p>
<pre><code class="lang-csharp">services.AddDbContext&lt;SchoolContext&gt;(options =&gt;
    options.UseSqlServer(Configuration.GetConnectionString(<span class="hljs-string">"SchoolContext"</span>)));
</code></pre>
<p>We also want to be good citizens &amp; document our API so that it can be used by others. So let's use <a target="_blank" href="https://github.com/domaindrivendev/Swashbuckle.AspNetCore">Swashbuckle</a> to add some Open API (Swagger) documentation to our API. So we add the following to <code>ConfigureServices</code>:</p>
<pre><code class="lang-csharp">services.AddSwaggerGen(c =&gt;
{
    c.SwaggerDoc(<span class="hljs-string">"v1"</span>, <span class="hljs-keyword">new</span> OpenApiInfo { Title = <span class="hljs-string">"My Awesome API"</span>, Version = <span class="hljs-string">"v1"</span> });
});
</code></pre>
<p>And the following to <code>Configure</code>, inside the <code>env.IsDevelopment()</code> check:</p>
<pre><code class="lang-csharp">app.UseSwagger();
app.UseSwaggerUI(c =&gt; c.SwaggerEndpoint(<span class="hljs-string">"/swagger/v1/swagger.json"</span>, <span class="hljs-string">"My Awesome API v1"</span>));
</code></pre>
<p>We've also written a couple of custom classes &amp; interfaces which we need to add to the <code>ServiceCollection</code> for Dependency Injection, so let's add them to <code>ConfigureServices</code>:</p>
<pre><code class="lang-csharp">services.AddScoped&lt;INotificationService, EmailNotificationService&gt;();
services.AddScoped&lt;INotificationService, SmsNotificationService&gt;();
services.AddScoped&lt;INotificationService, PushNotificationService&gt;();
</code></pre>
<p>That's enough features for today. Not too many, but enough to start off with. Now our <code>Startup</code> class looks like this:</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">Startup</span>
{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">Startup</span>(<span class="hljs-params">IConfiguration configuration</span>)</span>
    {
        Configuration = configuration;
    }

    <span class="hljs-keyword">public</span> IConfiguration Configuration { <span class="hljs-keyword">get</span>; }

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">ConfigureServices</span>(<span class="hljs-params">IServiceCollection services</span>)</span>
    {
        services.AddControllers();

        services.AddDbContext&lt;SchoolContext&gt;(options =&gt;
            options.UseSqlServer(Configuration.GetConnectionString(<span class="hljs-string">"SchoolContext"</span>)));

        services.AddSwaggerGen(c =&gt;
        {
            c.SwaggerDoc(<span class="hljs-string">"v1"</span>, <span class="hljs-keyword">new</span> OpenApiInfo { Title = <span class="hljs-string">"My Awesome API"</span>, Version = <span class="hljs-string">"v1"</span> });
        });

        services.AddScoped&lt;INotificationService, EmailNotificationService&gt;();
        services.AddScoped&lt;INotificationService, SmsNotificationService&gt;();
        services.AddScoped&lt;INotificationService, PushNotificationService&gt;();
    }

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">Configure</span>(<span class="hljs-params">IApplicationBuilder app, IWebHostEnvironment env</span>)</span>
    {
        <span class="hljs-keyword">if</span> (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
            app.UseSwagger();
            app.UseSwaggerUI(c =&gt; c.SwaggerEndpoint(<span class="hljs-string">"/swagger/v1/swagger.json"</span>, <span class="hljs-string">"My Awesome API v1"</span>));
        }

        app.UseHttpsRedirection();

        app.UseRouting();

        app.UseAuthorization();

        app.UseEndpoints(endpoints =&gt;
        {
            endpoints.MapControllers();
        });
    }
}
</code></pre>
<p>It's not too hairy quite yet, but we've only added three features. Imagine what this will look like once we add authentication, authorization, validation, exception handling, logging, caching, customizing Swagger, etc. It can get out of hand for a reasonably-sized application.</p>
<h2 id="the-alternative">The Alternative</h2>
<p>The following approach aims to address these issues by refactoring our <code>Startup.cs</code> file so that we can all sleep a little better at night, and we all know that sleep is important.</p>
<p>Let's try to separate some of the <code>Startup</code> class' concerns by refactoring some of them to separate classes. We can use some of the same tricks used by the ASP.NET Core team to accomplish this. For example, if we look at the following line:</p>
<pre><code class="lang-csharp">services.AddControllers();
</code></pre>
<p>What that's doing is configuring all the necessary bits &amp; pieces so that we can use Controllers in an ASP.NET Core Web API, including stuff like Authorization, CORS, Data Annotations, ApiExplorer, etc. But it's all just one line of code. Neat.</p>
<p>That is also an <em>extension method</em> that operates on <code>IServiceCollection</code>. So let's try and write some of our own extension methods which work with <code>IServiceCollection</code>, starting with the database:</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">internal</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">class</span> <span class="hljs-title">DatabaseServiceCollectionExtensions</span>
{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> IServiceCollection <span class="hljs-title">AddDatabaseConfiguration</span>(<span class="hljs-params"><span class="hljs-keyword">this</span> IServiceCollection services,
        IConfiguration configuration</span>)</span>
    {
        services.AddDbContext&lt;SchoolContext&gt;(options =&gt;
            options.UseSqlServer(Configuration.GetConnectionString(<span class="hljs-string">"SchoolContext"</span>)));

        <span class="hljs-keyword">return</span> services;
    }
}
</code></pre>
<p>Here we have a static class named <code>DatabaseServiceCollectionExtensions</code> because it contains extension methods which operate on <code>IServiceCollection</code>.</p>
<p>There is one static method, <code>AddDatabaseConfiguration</code> which takes the <code>IServiceCollection</code> &amp; <code>IConfiguration</code> as parameters and returns an <code>IServiceCollection</code>. This method then calls the same code we had in <code>Startup</code> to configure Entity Framework Core, and then returns the updated <code>IServiceCollection</code>.</p>
<p>Then we can use it in our <code>Startup</code> class inside <code>ConfigureServices</code> like so:</p>
<pre><code class="lang-csharp">services.AddDatabaseConfiguration(Configuration);
</code></pre>
<p>The <code>Configuration</code> argument is optional for these extension methods, it's just there for the case where the extension method needs something from config in order to perform it's logic, as is the case with the <code>AddDatabaseConfiguration</code> method.</p>
<p>We can then continue this trend for our own application services:</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">internal</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">class</span> <span class="hljs-title">ApplicationServicesServiceCollectionExtensions</span>
{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> IServiceCollection <span class="hljs-title">AddApplicationServicesConfiguration</span>(<span class="hljs-params"><span class="hljs-keyword">this</span> IServiceCollection services</span>)</span>
    {
        services.AddScoped&lt;INotificationService, EmailNotificationService&gt;();
        services.AddScoped&lt;INotificationService, SmsNotificationService&gt;();
        services.AddScoped&lt;INotificationService, PushNotificationService&gt;();    

        <span class="hljs-keyword">return</span> services;
    }
}
</code></pre>
<p>And use it in <code>Startup</code>:</p>
<pre><code class="lang-csharp">services.AddApplicationServicesConfiguration();
</code></pre>
<p>As for Swagger, we needed to make changes to both <code>ConfigureServices</code> and <code>Configure</code> in order to wire it up. Therefore,we'll need two extension methods, one which operates on <code>IServiceCollection</code> as before, and one which operates on <code>IApplicationBuilder</code> for the logic in the <code>Configure</code> method:</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">internal</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">class</span> <span class="hljs-title">SwaggerExtensions</span>
{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> IServiceCollection <span class="hljs-title">AddSwaggerConfiguration</span>(<span class="hljs-params"><span class="hljs-keyword">this</span> IServiceCollection services,
        IConfiguration configuration</span>)</span>
    {
        services.AddSwaggerGen(c =&gt;
        {
            c.SwaggerDoc(<span class="hljs-string">"v1"</span>, <span class="hljs-keyword">new</span> OpenApiInfo { Title = <span class="hljs-string">"My Awesome API"</span>, Version = <span class="hljs-string">"v1"</span> });
        });

        <span class="hljs-keyword">return</span> services;
    }

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> IApplicationBuilder <span class="hljs-title">UseSwaggerConfiguration</span>(<span class="hljs-params"><span class="hljs-keyword">this</span> IApplicationBuilder app</span>)</span>
    {
        app.UseSwagger();

        <span class="hljs-keyword">return</span> app.UseSwaggerUI(c =&gt; c.SwaggerEndpoint(<span class="hljs-string">"/swagger/v1/swagger.json"</span>, <span class="hljs-string">"My Awesome API v1"</span>));
    }
}
</code></pre>
<p>Then we just use them in <code>Startup</code> in their respective methods. In <code>ConfigureServices</code>:</p>
<pre><code class="lang-csharp">services.AddSwaggerConfiguration();
</code></pre>
<p>And in <code>Configure</code>:</p>
<pre><code class="lang-csharp">app.UseSwaggerConfiguration();
</code></pre>
<p>After all this, our <code>Startup</code> class looks like this:</p>
<pre><code class="lang-csharp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title">Startup</span>
{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">Startup</span>(<span class="hljs-params">IConfiguration configuration</span>)</span>
    {
        Configuration = configuration;
    }

    <span class="hljs-keyword">public</span> IConfiguration Configuration { <span class="hljs-keyword">get</span>; }

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">ConfigureServices</span>(<span class="hljs-params">IServiceCollection services</span>)</span>
    {
        services.AddControllers();
        services.AddDatabaseConfiguration(Configuration);
        services.AddApplicationServicesConfiguration();
        services.AddSwaggerConfiguration();
    }

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">Configure</span>(<span class="hljs-params">IApplicationBuilder app, IWebHostEnvironment env</span>)</span>
    {
        <span class="hljs-keyword">if</span> (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
            app.UseSwaggerConfiguration();
        }

        app.UseHttpsRedirection();

        app.UseRouting();

        app.UseAuthorization();

        app.UseEndpoints(endpoints =&gt;
        {
            endpoints.MapControllers();
        });
    }
}
</code></pre>
<p>That's a bit better. 😁</p>
<h2 id="conclusion">Conclusion</h2>
<p>This approach has a few benefits:</p>
<ul>
<li>It keeps the <code>Startup</code> class focused by moving configuration logic out to separate classes</li>
<li>It prevents the <code>Startup</code> class from growing uncontrollably</li>
<li>It maintains a better separation of concerns such that, when something changes in the configuration of Swagger, for example, that change is isolated to the class which deals with Swagger configuration</li>
<li>It improves maintainability because it's easier to find &amp; navigate to the class which deals with a specific feature rather than scrolling through the entirety of the <code>Startup</code> class looking for the specific line which needs to be changed</li>
</ul>
<p>I hope this has proved useful to someone &amp; that it helps you make your applications' code slightly simpler. 😀</p>
]]></content:encoded></item></channel></rss>