diff --git a/infrastructure/simple-apim/create.ipynb b/infrastructure/simple-apim/create.ipynb index 0f7df48..4b63e6f 100644 --- a/infrastructure/simple-apim/create.ipynb +++ b/infrastructure/simple-apim/create.ipynb @@ -26,7 +26,7 @@ "# ------------------------------\n", "\n", "rg_location = 'eastus2' # Azure region for deployment\n", - "index = 1 # Infrastructure index (use different numbers for multiple environments)\n", + "index = 4 # Infrastructure index (use different numbers for multiple environments)\n", "apim_sku = APIM_SKU.BASICV2 # Options: 'BASICV2', 'STANDARDV2', 'PREMIUMV2'\n", "\n", "\n", diff --git a/samples/_TEMPLATE/create.ipynb b/samples/_TEMPLATE/create.ipynb index a405b53..c9d0e31 100644 --- a/samples/_TEMPLATE/create.ipynb +++ b/samples/_TEMPLATE/create.ipynb @@ -137,7 +137,7 @@ ], "metadata": { "kernelspec": { - "display_name": ".venv (3.14.2)", + "display_name": ".venv", "language": "python", "name": "python3" }, @@ -151,7 +151,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.2" + "version": "3.12.10" } }, "nbformat": 4, diff --git a/samples/costing/README.md b/samples/costing/README.md new file mode 100644 index 0000000..46fbfa1 --- /dev/null +++ b/samples/costing/README.md @@ -0,0 +1,194 @@ +# Samples: APIM Costing & Showback + +This sample demonstrates how to track and allocate API costs using Azure API Management with Azure Monitor, Application Insights, Log Analytics, and Cost Management. This setup enables organizations to determine the cost of API consumption per business unit, department, or application. + +โš™๏ธ **Supported infrastructures**: All infrastructures (or bring your own existing APIM deployment) + +๐Ÿ‘Ÿ **Expected *Run All* runtime (excl. infrastructure prerequisite): ~15 minutes** + +## ๐ŸŽฏ Objectives + +1. **Track API usage by caller** - Use APIM subscription keys to identify business units, departments, or applications +2. **Capture request metrics** - Log subscriptionId, apiName, operationName, and status codes +3. **Aggregate cost data** - Combine API usage metrics with Azure Cost Management data +4. **Visualize showback data** - Create Azure Monitor Workbooks to display cost allocation by caller +5. **Enable cost governance** - Establish patterns for consistent tagging and naming conventions + +## โœ… Prerequisites + +Before running this sample, ensure you have the following: + +### Required + +| Prerequisite | Description | +|---|---| +| **Azure subscription** | An active Azure subscription with Owner or Contributor access | +| **Azure CLI** | Logged in (`az login`) with the correct subscription selected (`az account set -s `) | +| **APIM instance** | Either deploy one via this repo's infrastructure, or bring your own (see below) | +| **Python environment** | Python 3.12+ with dependencies installed (`uv sync` or `pip install -r requirements.txt`) | + +### Azure RBAC Permissions + +The signed-in user needs the following role assignments: + +| Role | Scope | Purpose | +|---|---|---| +| **Contributor** | Resource Group | Deploy Bicep resources (App Insights, Log Analytics, Storage, Workbook, Diagnostic Settings) | +| **Cost Management Contributor** | Subscription | Create Cost Management export | +| **Storage Blob Data Contributor** | Storage Account | Write cost export data (auto-assigned by the notebook) | + +### For Workbook Consumers + +Users who only need to **view** the deployed Azure Monitor Workbook (not deploy the sample) need: + +| Role | Scope | Purpose | +|---|---|---| +| **Monitoring Reader** | Resource Group | Open and view the workbook | +| **Log Analytics Reader** | Log Analytics Workspace | Execute the Kusto queries that power the workbook | + +> ๐Ÿ’ก If a user can open the workbook but sees empty visualizations, they are likely missing **Log Analytics Reader** on the workspace. + +## โš™๏ธ Configuration + +### Option A: Use a repository infrastructure (recommended) + +1. Navigate to the desired [infrastructure](../../infrastructure/) folder (e.g., [simple-apim](../../infrastructure/simple-apim/)) and follow its README.md to deploy. +2. Open `create.ipynb` and set: + ```python + infrastructure = INFRASTRUCTURE.SIMPLE_APIM # Match your deployed infra + index = 1 # Match your infra index + ``` +3. Run All Cells. + +### Option B: Bring your own existing APIM + +You can use any existing Azure API Management instance. The sample only adds diagnostic settings and sample resources to your APIM - it does **not** modify your existing APIs or policies. + +1. Open `create.ipynb` and **uncomment** the two lines in the User Configuration section: + ```python + existing_rg_name = 'your-resource-group-name' + existing_apim_name = 'your-apim-service-name' + ``` +2. Set the correct Azure subscription: `az account set -s ` +3. Run All Cells. + +**What the sample deploys into your resource group:** +- Application Insights instance +- Log Analytics Workspace +- Storage Account (for cost exports) +- Diagnostic Settings on your APIM (routes gateway logs to Log Analytics) +- Azure Monitor Workbook +- A sample API (`cost-tracking-api`) with 5 business unit subscriptions + +**What it does NOT touch:** +- Your existing APIs, policies, or subscriptions +- Your APIM SKU or networking configuration +- Any resources outside the specified resource group (except the subscription-scoped Cost Management export) + +## ๐Ÿ“ Scenario + +Organizations often need to allocate the cost of shared API Management infrastructure to different consumers (business units, departments, applications, or customers). This sample addresses: + +- **Cost Transparency**: Understanding which teams or applications drive API consumption +- **Chargeback/Showback**: Producing data that can inform internal billing or cost awareness +- **Resource Optimization**: Identifying high-cost consumers and opportunities for optimization +- **Budget Planning**: Historical usage patterns to forecast future costs + +### Key Principle: Cost Determination, Not Billing + +This sample focuses on **producing cost data**, not implementing billing processes. You determine costs; how you use that information (showback reports, chargeback, budgeting) is a separate business decision. + +## ๐Ÿ›ฉ๏ธ Lab Components + +This lab deploys and configures: + +- **Application Insights** - Receives APIM diagnostic logs for request tracking +- **Log Analytics Workspace** - Stores `ApiManagementGatewayLogs` with detailed request metadata (resource-specific mode) +- **Storage Account** - Receives Azure Cost Management exports +- **Cost Management Export** - Automated export of cost data (configurable frequency) +- **Diagnostic Settings** - Links APIM to Log Analytics with `logAnalyticsDestinationType: Dedicated` for resource-specific tables +- **Sample API & Subscriptions** - 5 subscriptions representing different business units +- **Azure Monitor Workbook** - Pre-built dashboard with: + - Cost allocation table (base + variable cost per BU) + - Base vs variable cost stacked bar chart + - Cost breakdown by API + - Request count and distribution charts + - Success/error rate analysis + - Response code distribution +- **Live Pricing Integration** - Auto-detects your APIM SKU and fetches current pricing from the [Azure Retail Prices API](https://learn.microsoft.com/rest/api/cost-management/retail-prices/azure-retail-prices) +- **Budget Alerts** (optional) - Per-BU scheduled query alerts when request thresholds are exceeded + +### Cost Allocation Model + +| Component | Formula | +|---|---| +| **Base Cost Share** | `Base Monthly Cost x (BU Requests / Total Requests)` | +| **Variable Cost** | `BU Requests x (Rate per 1K / 1000)` | +| **Total Allocated** | `Base Cost Share + Variable Cost` | + +### What Gets Logged + +| Field | Description | +|---|---| +| `ApimSubscriptionId` | Identifies the caller (BU / department / app) | +| `ApiId` | Which API was called | +| `OperationId` | Specific operation within the API | +| `ResponseCode` | Success / failure indication | +| Request count | Number of requests (primary cost metric) | + +> **Important**: The API must have `subscriptionRequired: true` for `ApimSubscriptionId` to be populated in logs. This sample configures it automatically. + +## ๐Ÿ–ผ๏ธ Expected Results + +After running the notebook, you will have: + +1. **Application Insights** showing real-time API requests +2. **Log Analytics** with queryable `ApiManagementGatewayLogs` (resource-specific table) +3. **Storage Account** receiving cost export data +4. **Azure Monitor Workbook** displaying cost allocation and usage analytics +5. **Portal links** printed in the notebook's final cell for quick access + +### Cost Management Export + +The cost export is configured automatically using a system-assigned managed identity with **Storage Blob Data Contributor** access. + +![Cost Report - Export Overview](screenshots/costreport-01.png) + +![Cost Report - Export Details](screenshots/costreport-02.png) + +### Azure Monitor Workbook Dashboard + +The deployed workbook provides a comprehensive view of API cost allocation and usage analytics across business units. + +![Dashboard - Cost Allocation Overview](screenshots/Dashboard-01.png) + +![Dashboard - Cost Breakdown by Business Unit](screenshots/Dashboard-02.png) + +![Dashboard - Request Distribution](screenshots/Dashboard-03.png) + +![Dashboard - Usage Analytics](screenshots/Dashboard-04.png) + +![Dashboard - Response Code Analysis](screenshots/Dashboard-05.png) + +## ๐Ÿงน Clean Up + +To remove all resources created by this sample, open and run `clean-up.ipynb`. This deletes: +- Sample API and subscriptions from APIM +- Application Insights, Log Analytics, Storage Account +- Azure Monitor Workbook +- Cost Management export + +> The clean-up notebook does **not** delete your APIM instance or resource group. + +## ๐Ÿ”— Additional Resources + +- [Azure API Management Pricing](https://azure.microsoft.com/pricing/details/api-management/) +- [Azure Retail Prices API](https://learn.microsoft.com/rest/api/cost-management/retail-prices/azure-retail-prices) +- [Azure Cost Management Documentation](https://learn.microsoft.com/azure/cost-management-billing/) +- [Log Analytics Kusto Query Language](https://learn.microsoft.com/azure/data-explorer/kusto/query/) +- [Azure Monitor Workbooks](https://learn.microsoft.com/azure/azure-monitor/visualize/workbooks-overview) +- [APIM Diagnostic Settings](https://learn.microsoft.com/azure/api-management/api-management-howto-use-azure-monitor) + +[infrastructure-architectures]: ../../README.md#infrastructure-architectures +[infrastructure-folder]: ../../infrastructure/ +[simple-apim-infra]: ../../infrastructure/simple-apim/ diff --git a/samples/costing/clean-up.ipynb b/samples/costing/clean-up.ipynb new file mode 100644 index 0000000..028d4cb --- /dev/null +++ b/samples/costing/clean-up.ipynb @@ -0,0 +1,342 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "fd379c0a", + "metadata": {}, + "source": [ + "# APIM Costing Sample - Clean Up\n", + "\n", + "This notebook removes all resources created by the costing sample.\n", + "\n", + "โš ๏ธ **Warning**: This will delete Azure resources. Use with caution!" + ] + }, + { + "cell_type": "markdown", + "id": "64d99fab", + "metadata": {}, + "source": [ + "## โš™๏ธ Initialize Configuration\n", + "\n", + "Set the same parameters used during deployment." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "44f52255", + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "from pathlib import Path\n", + "\n", + "# APIM Samples imports\n", + "import azure_resources as az\n", + "from apimtypes import INFRASTRUCTURE\n", + "from console import print_error, print_info, print_ok, print_val, print_warning\n", + "\n", + "# ------------------------------\n", + "# USER CONFIGURATION\n", + "# ------------------------------\n", + "\n", + "# Match the configuration from create.ipynb\n", + "infrastructure = INFRASTRUCTURE.SIMPLE_APIM\n", + "index = 2\n", + "sample_index = 1 # Match the index used during deployment\n", + "\n", + "# Optional: Use your own existing APIM deployment (uncomment both lines below)\n", + "# existing_rg_name = 'your-resource-group-name'\n", + "# existing_apim_name = 'your-apim-service-name'\n", + "\n", + "# ------------------------------\n", + "# SYSTEM CONFIGURATION\n", + "# ------------------------------\n", + "\n", + "# Determine resource group name and APIM name\n", + "if 'existing_rg_name' in dir() and 'existing_apim_name' in dir():\n", + " rg_name = existing_rg_name\n", + " apim_name = existing_apim_name\n", + " print_info(f'Using existing APIM: {apim_name} in {rg_name}')\n", + "else:\n", + " rg_name = az.get_infra_rg_name(infrastructure, index)\n", + " # Auto-detect APIM name from the resource group\n", + " apim_result = az.run(\n", + " f'az apim list --resource-group {rg_name} --query \"[0].name\" -o tsv',\n", + " log_command=False\n", + " )\n", + " apim_name = apim_result.text.strip() if apim_result.success and apim_result.text.strip() else None\n", + " if not apim_name:\n", + " print_error(f'No APIM instance found in resource group: {rg_name}')\n", + " sys.exit(1)\n", + " print_info(f'Using infrastructure: {infrastructure.value} (index: {index})')\n", + "\n", + "subscription_id_output = az.run('az account show --query id -o tsv', log_command=False)\n", + "subscription_id = subscription_id_output.text.strip() if subscription_id_output.success else None\n", + "\n", + "config = {\n", + " 'rg_name': rg_name,\n", + " 'apim_name': apim_name,\n", + " 'subscription_id': subscription_id,\n", + " 'sample_index': sample_index,\n", + " 'deployment_name': f'costing-{sample_index}'\n", + "}\n", + "\n", + "print_ok('Configuration loaded')\n", + "print_val('Resource Group', config['rg_name'])\n", + "print_val('APIM Service', config['apim_name'])\n", + "print_val('Sample Index', config['sample_index'])\n", + "print_val('Deployment Name', config['deployment_name'])" + ] + }, + { + "cell_type": "markdown", + "id": "7d0e6f2b", + "metadata": {}, + "source": [ + "## ๐Ÿ“‹ List Resources to Delete\n", + "\n", + "Query the deployment to see what will be deleted." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e274a3ab", + "metadata": {}, + "outputs": [], + "source": [ + "print_info(f'Checking deployment: {config[\"deployment_name\"]}')\n", + "\n", + "deployment_result = az.run(\n", + " f'az deployment group show --resource-group {config[\"rg_name\"]} --name {config[\"deployment_name\"]} -o json',\n", + " log_command=False\n", + ")\n", + "\n", + "if not deployment_result.success:\n", + " print_warning('Deployment not found')\n", + " print_info('Resources may have already been deleted or deployment name is incorrect')\n", + "else:\n", + " outputs = deployment_result.json_data.get('properties', {}).get('outputs', {})\n", + "\n", + " config['app_insights_name'] = outputs.get('applicationInsightsName', {}).get('value')\n", + " config['log_analytics_name'] = outputs.get('logAnalyticsWorkspaceName', {}).get('value')\n", + " config['storage_account_name'] = outputs.get('storageAccountName', {}).get('value')\n", + " config['workbook_id'] = outputs.get('workbookId', {}).get('value')\n", + " config['cost_export_name'] = f\"apim-cost-export-{config['sample_index']}-{config['rg_name']}\"\n", + "\n", + " print_ok('Found the following resources to delete:')\n", + " print()\n", + " if config.get('app_insights_name'):\n", + " print_val(' Application Insights', config['app_insights_name'])\n", + " if config.get('log_analytics_name'):\n", + " print_val(' Log Analytics', config['log_analytics_name'])\n", + " if config.get('storage_account_name'):\n", + " print_val(' Storage Account', config['storage_account_name'])\n", + " if config.get('workbook_id'):\n", + " print_val(' Workbook ID', config['workbook_id'])\n", + " print_val(' Cost Export', config['cost_export_name'])\n", + " print_val(' Deployment', config['deployment_name'])" + ] + }, + { + "cell_type": "markdown", + "id": "b4dd8306", + "metadata": {}, + "source": [ + "## ๐Ÿ—‘๏ธ Delete Sample API Resources\n", + "\n", + "Delete the sample API and subscriptions created for cost tracking." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "de0dab8d", + "metadata": {}, + "outputs": [], + "source": [ + "api_id = 'cost-tracking-api'\n", + "business_units = ['bu-hr', 'bu-finance', 'bu-marketing', 'bu-engineering']\n", + "\n", + "print_info('Deleting sample API subscriptions...')\n", + "for sub_id in business_units:\n", + " result = az.run(\n", + " f'az apim api subscription delete '\n", + " f'--resource-group {config[\"rg_name\"]} '\n", + " f'--service-name {config[\"apim_name\"]} '\n", + " f'--subscription-id {sub_id} '\n", + " f'--yes',\n", + " log_command=False\n", + " )\n", + " if result.success:\n", + " print_ok(f' Deleted subscription: {sub_id}')\n", + " else:\n", + " print_warning(f' Subscription not found: {sub_id}')\n", + "\n", + "print_info('Deleting sample API...')\n", + "result = az.run(\n", + " f'az apim api delete '\n", + " f'--resource-group {config[\"rg_name\"]} '\n", + " f'--service-name {config[\"apim_name\"]} '\n", + " f'--api-id {api_id} '\n", + " f'--delete-revisions true '\n", + " f'--yes',\n", + " log_command=False\n", + ")\n", + "\n", + "if result.success:\n", + " print_ok(f'Deleted API: {api_id}')\n", + "else:\n", + " print_warning(f'API not found: {api_id}')" + ] + }, + { + "cell_type": "markdown", + "id": "4d05ea5d", + "metadata": {}, + "source": [ + "## ๐Ÿ—‘๏ธ Delete Infrastructure Resources\n", + "\n", + "Delete Application Insights, Log Analytics, and Storage Account." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5d711725", + "metadata": {}, + "outputs": [], + "source": [ + "print_info('Deleting infrastructure resources...')\n", + "print()\n", + "\n", + "# Delete Storage Account\n", + "if config.get('storage_account_name'):\n", + " print_info(f'Deleting Storage Account: {config[\"storage_account_name\"]}')\n", + " result = az.run(\n", + " f'az storage account delete '\n", + " f'--name {config[\"storage_account_name\"]} '\n", + " f'--resource-group {config[\"rg_name\"]} '\n", + " f'--yes'\n", + " )\n", + " if result.success:\n", + " print_ok(' Storage Account deleted')\n", + " else:\n", + " print_warning(' Storage Account not found or already deleted')\n", + "\n", + "# Delete Application Insights\n", + "if config.get('app_insights_name'):\n", + " print_info(f'Deleting Application Insights: {config[\"app_insights_name\"]}')\n", + " result = az.run(\n", + " f'az monitor app-insights component delete '\n", + " f'--app {config[\"app_insights_name\"]} '\n", + " f'--resource-group {config[\"rg_name\"]}',\n", + " log_command=False\n", + " )\n", + " if result.success:\n", + " print_ok(' Application Insights deleted')\n", + " else:\n", + " print_warning(' Application Insights not found or already deleted')\n", + "\n", + "# Delete Log Analytics Workspace\n", + "if config.get('log_analytics_name'):\n", + " print_info(f'Deleting Log Analytics: {config[\"log_analytics_name\"]}')\n", + " result = az.run(\n", + " f'az monitor log-analytics workspace delete '\n", + " f'--workspace-name {config[\"log_analytics_name\"]} '\n", + " f'--resource-group {config[\"rg_name\"]} '\n", + " f'--yes '\n", + " f'--force true',\n", + " log_command=False\n", + " )\n", + " if result.success:\n", + " print_ok(' Log Analytics Workspace deleted')\n", + " else:\n", + " print_warning(' Log Analytics Workspace not found or already deleted')\n", + "\n", + "# Delete Workbook\n", + "if config.get('workbook_id'):\n", + " print_info('Deleting Azure Monitor Workbook')\n", + " result = az.run(\n", + " f'az resource delete --ids {config[\"workbook_id\"]}',\n", + " log_command=False\n", + " )\n", + " if result.success:\n", + " print_ok(' Workbook deleted')\n", + " else:\n", + " print_warning(' Workbook not found or already deleted')\n", + "\n", + "print()\n", + "print_ok('Infrastructure resources cleanup complete')" + ] + }, + { + "cell_type": "markdown", + "id": "88387447", + "metadata": {}, + "source": [ + "## ๐Ÿ—‘๏ธ Delete Cost Management Export\n", + "\n", + "Delete the Cost Management export (subscription-scoped resource)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5fd5e2fb", + "metadata": {}, + "outputs": [], + "source": [ + "print_info('Deleting Cost Management export...')\n", + "\n", + "result = az.run(\n", + " f'az costmanagement export delete '\n", + " f'--name {config[\"cost_export_name\"]} '\n", + " f'--scope \"/subscriptions/{config[\"subscription_id\"]}\" '\n", + " f'--yes',\n", + " log_command=False\n", + ")\n", + "\n", + "if result.success:\n", + " print_ok(f'Cost export deleted: {config[\"cost_export_name\"]}')\n", + "else:\n", + " print_warning('Cost export not found or already deleted')" + ] + }, + { + "cell_type": "markdown", + "id": "9e2a03a4", + "metadata": {}, + "source": [ + "## โœ… Cleanup Complete\n", + "\n", + "All costing sample resources have been deleted." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "427222b6", + "metadata": {}, + "outputs": [], + "source": [ + "print()\n", + "print_ok('='*60)\n", + "print_ok('Cleanup complete!')\n", + "print_ok('='*60)\n", + "print()\n", + "print_info('All costing sample resources have been removed')\n", + "print_info('You can now run create.ipynb again to deploy fresh resources')" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/samples/costing/cost-export.bicep b/samples/costing/cost-export.bicep new file mode 100644 index 0000000..184f9b9 --- /dev/null +++ b/samples/costing/cost-export.bicep @@ -0,0 +1,79 @@ +// ------------------------------ +// COST MANAGEMENT EXPORT MODULE +// ------------------------------ +// This module deploys a Cost Management export at subscription scope. +// It must be called from a resource-group-scoped template using: +// scope: subscription() + +targetScope = 'subscription' + + +// ------------------------------ +// PARAMETERS +// ------------------------------ + +@description('Name of the cost export') +param costExportName string + +@description('Resource ID of the storage account for export delivery') +param storageAccountId string + +@description('Container name for cost export data') +param containerName string = 'cost-exports' + +@description('Root folder path within the container') +param rootFolderPath string = 'apim-costing' + +@description('Export recurrence frequency') +@allowed([ + 'Daily' + 'Weekly' + 'Monthly' +]) +param recurrence string = 'Daily' + +@description('Start date for the export schedule (UTC)') +param startDate string + + +// ------------------------------ +// RESOURCES +// ------------------------------ + +// https://learn.microsoft.com/azure/templates/microsoft.costmanagement/exports +resource costExport 'Microsoft.CostManagement/exports@2023-11-01' = { + name: costExportName + properties: { + definition: { + type: 'ActualCost' + timeframe: 'MonthToDate' + dataSet: { + granularity: 'Daily' + } + } + deliveryInfo: { + destination: { + resourceId: storageAccountId + container: containerName + rootFolderPath: rootFolderPath + } + } + format: 'Csv' + schedule: { + status: 'Active' + recurrence: recurrence + recurrencePeriod: { + from: startDate + to: '2099-12-31T00:00:00Z' + } + } + } +} + + +// ------------------------------ +// OUTPUTS +// ------------------------------ + +@description('Name of the deployed cost export') +output costExportName string = costExport.name diff --git a/samples/costing/create.ipynb b/samples/costing/create.ipynb new file mode 100644 index 0000000..21da27e --- /dev/null +++ b/samples/costing/create.ipynb @@ -0,0 +1,1237 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "88a2be8c", + "metadata": {}, + "source": [ + "# APIM Costing & Showback Sample\n", + "\n", + "This notebook deploys and configures resources to track and allocate API costs using Azure API Management.\n", + "\n", + "๐Ÿ“– See [README.md](README.md) for detailed information about this sample." + ] + }, + { + "cell_type": "markdown", + "id": "d02d5cf8", + "metadata": {}, + "source": [ + "## ๐ŸŽฏ What This Sample Does\n", + "\n", + "1. Deploys observability stack (Application Insights, Log Analytics, Storage Account)\n", + "2. Configures APIM diagnostic settings to capture request logs\n", + "3. Creates sample API and multiple subscriptions representing different business units\n", + "4. Sets up Cost Management export for cost data\n", + "5. Deploys Azure Monitor Workbook for cost visualization\n", + "6. Generates sample API traffic to demonstrate cost tracking\n", + "7. Provides Kusto queries for cost analysis" + ] + }, + { + "cell_type": "markdown", + "id": "655130e3", + "metadata": {}, + "source": [ + "## โš™๏ธ Initialize Notebook Variables\n", + "\n", + "Configure the parameters for your environment.\n", + "\n", + "โ—๏ธ **Modify entries under _User-defined parameters_**." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2cceefff", + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import time\n", + "from pathlib import Path\n", + "\n", + "# APIM Samples imports\n", + "import azure_resources as az\n", + "from apimtypes import INFRASTRUCTURE\n", + "from console import print_error, print_info, print_ok, print_val, print_warning\n", + "import utils\n", + "\n", + "# ------------------------------\n", + "# USER CONFIGURATION\n", + "# ------------------------------\n", + "\n", + "# Infrastructure to use (must be deployed first via infrastructure/simple-apim/create.ipynb)\n", + "infrastructure = INFRASTRUCTURE.SIMPLE_APIM # Options: SIMPLE_APIM, APPGW_APIM, etc.\n", + "index = 4 # Infrastructure index (must match your deployed infrastructure)\n", + "\n", + "# Sample deployment configuration\n", + "sample_index = 2 # Sample deployment index (increment for multiple deployments)\n", + "\n", + "# Cost export configuration\n", + "cost_export_frequency = 'Daily' # Options: 'Daily', 'Weekly', 'Monthly'\n", + "\n", + "# Sample data generation\n", + "generate_sample_load = True # Generate sample API calls to demonstrate cost tracking\n", + "sample_requests_per_subscription = 50 # Base request count per BU (multiplied by each BU's weight)\n", + "\n", + "# Optional: Use your own existing APIM deployment (uncomment both lines below)\n", + "existing_rg_name = 'apim-infra-simple-apim-4'\n", + "existing_apim_name = 'apim-5lbrwbpu7nwii'\n", + "\n", + "\n", + "\n", + "# ------------------------------\n", + "# SYSTEM CONFIGURATION\n", + "# ------------------------------\n", + "\n", + "sample_folder = 'costing'\n", + "\n", + "# Determine resource group name\n", + "if 'existing_rg_name' in dir() and 'existing_apim_name' in dir():\n", + " rg_name = existing_rg_name\n", + " apim_name = existing_apim_name\n", + " print_info(f'Using existing APIM: {apim_name} in {rg_name}')\n", + "else:\n", + " rg_name = az.get_infra_rg_name(infrastructure, index)\n", + " apim_name = None # Will be auto-detected in cell 6\n", + " print_info(f'Using infrastructure: {infrastructure.value} (index: {index})')\n", + "\n", + "# Check resource group exists\n", + "if not az.does_resource_group_exist(rg_name):\n", + " print_error(f'Resource group \"{rg_name}\" does not exist.')\n", + " print_info(f'Deploy infrastructure first: infrastructure/{infrastructure.value}/create.ipynb')\n", + " raise SystemExit(1)\n", + "\n", + "rg_location = az.get_resource_group_location(rg_name)\n", + "\n", + "# Get Azure subscription ID\n", + "account_output = az.run('az account show --query id -o tsv', log_command=False)\n", + "subscription_id = account_output.text.strip() if account_output.success else None\n", + "\n", + "if not subscription_id:\n", + " print_error('Could not determine Azure subscription ID. Run: az login')\n", + " raise SystemExit(1)\n", + "\n", + "# Store configuration for later use\n", + "config = {\n", + " 'rg_name': rg_name,\n", + " 'apim_name': apim_name,\n", + " 'location': rg_location,\n", + " 'subscription_id': subscription_id,\n", + " 'cost_export_frequency': cost_export_frequency,\n", + " 'generate_sample_load': generate_sample_load,\n", + " 'sample_requests_per_subscription': sample_requests_per_subscription,\n", + " 'sample_folder': sample_folder,\n", + " 'infrastructure': infrastructure,\n", + " 'index': index,\n", + " 'sample_index': sample_index\n", + "}\n", + "\n", + "print_ok('Configuration loaded')\n", + "print_val('Resource Group', config['rg_name'])\n", + "if config['apim_name']:\n", + " print_val('APIM Service', config['apim_name'])\n", + "print_val('Location', config['location'])\n", + "print_val('Subscription ID', config['subscription_id'])" + ] + }, + { + "cell_type": "markdown", + "id": "3fa3d77e", + "metadata": {}, + "source": [ + "## ๐Ÿ“ฆ Deploy Observability & Cost Resources\n", + "\n", + "Deploy the Bicep template to create:\n", + "- Application Insights\n", + "- Log Analytics Workspace\n", + "- Storage Account (for cost exports)\n", + "- Diagnostic Settings (APIM โ†’ Application Insights & Log Analytics)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fc7d872c", + "metadata": {}, + "outputs": [], + "source": [ + "print_info('Deploying observability and cost management resources...')\n", + "\n", + "# Define deployment name with index (default used by utils function is just the folder name)\n", + "deployment_name = config['sample_folder'] # utils uses sample_name as deployment name\n", + "\n", + "# Check if sample deployment already exists\n", + "existing_deployment = az.run(\n", + " f'az deployment group show --resource-group {config[\"rg_name\"]} --name {deployment_name} -o json',\n", + " log_command=False\n", + ")\n", + "\n", + "if existing_deployment.success:\n", + " print_warning(f'Deployment \"{deployment_name}\" already exists')\n", + " print_info('This will update the existing deployment with current parameters')\n", + " print_info('Tip: Use sample_index to differentiate multiple sample deployments')\n", + "\n", + "# Get APIM service name if not already set\n", + "if not config['apim_name']:\n", + " apim_list_result = az.run(\n", + " f'az apim list --resource-group {config[\"rg_name\"]} -o json',\n", + " log_command=False\n", + " )\n", + " if apim_list_result.success and apim_list_result.json_data and len(apim_list_result.json_data) > 0:\n", + " config['apim_name'] = apim_list_result.json_data[0]['name']\n", + " print_val('Found APIM Service', config['apim_name'])\n", + " else:\n", + " print_error('No APIM service found in resource group. Please deploy infrastructure first.')\n", + " raise SystemExit(1)\n", + "\n", + "# Deploy using the standard utility function with indexed naming\n", + "deployment_result = utils.create_bicep_deployment_group_for_sample(\n", + " config['sample_folder'],\n", + " config['rg_name'],\n", + " config['location'],\n", + " {\n", + " 'apimServiceName': {'value': config['apim_name']},\n", + " 'location': {'value': config['location']},\n", + " 'costExportFrequency': {'value': config['cost_export_frequency']},\n", + " 'sampleIndex': {'value': config['sample_index']}\n", + " }\n", + ")\n", + "\n", + "if not deployment_result.success:\n", + " print_error('Deployment failed')\n", + " raise SystemExit(1)\n", + "\n", + "# Extract outputs\n", + "config['app_insights_name'] = deployment_result.get('applicationInsightsName')\n", + "config['log_analytics_name'] = deployment_result.get('logAnalyticsWorkspaceName')\n", + "config['storage_account_name'] = deployment_result.get('storageAccountName')\n", + "config['app_insights_connection_string'] = deployment_result.get('applicationInsightsConnectionString')\n", + "config['workbook_name'] = deployment_result.get('workbookName')\n", + "config['workbook_id'] = deployment_result.get('workbookId')\n", + "\n", + "# Query for cost export (subscription-scoped resource)\n", + "config['cost_export_name'] = f\"apim-cost-export-{config['sample_index']}-{config['rg_name']}\"\n", + "\n", + "print_ok('Resources deployed successfully')\n", + "print_val('Deployment Name', deployment_name)\n", + "print_val('Application Insights', config['app_insights_name'])\n", + "print_val('Log Analytics Workspace', config['log_analytics_name'])\n", + "print_val('Storage Account', config['storage_account_name'])\n", + "if config.get('workbook_name'):\n", + " print_val('Azure Monitor Workbook', config['workbook_name'])" + ] + }, + { + "cell_type": "markdown", + "id": "2b6c16c3", + "metadata": {}, + "source": [ + "## ๐Ÿ”ง Configure Cost Management Export\n", + "\n", + "Automatically set up cost data export to the storage account." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "075b180c", + "metadata": {}, + "outputs": [], + "source": [ + "if not config.get('storage_account_name'):\n", + " print_error('Please run cell 6 (Deploy Observability & Cost Resources) first')\n", + " raise SystemExit(1)\n", + "\n", + "import json\n", + "import tempfile\n", + "from datetime import datetime, timedelta, timezone\n", + "\n", + "print_info('Configuring automated Cost Management export (managed identity)...')\n", + "\n", + "# Get storage account resource ID\n", + "storage_account_id = (\n", + " f'/subscriptions/{config[\"subscription_id\"]}'\n", + " f'/resourceGroups/{config[\"rg_name\"]}'\n", + " f'/providers/Microsoft.Storage/storageAccounts/{config[\"storage_account_name\"]}'\n", + ")\n", + "\n", + "# Export scope and name\n", + "export_scope = f'/subscriptions/{config[\"subscription_id\"]}'\n", + "export_name = config['cost_export_name']\n", + "api_version = '2025-03-01'\n", + "\n", + "# Register required resource provider\n", + "print_info('Registering Microsoft.CostManagementExports resource provider...')\n", + "register_result = az.run(\n", + " 'az provider register --namespace Microsoft.CostManagementExports --wait',\n", + " log_command=False\n", + ")\n", + "\n", + "if register_result.success:\n", + " print_ok('Resource provider registered successfully')\n", + "\n", + "# Check if export already exists (must use 2025-03-01 API for managed identity exports)\n", + "existing_export = az.run(\n", + " f'az rest --method GET '\n", + " f'--url \"{export_scope}/providers/Microsoft.CostManagement/exports/{export_name}'\n", + " f'?api-version={api_version}\" -o json',\n", + " log_command=False\n", + ")\n", + "\n", + "if existing_export.success:\n", + " print_warning(f'Cost export \"{export_name}\" already exists')\n", + " print_info('Deleting existing export to recreate with current settings...')\n", + " az.run(\n", + " f'az rest --method DELETE '\n", + " f'--url \"{export_scope}/providers/Microsoft.CostManagement/exports/{export_name}'\n", + " f'?api-version={api_version}\"',\n", + " log_command=False\n", + " )\n", + "\n", + "# Build recurrence settings\n", + "recurrence_map = {'Daily': 'Daily', 'Weekly': 'Weekly', 'Monthly': 'Monthly'}\n", + "recurrence = recurrence_map.get(config['cost_export_frequency'], 'Daily')\n", + "\n", + "start_date = (datetime.now(timezone.utc) + timedelta(days=1)).strftime('%Y-%m-%dT00:00:00Z')\n", + "end_date = (datetime.now(timezone.utc) + timedelta(days=365)).strftime('%Y-%m-%dT00:00:00Z')\n", + "\n", + "# Build the export body with system-assigned managed identity\n", + "# Uses 2025-03-01 API which supports identity-based delivery to storage\n", + "export_body = {\n", + " 'identity': {\n", + " 'type': 'systemAssigned'\n", + " },\n", + " 'location': 'global',\n", + " 'properties': {\n", + " 'definition': {\n", + " 'type': 'ActualCost',\n", + " 'timeframe': 'MonthToDate',\n", + " 'dataSet': {\n", + " 'granularity': 'Daily'\n", + " }\n", + " },\n", + " 'deliveryInfo': {\n", + " 'destination': {\n", + " 'type': 'AzureBlob',\n", + " 'container': 'cost-exports',\n", + " 'rootFolderPath': 'apim-costing',\n", + " 'resourceId': storage_account_id\n", + " }\n", + " },\n", + " 'schedule': {\n", + " 'status': 'Active',\n", + " 'recurrence': recurrence,\n", + " 'recurrencePeriod': {\n", + " 'from': start_date,\n", + " 'to': end_date\n", + " }\n", + " },\n", + " 'format': 'Csv'\n", + " }\n", + "}\n", + "\n", + "print_info('Creating cost export with managed identity...')\n", + "\n", + "# Write body to a temp file for cross-platform compatibility\n", + "# (inline JSON in --body breaks on Windows/PowerShell due to quote handling)\n", + "with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as body_file:\n", + " json.dump(export_body, body_file)\n", + " body_file_path = body_file.name\n", + "\n", + "try:\n", + " export_result = az.run(\n", + " f'az rest --method PUT '\n", + " f'--url \"{export_scope}/providers/Microsoft.CostManagement/exports/{export_name}'\n", + " f'?api-version={api_version}\" '\n", + " f'--body @{body_file_path} -o json',\n", + " log_command=False\n", + " )\n", + "finally:\n", + " Path(body_file_path).unlink(missing_ok=True)\n", + "\n", + "if export_result and export_result.success:\n", + " print_ok(f'Cost export created: {export_name}')\n", + " print_val('Export frequency', recurrence)\n", + " print_val('Authentication', 'System-assigned managed identity')\n", + " config['cost_export_configured'] = True\n", + "\n", + " # Extract the managed identity principal ID from the response\n", + " export_data = json.loads(export_result.text)\n", + " principal_id = export_data.get('identity', {}).get('principalId')\n", + "\n", + " if principal_id:\n", + " print_info('Assigning Storage Blob Data Contributor role to export identity...')\n", + "\n", + " role_assignment = az.run(\n", + " f'az role assignment create '\n", + " f'--assignee-object-id {principal_id} '\n", + " f'--assignee-principal-type ServicePrincipal '\n", + " f'--role \"Storage Blob Data Contributor\" '\n", + " f'--scope {storage_account_id}',\n", + " log_command=False\n", + " )\n", + "\n", + " if role_assignment.success:\n", + " print_ok('Storage Blob Data Contributor role assigned to export identity')\n", + " else:\n", + " print_warning('Could not assign role - you may need to do this manually')\n", + " print_info(f'Principal ID: {principal_id}')\n", + " else:\n", + " print_warning('Could not retrieve export identity principal ID')\n", + " print_info('You may need to assign Storage Blob Data Contributor role manually')\n", + "\n", + " print_info('Cost data will be exported automatically starting tomorrow')\n", + "else:\n", + " print_error('Failed to create cost export')\n", + " if export_result and export_result.text:\n", + " print_warning(f'Error: {export_result.text[:500]}')\n", + "\n", + " print()\n", + " print_warning('Continuing without cost export - you can configure it manually later')\n", + " config['cost_export_configured'] = False" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "80be9404", + "metadata": {}, + "outputs": [], + "source": [ + "# Trigger the first cost export run immediately (instead of waiting for tomorrow's schedule)\n", + "if config.get('cost_export_configured'):\n", + " export_scope = f'/subscriptions/{config[\"subscription_id\"]}'\n", + " export_name = config['cost_export_name']\n", + " api_version = '2025-03-01'\n", + "\n", + " print_info(f'Triggering first cost export run for \"{export_name}\"...')\n", + "\n", + " run_result = az.run(\n", + " f'az rest --method POST '\n", + " f'--url \"{export_scope}/providers/Microsoft.CostManagement/exports/{export_name}'\n", + " f'/run?api-version={api_version}\"',\n", + " log_command=False\n", + " )\n", + "\n", + " if run_result.success:\n", + " print_ok('Cost export run triggered successfully')\n", + " print_info('Data will appear in the storage container within a few minutes')\n", + " else:\n", + " print_warning('Could not trigger export run - it will run on its next scheduled recurrence')\n", + " if run_result and run_result.text:\n", + " print_info(f'Details: {run_result.text[:300]}')\n", + "else:\n", + " print_warning('Cost export was not configured - skipping manual run')" + ] + }, + { + "cell_type": "markdown", + "id": "4b1d1533", + "metadata": {}, + "source": [ + "## ๐Ÿข Create Sample API & Business Unit Subscriptions\n", + "\n", + "Create a sample API and multiple APIM subscriptions representing different business units, departments, or applications." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6c86006d", + "metadata": {}, + "outputs": [], + "source": [ + "import json as json_module\n", + "\n", + "print_info('Creating sample API for cost tracking...')\n", + "\n", + "# APIM ARM base URL\n", + "apim_base_url = (\n", + " f'/subscriptions/{config[\"subscription_id\"]}'\n", + " f'/resourceGroups/{config[\"rg_name\"]}'\n", + " f'/providers/Microsoft.ApiManagement/service/{config[\"apim_name\"]}'\n", + ")\n", + "apim_api_version = '2024-06-01-preview'\n", + "\n", + "# Create a sample echo API\n", + "api_id = 'cost-tracking-api'\n", + "api_path = 'cost-demo'\n", + "\n", + "api_body = {\n", + " 'properties': {\n", + " 'displayName': 'Cost Tracking Demo API',\n", + " 'path': api_path,\n", + " 'protocols': ['https'],\n", + " 'subscriptionRequired': True,\n", + " 'serviceUrl': 'https://httpbin.org'\n", + " }\n", + "}\n", + "\n", + "with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:\n", + " json_module.dump(api_body, f)\n", + " api_body_path = f.name\n", + "\n", + "try:\n", + " az.run(\n", + " f'az rest --method PUT '\n", + " f'--url \"{apim_base_url}/apis/{api_id}?api-version={apim_api_version}\" '\n", + " f'--body @{api_body_path} -o json',\n", + " log_command=False\n", + " )\n", + "finally:\n", + " Path(api_body_path).unlink(missing_ok=True)\n", + "\n", + "# Create an operation\n", + "op_body = {\n", + " 'properties': {\n", + " 'displayName': 'Get Status',\n", + " 'method': 'GET',\n", + " 'urlTemplate': '/get'\n", + " }\n", + "}\n", + "\n", + "with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:\n", + " json_module.dump(op_body, f)\n", + " op_body_path = f.name\n", + "\n", + "try:\n", + " az.run(\n", + " f'az rest --method PUT '\n", + " f'--url \"{apim_base_url}/apis/{api_id}/operations/get-status?api-version={apim_api_version}\" '\n", + " f'--body @{op_body_path} -o json',\n", + " log_command=False\n", + " )\n", + "finally:\n", + " Path(op_body_path).unlink(missing_ok=True)\n", + "\n", + "print_ok(f'Created API: {api_id}')\n", + "\n", + "# Create subscriptions for different business units\n", + "# request_weight controls traffic distribution (higher = more requests)\n", + "business_units = [\n", + " {'name': 'bu-hr', 'display': 'Business Unit - Human Resources', 'request_weight': 1.0},\n", + " {'name': 'bu-finance', 'display': 'Business Unit - Finance', 'request_weight': 2.5},\n", + " {'name': 'bu-marketing', 'display': 'Business Unit - Marketing', 'request_weight': 0.5},\n", + " {'name': 'bu-engineering', 'display': 'Business Unit - Engineering', 'request_weight': 3.0}\n", + "]\n", + "\n", + "print_info(f'Creating {len(business_units)} business unit subscriptions...')\n", + "\n", + "config['subscriptions'] = {}\n", + "\n", + "for bu in business_units:\n", + " sub_id = bu['name']\n", + "\n", + " # Create subscription via ARM REST API\n", + " sub_body = {\n", + " 'properties': {\n", + " 'displayName': bu['display'],\n", + " 'scope': f'/apis/{api_id}',\n", + " 'state': 'active'\n", + " }\n", + " }\n", + "\n", + " with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:\n", + " json_module.dump(sub_body, f)\n", + " sub_body_path = f.name\n", + "\n", + " try:\n", + " result = az.run(\n", + " f'az rest --method PUT '\n", + " f'--url \"{apim_base_url}/subscriptions/{sub_id}?api-version={apim_api_version}\" '\n", + " f'--body @{sub_body_path} -o json',\n", + " log_command=False\n", + " )\n", + " finally:\n", + " Path(sub_body_path).unlink(missing_ok=True)\n", + "\n", + " # Get subscription keys via listSecrets\n", + " keys_result = az.run(\n", + " f'az rest --method POST '\n", + " f'--url \"{apim_base_url}/subscriptions/{sub_id}/listSecrets'\n", + " f'?api-version={apim_api_version}\" -o json',\n", + " log_command=False\n", + " )\n", + "\n", + " primary_key = None\n", + " if keys_result.success and keys_result.json_data:\n", + " primary_key = keys_result.json_data.get('primaryKey')\n", + "\n", + " config['subscriptions'][sub_id] = {\n", + " 'display_name': bu['display'],\n", + " 'primary_key': primary_key,\n", + " 'request_weight': bu.get('request_weight', 1.0)\n", + " }\n", + "\n", + " if result.success and primary_key:\n", + " print_ok(f' Created subscription: {sub_id}')\n", + " elif result.success:\n", + " print_warning(f' Created subscription {sub_id} but could not retrieve key')\n", + " else:\n", + " print_error(f' Failed to create subscription: {sub_id}')\n", + "\n", + "print_ok(f'Created {len(config[\"subscriptions\"])} subscriptions')" + ] + }, + { + "cell_type": "markdown", + "id": "8048503a", + "metadata": {}, + "source": [ + "## ๐Ÿš€ Generate Sample API Traffic\n", + "\n", + "Generate sample API calls from each business unit subscription to demonstrate cost tracking and allocation.\n", + "\n", + "This will create request logs in Application Insights and Log Analytics that can be used for cost analysis." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bc3d366c", + "metadata": {}, + "outputs": [], + "source": [ + "if config['generate_sample_load']:\n", + " import requests\n", + "\n", + " print_info('Generating sample API traffic...')\n", + "\n", + " # Get APIM gateway URL\n", + " apim_info_result = az.run(\n", + " f'az apim show '\n", + " f'--resource-group {config[\"rg_name\"]} '\n", + " f'--name {config[\"apim_name\"]} -o json',\n", + " log_command=False\n", + " )\n", + "\n", + " gateway_url = apim_info_result.json_data['gatewayUrl']\n", + " api_url = f'{gateway_url}/{api_path}/get'\n", + "\n", + " print_val('API Endpoint', api_url)\n", + " print_info(f'Sending {config[\"sample_requests_per_subscription\"]} requests per subscription...')\n", + "\n", + " total_requests = 0\n", + " total_success = 0\n", + "\n", + " base_count = config['sample_requests_per_subscription']\n", + "\n", + " for subscription_id, sub_info in config['subscriptions'].items():\n", + " bu_request_count = max(1, int(base_count * sub_info.get('request_weight', 1.0)))\n", + " print_info(f' Testing {subscription_id} ({bu_request_count} requests, weight={sub_info.get(\"request_weight\", 1.0)})...')\n", + " success_count = 0\n", + "\n", + " for i in range(bu_request_count):\n", + " try:\n", + " response = requests.get(\n", + " api_url,\n", + " headers={'Ocp-Apim-Subscription-Key': sub_info['primary_key']},\n", + " timeout=10\n", + " )\n", + " if response.status_code == 200:\n", + " success_count += 1\n", + " total_requests += 1\n", + " except Exception as e:\n", + " print_error(f' Request failed: {str(e)}')\n", + "\n", + " # Add small delay to avoid overwhelming the API\n", + " if (i + 1) % 10 == 0:\n", + " time.sleep(0.5)\n", + "\n", + " total_success += success_count\n", + " print_ok(f' Completed {success_count}/{bu_request_count} requests')\n", + "\n", + " print_ok(f'Generated {total_success}/{total_requests} successful API calls')\n", + " print_info('Note: It may take 2-5 minutes for logs to appear in Application Insights and Log Analytics')\n", + "else:\n", + " print_info('Sample load generation skipped (generate_sample_load = False)')" + ] + }, + { + "cell_type": "markdown", + "id": "179f9e77", + "metadata": {}, + "source": [ + "## ๐Ÿ” Verify Log Ingestion\n", + "\n", + "Waits for diagnostic logs to arrive in Log Analytics (auto-retries for up to 10 minutes)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9db5899e", + "metadata": {}, + "outputs": [], + "source": [ + "print_info('Waiting for APIM logs to arrive in Log Analytics...')\n", + "print_info('Log ingestion typically takes 2-5 minutes after generating traffic')\n", + "print()\n", + "\n", + "log_analytics_workspace_id = config.get('log_analytics_name')\n", + "\n", + "if not log_analytics_workspace_id:\n", + " print_error('Log Analytics workspace name not found in config')\n", + " raise SystemExit(1)\n", + "\n", + "print_val('Workspace', log_analytics_workspace_id)\n", + "\n", + "# Retry until logs arrive or timeout\n", + "max_wait_minutes = 10\n", + "poll_interval_seconds = 30\n", + "max_attempts = (max_wait_minutes * 60) // poll_interval_seconds\n", + "logs_found = False\n", + "\n", + "for attempt in range(1, max_attempts + 1):\n", + " query_cmd = (\n", + " f'az monitor log-analytics query '\n", + " f'--workspace {log_analytics_workspace_id} '\n", + " f'--resource-group {config[\"rg_name\"]} '\n", + " f'--analytics-query \"ApiManagementGatewayLogs | where ApimSubscriptionId != \\'\\' | summarize Count = count()\" '\n", + " f'-o json'\n", + " )\n", + "\n", + " result = az.run(query_cmd, log_command=False)\n", + "\n", + " if result.success and result.json_data:\n", + " tables = result.json_data.get('tables', [])\n", + " if tables and len(tables) > 0:\n", + " rows = tables[0].get('rows', [])\n", + " if rows and len(rows) > 0:\n", + " row_count = int(rows[0][0])\n", + " if row_count > 0:\n", + " print_ok(f'โœ“ Found {row_count} log entries with subscription IDs')\n", + " logs_found = True\n", + " break\n", + "\n", + " elapsed = attempt * poll_interval_seconds\n", + " remaining = (max_wait_minutes * 60) - elapsed\n", + " print_info(f' โณ No logs yet... retrying in {poll_interval_seconds}s ({remaining}s remaining)')\n", + " time.sleep(poll_interval_seconds)\n", + "\n", + "if logs_found:\n", + " print_ok('Log ingestion verified - workbook should now display data')\n", + "else:\n", + " print_warning(f'Logs did not appear within {max_wait_minutes} minutes')\n", + " print_info('This can happen with newly created workspaces. Tips:')\n", + " print_info(' 1. Wait a few more minutes and re-run this cell')\n", + " print_info(' 2. Verify diagnostic settings in Azure Portal')\n", + " print_info(' 3. Re-run cell 13 to generate more traffic')" + ] + }, + { + "cell_type": "markdown", + "id": "6ec1ac38", + "metadata": {}, + "source": [ + "## ๐Ÿ“Š Cost Analysis & Sample Kusto Queries\n", + "\n", + "### Cost Allocation Model\n", + "\n", + "| Component | Formula |\n", + "|---|---|\n", + "| **Base Cost** | Monthly platform cost for the APIM SKU (auto-detected or parameterised) |\n", + "| **Base Cost Share** | `Base Monthly Cost ร— (BU Requests รท Total Requests)` |\n", + "| **Variable Cost** | `BU Requests ร— (Rate per 1K รท 1000)` |\n", + "| **Total Allocated** | `Base Cost Share + Variable Cost` |\n", + "\n", + "The next cell auto-detects your APIM SKU and fetches live pricing from the [Azure Retail Prices API](https://learn.microsoft.com/rest/api/cost-management/retail-prices/azure-retail-prices)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5a426e0c", + "metadata": {}, + "outputs": [], + "source": [ + "import requests as http_requests\n", + "\n", + "# ------------------------------\n", + "# FETCH LIVE PRICING FROM AZURE RETAIL PRICES API\n", + "# ------------------------------\n", + "\n", + "print_info('Fetching live APIM pricing from Azure Retail Prices API...')\n", + "\n", + "# Detect SKU and capacity from the deployed APIM instance\n", + "sku_result = az.run(\n", + " f'az apim show '\n", + " f'--resource-group {config[\"rg_name\"]} '\n", + " f'--name {config[\"apim_name\"]} '\n", + " f'--query \"{{sku: sku.name, capacity: sku.capacity, location: location}}\" -o json',\n", + " log_command=False\n", + ")\n", + "\n", + "if sku_result.success and sku_result.json_data:\n", + " apim_sku = sku_result.json_data['sku'] # e.g. 'BasicV2'\n", + " apim_capacity = sku_result.json_data['capacity']\n", + " apim_location = sku_result.json_data['location']\n", + " print_val('APIM SKU', apim_sku)\n", + " print_val('Capacity (units)', apim_capacity)\n", + " print_val('Region', apim_location)\n", + "else:\n", + " apim_sku = 'BasicV2'\n", + " apim_capacity = 1\n", + " apim_location = config['location']\n", + " print_warning(f'Could not detect SKU, defaulting to {apim_sku}')\n", + "\n", + "# Map SKU names to Azure Retail Prices API format\n", + "sku_api_name_map = {\n", + " 'BasicV2': 'Basic v2',\n", + " 'StandardV2': 'Standard v2',\n", + " 'PremiumV2': 'Premium v2',\n", + " 'Basic': 'Basic',\n", + " 'Standard': 'Standard',\n", + " 'Premium': 'Premium'\n", + "}\n", + "\n", + "# Map location display names to ARM region names\n", + "location_arm_map = {\n", + " 'East US': 'eastus',\n", + " 'East US 2': 'eastus2',\n", + " 'West US': 'westus',\n", + " 'West US 2': 'westus2',\n", + " 'West US 3': 'westus3',\n", + " 'Central US': 'centralus',\n", + " 'North Europe': 'northeurope',\n", + " 'West Europe': 'westeurope',\n", + " 'UK South': 'uksouth',\n", + " 'Southeast Asia': 'southeastasia'\n", + "}\n", + "arm_region = location_arm_map.get(apim_location, apim_location.lower().replace(' ', ''))\n", + "\n", + "api_sku_name = sku_api_name_map.get(apim_sku, apim_sku)\n", + "pricing_url = (\n", + " 'https://prices.azure.com/api/retail/prices'\n", + " f\"?$filter=serviceName eq 'API Management'\"\n", + " f\" and skuName eq '{api_sku_name}'\"\n", + " f\" and priceType eq 'Consumption'\"\n", + " f\" and armRegionName eq '{arm_region}'\"\n", + ")\n", + "\n", + "base_monthly_cost = None\n", + "per_k_rate = None\n", + "included_requests_k = None\n", + "\n", + "try:\n", + " pricing_response = http_requests.get(pricing_url, timeout=15)\n", + " pricing_data = pricing_response.json()\n", + "\n", + " for item in pricing_data.get('Items', []):\n", + " meter = item.get('meterName', '')\n", + " price = item.get('retailPrice', 0)\n", + " unit = item.get('unitOfMeasure', '')\n", + " tier_min = item.get('tierMinimumUnits', 0)\n", + "\n", + " # Unit cost (per hour) - primary unit, not secondary\n", + " if 'Unit' in meter and 'Secondary' not in meter and unit == '1 Hour':\n", + " hourly_rate = price\n", + " base_monthly_cost = round(hourly_rate * 730, 2) # 730 hrs/month\n", + "\n", + " # Overage call rate (tier with minimum > 0)\n", + " if 'Calls' in meter and tier_min > 0:\n", + " # Price is per 10K calls\n", + " per_10k_rate = price\n", + " per_k_rate = round(per_10k_rate / 10, 6)\n", + " included_requests_k = int(tier_min * 10) # tier_min is in 10K units\n", + "\n", + " if base_monthly_cost and per_k_rate:\n", + " print()\n", + " print_ok('Live pricing retrieved from Azure Retail Prices API')\n", + " print_val('Base monthly cost (per unit)', f'${base_monthly_cost}')\n", + " print_val('Total base cost ({} unit(s))'.format(apim_capacity), f'${round(base_monthly_cost * apim_capacity, 2)}')\n", + " print_val('Included requests', f'{included_requests_k:,}K ({included_requests_k // 1000}M)')\n", + " print_val('Overage rate per 1K requests', f'${per_k_rate}')\n", + "\n", + " # Adjust for capacity\n", + " base_monthly_cost = round(base_monthly_cost * apim_capacity, 2)\n", + " else:\n", + " raise ValueError('Could not parse unit cost or call rate from API response')\n", + "\n", + "except Exception as e:\n", + " print_warning(f'Could not fetch live pricing: {e}')\n", + " print_info('Falling back to default BasicV2 pricing')\n", + " base_monthly_cost = 150.00\n", + " per_k_rate = 0.003\n", + " included_requests_k = 10000\n", + " print_val('Base monthly cost (default)', f'${base_monthly_cost:.2f}')\n", + " print_val('Overage rate per 1K (default)', f'${per_k_rate}')\n", + "\n", + "# Store in config for use by other cells\n", + "config['base_monthly_cost'] = base_monthly_cost\n", + "config['per_k_rate'] = per_k_rate\n", + "config['included_requests_k'] = included_requests_k\n", + "\n", + "# ------------------------------\n", + "# SAMPLE KUSTO QUERIES\n", + "# ------------------------------\n", + "\n", + "print()\n", + "print_info('Sample Kusto Queries for Log Analytics:')\n", + "print_info('These queries use the ApiManagementGatewayLogs table (resource-specific mode).')\n", + "print()\n", + "\n", + "queries = {\n", + " 'Cost Allocation by Business Unit': f'''\n", + "// Split base APIM cost proportionally + add variable per-request cost\n", + "// Pricing source: Azure Retail Prices API ({apim_sku}, {arm_region})\n", + "let baseCost = {base_monthly_cost};\n", + "let perKRate = {per_k_rate};\n", + "let logs = ApiManagementGatewayLogs\n", + "| where TimeGenerated > ago(30d) and ApimSubscriptionId != '';\n", + "let totalRequests = toscalar(logs | summarize count());\n", + "logs\n", + "| summarize RequestCount = count() by ApimSubscriptionId\n", + "| extend UsageShare = round(RequestCount * 100.0 / totalRequests, 2)\n", + "| extend BaseCostShare = round(baseCost * RequestCount / totalRequests, 2)\n", + "| extend VariableCost = round(RequestCount * perKRate / 1000.0, 2)\n", + "| extend TotalAllocatedCost = round(BaseCostShare + VariableCost, 2)\n", + "| order by TotalAllocatedCost desc\n", + "| project\n", + " ['Business Unit'] = ApimSubscriptionId,\n", + " ['Requests'] = RequestCount,\n", + " ['Usage Share (%)'] = UsageShare,\n", + " ['Base Cost ($)'] = baseCost,\n", + " ['Base Cost Share ($)'] = BaseCostShare,\n", + " ['Variable Cost ($)'] = VariableCost,\n", + " ['Total Allocated ($)'] = TotalAllocatedCost\n", + "''',\n", + " 'Cost Breakdown per API per Business Unit': f'''\n", + "// Drill down: cost allocation at the API level\n", + "let baseCost = {base_monthly_cost};\n", + "let perKRate = {per_k_rate};\n", + "let logs = ApiManagementGatewayLogs\n", + "| where TimeGenerated > ago(30d) and ApimSubscriptionId != '';\n", + "let totalRequests = toscalar(logs | summarize count());\n", + "logs\n", + "| summarize RequestCount = count() by ApimSubscriptionId, ApiId\n", + "| extend BaseCostShare = round(baseCost * RequestCount / totalRequests, 2)\n", + "| extend VariableCost = round(RequestCount * perKRate / 1000.0, 2)\n", + "| extend TotalCost = round(BaseCostShare + VariableCost, 2)\n", + "| order by TotalCost desc\n", + "| project\n", + " ['Business Unit'] = ApimSubscriptionId,\n", + " ['API'] = ApiId,\n", + " ['Requests'] = RequestCount,\n", + " ['Base Share ($)'] = BaseCostShare,\n", + " ['Variable ($)'] = VariableCost,\n", + " ['Total ($)'] = TotalCost\n", + "| take 25\n", + "''',\n", + " 'Request Count by Business Unit': '''\n", + "ApiManagementGatewayLogs\n", + "| where TimeGenerated > ago(24h) and ApimSubscriptionId != ''\n", + "| summarize RequestCount = count() by ApimSubscriptionId\n", + "| order by RequestCount desc\n", + "| project BusinessUnit = ApimSubscriptionId, RequestCount\n", + "''',\n", + " 'Success & Error Metrics by Business Unit': '''\n", + "ApiManagementGatewayLogs\n", + "| where TimeGenerated > ago(24h) and ApimSubscriptionId != ''\n", + "| summarize\n", + " TotalRequests = count(),\n", + " SuccessRequests = countif(ResponseCode < 400),\n", + " ClientErrors = countif(ResponseCode >= 400 and ResponseCode < 500),\n", + " ServerErrors = countif(ResponseCode >= 500)\n", + " by ApimSubscriptionId\n", + "| extend SuccessRate = round(SuccessRequests * 100.0 / TotalRequests, 2)\n", + "| extend ErrorRate = round((ClientErrors + ServerErrors) * 100.0 / TotalRequests, 2)\n", + "| project\n", + " BusinessUnit = ApimSubscriptionId,\n", + " TotalRequests,\n", + " SuccessRequests,\n", + " ClientErrors,\n", + " ServerErrors,\n", + " ['Success Rate (%)'] = SuccessRate,\n", + " ['Error Rate (%)'] = ErrorRate\n", + "| order by TotalRequests desc\n", + "''',\n", + " 'Hourly Request Trends': '''\n", + "ApiManagementGatewayLogs\n", + "| where TimeGenerated > ago(7d) and ApimSubscriptionId != ''\n", + "| summarize RequestCount = count() by bin(TimeGenerated, 1h), ApimSubscriptionId\n", + "| render timechart\n", + "''',\n", + " 'Response Code Distribution': '''\n", + "ApiManagementGatewayLogs\n", + "| where TimeGenerated > ago(24h) and ApimSubscriptionId != ''\n", + "| extend ResponseClass = case(\n", + " ResponseCode >= 200 and ResponseCode < 300, \"2xx\",\n", + " ResponseCode >= 300 and ResponseCode < 400, \"3xx\",\n", + " ResponseCode >= 400 and ResponseCode < 500, \"4xx\",\n", + " ResponseCode >= 500, \"5xx\",\n", + " \"Other\")\n", + "| summarize RequestCount = count() by ApimSubscriptionId, ResponseClass\n", + "| order by ApimSubscriptionId, ResponseClass\n", + "| project BusinessUnit = ApimSubscriptionId, ResponseClass, RequestCount\n", + "'''\n", + "}\n", + "\n", + "for query_name, query in queries.items():\n", + " print(f'๐Ÿ“ˆ {query_name}')\n", + " print('โ”€' * 60)\n", + " print(query.strip())\n", + " print()\n", + "\n", + "print_ok('Query templates ready to use in Log Analytics')" + ] + }, + { + "cell_type": "markdown", + "id": "d1908112", + "metadata": {}, + "source": [ + "## ๐Ÿ”” Set Up Budget Alerts per Business Unit\n", + "\n", + "Create Azure Monitor scheduled query alerts that fire when a business unit subscription exceeds a configurable request threshold.\n", + "\n", + "Each alert:\n", + "- Runs a Kusto query every **5 minutes** against the Log Analytics workspace\n", + "- Triggers when a business unit exceeds the threshold in a **1-hour** rolling window\n", + "- Sends notifications via an **Action Group** (email)\n", + "\n", + "> ๐Ÿ’ก Adjust `alert_threshold` and `alert_email` below to match your requirements." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f49f9785", + "metadata": {}, + "outputs": [], + "source": [ + "# ------------------------------\n", + "# ALERT CONFIGURATION\n", + "# ------------------------------\n", + "\n", + "alert_threshold = 1000 # Request count threshold per BU per hour\n", + "alert_email = 'sample@abc.com' # Email for alert notifications (leave empty to skip)\n", + "alert_severity = 2 # 0=Critical, 1=Error, 2=Warning, 3=Informational, 4=Verbose\n", + "\n", + "if not alert_email:\n", + " print_warning('No alert_email configured - skipping budget alert setup')\n", + " print_info('Set alert_email above to enable budget alerts per business unit')\n", + "else:\n", + " print_info('Setting up budget alerts per business unit subscription...')\n", + "\n", + " # Get Log Analytics workspace resource ID\n", + " workspace_result = az.run(\n", + " f'az monitor log-analytics workspace show '\n", + " f'--resource-group {config[\"rg_name\"]} '\n", + " f'--workspace-name {config[\"log_analytics_name\"]} '\n", + " f'--query id -o tsv',\n", + " log_command=False\n", + " )\n", + " workspace_id = workspace_result.text.strip()\n", + "\n", + " # Create an Action Group for alert notifications\n", + " action_group_name = f'ag-apim-cost-alerts-{config[\"sample_index\"]}'\n", + " print_info(f'Creating action group: {action_group_name}...')\n", + "\n", + " ag_result = az.run(\n", + " f'az monitor action-group create '\n", + " f'--resource-group {config[\"rg_name\"]} '\n", + " f'--name {action_group_name} '\n", + " f'--short-name apimcost '\n", + " f'--action email cost-alert-email {alert_email} '\n", + " f'-o json',\n", + " log_command=False\n", + " )\n", + "\n", + " if ag_result.success:\n", + " action_group_id = ag_result.json_data.get('id', '')\n", + " print_ok(f'Action group created: {action_group_name}')\n", + " else:\n", + " print_error(f'Failed to create action group: {ag_result.text}')\n", + " action_group_id = None\n", + "\n", + " if action_group_id:\n", + " # Get the list of business units from config or define them\n", + " bu_list = list(config.get('subscriptions', {}).keys())\n", + " if not bu_list:\n", + " bu_list = ['bu-hr', 'bu-finance', 'bu-marketing', 'bu-engineering']\n", + "\n", + " print_info(f'Creating alerts for {len(bu_list)} business units (threshold: {alert_threshold} requests/hour)...')\n", + "\n", + " for bu_name in bu_list:\n", + " alert_name = f'apim-budget-{bu_name}-{config[\"sample_index\"]}'\n", + "\n", + " # Kusto query: count requests for this specific BU in the last hour\n", + " kusto_query = (\n", + " f'ApiManagementGatewayLogs '\n", + " f'| where TimeGenerated > ago(1h) and ApimSubscriptionId == \\'{bu_name}\\' '\n", + " f'| summarize RequestCount = count() '\n", + " f'| where RequestCount > {alert_threshold}'\n", + " )\n", + "\n", + " # Create the scheduled query alert rule\n", + " result = az.run(\n", + " f'az monitor scheduled-query create '\n", + " f'--resource-group {config[\"rg_name\"]} '\n", + " f'--name {alert_name} '\n", + " f'--display-name \"APIM Budget Alert: {bu_name}\" '\n", + " f'--description \"Fires when {bu_name} exceeds {alert_threshold} API requests per hour\" '\n", + " f'--scopes {workspace_id} '\n", + " f'--condition \"count \\'RequestCount\\' from \\'{kusto_query}\\' > 0\" '\n", + " f'--condition-query \"{kusto_query}\" '\n", + " f'--evaluation-frequency 5m '\n", + " f'--window-size 1h '\n", + " f'--severity {alert_severity} '\n", + " f'--action-groups {action_group_id} '\n", + " f'-o json',\n", + " log_command=False\n", + " )\n", + "\n", + " if result.success:\n", + " print_ok(f' Alert created: {alert_name}')\n", + " else:\n", + " # Try alternative approach using REST API\n", + " import json as json_module\n", + "\n", + " alert_body = {\n", + " 'location': config['location'],\n", + " 'properties': {\n", + " 'displayName': f'APIM Budget Alert: {bu_name}',\n", + " 'description': f'Fires when {bu_name} exceeds {alert_threshold} API requests per hour',\n", + " 'severity': alert_severity,\n", + " 'enabled': True,\n", + " 'evaluationFrequency': 'PT5M',\n", + " 'windowSize': 'PT1H',\n", + " 'scopes': [workspace_id],\n", + " 'criteria': {\n", + " 'allOf': [\n", + " {\n", + " 'query': kusto_query,\n", + " 'timeAggregation': 'Count',\n", + " 'operator': 'GreaterThan',\n", + " 'threshold': 0,\n", + " 'failingPeriods': {\n", + " 'numberOfEvaluationPeriods': 1,\n", + " 'minFailingPeriodsToAlert': 1\n", + " }\n", + " }\n", + " ]\n", + " },\n", + " 'actions': {\n", + " 'actionGroups': [action_group_id]\n", + " }\n", + " }\n", + " }\n", + "\n", + " alert_id = (\n", + " f'/subscriptions/{config[\"subscription_id\"]}'\n", + " f'/resourceGroups/{config[\"rg_name\"]}'\n", + " f'/providers/Microsoft.Insights/scheduledQueryRules/{alert_name}'\n", + " )\n", + "\n", + " rest_result = az.run(\n", + " f'az rest --method PUT '\n", + " f'--uri https://management.azure.com{alert_id}?api-version=2023-03-15-preview '\n", + " f\"--body '{json_module.dumps(alert_body)}'\",\n", + " log_command=False\n", + " )\n", + "\n", + " if rest_result.success:\n", + " print_ok(f' Alert created (via REST): {alert_name}')\n", + " else:\n", + " print_error(f' Failed to create alert for {bu_name}: {rest_result.text[:200]}')\n", + "\n", + " print()\n", + " print_ok('Budget alerts configured!')\n", + " print_val('Action Group', action_group_name)\n", + " print_val('Alert Email', alert_email)\n", + " print_val('Threshold', f'{alert_threshold} requests per hour per BU')\n", + " print_val('Evaluation', 'Every 5 minutes, 1-hour rolling window')" + ] + }, + { + "cell_type": "markdown", + "id": "83ac6281", + "metadata": {}, + "source": [ + "## ๐Ÿ”— Access Your Resources\n", + "\n", + "Links to access the deployed resources in Azure Portal." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4d8c1864", + "metadata": {}, + "outputs": [], + "source": [ + "print_info('๐Ÿ“Ž Azure Portal Links:')\n", + "print()\n", + "\n", + "base_url = 'https://portal.azure.com/#@/resource'\n", + "subscription_path = f'/subscriptions/{config[\"subscription_id\"]}'\n", + "rg_path = f'{subscription_path}/resourceGroups/{config[\"rg_name\"]}'\n", + "\n", + "resources = [\n", + " ('Application Insights', f'{rg_path}/providers/Microsoft.Insights/components/{config[\"app_insights_name\"]}/overview'),\n", + " ('Log Analytics Workspace', f'{rg_path}/providers/Microsoft.OperationalInsights/workspaces/{config[\"log_analytics_name\"]}/Overview'),\n", + " ('Storage Account', f'{rg_path}/providers/Microsoft.Storage/storageAccounts/{config[\"storage_account_name\"]}/overview'),\n", + " ('APIM Service', f'{rg_path}/providers/Microsoft.ApiManagement/service/{config[\"apim_name\"]}/overview'),\n", + " ('Cost Management + Exports', f'{subscription_path}/costManagement')\n", + "]\n", + "\n", + "if config.get('workbook_id'):\n", + " resources.append(('Azure Monitor Workbook', f'{base_url}{config[\"workbook_id\"]}/workbook'))\n", + "\n", + "for name, path in resources:\n", + " print(f'{name}:')\n", + " print(f'{base_url}{path}')\n", + " print()\n", + "\n", + "print_ok('Setup complete!')\n", + "print()\n", + "print_info('๐Ÿ“Š Cost Allocation is now automated:')\n", + "print(' โœ“ Request logs flowing to Application Insights & Log Analytics')\n", + "if config.get('cost_export_configured'):\n", + " print(' โœ“ Cost data automatically exported daily to Storage Account')\n", + "else:\n", + " print(' โš  Cost export not configured - configure manually via Azure Portal')\n", + "print(' โœ“ Azure Monitor Workbook ready for cost visualization')\n", + "print(' โœ“ Business unit usage tracked via APIM subscriptions')\n", + "print()\n", + "print_info('๐Ÿ’ก Cost allocation workflow:')\n", + "print(' 1. APIM logs each API request with subscription info')\n", + "print(' 2. Azure exports daily cost data to storage')\n", + "print(' 3. Correlate usage metrics with costs using subscription IDs')\n", + "print(' 4. Create chargeback reports per business unit')\n", + "print(' 5. Set up budget alerts based on usage patterns')\n", + "print()\n", + "print_info('๐Ÿ” Next steps:')\n", + "print(' โ€ข Open Azure Monitor Workbook to view real-time cost dashboard')\n", + "print(' โ€ข Query Log Analytics for detailed usage analysis')\n", + "print(' โ€ข Review Cost Management exports (available after first export run)')\n", + "print()\n", + "print_info('To clean up resources, open and run: clean-up.ipynb')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/samples/costing/main.bicep b/samples/costing/main.bicep new file mode 100644 index 0000000..640c875 --- /dev/null +++ b/samples/costing/main.bicep @@ -0,0 +1,318 @@ +// ------------------------------ +// PARAMETERS +// ------------------------------ + +@description('Location to be used for resources. Defaults to the resource group location') +param location string = resourceGroup().location + +@description('The unique suffix to append. Defaults to a unique string based on subscription and resource group IDs.') +param resourceSuffix string = uniqueString(subscription().id, resourceGroup().id) + +@description('Name of the existing API Management service') +param apimServiceName string + +@description('Sample deployment index for unique resource naming') +param sampleIndex int = 1 + +@description('Enable Application Insights for APIM diagnostics') +param enableApplicationInsights bool = true + +@description('Enable Log Analytics for APIM diagnostics') +param enableLogAnalytics bool = true + +@description('Log Analytics data retention in days') +param logRetentionDays int = 30 + +@description('Storage account SKU for cost exports') +@allowed([ + 'Standard_LRS' + 'Standard_GRS' + 'Standard_ZRS' +]) +param storageAccountSku string = 'Standard_LRS' + +@description('Cost export frequency') +@allowed([ + 'Daily' + 'Weekly' + 'Monthly' +]) +param costExportFrequency string = 'Daily' + +@description('Start date for cost export schedule. Defaults to current deployment time.') +param costExportStartDate string = utcNow('yyyy-MM-ddT00:00:00Z') + +@description('Deploy the Cost Management export from Bicep. When false (default), the notebook handles export creation with retry logic to avoid key-access propagation failures.') +param enableCostExport bool = false + + +// ------------------------------ +// VARIABLES +// ------------------------------ + +var applicationInsightsName = 'appi-costing-${sampleIndex}-${resourceSuffix}' +var logAnalyticsWorkspaceName = 'log-costing-${sampleIndex}-${resourceSuffix}' +var storageAccountName = 'stcost${sampleIndex}${take(replace(resourceSuffix, '-', ''), 16)}' +var diagnosticSettingsName = 'costing-diagnostics-${sampleIndex}' +var workbookName = 'APIM Cost Analysis by Business Unit ${sampleIndex}' +var costExportName = 'apim-cost-export-${sampleIndex}-${resourceGroup().name}' + + +// ------------------------------ +// RESOURCES +// ------------------------------ + + +// https://learn.microsoft.com/azure/templates/microsoft.operationalinsights/workspaces +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = if (enableLogAnalytics) { + name: logAnalyticsWorkspaceName + location: location + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: logRetentionDays + features: { + enableLogAccessUsingOnlyResourcePermissions: true + } + } +} + + +// https://learn.microsoft.com/azure/templates/microsoft.insights/components +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = if (enableApplicationInsights) { + name: applicationInsightsName + location: location + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: enableLogAnalytics ? logAnalyticsWorkspace.id : null + publicNetworkAccessForIngestion: 'Enabled' + publicNetworkAccessForQuery: 'Enabled' + } +} + + +// https://learn.microsoft.com/azure/templates/microsoft.storage/storageaccounts +resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = { + name: storageAccountName + location: location + sku: { + name: storageAccountSku + } + kind: 'StorageV2' + properties: { + accessTier: 'Hot' + minimumTlsVersion: 'TLS1_2' + supportsHttpsTrafficOnly: true + allowBlobPublicAccess: false + allowSharedKeyAccess: false + } + + resource blobService 'blobServices' = { + name: 'default' + + resource costExportsContainer 'containers' = { + name: 'cost-exports' + properties: { + publicAccess: 'None' + } + } + } +} + + +// https://learn.microsoft.com/azure/templates/microsoft.apimanagement/service +resource apimService 'Microsoft.ApiManagement/service@2023-09-01-preview' existing = { + name: apimServiceName +} + + +// https://learn.microsoft.com/azure/templates/microsoft.insights/diagnosticsettings +resource apimDiagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: diagnosticSettingsName + scope: apimService + properties: { + workspaceId: enableLogAnalytics ? logAnalyticsWorkspace.id : null + logAnalyticsDestinationType: 'Dedicated' + logs: [ + { + category: 'GatewayLogs' + enabled: true + retentionPolicy: { + enabled: false + days: 0 + } + } + { + category: 'WebSocketConnectionLogs' + enabled: true + retentionPolicy: { + enabled: false + days: 0 + } + } + ] + metrics: [ + { + category: 'AllMetrics' + enabled: true + retentionPolicy: { + enabled: false + days: 0 + } + } + ] + } +} + + +// Configure APIM logger for Application Insights +// https://learn.microsoft.com/azure/templates/microsoft.apimanagement/service/loggers +resource apimLogger 'Microsoft.ApiManagement/service/loggers@2023-09-01-preview' = if (enableApplicationInsights) { + name: 'applicationinsights-logger' + parent: apimService + properties: { + loggerType: 'applicationInsights' + description: 'Application Insights logger for cost tracking' + credentials: { + instrumentationKey: enableApplicationInsights ? applicationInsights.properties.InstrumentationKey : '' + } + isBuffered: true + resourceId: enableApplicationInsights ? applicationInsights.id : '' + } +} + + +// https://learn.microsoft.com/azure/templates/microsoft.apimanagement/service/diagnostics +resource apimDiagnostic 'Microsoft.ApiManagement/service/diagnostics@2023-09-01-preview' = if (enableApplicationInsights) { + name: 'applicationinsights' + parent: apimService + properties: { + alwaysLog: 'allErrors' + loggerId: enableApplicationInsights ? apimLogger.id : '' + sampling: { + samplingType: 'fixed' + percentage: 100 + } + frontend: { + request: { + headers: [] + body: { + bytes: 0 + } + } + response: { + headers: [] + body: { + bytes: 0 + } + } + } + backend: { + request: { + headers: [] + body: { + bytes: 0 + } + } + response: { + headers: [] + body: { + bytes: 0 + } + } + } + logClientIp: true + httpCorrelationProtocol: 'W3C' + verbosity: 'information' + } +} + + +// Configure APIM diagnostic for Azure Monitor (Log Analytics) +// This ensures gateway logs include subscription IDs and other details +// https://learn.microsoft.com/azure/templates/microsoft.apimanagement/service/diagnostics +resource apimAzureMonitorDiagnostic 'Microsoft.ApiManagement/service/diagnostics@2023-09-01-preview' = if (enableLogAnalytics) { + name: 'azuremonitor' + parent: apimService + properties: { + loggerId: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.ApiManagement/service/${apimServiceName}/loggers/azuremonitor' + sampling: { + samplingType: 'fixed' + percentage: 100 + } + logClientIp: true + verbosity: 'information' + } +} + + +// https://learn.microsoft.com/azure/templates/microsoft.insights/workbooks +resource workbook 'Microsoft.Insights/workbooks@2023-06-01' = if (enableLogAnalytics) { + name: guid(resourceGroup().id, 'apim-costing-workbook', string(sampleIndex)) + location: location + kind: 'shared' + properties: { + displayName: workbookName + serializedData: string(loadJsonContent('workbook.json')) + version: '1.0' + sourceId: logAnalyticsWorkspace.id + category: 'APIM' + } +} + + +// Cost Management exports are subscription-scoped and must be deployed via a module. +// https://learn.microsoft.com/azure/templates/microsoft.costmanagement/exports +module costExportModule './cost-export.bicep' = if (enableCostExport) { + name: 'costExportDeployment' + scope: subscription() + params: { + costExportName: costExportName + storageAccountId: storageAccount.id + recurrence: costExportFrequency + startDate: costExportStartDate + } + dependsOn: [ + storageAccount::blobService::costExportsContainer + ] +} + + +// ------------------------------ +// OUTPUTS +// ------------------------------ + +@description('Name of the Application Insights resource') +output applicationInsightsName string = enableApplicationInsights ? applicationInsights.name : '' + +@description('Application Insights instrumentation key') +output applicationInsightsInstrumentationKey string = enableApplicationInsights ? applicationInsights.properties.InstrumentationKey : '' + +@description('Application Insights connection string') +output applicationInsightsConnectionString string = enableApplicationInsights ? applicationInsights.properties.ConnectionString : '' + +@description('Name of the Log Analytics Workspace') +output logAnalyticsWorkspaceName string = enableLogAnalytics ? logAnalyticsWorkspace.name : '' + +@description('Log Analytics Workspace ID') +output logAnalyticsWorkspaceId string = enableLogAnalytics ? logAnalyticsWorkspace.id : '' + +@description('Name of the Storage Account for cost exports') +output storageAccountName string = storageAccount.name + +@description('Storage Account ID') +output storageAccountId string = storageAccount.id + +@description('Cost exports container name') +output costExportsContainerName string = 'cost-exports' + +@description('Name of the Azure Monitor Workbook') +output workbookName string = enableLogAnalytics ? workbook.properties.displayName : '' + +@description('Workbook ID') +output workbookId string = enableLogAnalytics ? workbook.id : '' + +@description('Name of the Cost Management export') +output costExportName string = enableCostExport ? costExportModule.outputs.costExportName : costExportName diff --git a/samples/costing/screenshots/Dashboard-01.png b/samples/costing/screenshots/Dashboard-01.png new file mode 100644 index 0000000..ecd3b4d Binary files /dev/null and b/samples/costing/screenshots/Dashboard-01.png differ diff --git a/samples/costing/screenshots/Dashboard-02.png b/samples/costing/screenshots/Dashboard-02.png new file mode 100644 index 0000000..bbce14c Binary files /dev/null and b/samples/costing/screenshots/Dashboard-02.png differ diff --git a/samples/costing/screenshots/Dashboard-03.png b/samples/costing/screenshots/Dashboard-03.png new file mode 100644 index 0000000..6355e0b Binary files /dev/null and b/samples/costing/screenshots/Dashboard-03.png differ diff --git a/samples/costing/screenshots/Dashboard-04.png b/samples/costing/screenshots/Dashboard-04.png new file mode 100644 index 0000000..b8b8ff3 Binary files /dev/null and b/samples/costing/screenshots/Dashboard-04.png differ diff --git a/samples/costing/screenshots/Dashboard-05.png b/samples/costing/screenshots/Dashboard-05.png new file mode 100644 index 0000000..3cab610 Binary files /dev/null and b/samples/costing/screenshots/Dashboard-05.png differ diff --git a/samples/costing/screenshots/README.md b/samples/costing/screenshots/README.md new file mode 100644 index 0000000..0c21cb0 --- /dev/null +++ b/samples/costing/screenshots/README.md @@ -0,0 +1,35 @@ +# Screenshots for APIM Costing Sample + +This directory contains screenshots showing expected results after running the costing sample. + +## Cost Management Export + +### Cost Report - Export Overview + +![Cost Report - Export Overview](costreport-01.png) + +### Cost Report - Export Details + +![Cost Report - Export Details](costreport-02.png) + +## Azure Monitor Workbook Dashboard + +### Cost Allocation Overview + +![Dashboard - Cost Allocation Overview](Dashboard-01.png) + +### Cost Breakdown by Business Unit + +![Dashboard - Cost Breakdown by Business Unit](Dashboard-02.png) + +### Request Distribution + +![Dashboard - Request Distribution](Dashboard-03.png) + +### Usage Analytics + +![Dashboard - Usage Analytics](Dashboard-04.png) + +### Response Code Analysis + +![Dashboard - Response Code Analysis](Dashboard-05.png) diff --git a/samples/costing/screenshots/costreport-01.png b/samples/costing/screenshots/costreport-01.png new file mode 100644 index 0000000..405f535 Binary files /dev/null and b/samples/costing/screenshots/costreport-01.png differ diff --git a/samples/costing/screenshots/costreport-02.png b/samples/costing/screenshots/costreport-02.png new file mode 100644 index 0000000..ae9afe5 Binary files /dev/null and b/samples/costing/screenshots/costreport-02.png differ diff --git a/samples/costing/workbook.json b/samples/costing/workbook.json new file mode 100644 index 0000000..70a02a3 --- /dev/null +++ b/samples/costing/workbook.json @@ -0,0 +1,449 @@ +{ + "$schema": "https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json", + "fallbackResourceIds": [], + "fromTemplateId": "sentinel-UserWorkbook", + "items": [ + { + "content": { + "parameters": [ + { + "id": "b859a101-1fbb-4c30-a1df-97d7d4b0d6f2", + "isRequired": true, + "label": "Time Range", + "name": "TimeRange", + "type": 4, + "typeSettings": { + "allowCustom": true, + "selectableValues": [ + { "durationMs": 86400000 }, + { "durationMs": 172800000 }, + { "durationMs": 604800000 }, + { "durationMs": 1209600000 }, + { "durationMs": 2592000000 }, + { "durationMs": 5184000000 }, + { "durationMs": 7776000000 } + ] + }, + "value": { + "durationMs": 2592000000 + }, + "version": "KqlParameterItem/1.0" + }, + { + "id": "c1a2b3d4-e5f6-7890-abcd-ef1234567890", + "isRequired": true, + "label": "Base Monthly APIM Cost ($)", + "name": "BaseMonthlyCost", + "type": 1, + "typeSettings": { + "paramValidationRules": [ + { + "match": true, + "message": "Enter a valid dollar amount (e.g. 150.00)", + "regExp": "^\\d+(\\.\\d{1,2})?$" + } + ] + }, + "value": "150.00", + "version": "KqlParameterItem/1.0" + }, + { + "id": "d2b3c4e5-f6a7-8901-bcde-f12345678901", + "isRequired": true, + "label": "Variable Cost per 1000 Requests ($)", + "name": "PerRequestRate", + "type": 1, + "typeSettings": { + "paramValidationRules": [ + { + "match": true, + "message": "Enter a valid rate (e.g. 0.003)", + "regExp": "^\\d+(\\.\\d{1,6})?$" + } + ] + }, + "value": "0.003", + "version": "KqlParameterItem/1.0" + }, + { + "id": "e3c4d5f6-a7b8-9012-cdef-234567890abc", + "isHiddenWhenLocked": true, + "label": "Selected Business Unit", + "name": "SelectedBusinessUnit", + "type": 1, + "value": "*", + "version": "KqlParameterItem/1.0" + } + ], + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "style": "pills", + "version": "KqlParameterItem/1.0" + }, + "name": "parameters - 0", + "type": 9 + }, + { + "content": { + "json": "## APIM Cost Allocation & Showback Dashboard\n\nThis workbook splits the **base APIM infrastructure cost** across business units proportionally by usage, then adds **variable per-API costs** based on request volume.\n\n| Parameter | Description |\n|---|---|\n| **Base Monthly APIM Cost** | Fixed platform cost (SKU, networking, etc.) split proportionally by request share |\n| **Variable Cost per 1K Requests** | Usage-based rate applied on top of the base allocation |\n\n> Adjust parameters above to model different pricing scenarios." + }, + "name": "text - header", + "type": 1 + }, + { + "content": { + "expandable": true, + "expanded": true, + "groupType": "editable", + "items": [ + { + "content": { + "json": "| Component | Formula |\n|---|---|\n| **Base Cost** | Monthly platform cost for the APIM SKU (see parameter above): **${BaseMonthlyCost}** |\n| **Base Cost Share** | `Base Monthly Cost x (BU Requests / Total Requests)` |\n| **Variable Cost** | `BU Requests x (Rate per 1K / 1000)` |\n| **Total Allocated** | `Base Cost Share + Variable Cost` |\n\n> The base monthly cost and variable rate parameters are editable above. Use the notebook's pricing lookup cell to auto-detect values from the [Azure Retail Prices API](https://learn.microsoft.com/rest/api/cost-management/retail-prices/azure-retail-prices) and keep them in sync with your APIM SKU." + }, + "name": "text - cost-model-detail", + "type": 1 + } + ], + "loadType": "always", + "title": "Cost Allocation Model", + "version": "NotebookGroup/1.0" + }, + "name": "group - cost-model", + "type": 12 + }, + { + "content": { + "expandable": true, + "expanded": true, + "groupType": "editable", + "items": [ + { + "content": { + "exportDefaultValue": "*", + "exportFieldName": "Business Unit", + "exportParameterName": "SelectedBusinessUnit", + "gridSettings": { + "formatters": [ + { + "columnMatch": "Usage Share (%)", + "formatOptions": { + "max": 100, + "min": 0, + "palette": "blue" + }, + "formatter": 8 + }, + { + "columnMatch": "Base Cost Share ($)", + "formatOptions": { + "min": 0, + "palette": "blue" + }, + "formatter": 8 + }, + { + "columnMatch": "Total Allocated ($)", + "formatOptions": { + "min": 0, + "palette": "turquoise" + }, + "formatter": 8 + } + ] + }, + "query": "let baseCost = todouble('{BaseMonthlyCost}');\r\nlet perKRate = todouble('{PerRequestRate}');\r\nlet logs = ApiManagementGatewayLogs\r\n| where TimeGenerated {TimeRange} and ApimSubscriptionId != '';\r\nlet totalRequests = toscalar(logs | summarize count());\r\nlogs\r\n| summarize RequestCount = count() by ApimSubscriptionId\r\n| extend UsageShare = round(RequestCount * 100.0 / totalRequests, 2)\r\n| extend BaseCostShare = round(baseCost * RequestCount / totalRequests, 2)\r\n| extend VariableCost = round(RequestCount * perKRate / 1000.0, 2)\r\n| extend TotalAllocatedCost = round(BaseCostShare + VariableCost, 2)\r\n| order by TotalAllocatedCost desc\r\n| project\r\n ['Business Unit'] = ApimSubscriptionId,\r\n ['Requests'] = RequestCount,\r\n ['Usage Share (%)'] = UsageShare,\r\n ['Base Cost ($)'] = baseCost,\r\n ['Base Cost Share ($)'] = BaseCostShare,\r\n ['Variable Cost ($)'] = VariableCost,\r\n ['Total Allocated ($)'] = TotalAllocatedCost", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "size": 0, + "timeContext": { + "durationMs": 2592000000 + }, + "title": "Cost Allocation by Business Unit (click a row to filter charts below)", + "version": "KqlItem/1.0", + "visualization": "table" + }, + "name": "query - cost-allocation-table", + "type": 3 + }, + { + "content": { + "chartSettings": { + "customThresholdLine": "{BaseMonthlyCost}", + "customThresholdLineStyle": 1, + "seriesLabelSettings": [ + { "color": "blue", "label": "Base Cost ($)", "seriesName": "BaseCostShare" }, + { "color": "orange", "label": "Variable Cost ($)", "seriesName": "VariableCost" } + ], + "xAxis": "ApimSubscriptionId", + "ySettings": { + "min": 0 + } + }, + "query": "let baseCost = todouble('{BaseMonthlyCost}');\r\nlet perKRate = todouble('{PerRequestRate}');\r\nlet logs = ApiManagementGatewayLogs\r\n| where TimeGenerated {TimeRange} and ApimSubscriptionId != '';\r\nlet totalRequests = toscalar(logs | summarize count());\r\nlogs\r\n| summarize RequestCount = count() by ApimSubscriptionId\r\n| extend BaseCostShare = round(baseCost * RequestCount / totalRequests, 2)\r\n| extend VariableCost = round(RequestCount * perKRate / 1000.0, 2)\r\n| project ApimSubscriptionId, BaseCostShare, VariableCost", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "size": 0, + "timeContext": { + "durationMs": 2592000000 + }, + "title": "Base vs Variable Cost Split by Business Unit", + "version": "KqlItem/1.0", + "visualization": "barchart" + }, + "name": "query - cost-allocation-chart", + "type": 3 + }, + { + "content": { + "gridSettings": { + "formatters": [ + { + "columnMatch": "Total ($)", + "formatOptions": { + "min": 0, + "palette": "turquoise" + }, + "formatter": 8 + } + ] + }, + "query": "let baseCost = todouble('{BaseMonthlyCost}');\r\nlet perKRate = todouble('{PerRequestRate}');\r\nlet selectedBU = '{SelectedBusinessUnit}';\r\nlet logs = ApiManagementGatewayLogs\r\n| where TimeGenerated {TimeRange} and ApimSubscriptionId != '';\r\nlet filteredLogs = logs\r\n| where selectedBU == '*' or ApimSubscriptionId == selectedBU;\r\nlet totalRequests = toscalar(logs | summarize count());\r\nfilteredLogs\r\n| summarize RequestCount = count() by ApimSubscriptionId, ApiId\r\n| extend BaseCostShare = round(baseCost * RequestCount / totalRequests, 2)\r\n| extend VariableCost = round(RequestCount * perKRate / 1000.0, 2)\r\n| extend TotalCost = round(BaseCostShare + VariableCost, 2)\r\n| order by TotalCost desc\r\n| project\r\n ['Business Unit'] = ApimSubscriptionId,\r\n ['API'] = ApiId,\r\n ['Requests'] = RequestCount,\r\n ['Base Share ($)'] = BaseCostShare,\r\n ['Variable ($)'] = VariableCost,\r\n ['Total ($)'] = TotalCost\r\n| take 25", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "size": 0, + "timeContext": { + "durationMs": 2592000000 + }, + "title": "Cost Breakdown by Business Unit & API (Top 25)", + "version": "KqlItem/1.0", + "visualization": "table" + }, + "name": "query - cost-per-api", + "type": 3 + } + ], + "loadType": "always", + "title": "Cost Allocation Summary", + "version": "NotebookGroup/1.0" + }, + "name": "group - cost-allocation", + "type": 12 + }, + { + "content": { + "expandable": true, + "expanded": true, + "groupType": "editable", + "items": [ + { + "content": { + "chartSettings": { + "ySettings": { + "min": 0 + } + }, + "query": "ApiManagementGatewayLogs\r\n| where TimeGenerated {TimeRange} and ApimSubscriptionId != ''\r\n| summarize RequestCount = count() by ApimSubscriptionId\r\n| order by RequestCount desc\r\n| project BusinessUnit = ApimSubscriptionId, RequestCount", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "size": 0, + "timeContext": { + "durationMs": 2592000000 + }, + "title": "Request Count by Business Unit", + "version": "KqlItem/1.0", + "visualization": "barchart" + }, + "name": "query - request-count", + "type": 3 + }, + { + "content": { + "chartSettings": { + "seriesLabelSettings": [ + { "color": "blue", "seriesName": "RequestCount" } + ] + }, + "query": "let apimLogs = ApiManagementGatewayLogs\r\n| where TimeGenerated {TimeRange} and ApimSubscriptionId != '';\r\nlet totalCount = toscalar(apimLogs | count);\r\napimLogs\r\n| summarize RequestCount = count() by ApimSubscriptionId\r\n| extend Percentage = round(RequestCount * 100.0 / totalCount, 2)\r\n| order by RequestCount desc\r\n| project BusinessUnit = ApimSubscriptionId, RequestCount, ['Percentage (%)'] = Percentage", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "size": 0, + "timeContext": { + "durationMs": 2592000000 + }, + "title": "Request Distribution Across Business Units", + "version": "KqlItem/1.0", + "visualization": "piechart" + }, + "name": "query - distribution", + "type": 3 + }, + { + "content": { + "query": "let selectedBU = '{SelectedBusinessUnit}';\r\nApiManagementGatewayLogs\r\n| where TimeGenerated {TimeRange} and ApimSubscriptionId != ''\r\n| where selectedBU == '*' or ApimSubscriptionId == selectedBU\r\n| summarize RequestCount = count() by bin(TimeGenerated, 1h), ApimSubscriptionId\r\n| project TimeGenerated, BusinessUnit = ApimSubscriptionId, RequestCount", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "size": 0, + "timeContext": { + "durationMs": 2592000000 + }, + "title": "Request Trends Over Time by Business Unit", + "version": "KqlItem/1.0", + "visualization": "timechart" + }, + "name": "query - trends", + "type": 3 + } + ], + "loadType": "always", + "title": "Usage Analytics", + "version": "NotebookGroup/1.0" + }, + "name": "group - usage-analytics", + "type": 12 + }, + { + "content": { + "expandable": true, + "expanded": true, + "groupType": "editable", + "items": [ + { + "content": { + "gridSettings": { + "formatters": [ + { + "columnMatch": "Success Rate (%)", + "formatOptions": { + "max": 100, + "min": 0, + "palette": "redGreen" + }, + "formatter": 8 + }, + { + "columnMatch": "Error Rate (%)", + "formatOptions": { + "max": 100, + "min": 0, + "palette": "greenRed" + }, + "formatter": 8 + } + ] + }, + "query": "let selectedBU = '{SelectedBusinessUnit}';\r\nApiManagementGatewayLogs\r\n| where TimeGenerated {TimeRange} and ApimSubscriptionId != ''\r\n| where selectedBU == '*' or ApimSubscriptionId == selectedBU\r\n| summarize \r\n TotalRequests = count(),\r\n SuccessRequests = countif(ResponseCode < 400),\r\n ClientErrors = countif(ResponseCode >= 400 and ResponseCode < 500),\r\n ServerErrors = countif(ResponseCode >= 500)\r\n by ApimSubscriptionId\r\n| extend SuccessRate = round(SuccessRequests * 100.0 / TotalRequests, 2)\r\n| extend ErrorRate = round((ClientErrors + ServerErrors) * 100.0 / TotalRequests, 2)\r\n| project \r\n BusinessUnit = ApimSubscriptionId, \r\n TotalRequests, \r\n SuccessRequests, \r\n ClientErrors, \r\n ServerErrors, \r\n ['Success Rate (%)'] = SuccessRate,\r\n ['Error Rate (%)'] = ErrorRate\r\n| order by TotalRequests desc", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "size": 0, + "timeContext": { + "durationMs": 2592000000 + }, + "title": "Success & Error Metrics by Business Unit", + "version": "KqlItem/1.0", + "visualization": "table" + }, + "name": "query - success-errors", + "type": 3 + }, + { + "content": { + "chartSettings": { + "seriesLabelSettings": [ + { "color": "blue", "label": "2xx Success", "seriesName": "2xx" }, + { "color": "turquoise", "label": "3xx Redirect", "seriesName": "3xx" }, + { "color": "orange", "label": "4xx Client Error", "seriesName": "4xx" }, + { "color": "redBright", "label": "5xx Server Error", "seriesName": "5xx" } + ] + }, + "query": "let selectedBU = '{SelectedBusinessUnit}';\r\nApiManagementGatewayLogs\r\n| where TimeGenerated {TimeRange} and ApimSubscriptionId != ''\r\n| where selectedBU == '*' or ApimSubscriptionId == selectedBU\r\n| extend ResponseClass = case(\r\n ResponseCode >= 200 and ResponseCode < 300, '2xx',\r\n ResponseCode >= 300 and ResponseCode < 400, '3xx',\r\n ResponseCode >= 400 and ResponseCode < 500, '4xx',\r\n ResponseCode >= 500, '5xx',\r\n 'Other')\r\n| summarize RequestCount = count() by ApimSubscriptionId, ResponseClass\r\n| order by ApimSubscriptionId, ResponseClass\r\n| project BusinessUnit = ApimSubscriptionId, ResponseClass, RequestCount", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "size": 0, + "timeContext": { + "durationMs": 2592000000 + }, + "title": "Response Code Distribution by Business Unit", + "version": "KqlItem/1.0", + "visualization": "categoricalbar" + }, + "name": "query - response-codes", + "type": 3 + } + ], + "loadType": "always", + "title": "Health & Reliability", + "version": "NotebookGroup/1.0" + }, + "name": "group - health", + "type": 12 + }, + { + "content": { + "expandable": true, + "expanded": false, + "groupType": "editable", + "items": [ + { + "content": { + "json": "Select a **Business Unit** row in the Cost Allocation table above to see that unit's cost trend over time.\n\nThe chart shows daily base cost share and variable cost for the selected business unit. The horizontal line represents the total base monthly cost (${BaseMonthlyCost}) for reference." + }, + "name": "text - drilldown-help", + "type": 1 + }, + { + "content": { + "chartSettings": { + "customThresholdLine": "{BaseMonthlyCost}", + "customThresholdLineStyle": 1, + "seriesLabelSettings": [ + { "color": "blue", "label": "Base Cost Share ($)", "seriesName": "BaseCostShare" }, + { "color": "orange", "label": "Variable Cost ($)", "seriesName": "VariableCost" }, + { "color": "purple", "label": "Total Allocated ($)", "seriesName": "TotalAllocatedCost" } + ], + "ySettings": { + "min": 0 + } + }, + "query": "let baseCost = todouble('{BaseMonthlyCost}');\r\nlet perKRate = todouble('{PerRequestRate}');\r\nlet selectedBU = '{SelectedBusinessUnit}';\r\nlet allLogs = ApiManagementGatewayLogs\r\n| where TimeGenerated {TimeRange} and ApimSubscriptionId != '';\r\nlet dailyTotal = allLogs\r\n| summarize DayTotal = count() by bin(TimeGenerated, 1d);\r\nallLogs\r\n| where selectedBU != '*' and ApimSubscriptionId == selectedBU\r\n| summarize RequestCount = count() by bin(TimeGenerated, 1d)\r\n| join kind=inner dailyTotal on TimeGenerated\r\n| extend BaseCostShare = round(baseCost * RequestCount / DayTotal, 2)\r\n| extend VariableCost = round(RequestCount * perKRate / 1000.0, 4)\r\n| extend TotalAllocatedCost = round(BaseCostShare + VariableCost, 2)\r\n| project TimeGenerated, BaseCostShare, VariableCost, TotalAllocatedCost", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "size": 0, + "timeContext": { + "durationMs": 2592000000 + }, + "title": "Cost Trend for: {SelectedBusinessUnit}", + "version": "KqlItem/1.0", + "visualization": "linechart" + }, + "conditionalVisibility": { + "comparison": "isNotEqualTo", + "parameterName": "SelectedBusinessUnit", + "value": "*" + }, + "name": "query - drilldown-cost-trend", + "type": 3 + }, + { + "content": { + "json": "> Select a business unit row in the Cost Allocation table to view its cost trend.", + "style": "info" + }, + "conditionalVisibility": { + "comparison": "isEqualTo", + "parameterName": "SelectedBusinessUnit", + "value": "*" + }, + "name": "text - drilldown-placeholder", + "type": 1 + } + ], + "loadType": "always", + "title": "Business Unit Drill-Down (over time)", + "version": "NotebookGroup/1.0" + }, + "name": "group - drilldown", + "type": 12 + } + ], + "version": "Notebook/1.0" +}