Application Insights へ出力したログをバッチで取得する

2023/10/03に公開

はじめに

今回は、Azure コンポーネントを利用して、Application Insights に出力されたログをバッチ処理で取得してみたいと思います。コンポーネントは下記を利用します。

# コンポーネント 用途
1 Resource Groups Azure ソリューションの関連するリソースを保持するコンテナ
2 Azure Functions サーバーレスコンピューティング
3 Application Insights アプリケーションパフォーマンス監視
4 Storage Account データストレージソリューション ※今回はBlobのみ利用
5 Azure Container Registry コンテナイメージの保管場所

本記事の Azure Bicep や C#で実装した Azure Functions の ソースコードは、Gitに登録しています。

https://github.com/yutaka-art/ApplicationInsights_Worker

アーキテクチャ構成

アーキテクチャ構成はこんな感じです。

Azure Functions を使用する場合、Storage Account はセットで必要ですが、既に別の Storage Account があればそれを利用しても問題ありません。また、Application Insights は 監視要件 が無ければ 省略可能です。

Azure コンポーネントの作成

各種コンポーネントを Azure CLI と Bicep を利用して作成していきます。

まずはリソースグループを作成します。

bash
az group create --name rg-zenn-9e6580d41f8f62 --location japaneast

リソースグループが作成されるとAzure Portalではこんな感じに見えます

次に Bicep を使って、リソースグループの中に各種コンポーネントを作成していきます。

az deployment group create --resource-group rg-zenn-9e6580d41f8f62 --template-file deploy/main.bicep --parameters environmentName=zenn-9e6580d41f8f62

main.bicep では、Log Analytics、Application Insights、Storage Account、Azure Container Registry、App Service Plan、Azure Functions のリソースを定義します。

main.bicep
main.bicep
param environmentName string = 'zenn-9e6580d41f8f62'
param storageAccountName string = 'st${replace(environmentName, '-', '')}'
param logAnalyticsWorkspaceName string = 'logs-${environmentName}'
param appInsightsName string = 'appi-${environmentName}'
param acrName string = 'cr${replace(environmentName, '-', '')}'
param hostingPlanName string = 'plan-${environmentName}'
param functionAppName string = 'func-${environmentName}'
param location string = resourceGroup().location

// LogAnalyticsWorkspace
resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2020-08-01' = {
  name: logAnalyticsWorkspaceName
  location: location
  properties: any({
    retentionInDays: 30
    features: {
      searchVersion: 1
      legacy: 0
      enableLogAccessUsingOnlyResourcePermissions: true
    }
    sku: {
      name: 'PerGB2018'
    }
  })
}

// Application Insights
resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
  name: appInsightsName
  location: location
  kind: 'web'
  properties: { 
    Application_Type: 'web'
    WorkspaceResourceId:logAnalyticsWorkspace.id
  }
}

// Storage account
var strageSku = 'Standard_LRS'

resource storageAccount 'Microsoft.Storage/storageAccounts@2021-04-01' = {
  name: storageAccountName
  location: location
  kind: 'Storage'
  sku:{
    name: strageSku
  }
}

// Azure Container Registry
param acrSku string = 'Basic'

resource acrResource 'Microsoft.ContainerRegistry/registries@2023-01-01-preview' = {
  name: acrName
  location: location
  sku: {
    name: acrSku
  }
  properties: {
    adminUserEnabled: true
  }
}

// Function App Plan
resource hostingPlan  'Microsoft.Web/serverfarms@2021-03-01' = {
  name: hostingPlanName
  location: location
  sku: {
    name: 'EP1'
    tier: 'ElasticPremium'
    size: 'EP1'
  }
  properties: {
    reserved: true // for Linux
  }
}

