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
1 change: 0 additions & 1 deletion .agents/known-issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ When you fix one, remove it from this list (and update the relevant `.agents/*.m
| --- | --- | --- | --- | --- |
| **B3** | medium | `DurationJsonConverter` and `OverviewPolylineJsonConverter` resolve members by name via reflection (`GetProperty`) — rename-fragile, and they silently accept partial objects (a missing token leaves that field unset). | `Engine/JsonConverters/DurationJsonConverter.cs`, `Engine/JsonConverters/OverviewPolylineJsonConverter.cs` | Replace reflection with direct (de)serialization of explicit DTOs. |
| **B4** | low | HTTP status tag is set on `Activity.Current` instead of the captured `activity`, so it can misattribute under unusual ambient-Activity nesting. | `Engine/MapsAPIGenericEngine.cs:122` | Capture `activity` and tag it directly; pass it down or set the tag inside the parent scope. See [`observability.md`](observability.md). |
| **B5** | low | Observability is tracing-only — no `ILogger` integration and no metrics (counters/histograms). | `Engine/MapsAPIGenericEngine.cs` | Optional: add an `ILogger`-based debug log and OTel metrics (request count, duration histogram, error count). |
| **B7** | low | Inconsistent SSL enforcement (`TimeZoneRequest` forces HTTPS, others don't) and mixed enum handling (some `[EnumMember]`, some bare names). | `Entities/TimeZone/Request/TimeZoneRequest.cs`; various `Entities/**/Response/Status*.cs` | Decide one SSL policy; standardize on `[EnumMember]` (or document when it's intentionally omitted). |
| **B8** | medium | No manual approval gate before NuGet publish (a `v*` tag push publishes immediately), and Renovate auto-merge assumes branch protection is configured. | `.github/workflows/nuget.yml`, `renovate.json` | Confirm `master` branch protection requires CI; optionally add a release approval environment. (Process decision for the maintainer.) See [`build-release-ci.md`](build-release-ci.md). |

Expand Down
39 changes: 34 additions & 5 deletions .agents/observability.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Observability, events & error handling

Tracing, the two diagnostic events, and how failures surface. All of this lives in
`GoogleMapsApi/Engine/MapsAPIGenericEngine.cs` and `GoogleMapsApi/Diagnostics/GoogleMapsActivity.cs`.
Tracing, metrics, the two diagnostic events, and how failures surface. All of this lives in
`GoogleMapsApi/Engine/MapsAPIGenericEngine.cs`, `GoogleMapsApi/Diagnostics/GoogleMapsActivity.cs`, and
`GoogleMapsApi/Diagnostics/GoogleMapsMetrics.cs`.

## OpenTelemetry tracing

Expand Down Expand Up @@ -39,9 +40,37 @@ On exception the span is set to `ActivityStatusCode.Error` with the message and
> `GetHttpResponseAsync` rather than the captured `activity` reference (`MapsAPIGenericEngine.cs:122`).
> Fine in the common path; can misattribute under unusual ambient-Activity nesting. See
> [`known-issues.md`](known-issues.md).
>
> **Gaps (B5):** tracing only — there is no `ILogger` integration and no metrics (counters/histograms).
> Latency is implicit in span duration. Logging is a candidate enhancement.

## Metrics

The library also records **three metric instruments per call**, published from a `Meter` named
**`GoogleMapsMetrics.MeterName == "GoogleMapsApi"`** (same name as the trace source). Like the tracing, the
instruments are zero-cost until a listener subscribes — `GoogleMapsMetrics.Record` early-returns when no
instrument is `Enabled`. Recording happens once per call in the `finally` of `QueryGoogleAPIAsync`.

Consumer wiring (OpenTelemetry):

```csharp
meterProviderBuilder.AddMeter(GoogleMapsApi.Diagnostics.GoogleMapsMetrics.MeterName);
```

| Instrument | Type | Unit | Tags |
| --- | --- | --- | --- |
| `gmaps.client.requests` | `Counter<long>` | `{request}` | `gmaps.api`, `http.request.method` |
| `gmaps.client.request.duration` | `Histogram<double>` | `s` | `gmaps.api`, `http.request.method`, `http.response.status_code`*, `gmaps.response_status`* |
| `gmaps.client.request.errors` | `Counter<long>` | `{error}` | `gmaps.api`, `error.type` |

\* added only when available (a response arrived / the response type exposes a `Status`).

The request counter increments for **every** attempt (success or failure); the error counter increments only when
the call throws. Mirroring the trace model, Google business statuses (`ZERO_RESULTS`, `OVER_QUERY_LIMIT`, …) arrive
as `200 OK`, do **not** throw, and so are **not** counted as errors — they are visible via `gmaps.response_status`
on the duration histogram.

> **Non-goal — `ILogger`:** there is deliberately no `ILogger` integration. The OTel tracing already carries
> method/host/redacted URL/status/`error.type`/duration; the library has no retry logic to "warn" on; and the
> static HTTP engine makes threading an instance logger costly. Users who want logs should bridge the spans to a
> log sink rather than expect a built-in logger.

## The two events (`IEngineFacade`)

Expand Down
149 changes: 149 additions & 0 deletions GoogleMapsApi.Test/DiagnosticsMetricsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using GoogleMapsApi.Diagnostics;
using GoogleMapsApi.Entities.Geocoding.Request;
using NUnit.Framework;

namespace GoogleMapsApi.Test
{
/// <summary>
/// Hermetic tests asserting that each API call records to the metric instruments published from the
/// <see cref="GoogleMapsMetrics.MeterName"/> meter. No live network calls — every HTTP exchange is
/// intercepted by <see cref="StubHandler"/>.
/// </summary>
[TestFixture]
public class DiagnosticsMetricsTests
{
private const string OkGeocodingJson = "{\"status\":\"OK\",\"results\":[]}";

[Test]
public async Task QueryAsync_Success_RecordsRequestAndDuration_NoErrors()
{
using var collector = new MetricsCollector();

using var handler = new StubHandler(HttpStatusCode.OK, OkGeocodingJson);
using var http = new HttpClient(handler);
var client = new GoogleMapsClient(http, new GoogleMapsClientOptions { ApiKey = "k" });

await client.Geocode.QueryAsync(new GeocodingRequest { Address = "1600 Amphitheatre Pkwy" });

var requests = collector.Single("gmaps.client.requests");
Assert.That(requests.Value, Is.EqualTo(1L));
Assert.That(requests.Tags["gmaps.api"], Is.EqualTo("Geocoding"));
Assert.That(requests.Tags["http.request.method"], Is.EqualTo("GET"));
Assert.That(requests.Tags.ContainsKey("http.response.status_code"), Is.False);

var duration = collector.Single("gmaps.client.request.duration");
Assert.That(duration.Value, Is.GreaterThanOrEqualTo(0.0));
Assert.That(duration.Tags["gmaps.api"], Is.EqualTo("Geocoding"));
Assert.That(duration.Tags["http.response.status_code"], Is.EqualTo(200));
Assert.That(duration.Tags["gmaps.response_status"], Is.EqualTo("OK"));

Assert.That(collector.Count("gmaps.client.request.errors"), Is.EqualTo(0));
}

[Test]
public void QueryAsync_HttpError_RecordsErrorAndDuration()
{
using var collector = new MetricsCollector();

using var handler = new StubHandler(HttpStatusCode.Forbidden, "denied");
using var http = new HttpClient(handler);
var client = new GoogleMapsClient(http, new GoogleMapsClientOptions { ApiKey = "k" });

Assert.ThrowsAsync<System.Security.Authentication.AuthenticationException>(
(Func<Task>)(() => client.Geocode.QueryAsync(new GeocodingRequest { Address = "a" })));

Assert.That(collector.Single("gmaps.client.requests").Value, Is.EqualTo(1L));

var error = collector.Single("gmaps.client.request.errors");
Assert.That(error.Value, Is.EqualTo(1L));
Assert.That(error.Tags["gmaps.api"], Is.EqualTo("Geocoding"));
Assert.That(error.Tags["error.type"], Is.Not.Null);

var duration = collector.Single("gmaps.client.request.duration");
Assert.That(duration.Tags["http.response.status_code"], Is.EqualTo(403));
}

[Test]
public async Task QueryAsync_WithoutListener_DoesNotThrow()
{
using var handler = new StubHandler(HttpStatusCode.OK, OkGeocodingJson);
using var http = new HttpClient(handler);
var client = new GoogleMapsClient(http, new GoogleMapsClientOptions { ApiKey = "k" });

var response = await client.Geocode.QueryAsync(new GeocodingRequest { Address = "a" });

Assert.That(response, Is.Not.Null);
}

/// <summary>Captures measurements from the GoogleMapsApi meter for the duration of a test.</summary>
private sealed class MetricsCollector : IDisposable
{
private readonly MeterListener _listener;
private readonly List<(string Instrument, double Value, Dictionary<string, object?> Tags)> _records = new();

public MetricsCollector()
{
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == GoogleMapsMetrics.MeterName)
listener.EnableMeasurementEvents(instrument);
}
};
_listener.SetMeasurementEventCallback<long>(
(inst, value, tags, _) => Add(inst.Name, value, tags));
_listener.SetMeasurementEventCallback<double>(
(inst, value, tags, _) => Add(inst.Name, value, tags));
_listener.Start();
}

private void Add(string instrument, double value, ReadOnlySpan<KeyValuePair<string, object?>> tags)
{
var dict = new Dictionary<string, object?>();
foreach (var tag in tags)
dict[tag.Key] = tag.Value;
_records.Add((instrument, value, dict));
}

public int Count(string instrument) => _records.Count(r => r.Instrument == instrument);

public (double Value, Dictionary<string, object?> Tags) Single(string instrument)
{
var record = _records.Single(r => r.Instrument == instrument);
return (record.Value, record.Tags);
}

public void Dispose() => _listener.Dispose();
}

private sealed class StubHandler : HttpMessageHandler
{
private readonly HttpStatusCode _statusCode;
private readonly string _responseBody;

public StubHandler(HttpStatusCode statusCode, string responseBody)
{
_statusCode = statusCode;
_responseBody = responseBody;
}

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(new HttpResponseMessage(_statusCode)
{
Content = new StringContent(_responseBody)
});
}
}
}
}
87 changes: 87 additions & 0 deletions GoogleMapsApi/Diagnostics/GoogleMapsMetrics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Metrics;

namespace GoogleMapsApi.Diagnostics
{
/// <summary>
/// Metrics entry point for the library. Every Google Maps API call records to a small set of
/// instruments published from the <see cref="System.Diagnostics.Metrics.Meter"/> named
/// <see cref="MeterName"/>. Subscribe to it from your metrics pipeline — for OpenTelemetry,
/// call <c>AddMeter(GoogleMapsMetrics.MeterName)</c> on your <c>MeterProviderBuilder</c>.
/// Instrumentation is inert (no measurements taken) until a listener subscribes, so it is always on.
/// </summary>
/// <remarks>
/// Three instruments are published, all tagged with <c>gmaps.api</c> (the service name, e.g.
/// <c>Geocoding</c>):
/// <list type="bullet">
/// <item><description><c>gmaps.client.requests</c> — counter of every call attempted (success or failure).</description></item>
/// <item><description><c>gmaps.client.request.duration</c> — histogram of call latency in seconds.</description></item>
/// <item><description><c>gmaps.client.request.errors</c> — counter of calls that threw (transport/HTTP failures).</description></item>
/// </list>
/// Google "business" errors (for example <c>ZERO_RESULTS</c> or <c>OVER_QUERY_LIMIT</c>) arrive as a
/// <c>200 OK</c> and do not throw, so they are not counted as errors; they are visible through the
/// <c>gmaps.response_status</c> tag on the duration histogram.
/// </remarks>
public static class GoogleMapsMetrics
{
/// <summary>
/// The <see cref="System.Diagnostics.Metrics.Meter"/> name instruments are published from:
/// <c>"GoogleMapsApi"</c> (the same name as the tracing source). Pass this to <c>AddMeter(...)</c>
/// to collect the library's metrics.
/// </summary>
public const string MeterName = "GoogleMapsApi";

internal static readonly Meter Meter =
new Meter(MeterName, typeof(GoogleMapsMetrics).Assembly.GetName().Version?.ToString());

internal static readonly Counter<long> Requests = Meter.CreateCounter<long>(
"gmaps.client.requests", unit: "{request}",
description: "Number of Google Maps API calls attempted.");

internal static readonly Histogram<double> RequestDuration = Meter.CreateHistogram<double>(
"gmaps.client.request.duration", unit: "s",
description: "Duration of Google Maps API calls.");

internal static readonly Counter<long> RequestErrors = Meter.CreateCounter<long>(
"gmaps.client.request.errors", unit: "{error}",
description: "Number of Google Maps API calls that failed with an exception.");

internal static void Record(
string apiName,
string method,
int? statusCode,
string? responseStatus,
string? errorType,
long startTimestamp)
{
if (!Requests.Enabled && !RequestDuration.Enabled && !RequestErrors.Enabled)
return;

// Stopwatch.GetElapsedTime is unavailable on netstandard2.0, so compute seconds directly.
var elapsedSeconds = (Stopwatch.GetTimestamp() - startTimestamp) / (double)Stopwatch.Frequency;

var requestTags = new TagList
{
{ "gmaps.api", apiName },
{ "http.request.method", method },
};

Requests.Add(1, requestTags);

var durationTags = requestTags;
if (statusCode is not null)
durationTags.Add("http.response.status_code", statusCode.Value);
if (responseStatus is not null)
durationTags.Add("gmaps.response_status", responseStatus);
RequestDuration.Record(elapsedSeconds, durationTags);

if (errorType is not null)
{
RequestErrors.Add(1,
new KeyValuePair<string, object?>("gmaps.api", apiName),
new KeyValuePair<string, object?>("error.type", errorType));
}
}
}
}
31 changes: 22 additions & 9 deletions GoogleMapsApi/Engine/MapsAPIGenericEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,32 @@ internal static async Task<TResponse> QueryGoogleAPIAsync(
var body = request.GetRequestBody();

var apiName = GetApiName();
var method = body is null ? "GET" : "POST";
using var activity = GoogleMapsActivity.Source.StartActivity($"GoogleMapsApi {apiName}", ActivityKind.Client);
if (activity is not null)
{
activity.SetTag("gmaps.api", apiName);
activity.SetTag("http.request.method", body is null ? "GET" : "POST");
activity.SetTag("http.request.method", method);
activity.SetTag("server.address", uri.Host);
activity.SetTag("url.full", RedactUrl(uri));
}

var startTimestamp = Stopwatch.GetTimestamp();
int? statusCode = null;
string? responseStatus = null;
string? errorType = null;
Action<int> onResponseStatus = code =>
{
statusCode = code;
activity?.SetTag("http.response.status_code", code);
};

try
{
// Binary endpoints (e.g. Solar GeoTIFF) carry raw bytes, not JSON.
if (typeof(IBinaryResponse).IsAssignableFrom(typeof(TResponse)))
{
var (bytes, contentType) = await SendAsync(httpClient, uri, body, timeout, token, activity, async response =>
var (bytes, contentType) = await SendAsync(httpClient, uri, body, timeout, token, onResponseStatus, async response =>
(await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false),
response.Content.Headers.ContentType?.MediaType)).ConfigureAwait(false);

Expand All @@ -76,31 +87,33 @@ internal static async Task<TResponse> QueryGoogleAPIAsync(
return binaryResult;
}

var responseContent = await SendAsync(httpClient, uri, body, timeout, token, activity,
var responseContent = await SendAsync(httpClient, uri, body, timeout, token, onResponseStatus,
response => response.Content.ReadAsStringAsync()).ConfigureAwait(false);

onRawResponseReceived?.Invoke(Encoding.UTF8.GetBytes(responseContent));

var response = JsonSerializer.Deserialize<TResponse>(responseContent, jsonOptions)!;

if (activity is not null)
if (activity is not null || GoogleMapsMetrics.RequestDuration.Enabled)
{
var responseStatus = GetResponseStatus(response);
if (responseStatus is not null)
responseStatus = GetResponseStatus(response);
if (activity is not null && responseStatus is not null)
activity.SetTag("gmaps.response_status", responseStatus);
}

return response;
}
catch (Exception ex)
{
errorType = ex.GetType().FullName;
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.SetTag("error.type", ex.GetType().FullName);
activity?.SetTag("error.type", errorType);
throw;
}
finally
{
body?.Dispose();
GoogleMapsMetrics.Record(apiName, method, statusCode, responseStatus, errorType, startTimestamp);
}
}

Expand All @@ -121,7 +134,7 @@ private static string RedactUrl(Uri uri)
return property?.GetValue(response)?.ToString();
}

private static async Task<T> SendAsync<T>(HttpClient httpClient, Uri uri, HttpContent? body, TimeSpan timeout, CancellationToken cancellationToken, Activity? activity, Func<HttpResponseMessage, Task<T>> readContent)
private static async Task<T> SendAsync<T>(HttpClient httpClient, Uri uri, HttpContent? body, TimeSpan timeout, CancellationToken cancellationToken, Action<int>? onResponseStatus, Func<HttpResponseMessage, Task<T>> readContent)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
if (timeout != TimeSpan.FromMilliseconds(-1))
Expand All @@ -132,7 +145,7 @@ private static async Task<T> SendAsync<T>(HttpClient httpClient, Uri uri, HttpCo
using var response = body == null
? await httpClient.GetAsync(uri, cts.Token).ConfigureAwait(false)
: await httpClient.PostAsync(uri, body, cts.Token).ConfigureAwait(false);
activity?.SetTag("http.response.status_code", (int)response.StatusCode);
onResponseStatus?.Invoke((int)response.StatusCode);
await HandleHttpResponse(response, timeout).ConfigureAwait(false);
return await readContent(response).ConfigureAwait(false);
}
Expand Down
Loading
Loading