Extract KQL Queries from Azure Workbooks (Workbook β†’ App Tool)

Extract KQL Queries from Azure Workbooks (Workbook β†’ App Tool)

2025-10-13 Β· ~21 min read

Azure Workbooks hide 110+ items and complex KQL queries in nested JSON. Built a Streamlit tool that extracts everything in seconds. Parsed Billy York's workbook: 41 queries, full structure, exportable. No more clicking through Azure Portal.

Billy York's Azure Inventory Workbook is comprehensive: 110 items tracking 200+ Azure services across compute, networking, PaaS, monitoring, and security. When I needed to enhance it, I hit a problem.

Azure Workbooks are JSON files with deeply nested structure. To understand what's insideβ€”what queries run, what data gets displayed, how items connectβ€”I'd need to click through 110+ items in Azure Portal. Each query buried in JSON. Each parameter block hidden in properties. Each text section wrapped in metadata.

Or I could spend three hours building a tool that parses it in 10 seconds.

I built the tool.

The Problem with Azure Workbooks

Azure Workbooks are powerful: rich visualizations, interactive parameters, complex KQL queries across subscriptions. Microsoft uses them for everything from security dashboards to resource inventory.

But understanding someone else's workbook is painful:

Azure Portal workflow:
1. Open workbook in edit mode
2. Click first item
3. Try to figure out what it does
4. Click "Advanced Settings" to see the actual query
5. Copy query to notepad
6. Repeat 109 more times
7. Hope you didn't miss anything

For Billy York's 110-item workbook? That's hours of manual clicking and copying.

What I needed:
- See all 110 items at once
- Navigate between them quickly
- Extract all KQL queries in one shot
- Export everything for analysis
- Test queries locally without Portal

The Realization: Workbooks Are Just JSON

Like .pbix files (which are ZIP archives), Azure Workbooks have a simple truth:

Workbook files are JSON with a predictable structure.

When you export a workbook from Azure Portal:

{
  "items": [
    {
      "type": 1,
      "content": {
        "json": "## Azure Inventory Workbook\n### Change Log"
      }
    },
    {
      "type": 3,
      "content": {
        "query": "Resources | where type == 'microsoft.compute/virtualmachines'..."
      }
    },
    {
      "type": 9,
      "content": {
        "parameters": [...]
      }
    }
  ]
}

Item types:
- 1 = text/markdown
- 3 = KQL query
- 9 = parameters
- 10 = links
- 11 = groups
- 12 = metrics

The entire structure is sitting there in JSON. You just need to parse it.

Building the Workbook β†’ App Tool

I built a Streamlit app that turns workbook JSON into a navigable interface.

The Core Parser

def flatten_items(workbook: Dict[str, Any]) -> List[Dict[str, Any]]:
    """Extract all items from workbook JSON with type, name, and content."""
    items = []
    raw_items = workbook.get("items") or workbook.get("graph") or []

    if isinstance(raw_items, list):
        for i, it in enumerate(raw_items):
            t = it.get("type") or it.get("content",{}).get("type")
            name = it.get("name") or it.get("content",{}).get("name") or it.get("title")
            items.append({"index": i, "type": t, "name": name, "raw": it})

    # Handle ARM template format (value array)
    if not items and isinstance(workbook.get("value"), list):
        for entry in workbook["value"]:
            props = entry.get("properties", {})
            items2 = props.get("serializedData") or props.get("items")
            if isinstance(items2, str):
                try:
                    items2 = json.loads(items2)
                except Exception:
                    items2 = []
            if isinstance(items2, list):
                for i, it in enumerate(items2):
                    t = it.get("type") or it.get("content",{}).get("type")
                    name = it.get("name") or it.get("content",{}).get("name")
                    items.append({"index": i, "type": t, "name": name, "raw": it})

    return items

What this does:
- Handles multiple workbook export formats
- Extracts type, name, and full content for each item
- Returns flat list for easy navigation

Extracting KQL Queries

KQL queries hide in different places depending on the workbook version:

def extract_kql(item: Dict[str, Any]) -> Optional[str]:
    """Extract KQL query from item, handling multiple schema formats."""
    c = item.get("raw", {}).get("content", {})

    if isinstance(c, dict):
        # Direct query string
        if "query" in c and isinstance(c["query"], str):
            return c["query"]

        # Nested query object
        if "query" in c and isinstance(c["query"], dict) and "query" in c["query"]:
            return c["query"]["query"]

        # Versioned query format
        if "versionedQuery" in c and isinstance(c["versionedQuery"], dict):
            if "query" in c["versionedQuery"]:
                return c["versionedQuery"]["query"]

        # JSON-wrapped query
        if "json" in c and isinstance(c["json"], dict) and "query" in c["json"]:
            return c["json"]["query"]

    # Queries array (some workbooks)
    q = c.get("queries") if isinstance(c, dict) else None
    if isinstance(q, list) and q:
        first = q[0]
        if isinstance(first, dict) and "query" in first:
            return first["query"]

    return None

The challenge: Microsoft changed the workbook schema over time. This parser handles all common formats.

The Bug I Had to Fix

Initial version crashed on Billy York's workbook:

AttributeError: 'int' object has no attribute 'lower'

The problem: I assumed type was always a string like "query". Billy York's workbook uses numeric type codes like 3 for queries, 9 for parameters.

The fix:

# Item type mappings (numeric β†’ readable)
WORKBOOK_ITEM_TYPES = {
    1: "text",
    3: "query",
    9: "parameters",
    10: "links",
    11: "group",
    12: "metrics"
}

def normalize_type(raw_type: Any) -> str:
    """Convert workbook type (int or string) to normalized lowercase string."""
    if raw_type is None:
        return "unknown"
    if isinstance(raw_type, int):
        return WORKBOOK_ITEM_TYPES.get(raw_type, f"type{raw_type}")
    if isinstance(raw_type, str):
        return raw_type.lower()
    return str(raw_type).lower()

def get_type_display(raw_type: Any) -> str:
    """Get human-readable type name for display."""
    normalized = normalize_type(raw_type)
    if isinstance(raw_type, int):
        return f"{WORKBOOK_ITEM_TYPES.get(raw_type, f'type{raw_type}')} ({raw_type})"
    return normalized

Now type: 3 displays as "query (3)" and parses correctly.

The Streamlit Interface

import streamlit as st

st.set_page_config(page_title="Azure Workbook β†’ App", layout="wide")
st.title("πŸ“’ Azure Monitor Workbook β†’ App")

# File upload
uploaded = st.file_uploader("Upload an Azure Monitor Workbook JSON", type=["json"])

if not uploaded:
    st.info("Upload a workbook export (JSON).")
    st.stop()

# Parse workbook
workbook = load_workbook_json(uploaded.getvalue())
items = flatten_items(workbook)

st.success(f"Parsed {len(items)} item(s) from the workbook.")

# Sidebar navigation
with st.sidebar:
    st.markdown("### Items")
    def format_item(i):
        item = items[i]
        type_display = get_type_display(item.get('type'))
        name = item.get('name') or '(unnamed)'
        return f"{i}: {type_display} β€” {name}"

    idx = st.selectbox("Jump to item", list(range(len(items))), format_func=format_item)

# Display current item
current = items[idx]
itype = normalize_type(current.get('type'))

if "query" in itype or itype == "3":
    q = extract_kql(current)
    if q:
        st.code(q, language="kusto")
        # Optional: Execute query if workspace ID provided
        if run_queries and workspace_id:
            df = try_run_kql(workspace_id, q)
            if df is not None and not df.empty:
                st.dataframe(df.head(500), use_container_width=True)

Key features:
- Sidebar shows all 110 items with types
- Jump to any item instantly
- Display KQL queries with syntax highlighting
- Optionally execute queries (requires Log Analytics workspace ID)

Export Functionality

