ぷろじぇくと、みすじら。

MySQLへのクエリをApplication Insights(.NET)のDependencyに出したい

Created at:

Application InsightsにはDependencyテレメトリーというリクエスト中に発行された外部リソースへのアクセスなどを記録する仕組みがあり、.NET向けの一式を入れておけばHTTPリクエストやSQL Serverへの問い合わせが自動で記録されます。

一方、標準で対応してないものをDependencyに出すには自前で何らかの方法で記録してあげる必要があります。MySQL Connector/Netもその例にもれず自動では記録されません。SQL Serverへの問い合わせが記録されるのはSqlClient (SQL Serverクライアント)のイベントを記録しているからであって、それ以外のデータベースドライバーでは記録されないのです。

というわけで、当然MySQLへの問い合わせでもDependencyに表示されてほしくなります。

Dependencyとしての記録

まずはそもそもApplication Insights上でDependencyとして記録するにはどうすればいいのかというところからです。

ドキュメントを見るとDependencyはテレメトリーの一種で、DependencyTelemetry というレコードを記録すればよいということになっています。

記録するには DependencyTelemetry を生成する方法と、TrackDependencyメソッドで記録する方法があり、今回は DependencyTelemetry を使った記録方法で実装してみます。後者は細かいことはできないもののメソッド呼び出し一発とお手軽です。

実際の手順としては次のようになります。

これをコードにするとこうなります。

// TelemetryClientはスレッドセーフなので使いまわせる
var telemetryClient = new TelemetryClient();

using (var operation = telemetryClient.StartOperation<DependencyTelemetry>("DependencyName"))
{
    var telemetry = operation.Telemetry;
    telemetry.Type = "ResourceType"; // Dependencyの種類(Http, SQLなど)
    telemetry.Target = "リクエスト送信先"; // エンドポイントのホスト名とか
    telemetry.Data = "何か生データー"; // SQLとか

    // 何か時間のかかる処理...
    await Task.Delay(1000 * 3);
}

難しいことはないですね。これでApplication Insightsに記録する方法はなんとなく理解できました。

MySQLの呼び出しを記録する

Dependencyとして記録する方法がわかったので次はMySQLへの問い合わせを記録する方法です。

今回はMySQLドライバーはMySQL公式のConnector/Netを利用していて、単純にSQLのクエリを記録したいと考えていますが、それにはそのクエリのタイミングをつかむ必要があります。そこで少し調べてみると、Connector/NetにはInterceptorというExecute系メソッドに割り込んでSQLのロギングなどが行える仕組みがあったのでそれを利用します。

InterceptorにはあらかじめExecute系メソッドに割り込むためのベースクラスである CommandInterceptorBase クラスがあるので、このクラスを継承して各種メソッドをオーバーライドします。そしてここではオーバーライドしたメソッドでDependencyの記録を行えば良さそうというわけです。

実際に実装した例はこんな感じになります。DependencyのTargetやNameといった値の設定はSQL Serverでの記録と同じような形にしました。

using System;
using System.Collections.Generic;
using System.Data;
using System.Text;
using Microsoft.ApplicationInsights;
using Microsoft.ApplicationInsights.DataContracts;
using MySql.Data.MySqlClient;

namespace WebApplication2.Diagnostics
{
    public class ApplicationInsightsBaseCommandInterceptor : BaseCommandInterceptor
    {
        private TelemetryClient _telemetryClient = new TelemetryClient();
        private string _name;

        public override bool ExecuteNonQuery(string sql, ref int returnValue)
        {
            using (var operation = _telemetryClient.StartOperation<DependencyTelemetry>(_name))
            {
                var telemetry = operation.Telemetry;
                telemetry.Type = "SQL";
                telemetry.Data = sql;
                telemetry.Target = _name;

                return base.ExecuteNonQuery(sql, ref returnValue);
            }
        }

        public override bool ExecuteReader(string sql, CommandBehavior behavior, ref MySqlDataReader returnValue)
        {
            using (var operation = _telemetryClient.StartOperation<DependencyTelemetry>(_name))
            {
                var telemetry = operation.Telemetry;
                telemetry.Type = "SQL";
                telemetry.Data = sql;
                telemetry.Target = _name;

                return base.ExecuteReader(sql, behavior, ref returnValue);
            }
        }

