Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ A single KQL query against the Resource Graph `resources` table returns all VMs,

### Step 2 — Fetch availability metrics

Azure Monitor is queried per-resource in parallel (configurable via `--parallelism`, default auto-scales to CPU cores) with retry on 429/5xx errors. Granularity is PT1M (one data point per minute).
By default, Azure Monitor is queried per-resource in parallel (configurable via `--parallelism`, default auto-scales to CPU cores) with retry on 429/5xx errors. Granularity is PT1M (one data point per minute).

With `--batch` / `-Batch`, the tool uses the regional [Azure Monitor Metrics Batch API](https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/migrate-to-batch-api) instead of individual per-resource calls. Resources are grouped by (subscription, region, kind) and sent in configurable chunks (`--batch-size` / `-BatchSize`, default 10, max 50). This reduces the total number of API calls and can significantly improve throughput for large inventories. The batch endpoint requires a separate token (`https://metrics.monitor.azure.com`) and the tool validates each regional endpoint before fetching. Wave-based processing with garbage collection between waves keeps memory usage bounded.

| Resource type | Metrics requested | Native scale | Aggregation |
|---|---|---|---|
Expand Down Expand Up @@ -191,6 +193,8 @@ Storage Account `Availability` is a transaction-success-rate metric — it is on
| `--resource` | `-r` | *(all)* | Filter to a single resource name |
| `--parallelism` | `-p` | *(auto)* | Max concurrent API calls (scales to CPU cores) |
| `--activity-grace-minutes` | `-g` | `10` | Post-operation grace window for supported Activity Log lifecycle events |
| `--batch` | `-b` | off | Use the regional Metrics Batch API instead of per-resource calls |
| `--batch-size` | | `10` | Max resources per batch call (1–50); implies `--batch` |
| `--version` | `-v` | | Print version and exit |

### PowerShell (`get-availability.ps1`)
Expand All @@ -203,6 +207,8 @@ Storage Account `Availability` is a transaction-success-rate metric — it is on
| `-Resource` | *(all)* | Filter to a single resource name |
| `-Parallelism` | *(auto)* | Max concurrent API calls (scales to CPU cores, 4–16) |
| `-ActivityGraceMinutes` | `10` | Post-operation grace window for supported Activity Log lifecycle events |
| `-Batch` | off | Use the regional Metrics Batch API instead of per-resource calls |
| `-BatchSize` | `10` | Max resources per batch call (1–50); implies `-Batch` |
| `-Version` | | Print version and exit |

Both versions enforce the same constraints: `-Month` / `--month` cannot point to a month whose first day is more than 90 days before the current UTC time.
Expand Down Expand Up @@ -258,6 +264,12 @@ dotnet publish -c Release -r win-x64 # output in bin/Release/net10.0/win-x64/p
# Override the Activity Log grace window
./GetAvailability --subscriptions Contoso-Development --month 202603 --resource myvm02 --activity-grace-minutes 15

# Use batch API mode
./GetAvailability --subscriptions Contoso-Production --month 202603 --batch

# Use batch API with custom batch size
./GetAvailability --subscriptions Contoso-Production Contoso-Development --month 202603 --batch-size 20

# Or run directly without publishing
cd csharp/GetAvailability
dotnet run -- --subscriptions Contoso-Production --month 202603
Expand All @@ -281,6 +293,12 @@ dotnet run -- --subscriptions Contoso-Production --month 202603
# Override the Activity Log grace window
./get-availability.ps1 -Subscriptions 'Contoso-Development' -Month 202603 -Resource myvm02 -ActivityGraceMinutes 15

# Use batch API mode
./get-availability.ps1 -Subscriptions 'Contoso-Production' -Month 202603 -Batch

# Use batch API with custom batch size
./get-availability.ps1 -Subscriptions 'Contoso-Production','Contoso-Development' -Month 202603 -Batch -BatchSize 20