# Extract all KQL queries
kql_rows = []
for it in items:
    k = extract_kql(it)
    if k:
        type_display = get_type_display(it.get('type'))
        kql_rows.append({
            "index": it["index"], 
            "type": type_display,
            "name": it.get("name") or "(unnamed)", 
            "query": k
        })

kql_df = pd.DataFrame(kql_rows)

# CSV export
st.download_button(
    "πŸ“₯ Download all KQL to CSV", 
    kql_df.to_csv(index=False).encode("utf-8"), 
    "workbook_kql.csv", 
    "text/csv"
)

# Markdown export
md = io.StringIO()
md.write(f"# Workbook: {uploaded.name}\n\n")
md.write(f"**Items:** {len(items)}\n")
md.write(f"**KQL Queries:** {len(kql_rows)}\n\n")

for i, it in enumerate(items):
    type_display = get_type_display(it.get('type'))
    name = it.get('name') or '(unnamed)'
    md.write(f"## Item {i}: {type_display} β€” {name}\n\n")

    txt = extract_text(it)
    q = extract_kql(it)

    if txt:
        md.write(txt + "\n\n")
    if q:
        md.write("```kusto\n" + q + "\n```\n\n")

    md.write("---\n\n")

st.download_button(
    "πŸ“₯ Download as Markdown", 
    md.getvalue().encode("utf-8"), 
    "workbook_export.md", 
    "text/markdown"
)

Real-World Result: Billy York's Workbook

Running the tool on Billy York's Azure Inventory Workbook:

Input: template.json (213 KB)

Output:

βœ… Parsed 110 item(s) from the workbook.
βœ… Found 41 KQL queries in workbook.

Extracted structure:

# Workbook: template.json

**Items:** 110
**KQL Queries:** 41

---

## Item 2: text (1) β€” Change Log

## Azure Inventory Workbook
### Change Log

|Version|Description|
|---|---|
|v1.1.1| Initial Release - October 2020 - BY
|v2.0.2 | Added VM state, fixed WVD Details - Feb 2022 - BY

---

## Item 9: query (3) β€” Azure Compute Summary

```kusto
Resources | where type == "microsoft.compute/virtualmachines"
| extend vmState = tostring(properties.extended.instanceView.powerState.displayStatus)
| extend vmState = iif(isempty(vmState), "VM State Unknown", (vmState))
| summarize count() by vmState

Item 15: query (3) β€” Azure Compute Overview

Resources 
| where type == "microsoft.compute/virtualmachines"
| extend vmID = tolower(id)
| extend osDiskId= tolower(tostring(properties.storageProfile.osDisk.managedDisk.id))
| join kind=leftouter(resources
    | where type =~ 'microsoft.compute/disks'
    | where properties !has 'Unattached'
    | project timeCreated, OS, osSku, osDiskSizeGB, osDiskId) on osDiskId
| join kind=leftouter(resources
    | where type =~ 'microsoft.compute/availabilitysets'
    | mv-expand VirtualMachine=properties.virtualMachines
    | project AvailabilitySetID = id, vmID, FaultDomainCount) on vmID
...
**CSV export:** All 41 queries with index, type, name, and full query text.

**Time to parse:** 10 seconds  
**Time to understand structure:** 5 minutes reading the Markdown  
**Time saved vs manual Portal clicking:** Hours

## What This Enabled

### Enhanced Billy York's Workbook

Original workbook: 50 Azure resource types  
Enhanced version: 200+ resource types  

**The tool let me:**
1. **Extract all existing queries** - see what resources were already tracked
2. **Identify patterns** - understand the query structure Billy used
3. **Add new resource types** - following the same patterns
4. **Test queries locally** - verify before deploying
5. **Document everything** - exported Markdown served as reference

[Read about the enhancement here β†’](/blog/modernizing-azure-workbooks/)

### Built Custom Workbooks

Using the extracted queries as templates:
- Created security-focused workbook (NSG rules, exposed endpoints)
- Built cost analysis workbook (unattached resources, stopped VMs)
- Developed compliance tracking workbook (tagging, policy violations)

**Pattern:** Extract queries from existing workbooks β†’ modify β†’ deploy new workbooks

### Migrated Workbooks to Code

For complex scenarios, converted workbook queries to Python scripts:
- Extracted all queries using this tool
- Rewrote as Python using Azure SDK
- Automated daily reports
- Integrated with alerting systems

## Optional: Execute Queries

The tool can actually **run the KQL queries** if you provide a Log Analytics workspace ID:

```python
def try_run_kql(workspace_id: str, query: str) -> Optional[pd.DataFrame]:
    """Execute KQL query against Log Analytics workspace."""
    if not AZURE_AVAILABLE or not workspace_id or not query:
        return None

    try:
        cred = DefaultAzureCredential(exclude_shared_token_cache_credential=True)
        client = LogsQueryClient(credential=cred)
        resp = client.query_workspace(workspace_id, query, timespan=None)

        if not resp.tables:
            return pd.DataFrame()

        t = resp.tables[0]
        cols = [c.name for c in t.columns]
        rows = [list(r) for r in t.rows]
        return pd.DataFrame(rows, columns=cols)
    except Exception as e:
        st.warning(f"KQL execution failed: {e}")
        return None