// Function App
resource functionApp 'Microsoft.Web/sites@2021-03-01' = {
  name: functionAppName
  location: location
  kind: 'functionapp,linux,container'
  properties: {
    serverFarmId: hostingPlan.id
    siteConfig: {
      appSettings: [
        {
          name: 'APPINSIGHTS_INSTRUMENTATIONKEY'
          value: appInsights.properties.InstrumentationKey
        }
        {
          name: 'AzureWebJobsStorage'
          value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}'
        }
        {
          name: 'DOCKER_ENABLE_CI'
          value: 'true'
        }
        {
          name: 'DOCKER_REGISTRY_SERVER_PASSWORD'
          value: ''
        }
        {
          name: 'DOCKER_REGISTRY_SERVER_URL'
          value: 'https://${acrName}.azurecr.io'
        }
        {
          name: 'DOCKER_REGISTRY_SERVER_USERNAME'
          value: '${acrName}'
        }
        {
          name: 'FUNCTIONS_EXTENSION_VERSION'
          value: '~4'
        }
        {
          name: 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING'
          value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}'
        }
        {
          name: 'WEBSITES_ENABLE_APP_SERVICE_STORAGE'
          value: 'false'
        }
        {
          name: 'TZ'
          value: 'Asia/Tokyo'
        }
        {
          name: 'STORAGE_CONNECT_STRING'
          value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}'
        }
        {
          name: 'KQL_APPLICATION_ID'
          value: 'a78281cd-5c91-40ce-807e-1b47452ea16f'
        }
        {
          name: 'KQL_APPLICATION_API_KEY'
          value: '1hxiiqwavakwklgpqcejussrnfn3lkl5sv8mdh3b'
        }
        {
          name: 'KQL_MAIN_QUERY_REGION'
          value: 'traces | project timestamp, message, client_StateOrProvince, client_City'
        }
        {
          name: 'KQL_ORDER_BY_REGION'
          value: ' | order by timestamp desc'
        }
        {
          name: 'KQL_WHERE_REGION'
          value: ' | where timestamp >= datetime(TARGET_FROM) and timestamp <= datetime(TARGET_TO) and message contains \'AlphaProcessAsync\''
        }
        {
          name: 'KQL_WHERE_REGION_TIME_SPAN'
          value: '-12'
        }
        {
          name: 'BLOB_CONTAINER_NAME_OPE_LOG'
          value: 'aggregate'
        }
      ]
      linuxFxVersion: 'DOCKER|mcr.microsoft.com/azure-cognitive-services/decision/anomaly-detector:latest'
      ftpsState: 'FtpsOnly'
      minTlsVersion: '1.2'
    }
    httpsOnly: true
  }
}

作成されたリソースはこんな感じです。

各コンポーネント名称のプリフィックスは命名規約に従いましょう。
https://learn.microsoft.com/ja-jp/azure/cloud-adoption-framework/ready/azure-best-practices/resource-abbreviations

Application Insights に対して エンドポイントを作成

Application Insights に対して API アクセス より エンドポイントを作成します。
2023 年 10 月 時点では、BicepのコードでAPI アクセスの設定を変更することはできないため、Azure Portal より設定していきます。

Application Insights の API アクセス を クリックします。
記載されているアプリケーションID を Azure Functions の 環境変数に登録する必要があるため、メモしておきます。

続いて、API キーの作成 をクリックします。
キーに対して 任意の名前 を入力し、「利用統計情報の読み取り」へチェックを入れ、「キーの生成」をクリックします。

キーが発行されます。こちらも、Azure Functions の 環境変数に登録する必要があるため、メモしておきます。

これで、Application Insights に対して エンドポイントを作成することができました。
この APP ID と APP KEY の情報を利用することで、APIを経由して、Application Insights へ接続することができます。

Azure Functions 環境変数へ設定

続いて、APP ID と APP KEY の情報を、先ほど作成した Azure Functions リソース の 環境変数に設定します。
Azure Portal を開き、Azure Functions のページへ進みます。
「構成」をクリックし、「アプリケーション設定」タブの、「編集」をクリックし、「KQL_APPLICATION_ID」へ先ほどコピーした値を設定します。

「KQL_APPLICATION_API_KEY」も同様に設定します。

続いて、Azure Container Registry の パスワードを Functions がコンテナイメージを読み込むときに必要なので設定します。
Password が必要なので、Azure Container Registry の アクセスキーの情報を参照して入力ください。

「DOCKER_REGISTRY_SERVER_PASSWORD」に設定します。

Azure Functionsの実装

クラス図

Functions は C# で実装していきます。

  • Functions は処理のトリガーとして Timer, Event Grid, HTTP 3種類 の設定できますが、今回は HTTP とします。
  • Alpha の処理は単純に引数 name の値を 5 回 出力する処理になります。
  • Bravo の処理は Application Insights に出力されたログデータを Kusto Query Language (KQL) を使用して取得し、Csv 形式で Blob Storage に出力します。
  • Application Insights で作成したエンドポイントに API ID と API KEY を利用して、HttpClient 経由で GET リクエストを送信して取得します。
