.NET · .net-core · Automation · AzureDevOps · C#

Detect Inactive Azure DevOps Area-Paths efficiently

Every Azure DevOps project starts the same way: someone creates a handful of area paths – BackendFrontendPlatform — and life is good. Fast forward two years, three reorgs, and one “temporary” migration later, and your project has 200 area paths, half of which haven’t seen a work item since the last office holiday party.

Dead area paths aren’t just clutter. They confuse new team members (“should I file this under Sales\Pre-Sales or Sales\Pipeline?”), pollute board filters, and make backlog grooming feel like archaeology. But how do you find the ones that are actually dead?

The obvious approach (and why it’s terrible)

Your first instinct is probably something like this:

  1. Get all area paths
  2. For each area path, query for work items modified in the last 6 months
  3. If none found → inactive

Sounds reasonable. Here’s the problem: that’s N WIQL queries — one per area path. If your project has 200 area paths, that’s 200 API calls just to ask a simple question. Each call returns full work item payloads (unless you’re careful with $select), and any path with more than a few thousand work items triggers server-side paging.

At enterprise scale, you’ll chew through your TSTU budget before your morning coffee gets cold.

The trick: make the server do the math

Here’s the insight that changes everything: don’t ask about each path individually — ask for all of them at once.

Azure DevOps has an Analytics OData endpoint that supports server-side aggregation. Think of it as SQL GROUP BY over your entire work item dataset, executed on Microsoft’s servers, returning a tiny JSON response instead of thousands of rows.

The whole solution is two API calls. That’s it. Two.

Call 1: Get the full area path tree

GET https://dev.azure.com/{org}/{project}/_apis/wit/classificationnodes/areas
    ?$depth=10
    &api-version=7.1

One call, entire tree. You get back every area path in the project as a nested JSON structure.

Call 2: Get the last activity date per area path

This is where the magic happens. The $apply OData extension lets you push a groupby + aggregate operation to the server:

GET https://analytics.dev.azure.com/{org}/{project}/_odata/v4.0-preview/WorkItems
    ?$apply=groupby(
        (Area/AreaPath),
        aggregate(ChangedDate with max as LastChanged)
    )

The response is wonderfully compact:

{
  "value": [
    { "Area": { "AreaPath": "MyProject\\Backend" },    "LastChanged": "2026-03-15T..." },
    { "Area": { "AreaPath": "MyProject\\Legacy\\V1" }, "LastChanged": "2024-09-01T..." }
  ]
}

That’s MAX(ChangedDate) GROUP BY AreaPath — computed server-side, delivered as a flat list. No paging (unless you have 10,000+ distinct area paths, which, if you do, you have bigger problems).

For the 10,000+ area path edge case, there’s also a clever partitioning route using inline filter() inside $apply:

$apply=filter(startswith(Area/AreaPath,'Project\Engineering'))
/groupby((Area/AreaPath),aggregate(ChangedDate with max as LastChanged))

This lets you split by top-level area subtree — use the tree from Step 1 to partition into chunks, fire parallel queries per subtree,
then merge client-side. Each subtree query stays well under the page limit.

Bottom line: For any realistic scenario, @odata.nextLink auto-pagination handles it. For truly extreme cases, partition with
filter(startswith(…)) per subtree. Both are already supported — no workarounds needed.

Cross-reference locally

Now it’s just set arithmetic:

  • Area paths from Call 1 that don’t appear in Call 2 → zero work items ever. Ghost town.
  • Area paths in Call 2 where LastChanged is older than your threshold → inactive. Museum exhibit.
  • Everything else → alive and well.
foreach (var path in allPaths)
{
    if (!activity.TryGetValue(path, out var last))
        inactive.Add((path, "No work items"));
    else if (last < cutoff)
        inactive.Add((path, $"Last activity: {last:yyyy-MM-dd}"));
    else
        active.Add((path, last.ToString("yyyy-MM-dd")));
}

The gotcha nobody warns you about

The Classification Nodes API returns paths like \Project\Area\HR\Internal — note the backslash prefix and the synthetic Area segment. The Analytics API returns Project\HR\Internal. If you don’t normalise these, your cross-reference will find zero matches and you’ll conclude every path is a ghost.

The fix is straightforward — strip the leading backslash and remove the Area segment:

var parts = rawPath.TrimStart('\\').Split('\\').ToList();
if (parts.Count >= 2 && parts[1] == "Area")
    parts.RemoveAt(1);
var normalised = string.Join("\\", parts);

One more thing: use Area/AreaPath, not Node Name

The Analytics backend has indexes on Area ID and Iteration IDNode Name? No index. If you group by Node Name instead of Area/AreaPath, you’ll trigger a full table scan. Your query will still work, but it’ll be slow enough to make you question your life choices.

Try it yourself

I’ve published a single-file C# script that implements this entire workflow. It runs with dotnet run (no project file needed — .NET 9+ supports file-based scripts):

dotnet run detect_inactive_area_paths.cs -- \
    --org YOUR_ORG --project YOUR_PROJECT --pat YOUR_PAT --days 180

The output looks like this:

INACTIVE AREA PATHS  (no activity since 2025-10-23)
  Platform\Finance\EMEA                                    No work items
  Platform\Engineering\Cloud\Azure-Service-Health-events   Last activity: 2024-07-05
  Platform\HR\Recruitment                                  No work items

Total area paths : 28
Active           : 4
Inactive         : 24
Threshold        : 180 days

The script exits with code 1 when inactive paths exist, so you can wire it into a CI pipeline or a scheduled Azure Function and get notified automatically.

The bottom line

ApproachAPI callsScales?
WIQL per area pathN (one per path)😬
Analytics OData aggregation2

Two API calls. A few kilobytes of JSON. A flat list of every area path that’s been gathering dust. Go clean up your project — your team will thank you.


The full script and documentation are available at github.com/MoimHossain/azdo-inactive-area-path-detection.

Leave a comment