ROMANCE DAWN for the new world

Microsoft Azure を中心とした技術情報を書いています。

Semantic Logging Application Block で ETW ログを Azure Search に出力する

www.buildinsider.net

先月、Build Insider MEETUP with Grani に参加してきました。C# で Web アプリケーションを作るフレームワークの話のなかで、Semantic Logging Application Block(SLAB)が面白そうだったので、試してみました。

SLAB は、Event Tracing for Windows(ETW)のログを出力できるライブラリであり、Enterprise Library 6.0 に含まれるアプリケーションブロックのひとつです。厳密に型付けされたイベントをベースにしたログは、コンソールアプリ、テキストファイル、SQL Server、Azure Table Storage、Elasticsearch などに出力できます。Elasticsearch に出力できるなら、Azure Search に出力してもよさそうなので試してみました。ASP.NET Web API で発生した例外をハンドリングして、ETW ログを出力するシナリオです。

ETW ログを書き込む

ASP.NET Web API の Web アプリケーションを作成し、ETW ログを書き込むクラスを作成します。EventSource クラスを継承したクラスを作成し、ログを書き込む Error メソッドを実装します。ログに書き込む情報は、HTTP メソッド、URL の絶対パス、例外メッセージの3つとします。

#MyEventSource.cs
[EventSource(Name = "MyEventSource")]
public sealed class MyEventSource : EventSource
{
    private readonly static MyEventSource log = new MyEventSource();
    private MyEventSource() { }
    public static MyEventSource Log { get { return log; } }
 
    [Event(1, Level = EventLevel.Error, Message = "An error has occurred")]
    public void Error(string method, string path, string exceptionMessage)
    {
        WriteEvent(1, method, path, exceptionMessage);
    }
}

例外をハンドリングする ExceptionLogger クラスを継承したクラスを作成してロギングします。

#MyExceptionLogger.cs
[EventSource(Name = "MyEventSource")]
public class MyExceptionLogger : ExceptionLogger
{
    public override void Log(ExceptionLoggerContext context)
    {
        var method = context.Request.Method.ToString();
        var path = context.Request.RequestUri.AbsolutePath;
        var exceptionMessage = context.Exception.Message;
 
        MyEventSource.Log.Error(method ?? "", path ?? "", exceptionMessage ?? "");
    }
}

MyExceptionLogger クラスは、WebApiConfig クラスで有効化しておきます。

#WebApiConfig.cs
[EventSource(Name = "MyEventSource")]
public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        config.Services.Add(typeof(IExceptionLogger), new MyExceptionLogger());
 
        (省略)
        );
    }
}

Azure Search のアクセスクラスを作成する

Web アプリケーションのプロジェクトに、Azure Search Library(Preview)を NuGet からインストールします。ログのデータをモデルに詰め替えたいので、AutoMapper も合わせてインストールします。

  • Install-Package Microsoft.Azure.Search -Pre
  • Install-Package AutoMapper -Version 3.3.1

Azure Search に対して、インデックスの作成、ログのアップロード、ログの検索を行うアクセスクラスを作成します。

#EventSearchClient.cs
public class EventSearchClient
{
    private readonly string _serviceName;
    private readonly string _apiKey;
    private readonly string _indexName;
 
    public EventSearchClient(string serviceName, string apiKey)
    {
        this._serviceName = serviceName;
        this._apiKey = apiKey;
        this._indexName = "event";
    }
 
    public async Task CreateIndexAsync()
    {
        using (var serviceClient = new SearchServiceClient(this._serviceName, new SearchCredentials(this._apiKey)))
        {
            // インデックスの削除
            if (await serviceClient.Indexes.ExistsAsync(this._indexName))
            {
                await serviceClient.Indexes.DeleteAsync(this._indexName);
            }
 
            // インデックスの作成
            var definition = new Index()
            {
                Name = this._indexName,
                Fields = new[]
                    {
                        new Field("id", DataType.String)    { IsKey = true },
                        new Field("providerId", DataType.String)  { IsSearchable = true, IsFilterable = true },
                        new Field("eventId", DataType.Int32)  { IsFilterable = true },
                        new Field("keywords", DataType.Int32)  { IsFilterable = true },
                        new Field("level", DataType.Int32)  { IsFilterable = true },
                        new Field("message", DataType.String)  { IsSearchable = true, IsFilterable = true },
                        new Field("opcode", DataType.Int32)  { IsFilterable = true },
                        new Field("task", DataType.Int32)  { IsFilterable = true },
                        new Field("version", DataType.Int32)  { IsFilterable = true },
                        new Field("eventName", DataType.String)  { IsSearchable = true, IsFilterable = true },
                        new Field("timestamp", DataType.DateTimeOffset)  { IsFilterable = true, IsSortable = true },
                        new Field("processId", DataType.Int32)  { IsFilterable = true },
                        new Field("threadId", DataType.Int32)  { IsFilterable = true },
                        new Field("method", DataType.String)  { IsSearchable = true, IsFilterable = true },
                        new Field("path", DataType.String)  { IsSearchable = true, IsFilterable = true },
                        new Field("exceptionMessage", DataType.String)  { IsSearchable = true, IsFilterable = true },
                    }
            };
            await serviceClient.Indexes.CreateAsync(definition);
        }
    }
 