# Pipe results to CSV
./get-availability.ps1 -Subscriptions 'Contoso-Production' -Month 202603 | Export-Csv availability.csv
```
Expand Down Expand Up @@ -349,6 +367,7 @@ Console output also reports per-resource classification details:
- **Conservative on failure.** If a Resource Health API call fails for a period where Health History should exist, Activity Log matches still apply, but no remaining suspect minutes are excused through Health History.
- **Transaction-aware storage.** Zero-transaction minutes are excluded from eligibility rather than counted as unavailable, giving an accurate picture of actual storage service availability.
- **Whole-window no-signal exclusion.** If the metric API returns no usable datapoints across the full period, the resource is excluded from availability calculations and shown as `N/A`.
- **Batch API mode.** `--batch` / `-Batch` uses the regional Azure Monitor Metrics Batch API, grouping resources by (subscription, region, kind) and sending them in configurable chunks. This reduces API call count and improves throughput for large inventories. Regional endpoints are validated before fetching. Wave-based processing with GC between waves bounds memory usage.
- **`Parallel.ForEachAsync`** (C#) / **`ForEach-Object -Parallel`** (PowerShell) for concurrent metric and health queries with configurable parallelism.
- **Ticks-based metric keying** (`long` instead of `DateTime`) — zero-allocation per data point in both versions.
- **`System.Text.Json`** for efficient JSON parsing in both versions — avoids large PSObject trees in PowerShell and enables AOT-safe parsing in C#.
Expand Down
68 changes: 49 additions & 19 deletions csharp/GetAvailability/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@
string[] kinds = ["vm", "sql", "storage"];
string? resourceName = null;
string? monthParameter = null;
int parallelism = Math.Max(4, Math.Min(16, Environment.ProcessorCount)); // auto-scale to CPU cores
int parallelism = Math.Clamp(Environment.ProcessorCount, 4, 16);
int activityGraceMinutes = 10;
bool useBatch = false;
int batchSize = 10;

for (int i = 0; i < args.Length; i++)
{
Expand All @@ -66,6 +68,14 @@
case "--activity-grace-minutes" or "-g":
activityGraceMinutes = ParseIntOption(ReadRequiredValue(args, ref i, "--activity-grace-minutes"), "--activity-grace-minutes", minValue: 0);
break;
case "--batch" or "-b":
useBatch = true;
break;
case "--batch-size":
batchSize = ParseIntOption(ReadRequiredValue(args, ref i, "--batch-size"), "--batch-size", minValue: 1);
if (batchSize > 50) throw new ArgumentException("--batch-size must be <= 50.");
useBatch = true;
break;
case "--version" or "-v":
Console.WriteLine($"GetAvailability {typeof(Program).Assembly.GetName().Version?.ToString(3) ?? "0.0.0"}");
return 0;
Expand All @@ -77,6 +87,8 @@
Console.WriteLine(" --resource, -r Filter to a single resource name.");
Console.WriteLine(" --parallelism, -p Max concurrent metric calls (default: auto).");
Console.WriteLine(" --activity-grace-minutes, -g Post-operation grace window for supported Activity Log lifecycle events (default: 10).");
Console.WriteLine(" --batch, -b Use the regional Metrics Batch API instead of per-resource calls.");
Console.WriteLine(" --batch-size Max resources per batch call (default: 10, max: 50). Implies --batch.");
Console.WriteLine(" --version, -v Print version and exit.");
return 0;
}
Expand All @@ -96,7 +108,7 @@

try
{
await RunAsync(subscriptionNames, kinds, resourceName, monthParameter, parallelism, activityGraceMinutes);
await RunAsync(subscriptionNames, kinds, resourceName, monthParameter, parallelism, activityGraceMinutes, useBatch, batchSize);
return 0;
}
catch (Exception ex) when (ex is AuthenticationFailedException or CredentialUnavailableException)
Expand All @@ -118,7 +130,9 @@ static async Task RunAsync(
string? resourceName,
string monthParameter,
int parallelism,
int activityGraceMinutes)
int activityGraceMinutes,
bool useBatch,
int batchSize)
{
var sw = Stopwatch.StartNew();

Expand Down Expand Up @@ -179,8 +193,17 @@ static async Task RunAsync(
};
}

// Step 4: Fetch Azure Monitor metrics per resource in parallel
var metricResults = await MetricsService.QueryAsync(metricsClient, resources, utcStart, utcEnd, parallelism);
// Step 4: Fetch Azure Monitor metrics (batch or per-resource)
ConcurrentDictionary<string, MetricScalars> metricResults;
if (useBatch)
{
Console.WriteLine($"Using Batch API (batch size: {batchSize}).");
metricResults = await BatchMetricsService.QueryAsync(credential, resources, utcStart, utcEnd, parallelism, batchSize);
}
else
{
metricResults = await MetricsService.QueryAsync(metricsClient, resources, utcStart, utcEnd, parallelism);
}

// Step 5: For resources with suspect metric minutes, investigate null/0% suspect minutes and
// positive degraded datapoints. Activity Log is checked first for supported lifecycle actions,
Expand Down Expand Up @@ -315,18 +338,16 @@ static async Task RunAsync(
}
}

int confirmedHealthDowntimeMinutes = suspectResults is not null && suspectResults.TryGetValue(key, out var healthClassification)
? healthClassification.PlatformFaultGapMinutes + healthClassification.HealthConfirmedDegradedMinutes
: 0;

int unexplainedPositiveDegradedMinutes = suspectResults is not null && suspectResults.TryGetValue(key, out var unresolvedClassification)
? Math.Max(0, mr.DegradedMinutes - unresolvedClassification.CustomerExcusedDegradedMinutes - unresolvedClassification.HealthConfirmedDegradedMinutes)
: mr.DegradedMinutes;
int confirmedHealthDowntimeMinutes = 0;
int unexplainedPositiveDegradedMinutes = mr.DegradedMinutes;
int unexplainedSuspectMinutes = mr.SuspectMinutes;

int unexplainedSuspectMinutes = suspectResults is not null && suspectResults.TryGetValue(key, out var unexplainedClassification)
? unexplainedClassification.UnresolvedZeroDowntimeMinutes
+ unexplainedPositiveDegradedMinutes
: mr.SuspectMinutes;
if (suspectResults is not null && suspectResults.TryGetValue(key, out var cls))
{
confirmedHealthDowntimeMinutes = cls.PlatformFaultGapMinutes + cls.HealthConfirmedDegradedMinutes;
unexplainedPositiveDegradedMinutes = Math.Max(0, mr.DegradedMinutes - cls.CustomerExcusedDegradedMinutes - cls.HealthConfirmedDegradedMinutes);
unexplainedSuspectMinutes = cls.UnresolvedZeroDowntimeMinutes + unexplainedPositiveDegradedMinutes;
}

// For Storage Accounts, zero-transaction minutes have no availability signal and are
// excluded from eligibility. They count as suspect (no data) and excused (nothing to measure).
Expand Down Expand Up @@ -360,8 +381,8 @@ static async Task RunAsync(
$" [{res.Name}] eligible min = {totalMinutes} - {string.Join(" - ", eligibilityAdjustments)} = {elig.EligibleMinutes}");
}

double customerExcusedAvail = suspectResults is not null && suspectResults.TryGetValue(key, out var availClass)
? availClass.CustomerExcusedDegradedAvailableSum
double customerExcusedAvail = suspectResults is not null && suspectResults.TryGetValue(key, out var ac)
? ac.CustomerExcusedDegradedAvailableSum
: 0;
elig.AvailableMinutes = Math.Round(Math.Max(0, mr.AvailableSum - customerExcusedAvail), 2);
}
Expand Down Expand Up @@ -452,7 +473,16 @@ static string[] ReadRequiredValues(string[] args, ref int index, string optionNa
{
var values = new List<string>();
while (index + 1 < args.Length && !args[index + 1].StartsWith("-", StringComparison.Ordinal))
values.Add(args[++index]);
{
// Support comma-separated values: "A, B, C" or "A,B,C" or mixed
var raw = args[++index];
foreach (var part in raw.Split(','))
{
var trimmed = part.Trim();
if (trimmed.Length > 0)
values.Add(trimmed);
}
}

return values.Count > 0
? values.ToArray()
Expand Down
12 changes: 10 additions & 2 deletions csharp/GetAvailability/Services/ActivityLogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -259,12 +259,17 @@ private static string BuildActivityGroupKey(ActivityLogEvent evt)
private static DateTimeOffset ExtendActivityInterval(int graceMinutes, DateTimeOffset currentEnd)
=> graceMinutes > 0 ? currentEnd.AddMinutes(graceMinutes) : currentEnd;

/// <summary>
/// Parses Azure timestamp strings which may use several formats depending on
/// region and API version. Falls back to DateTimeOffset.TryParse for formats
/// not in the explicit list.
/// </summary>
private static DateTimeOffset? ParseAzureTimestamp(string? timestamp)
{
if (string.IsNullOrWhiteSpace(timestamp))
return null;

string[] formats =
ReadOnlySpan<string> formats =
[
"MM/dd/yyyy HH:mm:ss",
"M/d/yyyy H:mm:ss",
Expand All @@ -274,7 +279,7 @@ private static DateTimeOffset ExtendActivityInterval(int graceMinutes, DateTimeO
"yyyy-MM-ddTHH:mm:ss.fffffffZ",
];

foreach (string format in formats)
foreach (var format in formats)
{
if (DateTimeOffset.TryParseExact(
timestamp,
Expand All @@ -296,16 +301,19 @@ private static DateTimeOffset ExtendActivityInterval(int graceMinutes, DateTimeO
: null;
}

/// <summary>Truncates to minute boundary for interval grouping.</summary>
private static DateTimeOffset TruncateToMinute(DateTimeOffset value)
=> new(value.Year, value.Month, value.Day, value.Hour, value.Minute, 0, TimeSpan.Zero);

/// <summary>Reads a nested string property (e.g. operationName.value) from a JsonElement.</summary>
private static string GetNestedPropString(JsonElement parent, string propertyName, string nestedName)
{
if (!parent.TryGetProperty(propertyName, out var obj) || obj.ValueKind != JsonValueKind.Object)
return "";
return GetPropString(obj, nestedName);
}

/// <summary>Reads a string property from a JsonElement, returning empty if absent or null.</summary>
private static string GetPropString(JsonElement props, string name)
=> props.TryGetProperty(name, out var el) ? el.GetString() ?? "" : "";
}
Expand Down
Loading
Loading