1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Adaptive Card Charts(グラフ) を利用して、Azure の Daily Cost 通知を Teams へ

Posted at

背景

Adaptive Card が Updated されて、charts がいい感じになったと聞いたので試してみた

概要

グラフ表示ができるようになったので、以下をやってみた

  • Azure の Resource Group 単位での日々のコスト通知を視覚化

以下も記述してあるので環境に合わせれば使えるはず

  • Logic Apps の Code

通知イメージ

  • 概要
    image.png

  • 「日時コスト詳細」を開くと
    image.png

最新の Adaptive Card

Document
Designer

制約と対処

  • 凡例は、4点まで
    • 今回4より多かったので、2つのチャートに分割するようにした。
  • Javascript は、1024 文字まで
    • 機能単位で分割&文字数減らすために変数名を短く
  • 締め日が default の timeperiod で使えない場合
    • timeframe を custom 定義する

timeframe で、毎月 15 締めを行う場合

動的に生成する必要があったので、関数で生成

処理イメージ

  • 16 以降
    • 当月後半~翌月15
    • 前月後半~当月15
custom timeframe
 
  "timeframe": "Custom",
  "timePeriod": {
    "from": @{if(greaterOrEquals(dayOfMonth(utcNow()), 16), concat(formatDateTime(addDays(startOfMonth(utcNow()), 15), 'yyyy-MM-dd'), 'T00:00:00Z'), concat(formatDateTime(addDays(startOfMonth(subtractFromTime(utcNow(), 1, 'Month')), 15), 'yyyy-MM-dd'), 'T00:00:00Z'))},
    "to": @{if(greaterOrEquals(dayOfMonth(utcNow()), 16), concat(formatDateTime(addDays(startOfMonth(subtractFromTime(utcNow(), -1, 'Month')), 14), 'yyyy-MM-dd'), 'T23:59:59Z'), concat(formatDateTime(addDays(startOfMonth(utcNow()), 14), 'yyyy-MM-dd'), 'T23:59:59Z'))}
  },

設定

  • 全体での予算の上限設定
  • 対象とするリソースグループの設定
    image.png

実行概要

  1. Cost management API に Query をなげてDaily収集
  2. 結果を Javascript で集計
  3. Teams へ投稿 with Adaptive Card

Cost 収集方法

以下で Query をなげて収集

Cost Management API
https://management.azure.com/subscriptions/{SubscriptionID}/providers/Microsoft.CostManagement/query?api-version=2025-03-01

以下は、Defaultだと、月初ー月末なので、締め日が違うときは修正必要

Daily でのCost 収集。
{
  "type": "ActualCost",
  "timeframe": "MonthToDate",
  "dataset": {
    "granularity": "Daily",
    "aggregation": {
      "totalCost": {
        "name": "Cost",
        "function": "Sum"
      }
    },
    "grouping": [
      {
        "type": "Dimension",
        "name": "ResourceGroupName"
      }
    ],
    "filter": {
      "dimensions": {
        "name": "ResourceGroupName",
        "operator": "In",
        "values": @{body('Select')}
      }
    }
  }
}

Logic Apps の Code

  • Teams 投稿のとこだけ削除して、CodeView を貼り付け
  • SubscriptionID を変える
  • Teams の投稿のとこは、Connectorの問題があるので、replypost のCard用Actionを用意して、以下、Adaptive Card を張り付ける