    public async Task UploadAsync(string json)
    {
        using (var serviceClient = new SearchServiceClient(this._serviceName, new SearchCredentials(this._apiKey)))
        using (var indexClient = serviceClient.Indexes.GetClient(this._indexName))
        {
            // JSON 形式のログを、Azure Search で検索しやすい MyEventEntryModel 型に変換
            var eventEntry = JsonConvert.DeserializeObject<List<MyEventEntry>>(json);
            var documents = eventEntry.Select(x => Mapper.Map<MyEventEntryModel>(x));
            try
            {
                // ログのアップロード
                await indexClient.Documents.IndexAsync(IndexBatch.Create(documents.Select(x => IndexAction.Create(x))));
            }
            catch (IndexBatchException e)
            {
                var message = String.Format(
                    "Failed to index some of the documents: {0}",
                    String.Join(", ", e.IndexResponse.Results.Where(r => !r.Succeeded).Select(r => r.Key)));
                throw new Exception(message);
            }
            await Task.Delay(2000); // 非同期で作成されるので、少し待機する
        }
    }
 
    public async Task<IEnumerable<MyEventEntryModel>> SearchAsync(string searchText)
    {
        using (var serviceClient = new SearchServiceClient(this._serviceName, new SearchCredentials(this._apiKey)))
        using (var indexClient = serviceClient.Indexes.GetClient(this._indexName))
        {
            var sp = new SearchParameters();
            sp.OrderBy = new List<string> { "timestamp desc" };
            var response = await indexClient.Documents.SearchAsync<MyEventEntryModel>(searchText, sp);
            return response.Results.Select(x => x.Document);
        }
    }
}

UploadAsync メソッドでは、JSON 形式で渡されてきたログを MyEventEntry クラスにデシリアライズしています。MyEventEntry クラスは、Payload プロパティに HTTP メソッド、URL の絶対パス、例外メッセージが内包されていて Azure Search では検索し難いため、AutoMapper でフラットな MyEventEntryModel クラスに詰め替えています。

#MyEventEntry.cs
[SerializePropertyNamesAsCamelCase]
public class MyEventEntryModel
{
    public string Id { get; set; }
    public string ProviderId { get; set; }
    public int? EventId { get; set; }
    public int? Keywords { get; set; }
    public int? Level { get; set; }
    public string Message { get; set; }
    public int? Opcode { get; set; }
    public int? Task { get; set; }
    public int? Version { get; set; }
    public string EventName { get; set; }
    public DateTimeOffset? Timestamp { get; set; }
    public int? ProcessId { get; set; }
    public int? ThreadId { get; set; }
    public string Method { get; set; }
    public string Path { get; set; }
    public string ExceptionMessage { get; set; }
}
#MyEventEntryModel.cs
[SerializePropertyNamesAsCamelCase]
public class MyEventEntryModel
{
    public string Id { get; set; }
    public string ProviderId { get; set; }
    public int? EventId { get; set; }
    public int? Keywords { get; set; }
    public int? Level { get; set; }
    public string Message { get; set; }
    public int? Opcode { get; set; }
    public int? Task { get; set; }
    public int? Version { get; set; }
    public string EventName { get; set; }
    public DateTimeOffset? Timestamp { get; set; }
    public int? ProcessId { get; set; }
    public int? ThreadId { get; set; }
    public string Method { get; set; }
    public string Path { get; set; }
    public string ExceptionMessage { get; set; }
}

MyEventEntryModel クラスには、ポイントが3つあります。

  1. SerializePropertyNamesAsCamelCase 属性により、大文字小文字の違いを意識しないで大丈夫
  2. 値型は NULL を許容する必要がある
  3. Azure Search の日付型は、DateTimeOffset

AutoMapper のマッピング定義は、Global.asax の Application_Start メソッドで設定しておきます。

#Global.asax
public class MvcApplication : System.Web.HttpApplication
{
    protected void Application_Start()
    {
        (省略)
 
        Mapper.CreateMap<MyEventEntry, MyEventEntryModel>()
            .ForMember(dst => dst.Method, opt => opt.MapFrom(src => src.Payload.method))
            .ForMember(dst => dst.Path, opt => opt.MapFrom(src => src.Payload.path))
            .ForMember(dst => dst.ExceptionMessage, opt => opt.MapFrom(src => src.Payload.exceptionMessage))
            .ForMember(dst => dst.Timestamp, opt => opt.MapFrom(src => new DateTimeOffset(src.Timestamp)))
            .ForMember(dst => dst.Id, opt => opt.MapFrom(src => String.Format("{0:D19}", DateTime.MaxValue.Ticks - src.Timestamp.Ticks)));
    }
}