EntryPoint.cs
EntryPoint.cs
public class EntryPoint
{
    #region Variable・Const
    private readonly ITelemetryService TelemetryService;
    #endregion

    #region [EntryPoint]
    /// <summary>
    /// EntryPoint
    /// </summary>
    /// <param name="telemetryService"></param>
    public EntryPoint(ITelemetryService telemetryService)
    {
        this.TelemetryService = telemetryService;
    }
    #endregion

    [FunctionName("Alpha")]
    public IActionResult RunAlphaProcess(
        [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
        ILogger log)
    {
        var name = req.Query["name"];
        var returnModel = new TelemetryServiceReturnModel();

        try
        {
            this.TelemetryService.AlphaProcessAsync(name);
            returnModel.IsSucceed = true;
        }
        catch (Exception ex)
        {
            returnModel.IsSucceed = false;
            returnModel.Exception = ex.ToString();
        }

        return new OkObjectResult(JsonConvert.SerializeObject(returnModel, Formatting.Indented));
    }

    [FunctionName("Bravo")]
    public async Task<IActionResult> RunBravoProcessAsync(
        [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
        ILogger log)
    {
        var returnModel = new TelemetryServiceReturnModel();

        try
        {
            await this.TelemetryService.BravoProcessAsync();
            returnModel.IsSucceed = true;
        }
        catch (Exception ex)
        {
            returnModel.IsSucceed = false;
            returnModel.Exception = ex.ToString();
        }

        return new OkObjectResult(JsonConvert.SerializeObject(returnModel, Formatting.Indented));
    }
}
Services/TelemetryService.cs
Services/TelemetryService.cs
/// <summary>TelemetryService</summary>
public class TelemetryService : ITelemetryService
{
    #region Variable・Const
    /// <summary>Logger</summary>
    private static MetricLogger Logger = MetricLogger.GlobalInstance;
    /// <summary>StorageProvider</summary>
    private readonly IStorageProvider StorageProvider = null;
    /// <summary>ApplicationInsightProvider</summary>
    private readonly IApplicationInsightsProvider ApplicationInsightsProvider;
    #endregion

    /// <summary>
    /// Constructor
    /// </summary>
    /// <param name="storageProvider">StorageProvider</param>
    /// <param name="applicationInsightsProvider">ApplicationInsightProvider</param>
    /// <exception cref="ArgumentNullException"></exception>
    public TelemetryService(IStorageProvider storageProvider, IApplicationInsightsProvider applicationInsightsProvider) : base()
    {
        if (storageProvider == null)
            throw new ArgumentNullException(nameof(storageProvider));
        if (applicationInsightsProvider == null)
            throw new ArgumentNullException(nameof(applicationInsightsProvider));

        this.StorageProvider = storageProvider;
        this.ApplicationInsightsProvider = applicationInsightsProvider;
    }

    /// <summary>
    /// Alpha
    /// </summary>
    /// <param name="name"></param>
    public void AlphaProcess(string name)
    {
        for (int i = 0; i < 5; i++)
        {
            Logger.Info($"{BaseLogger.GetCurrentMethod()}_{name}_{i.ToString().PadLeft(4, '0')}");
        }
    }

    /// <summary>
    /// Bravo
    /// </summary>
    /// <returns></returns>
    public async Task BravoProcessAsync()
    {
        var dt = await this.ApplicationInsightsProvider.GetAppInsightDataTableAsync();
        var csv = this.ApplicationInsightsProvider.ConvertDataTableToCsvString(dt);

        var containerName = Environment.GetEnvironmentVariable("BLOB_CONTAINER_NAME_OPE_LOG");
        var csvBytes = Encoding.UTF8.GetBytes(csv);

        var targetTodayDirectory = DateTime.Now.ToString("yyyyMMdd");
        var outFileName = $"Log_{DateTime.Now.ToString("yyyyMMddHHmmss")}.csv";
        var outFilePath = $"{targetTodayDirectory}/{outFileName}";

        await this.StorageProvider.UploadFileToBlobAsync(containerName, outFilePath, csvBytes);
    }
}
Repositories/ApplicationInsightsProvider.cs
Repositories/ApplicationInsightsProvider.cs
#region Method
/// <summary>Obtain logs from Application Insight and return them in DataTable format</summary>
/// <returns></returns>
public async Task<DataTable> GetAppInsightDataTableAsync()
{
    return await GetTargetDataTableAsync();
}

/// <summary>
/// Fetches data via Kusto Query Language (KQL) and stores it in a DataTable.
/// </summary>
/// <returns>Data table with raw data.</returns>
private async Task<DataTable> GetTargetDataTableAsync()
{
    var appId = Environment.GetEnvironmentVariable("KQL_APPLICATION_ID");
    var apiKey = Environment.GetEnvironmentVariable("KQL_APPLICATION_API_KEY");
    var query = GetTargetKQL().ToString();

    var url = $"https://api.applicationinsights.io/v1/apps/{appId}/query?query={query}";

    var request = new HttpRequestMessage(HttpMethod.Get, url);
    request.Headers.Add("x-api-key", apiKey);

    var response = await HttpClient.SendAsync(request);
    var responseContent = await response.Content.ReadAsStringAsync();

    var responseJson = JsonConvert.DeserializeObject<JToken>(responseContent);
    var layers = responseJson.Value<JArray>("tables");

    var dataTable = new DataTable();

    foreach (var item in layers.Children())
    {
        ExtractColumns(item, dataTable);
        PopulateRows(item, dataTable);
    }

    return dataTable;
}

private void ExtractColumns(JToken item, DataTable dataTable)
{
    var columns = item["columns"];

    foreach (JObject column in columns)
    {
        var colName = column["name"].ToString();
        dataTable.Columns.Add(colName, typeof(string)).AllowDBNull = true;
    }
}

private void PopulateRows(JToken item, DataTable dataTable)
{
    var rows = item["rows"];

    foreach (var row in rows)
    {
        var newRow = dataTable.NewRow();

        for (int idx = 0; idx < dataTable.Columns.Count; idx++)
        {
            var columnName = dataTable.Columns[idx].ToString();
            var value = row[idx];

            if (columnName.Equals("timestamp"))
            {
                var jst = TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time");
                var utcDateTime = DateTime.Parse(value.ToString());
                newRow[columnName] = TimeZoneInfo.ConvertTimeFromUtc(utcDateTime, jst);
            }
            else
            {
                newRow[columnName] = value;
            }
        }

        dataTable.Rows.Add(newRow);
    }
}

/// <summary>
/// KQL Build
/// </summary>
/// <returns>KQL</returns>
private StringBuilder GetTargetKQL()
{
    var result = new StringBuilder();

    var queryMain = Environment.GetEnvironmentVariable("KQL_MAIN_QUERY_REGION");
    var queryOrder = Environment.GetEnvironmentVariable("KQL_ORDER_BY_REGION");
    var queryWhere = Environment.GetEnvironmentVariable("KQL_WHERE_REGION");

    var targetToday = DateTime.Now;
    var targetFrom = targetToday.AddMonths(int.Parse(Environment.GetEnvironmentVariable("KQL_WHERE_REGION_TIME_SPAN"))).ToString("yyyy-MM-dd 00:00:00");
    var targetTo = targetToday.ToString("yyyy-MM-dd 23:59:59");

    queryWhere = queryWhere.Replace("TARGET_FROM", targetFrom);
    queryWhere = queryWhere.Replace("TARGET_TO", targetTo);

    result.Append(queryMain);
    result.Append(queryOrder);
    result.Append(queryWhere);

    return result;
}

/// <summary>Converts a DataTable to a CSV formatted string.</summary>
/// <param name="dt">The DataTable to convert.</param>
/// <param name="writeHeader">Whether to include headers in the CSV output.</param>
/// <returns>The CSV formatted string.</returns>
public string ConvertDataTableToCsvString(DataTable dt, bool writeHeader = true)
{
    var result = new StringBuilder();

    int colCount = dt.Columns.Count;
    int lastColIndex = colCount - 1;

    if (writeHeader)
    {
        for (int i = 0; i < colCount; i++)
        {
            string field = EncloseDoubleQuotesIfNeed(dt.Columns[i].Caption);
            result.Append(field);
            if (lastColIndex > i)
            {
                result.Append(",");
            }
        }
        result.AppendLine();
    }

    foreach (DataRow row in dt.Rows)
    {
        for (int i = 0; i < colCount; i++)
        {
            string field = row[i].ToString();
            if (!string.IsNullOrEmpty(field))
            {
                field = EncloseDoubleQuotes(field);
            }

            result.Append(field);
            if (lastColIndex > i)
            {
                result.Append(",");
            }
        }

        result.AppendLine();
    }

    return result.ToString();
}

private string EncloseDoubleQuotesIfNeed(string field)
{
    if (NeedEncloseDoubleQuotes(field))
    {
        return EncloseDoubleQuotes(field);
    }
    return field;
}

private static string EncloseDoubleQuotes(string field)
{
    if (field.Contains("\""))
    {
        field = field.Replace("\"", "\"\"");
    }
    return $"\"{field}\"";
}

private bool NeedEncloseDoubleQuotes(string field)
{
    return field.Contains("\"") ||
        field.Contains(",") ||
        field.Contains("\r") ||
        field.Contains("\n") ||
        field.StartsWith(" ") ||
        field.StartsWith("\t") ||
        field.EndsWith(" ") ||
        field.EndsWith("\t");
}
#endregion

docker コマンドによる コンテナイメージ化 から Azure Container Registry へ 登録

実装した Azure Functions のコードをコンテナイメージ化します。
dockerコマンドを利用するため、ローカルの端末に Docker Desktop が必要です。
https://www.docker.com/products/docker-desktop/

また、これから利用する「docker login」コマンドを実行する際に、認証情報が必要です。UserとPasswordが必要なので、Azure Container Registry のアクセスキー情報を参照して入力してください。

それでは、docker コマンドでイメージを作成していきます。

bash
docker build -t applicationinsights_worker .
docker login crzenn9e6580d41f8f62.azurecr.io
docker tag applicationinsights_worker crzenn9e6580d41f8f62.azurecr.io/applicationinsights_worker:v0.1
docker push crzenn9e6580d41f8f62.azurecr.io/applicationinsights_worker:v0.1

Dockerfile は下記のような形で、docker build コマンドで利用されます。

Dockerfile
#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging.

FROM mcr.microsoft.com/azure-functions/dotnet:4 AS base
WORKDIR /home/site/wwwroot
EXPOSE 80

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["ApplicationInsights_Worker/ApplicationInsights_Worker.csproj", "ApplicationInsights_Worker/"]
RUN dotnet restore "ApplicationInsights_Worker/ApplicationInsights_Worker.csproj"
COPY . .
WORKDIR "/src/ApplicationInsights_Worker"
RUN dotnet build "ApplicationInsights_Worker.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "ApplicationInsights_Worker.csproj" -c Release -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /home/site/wwwroot
COPY --from=publish /app/publish .
ENV AzureWebJobsScriptRoot=/home/site/wwwroot \
    AzureFunctionsJobHost__Logging__Console__IsEnabled=true

Azure Container Registry へ イメージが登録されていることを確認します。
Azure Portal を開き、Azure Container Registry を開きます。

Azure Functions へ コンテナイメージを設定

Azure Container Registry へ 登録したイメージを、Azure Functions より 読み込む設定をします。

Azure Portal を 開き、Functions より、デプロイ センター を クリックします。

「イメージ」へ、"applicationinsights_worker"
「タグ」へ、"v0.1" と、それぞれ設定します。

保存ボタンをクリックします。

Azure Container Registry より イメージを正常に読み込むことで、Azure Functionsの概要に、ソースコードで定義したエンドポイントが表示されます。

動作確認をしてみる

Azure Functions の エンドポイント へ 接続します。
エンドポイントは、Azure Portal を 開き、概要 から URL が表示されます。

まずは、Alpha を起動します。

url
https://func-zenn-9e6580d41f8f62.azurewebsites.net/api/Alpha?name=hoge

Alphaの処理は、引数の値を 5回 Application Insights へログ出力します。
Application Insights に対して、Kusto Query Language (KQL) を発行し確認してみます。

kusto
traces
| where message contains "AlphaProcessAsync"

出ていますね。OKです。

次に、Bravo を起動します。

url
https://func-zenn-9e6580d41f8f62.azurewebsites.net/api/Bravo

Bravoの処理は、Application Insights よりログを取得し Blob Storage へ Csv形式で保存します。
Blob Storage を確認します。

こちらも出力されていますね。OKです。

まとめ

Application Insights に対してエンドポイントを作成することで、Azure Portalからログを取得するなどのタスクを自動化できることが分かりました。特に運用や保守など、Application Insights ログを操作する必要がある場合は、この方法を用いてバッチ処理を実行することで負荷を軽減できます。

GitHubで編集を提案

Discussion