Adaptive Card
{
  "type": "AdaptiveCard",
  "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
  "version": "1.5",
  "msteams": {
    "width": "full"
  },
  "body": [
    {
      "type": "TextBlock",
      "text": "📊 Azure Cost Report - Multi Resource Groups",
      "weight": "Bolder",
      "size": "Large",
      "spacing": "None"
    },
    {
      "type": "TextBlock",
      "text": "Period: @{body('Parse_JSON')?['period']} | Total: ¥@{body('Parse_JSON')?['grandTotal']} / Budget: ¥@{body('Parse_JSON')?['budget']}",
      "wrap": true,
      "spacing": "Small"
    },
    {
      "type": "TextBlock",
      "text": "💰 Overall Budget Status",
      "weight": "Bolder",
      "size": "Medium",
      "spacing": "Medium",
      "color": "Attention"
    },
    {
      "type": "ColumnSet",
      "columns": [
        {
          "type": "Column",
          "width": "auto",
          "items": [
            {
              "type": "Chart.Gauge",
              "title": "@{body('Parse_JSON')?['usedPercent']}% Used",
              "value": @{body('Parse_JSON')?['usedPercent']},
              "segments": [
                {
                  "legend": "Safe (75%)",
                  "size": 75,
                  "color": "good"
                },
                {
                  "legend": "Attention (75%-)",
                  "size": 20,
                  "color": "warning"
                },
                {
                  "legend": "High risk (95%-)",
                  "size": 5,
                  "color": "attention"
                }
              ]
            }
          ]
        },
        {
          "type": "Column",
          "width": "stretch",
          "items": [
            {
              "type": "TextBlock",
              "text": "📋 Resource Group Summary",
              "weight": "Bolder",
              "size": "Medium",
              "spacing": "None"
            },
            {
              "type": "FactSet",
              "facts": @{body('Parse_JSON')?['resourceGroupSummary'
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "type": "ActionSet",
      "actions": [
        {
          "type": "Action.ToggleVisibility",
          "title": "📈 日次コスト詳細を表示",
          "targetElements": [
            "dailyBotCost",
            "dailyOtherCost"
          ]
        }
      ]
    },
    {
      "type": "Container",
      "id": "dailyBotCost",
      "isVisible": false,
      "items": [
        {
          "type": "TextBlock",
          "text": "@{concat('📈 Daily Cost Breakdown - ', body('Parse_JSON')?['dailyGroupedCharts'][0]['title'])}",
          "weight": "Bolder",
          "size": "Medium",
          "spacing": "Large",
          "separator": true
        },
        {
          "type": "Chart.VerticalBar.Grouped",
          "stacked": true,
          "title": "@{concat('Daily Costs - ', body('Parse_JSON')?['dailyGroupedCharts'][0]['title'], ' (JPY)')}",
          "yAxisTitle": "Cost (JPY)",
          "colorSet": "diverging",
          "data": @{body('Parse_JSON')?['dailyGroupedCharts'][0]['data']}
        },
        {
              "type": "Container",
              "id": "dailyOtherCost",
              "isVisible": false,
              "items": [
                {
          "type": "TextBlock",
          "text": "@{concat('📊 Daily Cost Breakdown - ', body('Parse_JSON')?['dailyGroupedCharts'][1]['title'])}",
          "weight": "Bolder",
          "size": "Medium",
          "spacing": "Medium",
          "separator": true
        },
        {
          "type": "Chart.VerticalBar.Grouped",
          "stacked": true,
          "title": "@{concat('Daily Costs - ', body('Parse_JSON')?['dailyGroupedCharts'][1]['title'], ' (JPY)')}",
          "yAxisTitle": "Cost (JPY)",
          "data": @{body('Parse_JSON')?['dailyGroupedCharts'][1]['data']}       
        }
      ]
    }
  ]
}
]
}
Code View での全体
 
{
    "definition": {
        "$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
        "contentVersion": "1.0.0.0",
        "triggers": {
            "Recurrence": {
                "type": "Recurrence",
                "recurrence": {
                    "interval": 1,
                    "frequency": "Day",
                    "timeZone": "Tokyo Standard Time",
                    "schedule": {
                        "hours": [
                            3
                        ]
                    }
                }
            }
        },
        "actions": {
            "HTTP": {
                "type": "Http",
                "inputs": {
                    "uri": "https://management.azure.com/subscriptions/{SubscriptionID}/providers/Microsoft.CostManagement/query?api-version=2025-03-01",
                    "method": "POST",
                    "body": {
                        "type": "ActualCost",
                        "timeframe": "MonthToDate",
                        "dataset": {
                            "granularity": "Daily",
                            "aggregation": {
                                "totalCost": {
                                    "name": "Cost",
                                    "function": "Sum"
                                }
                            },
                            "grouping": [
                                {
                                    "type": "Dimension",
                                    "name": "ResourceGroupName"
                                }
                            ],
                            "filter": {
                                "dimensions": {
                                    "name": "ResourceGroupName",
                                    "operator": "In",
                                    "values": "@body('Select')"
                                }
                            }
                        }
                    },
                    "authentication": {
                        "type": "ManagedServiceIdentity",
                        "audience": ""
                    }
                },
                "runAfter": {
                    "Compose_Budget": [
                        "Succeeded"
                    ],
                    "Select": [
                        "Succeeded"
                    ]
                },
                "runtimeConfiguration": {
                    "contentTransfer": {
                        "transferMode": "Chunked"
                    }
                }
            },
            "step1": {
                "type": "JavaScriptCode",
                "inputs": {
                    "code": "function preprocessCost(apiResponse) {\r\n  const rows = apiResponse.properties.rows;\r\n  const rgData = {};\r\n  const dates = new Set();\r\n\r\n  rows.forEach(([cost, date, rgName]) => {\r\n    const d = date.toString();\r\n    const fd = `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6)}`;\r\n    dates.add(fd);\r\n\r\n    if (!rgData[rgName]) {\r\n      rgData[rgName] = { daily: {}, total: 0 };\r\n    }\r\n\r\n    const c = parseFloat(cost.toFixed(2));\r\n    rgData[rgName].daily[fd] = c;\r\n    rgData[rgName].total += cost;\r\n  });\r\n\r\n  const sortedDates = Array.from(dates).sort();\r\n\r\n  return {\r\n    rgData,\r\n    sortedDates,\r\n    currency: rows[0][3],\r\n    period: `${sortedDates[0]} ~ ${sortedDates[sortedDates.length - 1]}`\r\n  };\r\n}\r\n\r\nreturn preprocessCost(workflowContext.actions.HTTP.outputs.body);\r\n"
                },
                "runAfter": {
                    "HTTP": [
                        "Succeeded"
                    ]
                }
            },
            "チャットやチャネルにカードを投稿する": {
                "type": "ApiConnection",
                "inputs": {
                    "host": {
                        "connection": {
                            "name": "@parameters('$connections')['teams']['connectionId']"
                        }
                    },
                    "method": "post",
                    "body": {
                        "recipient": "dd20711@melgit.com",
                        "messageBody": "{\n  \"type\": \"AdaptiveCard\",\n  \"$schema\": \"http://adaptivecards.io/schemas/adaptive-card.json\",\n  \"version\": \"1.5\",\n  \"msteams\": {\n    \"width\": \"full\"\n  },\n  \"body\": [\n    {\n      \"type\": \"TextBlock\",\n      \"text\": \"📊 Azure Cost Report - Multi Resource Groups\",\n      \"weight\": \"Bolder\",\n      \"size\": \"Large\",\n      \"spacing\": \"None\"\n    },\n    {\n      \"type\": \"TextBlock\",\n      \"text\": \"Period: @{body('Parse_JSON')?['period']} | Total: ¥@{body('Parse_JSON')?['grandTotal']} / Budget: ¥@{body('Parse_JSON')?['budget']}\",\n      \"wrap\": true,\n      \"spacing\": \"Small\"\n    },\n    {\n      \"type\": \"TextBlock\",\n      \"text\": \"💰 Overall Budget Status\",\n      \"weight\": \"Bolder\",\n      \"size\": \"Medium\",\n      \"spacing\": \"Medium\",\n      \"color\": \"Attention\"\n    },\n    {\n      \"type\": \"ColumnSet\",\n      \"columns\": [\n        {\n          \"type\": \"Column\",\n          \"width\": \"auto\",\n          \"items\": [\n            {\n              \"type\": \"Chart.Gauge\",\n              \"title\": \"@{body('Parse_JSON')?['usedPercent']}% Used\",\n              \"value\": @{body('Parse_JSON')?['usedPercent']},\n              \"segments\": [\n                {\n                  \"legend\": \"Safe (75%)\",\n                  \"size\": 75,\n                  \"color\": \"good\"\n                },\n                {\n                  \"legend\": \"Attention (75%-)\",\n                  \"size\": 20,\n                  \"color\": \"warning\"\n                },\n                {\n                  \"legend\": \"High risk (95%-)\",\n                  \"size\": 5,\n                  \"color\": \"attention\"\n                }\n              ]\n            }\n          ]\n        },\n        {\n          \"type\": \"Column\",\n          \"width\": \"stretch\",\n          \"items\": [\n            {\n              \"type\": \"TextBlock\",\n              \"text\": \"📋 Resource Group Summary\",\n              \"weight\": \"Bolder\",\n              \"size\": \"Medium\",\n              \"spacing\": \"None\"\n            },\n            {\n              \"type\": \"FactSet\",\n              \"facts\": @{body('Parse_JSON')?['resourceGroupSummary'\n                ]\n              }\n            }\n          ]\n        }\n      ]\n    },\n    {\n      \"type\": \"ActionSet\",\n      \"actions\": [\n        {\n          \"type\": \"Action.ToggleVisibility\",\n          \"title\": \"📈 日次コスト詳細を表示\",\n          \"targetElements\": [\n            \"dailyBotCost\",\n            \"dailyOtherCost\"\n          ]\n        }\n      ]\n    },\n    {\n      \"type\": \"Container\",\n      \"id\": \"dailyBotCost\",\n      \"isVisible\": false,\n      \"items\": [\n        {\n          \"type\": \"TextBlock\",\n          \"text\": \"@{concat('📈 Daily Cost Breakdown - ', body('Parse_JSON')?['dailyGroupedCharts'][0]['title'])}\",\n          \"weight\": \"Bolder\",\n          \"size\": \"Medium\",\n          \"spacing\": \"Large\",\n          \"separator\": true\n        },\n        {\n          \"type\": \"Chart.VerticalBar.Grouped\",\n          \"stacked\": true,\n          \"title\": \"@{concat('Daily Costs - ', body('Parse_JSON')?['dailyGroupedCharts'][0]['title'], ' (JPY)')}\",\n          \"yAxisTitle\": \"Cost (JPY)\",\n          \"colorSet\": \"diverging\",\n          \"data\": @{body('Parse_JSON')?['dailyGroupedCharts'][0]['data']}\n        },\n        {\n              \"type\": \"Container\",\n              \"id\": \"dailyOtherCost\",\n              \"isVisible\": false,\n              \"items\": [\n                {\n          \"type\": \"TextBlock\",\n          \"text\": \"@{concat('📊 Daily Cost Breakdown - ', body('Parse_JSON')?['dailyGroupedCharts'][1]['title'])}\",\n          \"weight\": \"Bolder\",\n          \"size\": \"Medium\",\n          \"spacing\": \"Medium\",\n          \"separator\": true\n        },\n        {\n          \"type\": \"Chart.VerticalBar.Grouped\",\n          \"stacked\": true,\n          \"title\": \"@{concat('Daily Costs - ', body('Parse_JSON')?['dailyGroupedCharts'][1]['title'], ' (JPY)')}\",\n          \"yAxisTitle\": \"Cost (JPY)\",\n          \"data\": @{body('Parse_JSON')?['dailyGroupedCharts'][1]['data']}        \n        }\n      ]\n    }\n  ]\n}\n]\n}"
                    },
                    "path": "/v1.0/teams/conversation/adaptivecard/poster/Flow bot/location/@{encodeURIComponent('Chat with Flow bot')}"
                },
                "runAfter": {
                    "Parse_JSON": [
                        "SUCCEEDED"
                    ]
                }
            },
            "Parse_JSON": {
                "type": "ParseJson",
                "inputs": {
                    "content": "@body('step3')",
                    "schema": {
                        "type": "object",
                        "properties": {
                            "period": {
                                "type": "string"
                            },
                            "grandTotal": {
                                "type": "number"
                            },
                            "budget": {
                                "type": "number"
                            },
                            "currency": {
                                "type": "string"
                            },
                            "usedPercent": {
                                "type": "number"
                            },
                            "remainingPercent": {
                                "type": "number"
                            },
                            "dailyGroupedData": {
                                "type": "array",
                                "items": {
                                    "type": "object",
                                    "properties": {
                                        "legend": {
                                            "type": "string"
                                        },
                                        "values": {
                                            "type": "array",
                                            "items": {
                                                "type": "object",
                                                "properties": {
                                                    "x": {
                                                        "type": "string"
                                                    },
                                                    "y": {
                                                        "type": "number"
                                                    }
                                                }
                                            }
                                        }
                                    }
                                }
                            },
                            "dailyGroupedCharts": {
                                "type": "array",
                                "items": {
                                    "type": "object",
                                    "properties": {
                                        "title": {
                                            "type": "string"
                                        },
                                        "data": {
                                            "type": "array",
                                            "items": {
                                                "type": "object",
                                                "properties": {
                                                    "legend": {
                                                        "type": "string"
                                                    },
                                                    "values": {
                                                        "type": "array",
                                                        "items": {
                                                            "type": "object",
                                                            "properties": {
                                                                "x": {
                                                                    "type": "string"
                                                                },
                                                                "y": {
                                                                    "type": "number"
                                                                }
                                                            }
                                                        }
                                                    }
                                                }
                                            }
                                        }
                                    }
                                }
                            },
                            "cumulativeStackedData": {
                                "type": "array",
                                "items": {
                                    "type": "object",
                                    "properties": {
                                        "legend": {
                                            "type": "string"
                                        },
                                        "values": {
                                            "type": "array",
                                            "items": {
                                                "type": "object",
                                                "properties": {
                                                    "x": {
                                                        "type": "string"
                                                    },
                                                    "y": {
                                                        "type": "number"
                                                    }
                                                }
                                            }
                                        }
                                    }
                                }
                            },
                            "resourceGroupSummary": {
                                "type": "array",
                                "items": {
                                    "type": "object",
                                    "properties": {
                                        "title": {
                                            "type": "string"
                                        },
                                        "value": {
                                            "type": "string"
                                        }
                                    }
                                }
                            }
                        }
                    }
                },
                "runAfter": {
                    "step3": [
                        "Succeeded"
                    ]
                }
            },
            "step2": {
                "type": "JavaScriptCode",
                "inputs": {
                    "code": "function formatCostReport(p, budget) {  const { rgData: r, sortedDates: d, currency: c, period } = p;\r\n  const dailyGroupedData = Object.keys(r).map(g => ({\r\n    legend: g, values: d.map(dt => ({ x: dt, y: r[g].daily[dt] || 0 }))\r\n  }));\r\n  const cumulativeStackedData = Object.keys(r).map(g => ({\r\n    legend: g, values: [{ x: g, y: parseFloat(r[g].total.toFixed(2)) }]\r\n  }));\r\n  const resourceGroupSummary = Object.keys(r).map(g => ({\r\n    title: g, value: `¥${parseFloat(r[g].total.toFixed(2))}`\r\n  }));\r\n  const grandTotal = parseFloat(\r\n    Object.values(r).reduce((s, rg) => s + rg.total, 0).toFixed(2)\r\n  );\r\n  const used = parseFloat((grandTotal / budget * 100).toFixed(2));\r\n  return {\r\n    period, grandTotal, budget, currency: c,\r\n    usedPercent: used,\r\n    remainingPercent: parseFloat((100 - used).toFixed(2)),\r\n    dailyGroupedData, cumulativeStackedData, resourceGroupSummary\r\n  };\r\n}\r\nreturn formatCostReport(workflowContext.actions.step1.outputs.body, workflowContext.actions.Compose_Budget.outputs);\r\n"
                },
                "runAfter": {
                    "step1": [
                        "Succeeded"
                    ]
                }
            },
            "Initialize_variables": {
                "type": "InitializeVariable",
                "inputs": {
                    "variables": [
                        {
                            "name": "Budget",
                            "type": "integer",
                            "value": 7500
                        },
                        {
                            "name": "ResourceGroups",
                            "type": "array",
                            "value": [
                                {
                                    "name": "ResourceGroupName1",
                                    "title": "ボット用"
                                },
                                {
                                    "name": "ResourceGroupName2",
                                    "title": "その他"
                                }
                            ]
                        }
                    ]
                },
                "runAfter": {}
            },
            "Compose_Budget": {
                "type": "Compose",
                "inputs": "@variables('Budget')",
                "runAfter": {
                    "Initialize_variables": [
                        "Succeeded"
                    ]
                }
            },
            "Select": {
                "type": "Select",
                "inputs": {
                    "from": "@variables('ResourceGroups')",
                    "select": "@item()['Name']"
                },
                "runAfter": {
                    "Initialize_variables": [
                        "Succeeded"
                    ]
                }
            },
            "step3": {
                "type": "JavaScriptCode",
                "inputs": {
                    "code": "function groupByTitle(r, m) {\r\n  var idx = {}, order = [], i, j, k, e, t, name, data, item;\r\n\r\n  for (i = 0; i < m.length; i++) {\r\n    e = m[i];\r\n    t = e.title;\r\n    if (!t) { continue; }\r\n    if (!idx[t]) {\r\n      idx[t] = { title: t, names: [] };\r\n      order.push(t);\r\n    }\r\n    idx[t].names.push(e.name);\r\n  }\r\n\r\n  var charts = [];\r\n  for (i = 0; i < order.length; i++) {\r\n    t = order[i];\r\n    data = [];\r\n    var names = idx[t].names;\r\n\r\n    for (j = 0; j < names.length; j++) {\r\n      name = names[j];\r\n      for (k = 0; k < r.dailyGroupedData.length; k++) {\r\n        item = r.dailyGroupedData[k];\r\n        if (item.legend === name) {\r\n          data.push(item);\r\n          break;\r\n        }\r\n      }\r\n    }\r\n\r\n    charts.push({ title: t, data: data });\r\n  }\r\n\r\n  r.dailyGroupedCharts = charts;\r\n  return r;\r\n}\r\n\r\nreturn groupByTitle(\r\n  workflowContext.actions.Step2.outputs.body,\r\n  workflowContext.actions.Compose_RGGroupMapping.outputs\r\n);\r\n"
                },
                "runAfter": {
                    "step2": [
                        "Succeeded"
                    ],
                    "Compose_RGGroupMapping": [
                        "Succeeded"
                    ]
                }
            },
            "Compose_RGGroupMapping": {
                "type": "Compose",
                "inputs": "@variables('ResourceGroups')",
                "runAfter": {
                    "Initialize_variables": [
                        "Succeeded"
                    ]
                }
            }
        },
        "outputs": {},
        "parameters": {
            "$connections": {
                "type": "Object",
                "defaultValue": {}
            }
        }
    },
    "parameters": {
        "$connections": {
            "type": "Object",
            "value": {
                "teams": {
                    "id": "/subscriptions/{SubscriptionID}/providers/Microsoft.Web/locations/northcentralus/managedApis/teams",
                    "connectionId": "/subscriptions/{SubscriptionID}/resourceGroups/azure_cost_notifications/providers/Microsoft.Web/connections/teams",
                    "connectionName": "teams"
                }
            }
        }
    }
}
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?