SLAB の Custom Event Sink を作成する

SLAB で任意のストレージにログを出力したい場合には、Custom Event Sink を作成します。IObserver<EventEntry> クラスを継承したクラスを作成し、OnNext メソッドでログの出力を実装します。EventSearchClient クラスの UploadAsync メソッドの呼び出しは、UI スレッドに戻さないように ConfigureAwait(false) で待機する必要があります。

#AzureSearchSink.cs
public class AzureSearchSink : IObserver<EventEntry>
{
    private readonly string _serviceName;
    private readonly string _apiKey;
 
    public AzureSearchSink(string serviceName, string apiKey)
    {
        this._serviceName = serviceName;
        this._apiKey = apiKey;
    }
 
    public void OnCompleted()
    {
    }
 
    public void OnError(Exception error)
    {
    }
 
    public void OnNext(EventEntry value)
    {
        if (value != null)
        {
            using (var writer = new StringWriter())
            {
                new JsonEventTextFormatter().WriteEvent(value, writer);
                Post(writer.ToString());
            }
        }
    }
 
    private void Post(string body)
    {
        var json = String.Format("[{0}]", body);    // JSON配列
        new EventSearchClient(this._serviceName, this._apiKey).UploadAsync(json).ConfigureAwait(false);
    }
}

ObservableEventListener クラスに AzureSearchSink クラスを設定する拡張メソッドを定義し、Global.asax の Application_Start メソッドで設定します。LogToAzureSearch メソッドのサービス名と API キーは、Azure 管理ポータルから取得した情報を設定してください。

#AzureSearchSinkExtensions.cs
public static class AzureSearchSinkExtensions
{
    public static EventListener CreateListener(string serviceName, string apiKey)
    {
        var listener = new ObservableEventListener();
        listener.LogToAzureSearch(serviceName, apiKey);
        return listener;
    }
 
    public static SinkSubscription<AzureSearchSink> LogToAzureSearch(this IObservable<EventEntry> eventStream, string serviceName, string apiKey)
    {
        var sink = new AzureSearchSink(serviceName, apiKey);
        var subscription = eventStream.Subscribe(sink);
        return new SinkSubscription<AzureSearchSink>(subscription, sink);
    }
}
#Global.asax
public class MvcApplication : System.Web.HttpApplication
{
    protected void Application_Start()
    {
        (省略)
 
        var listener = new ObservableEventListener();
        listener.EnableEvents(MyEventSource.Log, EventLevel.Error);
        listener.LogToAzureSearch("xxx", "xxx");
    }
}

Web API を作成する

ASP.NET Web API の EventController クラスを作成し、POST メソッドでインデックスの作成、GET メソッドでログの検索を行います。

#EventController.cs
[RoutePrefix("api/event")]
public class EventController : ApiController
{
    private readonly string _serviceName = "xxx";
    private readonly string _apiKey = "xxx";
 
    [Route]
    public async Task<IEnumerable<MyEventEntryModel>> Get()
    {
        return await new EventSearchClient(this._serviceName, this._apiKey).SearchAsync("");
    }
 
    [Route("{searchText}")]
    public async Task<IEnumerable<MyEventEntryModel>> Get(string searchText)
    {
        return await new EventSearchClient(this._serviceName, this._apiKey).SearchAsync(searchText);
    }
 
    [Route]
    public async Task Post()
    {
        await new EventSearchClient(this._serviceName, this._apiKey).CreateIndexAsync();
    }
}

もうひとつ、例外を発生させる TestController クラスも作成します。

#TestController.cs
[RoutePrefix("api/test")]
public class TestController : ApiController
{
    [Route("{id:int}")]
    public string Get(int id)
    {
        throw new Exception(String.Format("test{0}", id));
        return "success";
    }
}

結果確認

Google Chrome の Postman を使って、Web API を実行します。

  1. POST api/event/
  2. GET api/test/1/
  3. GET api/test/2/
  4. GET api/test/3/
  5. GET api/event/

上記のような 5 つの Web API を実行すると、Azure Search にインデックスが作成され、発生した例外のログがアップロードされます。結果として、3 件のログを取得できます。

slab01

また、GET api/event/test2/ を実行すると、条件を絞り込んでログを取得することも可能です。

slab02

まとめ

アプリケーションのログは、単に溜め込んでいくだけでは十分な分析や活用ができないので、活用しやすい構造で保管していくことが重要です。SLAB と ETW を使えば、セマンティックな構造化されたログを、任意のストレージに出力することができます。コンソールや Windows Service を使って SLAB を Out of Process でホストさせることも可能です。自前でログを作り込むのもよいですが、SLAB はなかなか面白いライブラリなので、使ってみるのもありだと思いました。