        public override bool ExecuteScalar(string sql, ref object returnValue)
        {
            using (var operation = _telemetryClient.StartOperation<DependencyTelemetry>(_name))
            {
                var telemetry = operation.Telemetry;
                telemetry.Type = "SQL";
                telemetry.Data = sql;
                telemetry.Target = _name;

                return base.ExecuteScalar(sql, ref returnValue);
            }
        }

        public override void Init(MySqlConnection connection)
        {
            _name = String.Format("{0} | {1}", connection.DataSource, connection.Database);

            base.Init(connection);
        }
    }
}

Interceptorを実装したら最後にアプリケーションの設定でデータベース接続文字列に commandinterceptors パラメータを追加して読み込ませます。パラメータの値は<CommandInterceptorClass>,<Assembly>というフォーマットで、次のようなものになります。

commandinterceptors=WebApplication2.Diagnostics.ApplicationInsightsBaseCommandInterceptor,WebApplication2

そしてこれを有効にした状態でアプリケーションを実行するとApplication Insightsに記録されます。もちろんAzureに接続していない場合でもVisual Studioで確認できます。


この例では問い合わせが1ms以下なので全く面白くなくて残念ですが、ともあれこれで取れるようになったので無いよりは全然よさそうです。

AuthenticationHandlerで処理する方法をアクションごとに変更する

Created at:

ASP.NET Core MVCでは認証は基本的にフィルターではなく、認証ハンドラーを認証スキーム名で登録しておき、使用したい認証スキームをAuthorize属性などで指定するという形になっています(デフォルトという指定もできます)。

今回やりたいこととしては「認証機構は基本的に一つで、あるアクションの時だけ認証中に追加で特殊な処理を行いたい」というパターンです。例えばコントローラーに共通のAuthorizeを付けているが、一部特殊なアクションでは追加の処理をしたいというケースです。

難しいところ

そもそも認証ハンドラーはASP.NET Coreの一部であってMVCとは切り離されたものなので、操作可能なものは主にHttpContextとなります。つまりASP.NET Core MVCが認識しているアクションであるとかコントローラーであるとかを取り出すことは難しいため、アクションに付けた属性を引いてくるといったことはできないという悩みがあります。

うまくいかないパターン

そこで最初に考えたのは、二つの認証スキームとして登録してそれぞれで利用する認証スキームを変更するという方法です。

まず次のようなオプションを持つAuthenticationHandlerを用意します。

public class CustomAuthenticationSchemeOptions : AuthenticationSchemeOptions
{
    public bool EnableNanika { get; set; }
}

public class CustomAuthenticationHandler : AuthenticationHandler<CustomAuthenticationSchemeOptions>
{
    public CustomAuthenticationHandler(IOptionsMonitor<CustomAuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
        : base(options, logger, encoder, clock)
    {
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (Options.EnableNanika)
        {
            // 一部で必要な特殊な処理...
        }
        return Task.FromResult(AuthenticateResult.Fail("Unauthorized"));
    }

    protected override Task HandleChallengeAsync(AuthenticationProperties properties)
    {
        Response.Headers["WWW-Authenticate"] = "Nanika";
        return base.HandleChallengeAsync(properties);
    }
}

このハンドラーをStartupで二つの認証スキームとして登録します。

services.AddAuthentication()
    .AddScheme<CustomAuthenticationSchemeOptions, CustomAuthenticationHandler>("Custom1", options =>
    {
        options.EnableNanika = false;
    })
    .AddScheme<CustomAuthenticationSchemeOptions, CustomAuthenticationHandler>("Custom2", options =>
    {
        options.EnableNanika = true;
    });

コントローラーにAuthorize属性を付け、その際にコントローラーとアクションで認証スキームを別々に設定します。

[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "Custom1")]
public class ValuesController : Controller
{
    // GET api/values
    [HttpGet]
    [Authorize(AuthenticationSchemes = "Custom2")]
    public IEnumerable<string> Get()
    {
        return new string[] { "value1", "value2" };
    }
}

