diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Transport/GeoLocationServiceTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Transport/GeoLocationServiceTests.cs
new file mode 100644
index 00000000..bb54366f
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Transport/GeoLocationServiceTests.cs
@@ -0,0 +1,88 @@
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
+using Newtonsoft.Json.Linq;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
+{
+ ///
+ /// Response parsers of the geo providers. Addresses come from the RFC 5737 documentation
+ /// ranges — never real ones.
+ ///
+ public class GeoLocationServiceTests
+ {
+ [Fact]
+ public void ParseDoGeo_maps_country_and_egress_ip_and_ignores_the_do_service_fields()
+ {
+ var json = JObject.Parse(@"{
+ ""ExternalIpAddress"": ""203.0.113.7"",
+ ""CountryCode"": ""DE"",
+ ""KeyValue_EndpointFullUri"": ""https://kv601.prod.do.dsp.mp.microsoft.com/all"",
+ ""Version"": ""26A6D4D6994E8DA5"",
+ ""CacheId"": ""7"",
+ ""ContentCert"": false,
+ ""DownloadModeFailSafe"": """"
+ }");
+
+ var result = GeoLocationService.ParseDoGeo(json);
+
+ Assert.NotNull(result);
+ Assert.Equal("DE", result.Country);
+ Assert.Equal("203.0.113.7", result.Ip);
+ Assert.Equal("do-geo", result.Source);
+ Assert.Null(result.Region);
+ Assert.Null(result.City);
+ Assert.Null(result.Loc);
+ Assert.Null(result.Timezone);
+ }
+
+ [Theory]
+ [InlineData(@"{ ""ExternalIpAddress"": ""203.0.113.7"" }")]
+ [InlineData(@"{ ""ExternalIpAddress"": ""203.0.113.7"", ""CountryCode"": """" }")]
+ public void ParseDoGeo_without_country_is_a_failure(string body)
+ {
+ Assert.Null(GeoLocationService.ParseDoGeo(JObject.Parse(body)));
+ }
+
+ [Fact]
+ public void ParseIpInfo_maps_all_fields()
+ {
+ var json = JObject.Parse(@"{
+ ""ip"": ""198.51.100.4"", ""city"": ""Stuttgart"", ""region"": ""Baden-Wurttemberg"",
+ ""country"": ""DE"", ""loc"": ""48.7823,9.1770"", ""timezone"": ""Europe/Berlin""
+ }");
+
+ var result = GeoLocationService.ParseIpInfo(json);
+
+ Assert.Equal("DE", result.Country);
+ Assert.Equal("Baden-Wurttemberg", result.Region);
+ Assert.Equal("Stuttgart", result.City);
+ Assert.Equal("48.7823,9.1770", result.Loc);
+ Assert.Equal("Europe/Berlin", result.Timezone);
+ Assert.Equal("198.51.100.4", result.Ip);
+ Assert.Equal("ipinfo", result.Source);
+ }
+
+ [Fact]
+ public void ParseIfConfigCo_joins_coordinates_and_leaves_loc_empty_without_them()
+ {
+ var withCoords = GeoLocationService.ParseIfConfigCo(JObject.Parse(@"{
+ ""ip"": ""198.51.100.4"", ""country_iso"": ""DE"", ""region_name"": ""Bavaria"",
+ ""city"": ""Munich"", ""latitude"": 48.13, ""longitude"": 11.58, ""time_zone"": ""Europe/Berlin""
+ }"));
+ var withoutCoords = GeoLocationService.ParseIfConfigCo(JObject.Parse(@"{ ""country_iso"": ""DE"" }"));
+
+ Assert.Equal("48.13,11.58", withCoords.Loc);
+ Assert.Equal("DE", withCoords.Country);
+ Assert.Equal("Bavaria", withCoords.Region);
+ Assert.Equal("ifconfig.co", withCoords.Source);
+ Assert.Equal("", withoutCoords.Loc);
+ }
+
+ [Fact]
+ public void Describe_skips_the_parts_a_provider_did_not_return()
+ {
+ Assert.Equal("Stuttgart, BW, DE", new GeoLocationResult { City = "Stuttgart", Region = "BW", Country = "DE" }.Describe());
+ Assert.Equal("DE", new GeoLocationResult { Country = "DE", Region = "" }.Describe());
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/StartupEnvironmentProbesTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/StartupEnvironmentProbesTests.cs
index 854b48b8..ecdc1dca 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/StartupEnvironmentProbesTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/StartupEnvironmentProbesTests.cs
@@ -62,6 +62,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
Assert.Equal("ipinfo.io", evt.Data["source"]);
}
+ [Fact]
+ public void BuildGeoEvent_country_only_location_has_no_empty_parts_in_message()
+ {
+ var loc = new GeoLocationResult { Country = "DE", Source = "do-geo" };
+
+ var evt = StartupEnvironmentProbes.BuildGeoEvent(Config(), loc);
+
+ Assert.Equal("Device location: DE (via do-geo)", evt.Message);
+ Assert.Equal("DE", evt.Data["country"]);
+ Assert.Equal("do-geo", evt.Data["source"]);
+ }
+
// ================================================================= Outbound IP
[Fact]
@@ -98,6 +110,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
PrimaryError = "connection refused",
PrimaryRetryError = "DNS failure",
FallbackError = "timeout",
+ DoGeoError = "HTTP 403 (Forbidden)",
};
var evt = StartupEnvironmentProbes.BuildGeoFailureEvent(cfg, attempt);
@@ -108,6 +121,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
Assert.Equal("connection refused", evt.Data["primaryError"]);
Assert.Equal("DNS failure", evt.Data["primaryRetryError"]);
Assert.Equal("timeout", evt.Data["fallbackError"]);
+ Assert.Equal("HTTP 403 (Forbidden)", evt.Data["doGeoError"]);
+ Assert.Equal("do-geo", evt.Data["lastResortProvider"]);
}
[Fact]
@@ -119,6 +134,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
Assert.Equal("unknown", evt.Data["primaryError"]);
Assert.Equal("unknown", evt.Data["primaryRetryError"]);
Assert.Equal("unknown", evt.Data["fallbackError"]);
+ Assert.Equal("unknown", evt.Data["doGeoError"]);
}
// ================================================================= Timezone
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/GeoLocationService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/GeoLocationService.cs
index 99add092..5f5f1d59 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/GeoLocationService.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/GeoLocationService.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
@@ -25,6 +26,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
///
public string Ip { get; set; }
+ /// "City, Region, Country" without the parts a provider did not return.
+ public string Describe() =>
+ string.Join(", ", new[] { City, Region, Country }.Where(p => !string.IsNullOrEmpty(p)));
+
public Dictionary ToDictionary()
{
return new Dictionary
@@ -45,6 +50,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
public string PrimaryError { get; set; }
public string PrimaryRetryError { get; set; }
public string FallbackError { get; set; }
+ public string DoGeoError { get; set; }
}
public static class GeoLocationService
@@ -52,6 +58,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(2);
+ internal const string IpInfoUrl = "https://ipinfo.io/json";
+ internal const string IfConfigCoUrl = "https://ifconfig.co/json";
+
+ ///
+ /// Delivery Optimization's own geo endpoint. Country and egress IP only (no region, city,
+ /// coordinates or timezone), but it is part of the Windows Update / DO endpoint set that
+ /// Autopilot needs anyway, so it stays reachable in networks that block public geo services.
+ ///
+ internal const string DoGeoUrl = "https://geo.prod.do.dsp.mp.microsoft.com/geo";
+
// Static HttpClient to avoid socket exhaustion from repeated short-lived instances.
// HttpClient is designed to be reused across requests.
private static readonly HttpClient SharedHttpClient = CreateSharedHttpClient();
@@ -68,7 +84,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
var attempt = new GeoLocationAttemptResult();
// Try ipinfo.io first
- var (result, error) = await TryIpInfo(logger);
+ var (result, error) = await TryProvider("ipinfo.io", IpInfoUrl, ParseIpInfo, logger);
if (result != null)
{
attempt.Location = result;
@@ -80,7 +96,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
logger?.Info($"GeoLocation: Retrying ipinfo.io after {RetryDelay.TotalSeconds}s...");
await Task.Delay(RetryDelay);
- (result, error) = await TryIpInfo(logger);
+ (result, error) = await TryProvider("ipinfo.io", IpInfoUrl, ParseIpInfo, logger);
if (result != null)
{
attempt.Location = result;
@@ -89,7 +105,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
attempt.PrimaryRetryError = error;
// Fallback to ifconfig.co
- (result, error) = await TryIfConfigCo(logger);
+ (result, error) = await TryProvider("ifconfig.co", IfConfigCoUrl, ParseIfConfigCo, logger);
if (result != null)
{
attempt.Location = result;
@@ -97,108 +113,111 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
}
attempt.FallbackError = error;
+ // Last resort: Delivery Optimization geo — country only, but better than no location
+ (result, error) = await TryProvider("do-geo", DoGeoUrl, ParseDoGeo, logger);
+ if (result != null)
+ {
+ attempt.Location = result;
+ return attempt;
+ }
+ attempt.DoGeoError = error;
+
logger?.Warning("GeoLocation: All providers failed, skipping location event");
return attempt;
}
- private static async Task<(GeoLocationResult result, string error)> TryIpInfo(AgentLogger logger)
+ private static async Task<(GeoLocationResult result, string error)> TryProvider(
+ string name, string url, Func parse, AgentLogger logger)
{
try
{
- logger?.Info("GeoLocation: Querying ipinfo.io...");
+ logger?.Info($"GeoLocation: Querying {name}...");
- using (var httpResponse = await SharedHttpClient.GetAsync("https://ipinfo.io/json"))
+ using (var httpResponse = await SharedHttpClient.GetAsync(url))
{
if (!httpResponse.IsSuccessStatusCode)
{
var error = $"HTTP {(int)httpResponse.StatusCode} ({httpResponse.ReasonPhrase})";
- logger?.Warning($"GeoLocation: ipinfo.io failed: {error}");
+ logger?.Warning($"GeoLocation: {name} failed: {error}");
return (null, error);
}
var response = await httpResponse.Content.ReadAsStringAsync();
- var json = JObject.Parse(response);
-
- var result = new GeoLocationResult
+ var result = parse(JObject.Parse(response));
+ if (result == null)
{
- Country = json.Value("country"),
- Region = json.Value("region"),
- City = json.Value("city"),
- Loc = json.Value("loc"),
- Timezone = json.Value("timezone"),
- Ip = json.Value("ip"),
- Source = "ipinfo"
- };
+ var error = "No country in response";
+ logger?.Warning($"GeoLocation: {name} failed: {error}");
+ return (null, error);
+ }
- logger?.Info($"GeoLocation: ipinfo.io returned {result.City}, {result.Region}, {result.Country}");
+ logger?.Info($"GeoLocation: {name} returned {result.Describe()}");
return (result, null);
}
}
catch (TaskCanceledException)
{
- var error = "Timeout (5s)";
- logger?.Warning($"GeoLocation: ipinfo.io failed: {error}");
+ var error = $"Timeout ({RequestTimeout.TotalSeconds}s)";
+ logger?.Warning($"GeoLocation: {name} failed: {error}");
return (null, error);
}
catch (Exception ex)
{
var error = ex.Message;
- logger?.Warning($"GeoLocation: ipinfo.io failed: {error}");
+ logger?.Warning($"GeoLocation: {name} failed: {error}");
return (null, error);
}
}
- private static async Task<(GeoLocationResult result, string error)> TryIfConfigCo(AgentLogger logger)
+ internal static GeoLocationResult ParseIpInfo(JObject json) =>
+ new GeoLocationResult
+ {
+ Country = json.Value("country"),
+ Region = json.Value("region"),
+ City = json.Value("city"),
+ Loc = json.Value("loc"),
+ Timezone = json.Value("timezone"),
+ Ip = json.Value("ip"),
+ Source = "ipinfo"
+ };
+
+ internal static GeoLocationResult ParseIfConfigCo(JObject json)
{
- try
+ var latitude = json.Value("latitude") ?? "";
+ var longitude = json.Value("longitude") ?? "";
+ var loc = !string.IsNullOrEmpty(latitude) && !string.IsNullOrEmpty(longitude)
+ ? $"{latitude},{longitude}"
+ : "";
+
+ return new GeoLocationResult
{
- logger?.Info("GeoLocation: Falling back to ifconfig.co...");
+ Country = json.Value("country_iso"),
+ Region = json.Value("region_name"),
+ City = json.Value("city"),
+ Loc = loc,
+ Timezone = json.Value("time_zone"),
+ Ip = json.Value("ip"),
+ Source = "ifconfig.co"
+ };
+ }
- using (var httpResponse = await SharedHttpClient.GetAsync("https://ifconfig.co/json"))
- {
- if (!httpResponse.IsSuccessStatusCode)
- {
- var error = $"HTTP {(int)httpResponse.StatusCode} ({httpResponse.ReasonPhrase})";
- logger?.Warning($"GeoLocation: ifconfig.co failed: {error}");
- return (null, error);
- }
+ ///
+ /// The DO geo response carries DO service configuration next to the geo fields; only
+ /// CountryCode and ExternalIpAddress are location data. Without a country
+ /// the response is worthless as a location, so it counts as a failure (null).
+ ///
+ internal static GeoLocationResult ParseDoGeo(JObject json)
+ {
+ var country = json.Value("CountryCode");
+ if (string.IsNullOrEmpty(country))
+ return null;
- var response = await httpResponse.Content.ReadAsStringAsync();
- var json = JObject.Parse(response);
-
- var latitude = json.Value("latitude") ?? "";
- var longitude = json.Value("longitude") ?? "";
- var loc = !string.IsNullOrEmpty(latitude) && !string.IsNullOrEmpty(longitude)
- ? $"{latitude},{longitude}"
- : "";
-
- var result = new GeoLocationResult
- {
- Country = json.Value("country_iso"),
- Region = json.Value("region_name"),
- City = json.Value("city"),
- Loc = loc,
- Timezone = json.Value("time_zone"),
- Ip = json.Value("ip"),
- Source = "ifconfig.co"
- };
-
- logger?.Info($"GeoLocation: ifconfig.co returned {result.City}, {result.Region}, {result.Country}");
- return (result, null);
- }
- }
- catch (TaskCanceledException)
+ return new GeoLocationResult
{
- var error = "Timeout (5s)";
- logger?.Warning($"GeoLocation: ifconfig.co failed: {error}");
- return (null, error);
- }
- catch (Exception ex)
- {
- var error = ex.Message;
- logger?.Warning($"GeoLocation: ifconfig.co failed: {error}");
- return (null, error);
- }
+ Country = country,
+ Ip = json.Value("ExternalIpAddress"),
+ Source = "do-geo"
+ };
}
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/StartupEnvironmentProbes.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/StartupEnvironmentProbes.cs
index 20cc806b..890bf73c 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/StartupEnvironmentProbes.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/StartupEnvironmentProbes.cs
@@ -18,7 +18,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
///
///
/// - device_location (or agent_trace on failure) — IP-based geo via
- /// . Skipped when EnableGeoLocation=false.
+ /// . Skipped when EnableGeoLocation=false. The
+ /// last-resort provider (DO geo) yields a country-only location without timezone.
/// - timezone_auto_set — only when EnableTimezoneAutoSet=true AND the
/// geo lookup returned an IANA timezone. Uses (tzutil).
/// - ntp_time_check — NTP offset from NtpServer (default time.windows.com).
@@ -225,7 +226,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
Source = "StartupEnvironmentProbes",
Phase = EnrollmentPhase.Unknown,
Timestamp = DateTime.UtcNow,
- Message = $"Device location: {location.City}, {location.Region}, {location.Country} (via {location.Source})",
+ Message = $"Device location: {location.Describe()} (via {location.Source})",
Data = location.ToDictionary(),
ImmediateUpload = true,
};
@@ -266,8 +267,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
{ "primaryError", attempt?.PrimaryError ?? "unknown" },
{ "primaryRetryError", attempt?.PrimaryRetryError ?? "unknown" },
{ "fallbackError", attempt?.FallbackError ?? "unknown" },
+ { "doGeoError", attempt?.DoGeoError ?? "unknown" },
{ "primaryProvider", "ipinfo.io" },
{ "fallbackProvider", "ifconfig.co" },
+ { "lastResortProvider", "do-geo" },
},
};
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GeographicDrilldownFilterTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GeographicDrilldownFilterTests.cs
index cb2b1c5b..132eac44 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/GeographicDrilldownFilterTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/GeographicDrilldownFilterTests.cs
@@ -115,4 +115,21 @@ public class GeographicDrilldownFilterTests
Assert.Equal("Saxony", fal.Region);
Assert.Equal("Falkenstein", fal.City);
}
+
+ [Theory]
+ [InlineData("city")]
+ [InlineData("region")]
+ [InlineData("country")]
+ public void CountryOnlySession_KeyIsTheCountryAndDrillsDownToItself(string groupBy)
+ {
+ // The agent's last-resort geo provider returns the country only.
+ var countryOnly = Geo("de-only", "DE", "", "");
+ var sessions = new List { countryOnly, Geo("de-bav", "DE", "Bavaria", "Munich") };
+
+ var key = GetGeographicMetricsFunction.GetLocationKey(countryOnly, groupBy);
+ var drilled = GetGeographicLocationSessionsFunction.FilterSessionsByLocation(sessions, key, groupBy);
+
+ Assert.Equal("DE", key); // never ", DE" or ", , DE"
+ Assert.Contains(drilled, s => s.SessionId == "de-only");
+ }
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs
index 8c678146..e1734cdf 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs
@@ -301,11 +301,14 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return groupBy.ToLower() switch
{
"country" => session.GeoCountry,
- "region" => $"{session.GeoRegion}, {session.GeoCountry}",
- _ => !string.IsNullOrEmpty(session.GeoCity)
- ? $"{session.GeoCity}, {session.GeoRegion}, {session.GeoCountry}"
- : $"{session.GeoRegion}, {session.GeoCountry}"
+ "region" => JoinLocationParts(session.GeoRegion, session.GeoCountry),
+ _ => JoinLocationParts(session.GeoCity, session.GeoRegion, session.GeoCountry)
};
}
+
+ // Providers can omit region/city (the agent's last-resort provider returns the country
+ // only), so empty parts are skipped instead of producing keys like ", DE".
+ private static string JoinLocationParts(params string[] parts) =>
+ string.Join(", ", parts.Where(p => !string.IsNullOrEmpty(p)));
}
}