Use cases:
- Test query modifications before deploying
- Validate query results locally
- Debug complex queries without Portal
- Quick data exploration

Requirements:
- Azure authentication (az login or DefaultAzureCredential)
- Log Analytics workspace ID
- Toggle enabled in sidebar

When You Need This Tool

Use Workbook β†’ App when you need to:
- Understand someone else's workbook structure
- Extract all KQL queries for reuse
- Document workbook contents
- Enhance existing workbooks
- Migrate workbooks to code
- Debug complex queries locally

Don't need this if:
- You built the workbook yourself (you already know the structure)
- Workbook is simple (< 10 items, basic queries)
- Just viewing results (use Azure Portal)

Comparison to PBIX Modernizer

Similar concept, different Azure file format:

Feature PBIX Modernizer Workbook β†’ App
Input Power BI .pbix files (ZIP) Azure Workbook JSON
Extracts DAX measures, M queries, tables KQL queries, parameters, content
Format XML/JSON inside ZIP Pure JSON
Type codes String types Numeric + string types
Query language DAX + Power Query M KQL (Kusto)
Execute queries No (requires Power BI) Yes (with workspace ID)
Use case Understand Power BI dashboards Understand Azure Workbooks

Both solve the same problem: Extract and analyze Azure/BI files without clicking through slow UI.

The GitHub Repo

The complete tool is available on GitHub:

Features:
- Upload workbook JSON (drag and drop)
- Parse all items (text, queries, parameters, groups, metrics)
- Navigate via sidebar
- Syntax-highlighted KQL display
- Optional query execution
- Export to CSV (all queries)
- Export to Markdown (full structure)
- Handles numeric and string type codes

Usage:

# Install
git clone https://github.com/yourusername/workbook_app
cd workbook_app
pip install -r requirements.txt

# Run locally
streamlit run streamlit_workbook_app.py

# Opens http://127.0.0.1:8502
# Upload your workbook JSON

No Azure credentials required unless you want to execute queries.

The Real Lesson

When you need to understand complex Azure files (workbooks, ARM templates, policies), don't click through the Portal for hours.

The structure is sitting in JSON. Extract it. Parse it. Analyze it.

Billy York's 110-item workbook with 41 complex KQL queries? Parsed in 10 seconds. Full structure exported to Markdown. All queries in CSV. Ready for enhancement.

This tool saved weeks of manual work during workbook enhancement and migration projects.

It might do the same for you.


Resources

GitHub Repo: Azure Workbook β†’ App Tool
Billy York's Original Workbook: Azure Inventory Workbook
My Enhancement: Modernizing Azure Workbooks
Similar Tool: PBIX Modernizer (for Power BI files)


Building tools to solve real Azure problems? That's what this blog is about. If Workbook β†’ App helps you understand workbooks faster, let me know what you discover.

Get Azure tips in your inbox

Join Azure pros getting practical KQL queries, cost optimization tips, and real-world solutions.