これでどうかと思ったのですが、この指定で実際に動かすとコントローラーの指定の方が勝つことになります。当然ですがコントローラーにAuthorizeを付けずにアクションごと個別につけると期待通りに動作します。

うまくいかないパターン(2)

認証はフィルターとしても実装できるので(Authorizeも実体はMVCのフィルターで、中で認証ハンドラーを呼び出している)、それをかけてみるのはどうかという風になります。

public class CustomAuthorize : Attribute, IAsyncAuthorizationFilter
{
    public bool EnableNanika { get; set; }

    public Task OnAuthorizationAsync(AuthorizationFilterContext context)
    {
        // ここで認証処理をあれこれやる
        return Task.CompletedTask;
    }
}

この場合にはコントローラーとアクション両方に指定すると二つのフィルターが呼ばれることになる(=二回処理が走る)ので都合が悪い感じになります。それに加えてAuthorize属性と同じようなことを書いてあげる必要があり面倒です。

解決策: フィルターを組み合わせるパターン

認証はフィルターとして実装できるという点を利用して、認証自体は行わずリクエストのデータにフラグを立てておき、認証ハンドラーで取り出して何とかする方法があります。その方法であれば 自前のフィルター(ASP.NET Core MVC) → 自前の認証ハンドラー (ASP.NET Core) という形にできます。

まずアクションにつけるフィルターを作ります。

public class UseCustomAuthenticationWithNanikaAttribute : Attribute, IAsyncAuthorizationFilter, IOrderedFilter
{
    public int Order => int.MinValue;

    public Task OnAuthorizationAsync(AuthorizationFilterContext context)
    {
        context.HttpContext.Features.Set<IUseCustomAuthenticationWithNanikaFeature>(new UseCustomAuthenticationWithNanikaFeature
        {
            Enable = true
        });
        return Task.CompletedTask;
    }
}

public interface IUseCustomAuthenticationWithNanikaFeature
{
    bool Enable { get; set; }
}
public class UseCustomAuthenticationWithNanikaFeature : IUseCustomAuthenticationWithNanikaFeature
{
    public bool Enable { get; set; }
}

このフィルターはOnAuthorizationAsyncでHttpContextのFeatures(リクエスト単位の機能を入れておけるもの)にフラグ情報を持つFeatureを保存します。IOrderedFilterを実装しているのはAuthorize属性より先に来てほしいためです。

次に認証ハンドラーを作ります。このハンドラーの中でFeaturesから先ほどのフィルターでつけていたFeatureを引っ張り出します。

public class CustomAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public CustomAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
        : base(options, logger, encoder, clock)
    {
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var enableNanika = Context.Features.Get<IUseCustomAuthenticationWithNanikaFeature>()?.Enable ?? false;
        if (enableNanika)
        {
            // 一部で必要な特殊な処理...
        }

        return Task.FromResult(AuthenticateResult.Fail("Unauthorized"));
    }

    protected override Task HandleChallengeAsync(AuthenticationProperties properties)
    {
        Response.Headers["WWW-Authenticate"] = "Nanika";
        return base.HandleChallengeAsync(properties);
    }
}

Startupで認証ハンドラーを登録します。この際、AddAuthenticationでデフォルト認証スキームを登録しないように注意します。デフォルトの認証スキームとなるハンドラーは順番が特別なことになるので意図した挙動になりません。

services.AddAuthentication()
    .AddScheme<AuthenticationSchemeOptions, CustomAuthenticationHandler>("Custom", options => { });

そしてコントローラーにはAuthorize属性を、特別な処理をしたいアクションにはUseCustomAuthenticationWithNanika属性でフィルターを付けます。

[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "Custom")]
public class ValuesController : Controller
{
    // GET api/values
    [HttpGet]
    [UseCustomAuthenticationWithNanika]
    public IEnumerable<string> Get()
    {
        return new string[] { "value1", "value2" };
    }

    // GET api/values/5
    [HttpGet("{id}")]
    public string Get(int id)
    {
        return "value";
    }
}

これでアクションに属性を指定するだけで認証時の挙動を少し変更するということが可能になります。