From 03a83f5c8fcbe4ad55d926e0c7917c5a1c62a9f5 Mon Sep 17 00:00:00 2001
From: Raghd Hamzeh <raghd.hamzeh@auth0.com>
Date: Tue, 23 Jul 2024 02:52:13 -0400
Subject: [PATCH 01/12] feat(dotnet): add support for exporting otel metrics

---
 config/clients/dotnet/config.overrides.json   |  24 ++
 .../dotnet/template/Client_ApiClient.mustache |  95 ++++---
 .../template/Client_BaseClient.mustache       | 123 ++++-----
 .../template/Client_OAuth2Client.mustache     |  81 +++---
 .../dotnet/template/OpenTelemetry.md.mustache |  42 +++
 .../OpenTelemetryDocs_custom.mustache         |  55 ++++
 .../clients/dotnet/template/README.mustache   | 139 ++++++++++
 .../template/Telemetry/Attributes.cs.mustache | 244 +++++++++++++++++
 .../template/Telemetry/Counters.cs.mustache   |  22 ++
 .../template/Telemetry/Histograms.cs.mustache |  43 +++
 .../template/Telemetry/Metrics.cs.mustache    |  31 +++
 .../example/OpenTelemetryExample/.env.example |  11 +
 .../OpenTelemetryExample.cs                   | 248 ++++++++++++++++++
 .../OpenTelemetryExample.csproj               |  30 +++
 .../dotnet/template/netcore_project.mustache  |  10 +-
 15 files changed, 1042 insertions(+), 156 deletions(-)
 create mode 100644 config/clients/dotnet/template/OpenTelemetry.md.mustache
 create mode 100644 config/clients/dotnet/template/OpenTelemetryDocs_custom.mustache
 create mode 100644 config/clients/dotnet/template/README.mustache
 create mode 100644 config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
 create mode 100644 config/clients/dotnet/template/Telemetry/Counters.cs.mustache
 create mode 100644 config/clients/dotnet/template/Telemetry/Histograms.cs.mustache
 create mode 100644 config/clients/dotnet/template/Telemetry/Metrics.cs.mustache
 create mode 100644 config/clients/dotnet/template/example/OpenTelemetryExample/.env.example
 create mode 100644 config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.cs
 create mode 100644 config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.csproj

diff --git a/config/clients/dotnet/config.overrides.json b/config/clients/dotnet/config.overrides.json
index 3e01faef..2aad33cc 100644
--- a/config/clients/dotnet/config.overrides.json
+++ b/config/clients/dotnet/config.overrides.json
@@ -33,6 +33,7 @@
   "enablePostProcessFile": true,
   "hashCodeBasePrimeNumber": 9661,
   "hashCodeMultiplierPrimeNumber": 9923,
+  "supportsOpenTelemetry": true,
   "files": {
     "Client_OAuth2Client.mustache": {
       "destinationFilename": "src/OpenFga.Sdk/ApiClient/OAuth2Client.cs",
@@ -226,6 +227,22 @@
       "destinationFilename": "src/OpenFga.Sdk/Client/Model/StoreIdOptions.cs",
       "templateType": "SupportingFiles"
     },
+    "Telemetry/Attributes.cs.mustache": {
+      "destinationFilename": "src/OpenFga.Sdk/Telemetry/Attributes.cs",
+      "templateType": "SupportingFiles"
+    },
+    "Telemetry/Counters.cs.mustache": {
+      "destinationFilename": "src/OpenFga.Sdk/Telemetry/Counters.cs",
+      "templateType": "SupportingFiles"
+    },
+    "Telemetry/Histograms.cs.mustache": {
+      "destinationFilename": "src/OpenFga.Sdk/Telemetry/Histograms.cs",
+      "templateType": "SupportingFiles"
+    },
+    "Telemetry/Metrics.cs.mustache": {
+      "destinationFilename": "src/OpenFga.Sdk/Telemetry/Metrics.cs",
+      "templateType": "SupportingFiles"
+    },
     "Configuration_Configuration.mustache": {
       "destinationFilename": "src/OpenFga.Sdk/Configuration/Configuration.cs",
       "templateType": "SupportingFiles"
@@ -294,10 +311,17 @@
       "destinationFilename": ".fossa.yml",
       "templateType": "SupportingFiles"
     },
+    "OpenTelemetry.md.mustache": {
+      "destinationFilename": "OpenTelemetry.md",
+      "templateType": "SupportingFiles"
+    },
     "example/Makefile": {},
     "example/README.md": {},
     "example/Example1/Example1.cs": {},
     "example/Example1/Example1.csproj": {},
+    "example/OpenTelemetryExample/.env.example": {},
+    "example/OpenTelemetryExample/OpenTelemetryExample.cs": {},
+    "example/OpenTelemetryExample/OpenTelemetryExample.csproj": {},
     "assets/FGAIcon.png": {},
     ".editorconfig": {}
   }
diff --git a/config/clients/dotnet/template/Client_ApiClient.mustache b/config/clients/dotnet/template/Client_ApiClient.mustache
index 97e5bb84..85b6fbdf 100644
--- a/config/clients/dotnet/template/Client_ApiClient.mustache
+++ b/config/clients/dotnet/template/Client_ApiClient.mustache
@@ -3,19 +3,22 @@
 using {{packageName}}.Client.Model;
 using {{packageName}}.Configuration;
 using {{packageName}}.Exceptions;
+using {{packageName}}.Telemetry;
+using System.Diagnostics;
 
 namespace {{packageName}}.ApiClient;
 
 /// <summary>
-/// API Client - used by all the API related methods to call the API. Handles token exchange and retries.
+///     API Client - used by all the API related methods to call the API. Handles token exchange and retries.
 /// </summary>
 public class ApiClient : IDisposable {
     private readonly BaseClient _baseClient;
-    private readonly OAuth2Client? _oauth2Client;
     private readonly Configuration.Configuration _configuration;
+    private readonly OAuth2Client? _oauth2Client;
+    private readonly Metrics metrics = new();
 
     /// <summary>
-    /// Initializes a new instance of the <see cref="ApiClient"/> class.
+    ///     Initializes a new instance of the <see cref="ApiClient" /> class.
     /// </summary>
     /// <param name="configuration">Client Configuration</param>
     /// <param name="userHttpClient">User Http Client - Allows Http Client reuse</param>
@@ -28,14 +31,15 @@ public class ApiClient : IDisposable {
             return;
         }
 
-        switch (_configuration.Credentials.Method)
-        {
+        switch (_configuration.Credentials.Method) {
             case CredentialsMethod.ApiToken:
-                _configuration.DefaultHeaders["Authorization"] = $"Bearer {_configuration.Credentials.Config!.ApiToken}";
+                _configuration.DefaultHeaders["Authorization"] =
+                    $"Bearer {_configuration.Credentials.Config!.ApiToken}";
                 _baseClient = new BaseClient(_configuration, userHttpClient);
                 break;
             case CredentialsMethod.ClientCredentials:
-                _oauth2Client = new OAuth2Client(_configuration.Credentials, _baseClient,  new RetryParams { MaxRetry = _configuration.MaxRetry, MinWaitInMs = _configuration.MinWaitInMs});
+                _oauth2Client = new OAuth2Client(_configuration.Credentials, _baseClient,
+                    new RetryParams { MaxRetry = _configuration.MaxRetry, MinWaitInMs = _configuration.MinWaitInMs });
                 break;
             case CredentialsMethod.None:
             default:
@@ -44,8 +48,9 @@ public class ApiClient : IDisposable {
     }
 
     /// <summary>
-    /// Handles getting the access token, calling the API and potentially retrying
-    /// Based on: https://github.com/auth0/auth0.net/blob/595ae80ccad8aa7764b80d26d2ef12f8b35bbeff/src/Auth0.ManagementApi/HttpClientManagementConnection.cs#L67
+    ///     Handles getting the access token, calling the API and potentially retrying
+    ///     Based on:
+    ///     https://github.com/auth0/auth0.net/blob/595ae80ccad8aa7764b80d26d2ef12f8b35bbeff/src/Auth0.ManagementApi/HttpClientManagementConnection.cs#L67
     /// </summary>
     /// <param name="requestBuilder"></param>
     /// <param name="apiName"></param>
@@ -57,6 +62,7 @@ public class ApiClient : IDisposable {
         CancellationToken cancellationToken = default) {
         IDictionary<string, string> additionalHeaders = new Dictionary<string, string>();
 
+        var sw = Stopwatch.StartNew();
         if (_oauth2Client != null) {
             try {
                 var token = await _oauth2Client.GetAccessTokenAsync();
@@ -64,16 +70,25 @@ public class ApiClient : IDisposable {
                 if (!string.IsNullOrEmpty(token)) {
                     additionalHeaders["Authorization"] = $"Bearer {token}";
                 }
-            } catch (ApiException e) {
+            }
+            catch (ApiException e) {
                 throw new FgaApiAuthenticationError("Invalid Client Credentials", apiName, e);
             }
         }
 
-        return await Retry(async () => await _baseClient.SendRequestAsync<T>(requestBuilder, additionalHeaders, apiName, cancellationToken));
+        var response = await Retry(async () =>
+            await _baseClient.SendRequestAsync<T>(requestBuilder, additionalHeaders, apiName, cancellationToken));
+
+        sw.Stop();
+        metrics.buildForResponse(apiName, response.rawResponse, requestBuilder, _configuration.Credentials, sw,
+            response.retryCount);
+
+        return response.responseContent;
     }
 
     /// <summary>
-    /// Handles getting the access token, calling the API and potentially retrying (use for requests that return no content)
+    ///     Handles getting the access token, calling the API and potentially retrying (use for requests that return no
+    ///     content)
     /// </summary>
     /// <param name="requestBuilder"></param>
     /// <param name="apiName"></param>
@@ -83,6 +98,7 @@ public class ApiClient : IDisposable {
         CancellationToken cancellationToken = default) {
         IDictionary<string, string> additionalHeaders = new Dictionary<string, string>();
 
+        var sw = Stopwatch.StartNew();
         if (_oauth2Client != null) {
             try {
                 var token = await _oauth2Client.GetAccessTokenAsync();
@@ -90,56 +106,38 @@ public class ApiClient : IDisposable {
                 if (!string.IsNullOrEmpty(token)) {
                     additionalHeaders["Authorization"] = $"Bearer {token}";
                 }
-            } catch (ApiException e) {
+            }
+            catch (ApiException e) {
                 throw new FgaApiAuthenticationError("Invalid Client Credentials", apiName, e);
             }
         }
 
-        await Retry(async () => await _baseClient.SendRequestAsync(requestBuilder, additionalHeaders, apiName, cancellationToken));
+        var response = await Retry(async () =>
+            await _baseClient.SendRequestAsync<object>(requestBuilder, additionalHeaders, apiName, cancellationToken));
+
+        sw.Stop();
+        metrics.buildForResponse(apiName, response.rawResponse, requestBuilder, _configuration.Credentials, sw,
+            response.retryCount);
     }
 
-    private async Task<TResult> Retry<TResult>(Func<Task<TResult>> retryable) {
+    private async Task<ResponseWrapper<TResult>> Retry<TResult>(Func<Task<ResponseWrapper<TResult>>> retryable) {
         var numRetries = 0;
         while (true) {
             try {
                 numRetries++;
 
-                return await retryable();
-            } catch (FgaApiRateLimitExceededError err) {
-                if (numRetries > _configuration.MaxRetry) {
-                    throw;
-                }
-                var waitInMs = (int) ((err.ResetInMs == null || err.ResetInMs < _configuration.MinWaitInMs)
-                    ? _configuration.MinWaitInMs
-                    : err.ResetInMs);
+                var response = await retryable();
 
-                await Task.Delay(waitInMs);
-            }
-            catch (FgaApiError err) {
-                if (!err.ShouldRetry || numRetries > _configuration.MaxRetry) {
-                    throw;
-                }
-                var waitInMs = (int)(_configuration.MinWaitInMs);
+                response.retryCount = numRetries;
 
-                await Task.Delay(waitInMs);
+                return response;
             }
-        }
-    }
-
-    private async Task Retry(Func<Task> retryable) {
-        var numRetries = 0;
-        while (true) {
-            try {
-                numRetries++;
-
-                await retryable();
-
-                return;
-            } catch (FgaApiRateLimitExceededError err) {
+            catch (FgaApiRateLimitExceededError err) {
                 if (numRetries > _configuration.MaxRetry) {
                     throw;
                 }
-                var waitInMs = (int) ((err.ResetInMs == null || err.ResetInMs < _configuration.MinWaitInMs)
+
+                var waitInMs = (int)(err.ResetInMs == null || err.ResetInMs < _configuration.MinWaitInMs
                     ? _configuration.MinWaitInMs
                     : err.ResetInMs);
 
@@ -149,14 +147,13 @@ public class ApiClient : IDisposable {
                 if (!err.ShouldRetry || numRetries > _configuration.MaxRetry) {
                     throw;
                 }
-                var waitInMs = (int)(_configuration.MinWaitInMs);
+
+                var waitInMs = _configuration.MinWaitInMs;
 
                 await Task.Delay(waitInMs);
             }
         }
     }
 
-    public void Dispose() {
-        _baseClient.Dispose();
-    }
+    public void Dispose() => _baseClient.Dispose();
 }
diff --git a/config/clients/dotnet/template/Client_BaseClient.mustache b/config/clients/dotnet/template/Client_BaseClient.mustache
index 27d718bc..65974515 100644
--- a/config/clients/dotnet/template/Client_BaseClient.mustache
+++ b/config/clients/dotnet/template/Client_BaseClient.mustache
@@ -1,46 +1,53 @@
 {{>partial_header}}
 
+using {{packageName}}.Exceptions;
+using System.Net;
 using System.Net.Http.Headers;
 using System.Net.Http.Json;
 
-using {{packageName}}.Exceptions;
-
 namespace {{packageName}}.ApiClient;
 
+public class ResponseWrapper<T> {
+    public HttpResponseMessage rawResponse;
+    public T? responseContent;
+
+    public int retryCount;
+}
+
 /// <summary>
-/// Base Client, used by the API and OAuth Clients
+///     Base Client, used by the API and OAuth Clients
 /// </summary>
 public class BaseClient : IDisposable {
     private readonly HttpClient _httpClient;
     private bool _shouldDisposeWhenDone;
 
     /// <summary>
-    /// Initializes a new instance of the <see cref="BaseClient"/> class.
+    ///     Initializes a new instance of the <see cref="BaseClient" /> class.
     /// </summary>
     /// <param name="configuration"></param>
-    /// <param name="httpClient">Optional <see cref="HttpClient"/> to use when sending requests.</param>
+    /// <param name="httpClient">Optional <see cref="HttpClient" /> to use when sending requests.</param>
     /// <remarks>
-    /// If you supply a <see cref="HttpClient"/> it is your responsibility to manage its lifecycle and
-    /// dispose it when appropriate.
-    /// If you do not supply a <see cref="HttpClient"/> one will be created automatically and disposed
-    /// of when this object is disposed.
+    ///     If you supply a <see cref="HttpClient" /> it is your responsibility to manage its lifecycle and
+    ///     dispose it when appropriate.
+    ///     If you do not supply a <see cref="HttpClient" /> one will be created automatically and disposed
+    ///     of when this object is disposed.
     /// </remarks>
     public BaseClient(Configuration.Configuration configuration, HttpClient? httpClient = null) {
         _shouldDisposeWhenDone = httpClient == null;
-        this._httpClient = httpClient ?? new HttpClient();
-        this._httpClient.DefaultRequestHeaders.Accept.Clear();
-        this._httpClient.DefaultRequestHeaders.Accept.Add(
+        _httpClient = httpClient ?? new HttpClient();
+        _httpClient.DefaultRequestHeaders.Accept.Clear();
+        _httpClient.DefaultRequestHeaders.Accept.Add(
             new MediaTypeWithQualityHeaderValue("application/json"));
 
         foreach (var header in configuration.DefaultHeaders) {
             if (header.Value != null) {
-                this._httpClient.DefaultRequestHeaders.Add(header.Key, header.Value);
+                _httpClient.DefaultRequestHeaders.Add(header.Key, header.Value);
             }
         }
     }
 
     /// <summary>
-    /// Handles calling the API
+    ///     Handles calling the API
     /// </summary>
     /// <param name="requestBuilder"></param>
     /// <param name="additionalHeaders"></param>
@@ -48,7 +55,7 @@ public class BaseClient : IDisposable {
     /// <param name="cancellationToken"></param>
     /// <typeparam name="T"></typeparam>
     /// <returns></returns>
-    public async Task<T> SendRequestAsync<T>(RequestBuilder requestBuilder,
+    public async Task<ResponseWrapper<T>> SendRequestAsync<T>(RequestBuilder requestBuilder,
         IDictionary<string, string>? additionalHeaders = null,
         string? apiName = null, CancellationToken cancellationToken = default) {
         var request = requestBuilder.BuildRequest();
@@ -56,24 +63,24 @@ public class BaseClient : IDisposable {
         return await SendRequestAsync<T>(request, additionalHeaders, apiName, cancellationToken);
     }
 
-    /// <summary>
-    /// Handles calling the API for requests that are expected to return no content
-    /// </summary>
-    /// <param name="requestBuilder"></param>
-    /// <param name="additionalHeaders"></param>
-    /// <param name="apiName"></param>
-    /// <param name="cancellationToken"></param>
-    /// <returns></returns>
-    public async Task SendRequestAsync(RequestBuilder requestBuilder,
-        IDictionary<string, string>? additionalHeaders = null,
-        string? apiName = null, CancellationToken cancellationToken = default) {
-        var request = requestBuilder.BuildRequest();
-
-        await this.SendRequestAsync(request, additionalHeaders, apiName, cancellationToken);
-    }
+    // /// <summary>
+    // /// Handles calling the API for requests that are expected to return no content
+    // /// </summary>
+    // /// <param name="requestBuilder"></param>
+    // /// <param name="additionalHeaders"></param>
+    // /// <param name="apiName"></param>
+    // /// <param name="cancellationToken"></param>
+    // /// <returns></returns>
+    // public async Task SendRequestAsync(RequestBuilder requestBuilder,
+    //     IDictionary<string, string>? additionalHeaders = null,
+    //     string? apiName = null, CancellationToken cancellationToken = default) {
+    //     var request = requestBuilder.BuildRequest();
+    //
+    //     await this.SendRequestAsync(request, additionalHeaders, apiName, cancellationToken);
+    // }
 
     /// <summary>
-    /// Handles calling the API
+    ///     Handles calling the API
     /// </summary>
     /// <param name="request"></param>
     /// <param name="additionalHeaders"></param>
@@ -83,7 +90,7 @@ public class BaseClient : IDisposable {
     /// <returns></returns>
     /// <exception cref="ApiException"></exception>
     /// <exception cref="InvalidOperationException"></exception>
-    public async Task<T> SendRequestAsync<T>(HttpRequestMessage request,
+    public async Task<ResponseWrapper<T>> SendRequestAsync<T>(HttpRequestMessage request,
         IDictionary<string, string>? additionalHeaders = null,
         string? apiName = null, CancellationToken cancellationToken = default) {
         if (additionalHeaders != null) {
@@ -96,48 +103,28 @@ public class BaseClient : IDisposable {
 
         var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
         {
-            if (response == null || (response.StatusCode != null && !response.IsSuccessStatusCode)) {
+            try {
+                response.EnsureSuccessStatusCode();
+            }
+            catch {
                 throw await ApiException.CreateSpecificExceptionAsync(response, request, apiName).ConfigureAwait(false);
             }
 
-            return await response.Content.ReadFromJsonAsync<T>(cancellationToken: cancellationToken).ConfigureAwait(false) ??
-                throw new FgaError();
-        }
-    }
-
-    /// <summary>
-    /// Handles calling the API for requests that are expected to return no content
-    /// </summary>
-    /// <param name="request"></param>
-    /// <param name="additionalHeaders"></param>
-    /// <param name="apiName"></param>
-    /// <param name="cancellationToken"></param>
-    /// <returns></returns>
-    /// <exception cref="ApiException"></exception>
-    /// <exception cref="InvalidOperationException"></exception>
-    public async Task SendRequestAsync(HttpRequestMessage request,
-        IDictionary<string, string>? additionalHeaders = null,
-        string? apiName = null, CancellationToken cancellationToken = default) {
-        if (additionalHeaders != null) {
-            foreach (var header in additionalHeaders) {
-                if (header.Value != null) {
-                    request.Headers.Add(header.Key, header.Value);
-                }
+            T responseContent = default;
+            if (response.Content != null && response.StatusCode != HttpStatusCode.NoContent) {
+                responseContent = await response.Content.ReadFromJsonAsync<T>(cancellationToken: cancellationToken)
+                                      .ConfigureAwait(false) ??
+                                  throw new FgaError();
             }
-        }
 
-        var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
-        {
-            if (response == null || (response.StatusCode != null && !response.IsSuccessStatusCode)) {
-                throw await ApiException.CreateSpecificExceptionAsync(response, request, apiName).ConfigureAwait(false);
-            }
+            return new ResponseWrapper<T> { rawResponse = response, responseContent = responseContent };
         }
     }
 
     /// <summary>
-    /// Disposes of any owned disposable resources such as the underlying <see cref="HttpClient"/> if owned.
+    ///     Disposes of any owned disposable resources such as the underlying <see cref="HttpClient" /> if owned.
     /// </summary>
-    /// <param name="disposing">Whether we are actually disposing (<see langword="true"/>) or not (<see langword="false"/>).</param>
+    /// <param name="disposing">Whether we are actually disposing (<see langword="true" />) or not (<see langword="false" />).</param>
     protected virtual void Dispose(bool disposing) {
         if (disposing && _shouldDisposeWhenDone) {
             _httpClient.Dispose();
@@ -146,9 +133,7 @@ public class BaseClient : IDisposable {
     }
 
     /// <summary>
-    /// Disposes of any owned disposable resources such as the underlying <see cref="HttpClient"/> if owned.
+    ///     Disposes of any owned disposable resources such as the underlying <see cref="HttpClient" /> if owned.
     /// </summary>
-    public void Dispose() {
-        Dispose(true);
-    }
-}
+    public void Dispose() => Dispose(true);
+}
\ No newline at end of file
diff --git a/config/clients/dotnet/template/Client_OAuth2Client.mustache b/config/clients/dotnet/template/Client_OAuth2Client.mustache
index 17b0edd2..2ed8d0c3 100644
--- a/config/clients/dotnet/template/Client_OAuth2Client.mustache
+++ b/config/clients/dotnet/template/Client_OAuth2Client.mustache
@@ -1,44 +1,46 @@
 {{>partial_header}}
 
-using System.Text.Json.Serialization;
 
 using {{packageName}}.Client.Model;
 using {{packageName}}.Configuration;
 using {{packageName}}.Exceptions;
+using {{packageName}}.Telemetry;
+using System.Diagnostics;
+using System.Text.Json.Serialization;
 
 namespace {{packageName}}.ApiClient;
 
 /// <summary>
-/// OAuth2 Client to exchange the credentials for an access token using the client credentials flow
+///     OAuth2 Client to exchange the credentials for an access token using the client credentials flow
 /// </summary>
 public class OAuth2Client {
-    private const int TOKEN_EXPIRY_BUFFER_THRESHOLD_IN_SEC = {{tokenExpiryThresholdBufferInSec}};
+    private const int TOKEN_EXPIRY_BUFFER_THRESHOLD_IN_SEC = 300;
 
     private const int
-        TOKEN_EXPIRY_JITTER_IN_SEC = {{tokenExpiryJitterInSec}}; // We add some jitter so that token refreshes are less likely to collide
+        TOKEN_EXPIRY_JITTER_IN_SEC = 300; // We add some jitter so that token refreshes are less likely to collide
 
     private static readonly Random _random = new();
+    private readonly Metrics metrics = new();
 
     /// <summary>
-    /// Credentials Flow Response
-    ///
-    /// https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-credentials-flow
+    ///     Credentials Flow Response
+    ///     https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-credentials-flow
     /// </summary>
     public class AccessTokenResponse {
         /// <summary>
-        /// Time period after which the token will expire (in ms)
+        ///     Time period after which the token will expire (in ms)
         /// </summary>
         [JsonPropertyName("expires_in")]
         public long ExpiresIn { get; set; }
 
         /// <summary>
-        /// Token Type
+        ///     Token Type
         /// </summary>
         [JsonPropertyName("token_type")]
         public string? TokenType { get; set; }
 
         /// <summary>
-        /// Access token to use
+        ///     Access token to use
         /// </summary>
         [JsonPropertyName("access_token")]
         public string? AccessToken { get; set; }
@@ -49,29 +51,29 @@ public class OAuth2Client {
 
         public string? AccessToken { get; set; }
 
-        public bool IsValid() {
-            return !string.IsNullOrWhiteSpace(AccessToken) && (ExpiresAt == null ||
-                                                               ExpiresAt - DateTime.Now >
-                                                               TimeSpan.FromSeconds(
-                                                                   TOKEN_EXPIRY_BUFFER_THRESHOLD_IN_SEC +
-                                                                   (_random.Next(0, TOKEN_EXPIRY_JITTER_IN_SEC))));
-        }
+        public bool IsValid() =>
+            !string.IsNullOrWhiteSpace(AccessToken) && (ExpiresAt == null ||
+                                                        ExpiresAt - DateTime.Now >
+                                                        TimeSpan.FromSeconds(
+                                                            TOKEN_EXPIRY_BUFFER_THRESHOLD_IN_SEC +
+                                                            _random.Next(0, TOKEN_EXPIRY_JITTER_IN_SEC)));
     }
 
     #region Fields
 
     private readonly BaseClient _httpClient;
     private AuthToken _authToken = new();
-    private IDictionary<string, string> _authRequest { get; set; }
-    private string _apiTokenIssuer { get; set; }
-    private RetryParams _retryParams;
+    private IDictionary<string, string> _authRequest { get; }
+    private string _apiTokenIssuer { get; }
+    private readonly RetryParams _retryParams;
+    private readonly Credentials _credentialsConfig;
 
     #endregion
 
     #region Methods
 
     /// <summary>
-    /// Initializes a new instance of the <see cref="OAuth2Client" /> class
+    ///     Initializes a new instance of the <see cref="OAuth2Client" /> class
     /// </summary>
     /// <param name="credentialsConfig"></param>
     /// <param name="httpClient"></param>
@@ -85,41 +87,48 @@ public class OAuth2Client {
             throw new FgaRequiredParamError("OAuth2Client", "config.ClientSecret");
         }
 
-        this._httpClient = httpClient;
-        this._apiTokenIssuer = credentialsConfig.Config.ApiTokenIssuer;
-        this._authRequest = new Dictionary<string, string>() {
+        _credentialsConfig = credentialsConfig;
+        _httpClient = httpClient;
+        _apiTokenIssuer = credentialsConfig.Config.ApiTokenIssuer;
+        _authRequest = new Dictionary<string, string> {
             { "client_id", credentialsConfig.Config.ClientId },
             { "client_secret", credentialsConfig.Config.ClientSecret },
             { "audience", credentialsConfig.Config.ApiAudience },
             { "grant_type", "client_credentials" }
         };
 
-        this._retryParams = retryParams;
+        _retryParams = retryParams;
     }
 
     /// <summary>
-    /// Exchange client id and client secret for an access token, and handles token refresh
+    ///     Exchange client id and client secret for an access token, and handles token refresh
     /// </summary>
     /// <exception cref="NullReferenceException"></exception>
     /// <exception cref="Exception"></exception>
     private async Task ExchangeTokenAsync(CancellationToken cancellationToken = default) {
         var requestBuilder = new RequestBuilder {
             Method = HttpMethod.Post,
-            BasePath = $"https://{this._apiTokenIssuer}",
+            BasePath = $"https://{_apiTokenIssuer}",
             PathTemplate = "/oauth/token",
-            Body = Utils.CreateFormEncodedConent(this._authRequest),
+            Body = Utils.CreateFormEncodedConent(_authRequest)
         };
 
+        var sw = Stopwatch.StartNew();
         var accessTokenResponse = await Retry(async () => await _httpClient.SendRequestAsync<AccessTokenResponse>(
             requestBuilder,
             null,
             "ExchangeTokenAsync",
             cancellationToken));
 
-        _authToken = new AuthToken() {
-            AccessToken = accessTokenResponse.AccessToken,
-            ExpiresAt = DateTime.Now + TimeSpan.FromSeconds(accessTokenResponse.ExpiresIn)
-        };
+        sw.Stop();
+        metrics.buildForClientCredentialsResponse(accessTokenResponse.rawResponse, requestBuilder, _credentialsConfig,
+            sw, accessTokenResponse.retryCount);
+
+        _authToken = new AuthToken { AccessToken = accessTokenResponse.responseContent?.AccessToken };
+
+        if (accessTokenResponse.responseContent?.ExpiresIn != null) {
+            _authToken.ExpiresAt = DateTime.Now + TimeSpan.FromSeconds(accessTokenResponse.responseContent.ExpiresIn);
+        }
     }
 
     private async Task<TResult> Retry<TResult>(Func<Task<TResult>> retryable) {
@@ -134,7 +143,8 @@ public class OAuth2Client {
                 if (numRetries > _retryParams.MaxRetry) {
                     throw;
                 }
-                var waitInMs = (int)((err.ResetInMs == null || err.ResetInMs < _retryParams.MinWaitInMs)
+
+                var waitInMs = (int)(err.ResetInMs == null || err.ResetInMs < _retryParams.MinWaitInMs
                     ? _retryParams.MinWaitInMs
                     : err.ResetInMs);
 
@@ -144,6 +154,7 @@ public class OAuth2Client {
                 if (!err.ShouldRetry || numRetries > _retryParams.MaxRetry) {
                     throw;
                 }
+
                 var waitInMs = _retryParams.MinWaitInMs;
 
                 await Task.Delay(waitInMs);
@@ -152,7 +163,7 @@ public class OAuth2Client {
     }
 
     /// <summary>
-    /// Gets the access token, and handles exchanging, rudimentary in memory caching and refreshing it when expired
+    ///     Gets the access token, and handles exchanging, rudimentary in memory caching and refreshing it when expired
     /// </summary>
     /// <returns></returns>
     /// <exception cref="InvalidOperationException"></exception>
@@ -168,4 +179,4 @@ public class OAuth2Client {
     }
 
     #endregion
-}
+}
\ No newline at end of file
diff --git a/config/clients/dotnet/template/OpenTelemetry.md.mustache b/config/clients/dotnet/template/OpenTelemetry.md.mustache
new file mode 100644
index 00000000..e3e7af51
--- /dev/null
+++ b/config/clients/dotnet/template/OpenTelemetry.md.mustache
@@ -0,0 +1,42 @@
+# OpenTelemetry
+
+This SDK produces [metrics](https://opentelemetry.io/docs/concepts/signals/metrics/) using [OpenTelemetry](https://opentelemetry.io/) that allow you to view data such as request timings. These metrics also include attributes for the model and store ID, as well as the API called to allow you to build reporting.
+
+When an OpenTelemetry SDK instance is configured, the metrics will be exported and sent to the collector configured as part of your applications configuration. If you are not using OpenTelemetry, the metric functionality is a no-op and the events are never sent.
+
+In cases when metrics events are sent, they will not be viewable outside of infrastructure configured in your application, and are never available to the OpenFGA team or contributors.
+
+## Metrics
+
+### Supported Metrics
+
+| Metric Name                     | Type      | Description                                                                          |
+|---------------------------------|-----------|--------------------------------------------------------------------------------------|
+| `fga-client.request.duration`   | Histogram | The total request time for FGA requests                                              |
+| `fga-client.query.duration`     | Histogram | The amount of time the FGA server took to internally process nd evaluate the request |
+|` fga-client.credentials.request`| Counter   | The total number of times a new token was requested when using ClientCredentials     |
+
+### Supported attributes
+
+| Attribute Name                 | Type     | Description                                                                                                             |
+|--------------------------------|----------|-------------------------------------------------------------------------------------------------------------------------|
+| `fga-client.response.model_id` | `string` | The authorization model ID that the FGA server used                                                                     |
+| `fga-client.request.method`    | `string` | The FGA method/action that was performed (e.g. `check`, `listObjects`, ...) in camelCase                                |
+| `fga-client.request.store_id`  | `string` | The store ID that was sent as part of the request                                                                       |
+| `fga-client.request.model_id`  | `string` | The authorization model ID that was sent as part of the request, if any                                                 |
+| `fga-client.request.client_id` | `string` | The client ID associated with the request, if any                                                                       |
+| `fga-client.user`              | `string` | The user that is associated with the action of the request for check and list objects                                   |
+| `fga-client.request.retries`   | `int`    | The number of retries attempted (starting from 1 for the original request). Deprecated, use `http.request.resend_count` |
+| `http.request.resend_count`    | `int`    | The number of retries attempted (starting from 1 for the original request)                                              |
+| `http.status_code`             | `int`    | The status code of the response. Deprecated, use `http.response.status_code`                                            |
+| `http.response.status_code`    | `int`    | The status code of the response                                                                                         |
+| `http.method`                  | `string` | The HTTP method for the request. Deprecated, use `http.request.method`                                                  |
+| `http.request.method`          | `string` | The HTTP method for the request                                                                                         |
+| `http.host`                    | `string` | Host identifier of the origin the request was sent to                                                                   |
+| `url.scheme`                   | `string` | HTTP Scheme of the request (`http`/`https`)                                                                             |
+| `url.full`                     | `string` | Full URL of the request                                                                                                 |
+| `user_agent.original`          | `string` | User Agent used in the query                                                                                            |
+| `http.client.request.duration` | `int`    | The total request time for FGA requests                                                                                 |
+| `http.server.request.duration` | `int`    | The amount of time the FGA server took to internally process nd evaluate the request                                    |
+
+{{>OpenTelemetryDocs_custom}}
\ No newline at end of file
diff --git a/config/clients/dotnet/template/OpenTelemetryDocs_custom.mustache b/config/clients/dotnet/template/OpenTelemetryDocs_custom.mustache
new file mode 100644
index 00000000..68caf90e
--- /dev/null
+++ b/config/clients/dotnet/template/OpenTelemetryDocs_custom.mustache
@@ -0,0 +1,55 @@
+## Configuration
+
+See the OpenTelemetry docs on [Customizing the SDK](https://github.com/open-telemetry/opentelemetry-dotnet/blob/main/docs/metrics/customizing-the-sdk/README.md).
+
+```csharp
+using {{packageName}}.Client;
+using {{packageName}}.Client.Model;
+using {{packageName}}.Model;
+using {{packageName}}.Telemetry;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+using System.Diagnostics;
+
+namespace Example {
+    public class Example {
+        public static async Task Main() {
+            try {
+                // Setup OpenTelemetry Metrics
+                using var meterProvider = Sdk.CreateMeterProviderBuilder()
+                    .AddHttpClientInstrumentation() // To instrument the default http client
+                    .AddMeter(Metrics.name) // .AddMeter("OpenFga.Sdk") also works
+                    .ConfigureResource(resourceBuilder => resourceBuilder.AddService("openfga-dotnet-example"))
+                    .AddOtlpExporter() // Required to export to an OTLP compatible endpoint
+                    .AddConsoleExporter() // Only needed to export the metrics to the console (e.g. when debugging)
+                    .Build();
+
+                // Configure the OpenFGA SDK
+                var configuration = new ClientConfiguration() {
+                    ApiUrl = Environment.GetEnvironmentVariable("FGA_API_URL") ?? "http://localhost:8080", // required, e.g. https://api.fga.example
+                    StoreId = Environment.GetEnvironmentVariable("FGA_STORE_ID"), // not needed when calling `CreateStore` or `ListStores`
+                    AuthorizationModelId = Environment.GetEnvironmentVariable("FGA_MODEL_ID"), // Optional, can be overridden per request
+                    // Credentials = ... // If needed
+                };
+                var fgaClient = new OpenFgaClient(configuration);
+
+                // Call the SDK normally
+                var response = await fgaClient.ReadAuthorizationModels();
+            } catch (ApiException e) {
+                 Debug.Print("Error: "+ e);
+            }
+        }
+    }
+}
+```
+
+### More Resources
+* [OpenTelemetry.Instrumentation.Http](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/blob/main/src/OpenTelemetry.Instrumentation.Http/README.md) for instrumenting the HttpClient.
+* If you are using .NET 8+, checkout the built-in metrics.
+
+A number of these metrics are baked into .NET 8+ as well:
+
+## Example
+
+There is an [example project](https://github.com/openfga/dotnet-sdk/blob/main/example/OpenTelemetryExample) that provides some guidance on how to configure OpenTelemetry available in the examples directory.
diff --git a/config/clients/dotnet/template/README.mustache b/config/clients/dotnet/template/README.mustache
new file mode 100644
index 00000000..0399d8ad
--- /dev/null
+++ b/config/clients/dotnet/template/README.mustache
@@ -0,0 +1,139 @@
+# {{packageDescription}}
+
+{{>README_custom_badges}}[![Release](https://img.shields.io/github/v/release/{{gitUserId}}/{{gitRepoId}}?sort=semver&color=green)](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/releases)
+{{#licenseBadgeId}}[![License](https://img.shields.io/badge/License-{{licenseBadgeId}}-blue.svg)](./LICENSE){{/licenseBadgeId}}
+[![FOSSA Status](https://app.fossa.com/api/projects/git%2B{{gitHost}}%2F{{gitUserId}}%2F{{gitRepoId}}.svg?type=shield)](https://app.fossa.com/projects/git%2B{{gitHost}}%2F{{gitUserId}}%2F{{gitRepoId}}?ref=badge_shield)
+[![Join our community](https://img.shields.io/badge/slack-cncf_%23openfga-40abb8.svg?logo=slack)](https://openfga.dev/community)
+{{#twitterUserName}}[![Twitter](https://img.shields.io/twitter/follow/{{.}}?color=%23179CF0&logo=twitter&style=flat-square "@{{.}} on Twitter")](https://twitter.com/{{.}}){{/twitterUserName}}
+
+{{#packageDetailedDescription}}
+{{{.}}}
+{{/packageDetailedDescription}}
+
+## Table of Contents
+
+- [About {{appLongName}}](#about)
+- [Resources](#resources)
+- [Installation](#installation)
+- [Getting Started](#getting-started)
+  - [Initializing the API Client](#initializing-the-api-client)
+  - [Get your Store ID](#get-your-store-id)
+  - [Calling the API](#calling-the-api)
+    - [Stores](#stores)
+      - [List All Stores](#list-stores)
+      - [Create a Store](#create-store)
+      - [Get a Store](#get-store)
+      - [Delete a Store](#delete-store)
+    - [Authorization Models](#authorization-models)
+      - [Read Authorization Models](#read-authorization-models)
+      - [Write Authorization Model](#write-authorization-model)
+      - [Read a Single Authorization Model](#read-a-single-authorization-model)
+      - [Read the Latest Authorization Model](#read-the-latest-authorization-model)
+    - [Relationship Tuples](#relationship-tuples)
+      - [Read Relationship Tuple Changes (Watch)](#read-relationship-tuple-changes-watch)
+      - [Read Relationship Tuples](#read-relationship-tuples)
+      - [Write (Create and Delete) Relationship Tuples](#write-create-and-delete-relationship-tuples)
+    - [Relationship Queries](#relationship-queries)
+      - [Check](#check)
+      - [Batch Check](#batch-check)
+      - [Expand](#expand)
+      - [List Objects](#list-objects)
+      - [List Relations](#list-relations)
+      - [List Users](#list-users)
+    - [Assertions](#assertions)
+      - [Read Assertions](#read-assertions)
+      - [Write Assertions](#write-assertions)
+  - [Retries](#retries)
+  - [API Endpoints](#api-endpoints)
+  - [Models](#models)
+{{#supportsOpenTelemetry}}
+  - [OpenTelemetry](#opentelemetry)
+{{/supportsOpenTelemetry}}
+- [Contributing](#contributing)
+  - [Issues](#issues)
+  - [Pull Requests](#pull-requests)
+- [License](#license)
+
+## About
+
+{{>README_project_introduction}}
+
+## Resources
+
+{{#docsUrl}}
+- [{{appName}} Documentation]({{docsUrl}})
+{{/docsUrl}}
+{{#apiDocsUrl}}
+- [{{appName}} API Documentation]({{apiDocsUrl}})
+{{/apiDocsUrl}}
+{{#twitterUserName}}
+- [Twitter](https://twitter.com/{{.}})
+{{/twitterUserName}}
+{{#redditUrl}}
+- [{{appName}} Subreddit]({{redditUrl}})
+{{/redditUrl}}
+{{#supportInfo}}
+- [{{appName}} Community]({{supportInfo}})
+{{/supportInfo}}
+- [Zanzibar Academy](https://zanzibar.academy)
+- [Google's Zanzibar Paper (2019)](https://research.google/pubs/pub48190/)
+
+## Installation
+
+{{>README_installation}}
+
+## Getting Started
+
+### Initializing the API Client
+
+[Learn how to initialize your SDK]({{docsUrl}}/getting-started/setup-sdk-client)
+
+{{>README_initializing}}
+
+### Get your Store ID
+
+You need your store id to call the {{appName}} API (unless it is to call the [CreateStore](#create-store) or [ListStores](#list-stores) methods).
+
+If your server is configured with [authentication enabled]({{docsUrl}}/getting-started/setup-openfga#configuring-authentication), you also need to have your credentials ready.
+
+### Calling the API
+
+{{>README_calling_api}}
+
+### Retries
+
+{{>README_retries}}
+
+### API Endpoints
+
+{{>README_api_endpoints}}
+
+### Models
+
+{{>README_models}}
+
+{{#supportsOpenTelemetry}}
+### OpenTelemetry
+
+This SDK supports producing metrics that can be consumed as part of an [OpenTelemetry](https://opentelemetry.io/) setup. For more information, please see [the documentation](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/blob/main/OpenTelemetry.md)
+
+{{/supportsOpenTelemetry}}
+## Contributing
+
+### Issues
+
+If you have found a bug or if you have a feature request, please report them on the [sdk-generator repo](https://{{gitHost}}/{{gitUserId}}/sdk-generator/issues) issues section. Please do not report security vulnerabilities on the public GitHub issue tracker.
+
+### Pull Requests
+
+All changes made to this repo will be overwritten on the next generation, so we kindly ask that you send all pull requests related to the SDKs to the [sdk-generator repo](https://{{gitHost}}/{{gitUserId}}/sdk-generator) instead.
+
+## Author
+
+[{{author}}](https://{{gitHost}}/{{gitUserId}})
+
+## License
+
+This project is licensed under the {{licenseId}} license. See the [LICENSE](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/blob/main/LICENSE) file for more info.
+
+{{>README_license_disclaimer}}
diff --git a/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
new file mode 100644
index 00000000..528d5846
--- /dev/null
+++ b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
@@ -0,0 +1,244 @@
+{{>partial_header}}
+
+using {{packageName}}.ApiClient;
+using {{packageName}}.Configuration;
+using System.Diagnostics;
+using System.Net.Http.Headers;
+using System.Runtime.Serialization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace {{packageName}}.Telemetry;
+
+internal interface IPartialApiRequest {
+    [DataMember(Name = "authorization_model_id", IsRequired = false, EmitDefaultValue = false)]
+    [JsonPropertyName("authorization_model_id")]
+    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+    public string AuthorizationModelId { get; set; }
+
+    [DataMember(Name = "user", IsRequired = false, EmitDefaultValue = false)]
+    [JsonPropertyName("user")]
+    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+    public string User { get; set; }
+
+    [DataMember(Name = "tuple_key.user", IsRequired = false, EmitDefaultValue = false)]
+    [JsonPropertyName("tuple_key.user")]
+    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+    public string TupleKeyUser { get; set; }
+}
+
+public class Attributes {
+    /**
+     * Common attribute (tag) names
+     */
+
+    // Attributes (tags) associated with the request made //
+
+    // The FGA method/action that was performed (e.g. `check`, `listObjects`, ...) in camelCase
+    private const string AttributeRequestMethod = "fga-client.request.method";
+
+    // The store ID that was sent as part of the request
+    private const string AttributeRequestStoreId = "fga-client.request.store_id";
+
+    // The authorization model ID that was sent as part of the request, if any
+    private const string AttributeRequestModelId = "fga-client.request.model_id";
+
+    // The client ID associated with the request, if any
+    private const string AttributeRequestClientId = "fga-client.request.client_id";
+
+    // Attributes (tags) associated with the response //
+
+    // The authorization model ID that the FGA server used
+    private const string AttributeResponseModelId = "fga-client.response.model_id";
+
+    // Attributes (tags) associated with specific actions //
+
+    // The user that is associated with the action of the request for check and list objects
+    private const string AttributeFgaRequestUser = "fga-client.user";
+
+    // OTEL Semantic Attributes (tags) //
+
+    // The total request time for FGA requests
+    private const string AttributeHttpClientRequestDuration = "http.client.request.duration";
+
+    // The amount of time the FGA server took to internally process nd evaluate the request
+    private const string AttributeHttpServerRequestDuration = "http.server.request.duration";
+
+    // The HTTP method for the request
+    [Obsolete("Deprecated - use AttributeHttpMethod.")]
+    private const string AttributeHttpMethodOld = "http.method";
+
+    // The HTTP method for the request
+    private const string AttributeHttpMethod = "http.request.method";
+
+    // The status code of the response
+    [Obsolete("Deprecated - use AttributeHttpStatus.")]
+    private const string AttributeHttpStatusOld = "http.status_code";
+
+    // The status code of the response
+    private const string AttributeHttpStatus = "http.response.status_code";
+
+    // Host identifier of the origin the request was sent to
+    private const string AttributeHttpHost = "http.host";
+
+    // HTTP Scheme of the request (`http`/`https`)
+    private const string AttributeHttpScheme = "url.scheme";
+
+    // Full URL of the request
+    private const string AttributeHttpUrl = "url.full";
+
+    // User Agent used in the query
+    private const string AttributeHttpUserAgent = "user_agent.original";
+
+    // The number of retries attempted (starting from 1 for the original request)
+    [Obsolete("Deprecated - use AttributeRequestRetryCount.")]
+    private const string AttributeRequestRetryCountOld = "fga-client.request.retries";
+
+    // The number of retries attempted (starting from 1 for the original request)
+    private const string AttributeRequestRetryCount = "http.request.resend_count";
+
+    private static string? GetHeaderValueIfValid(HttpResponseHeaders headers, string headerName) {
+        if (headers.Contains(headerName) && headers.GetValues(headerName).Any()) {
+            return headers.GetValues(headerName).First();
+        }
+
+        return null;
+    }
+
+    /**
+     * Builds an object of attributes that can be used to report alongside an OpenTelemetry metric event.
+     *
+     * @param response - The Axios response object, used to add data like HTTP status, host, method, and headers.
+     * @param credentials - The credentials object, used to add data like the ClientID when using ClientCredentials.
+     * @param methodAttributes - Extra attributes that the method (i.e. check, listObjects) wishes to have included. Any custom attributes should use the common names.
+     * @returns {Attributes}
+     */
+    public static KeyValuePair<string, object>[] buildAttributesForResponse(string apiName,
+        HttpResponseMessage response, RequestBuilder requestBuilder, Credentials? credentials,
+        Stopwatch requestDuration, int retryCount) {
+        var attributes = new List<KeyValuePair<string, object>>();
+
+        // To match the JS SDK, we are converting the method name to camelCase
+        attributes.Add(new KeyValuePair<string, object>(AttributeRequestMethod,
+            char.ToLowerInvariant(apiName[0]) + apiName.Substring(1)));
+
+        if (requestBuilder.PathParameters.ContainsKey("store_id")) {
+            var storeId = requestBuilder.PathParameters.GetValueOrDefault("store_id");
+            if (!string.IsNullOrEmpty(storeId)) {
+                attributes.Add(new KeyValuePair<string, object>(AttributeRequestStoreId, storeId));
+            }
+        }
+
+        string? modelId = null;
+        // if the model id is in the path params try to get it from there
+        if (requestBuilder.PathParameters.ContainsKey("authorization_model_id")) {
+            modelId = requestBuilder.PathParameters.GetValueOrDefault("authorization_model_id");
+            if (!string.IsNullOrEmpty(modelId)) {
+                attributes.Add(new KeyValuePair<string, object>(AttributeRequestModelId, modelId));
+            }
+        }
+        // In the case of ReadAuthorizationModel, the path param is called ID
+        else if (requestBuilder.PathTemplate == "/stores/{store_id}/authorization-models/{id}" &&
+                 requestBuilder.PathParameters.ContainsKey("id")) {
+            modelId = requestBuilder.PathParameters.GetValueOrDefault("id");
+            if (!string.IsNullOrEmpty(modelId)) {
+                attributes.Add(new KeyValuePair<string, object>(AttributeRequestModelId, modelId));
+            }
+        }
+        // In many endpoints authorization_model_id is sent as a field in the body
+        // if the apiName is Check or ListObjects, we always want to parse the request body to get the model ID and the user (subject)
+        // if the apiName is Write, Expand or ListUsers we want to parse it to get the model ID
+        else if (apiName is "Check" or "ListObjects" or "Write" or "Expand" or "ListUsers") {
+            try {
+                var partialApiResquest =
+                    JsonSerializer.Deserialize<IPartialApiRequest>(requestBuilder.Body?.ToString()!);
+
+                if (!string.IsNullOrEmpty(partialApiResquest?.AuthorizationModelId)) {
+                    attributes.Add(new KeyValuePair<string, object>(AttributeRequestModelId,
+                        partialApiResquest.AuthorizationModelId));
+                }
+
+                switch (apiName) {
+                    case "Check": {
+                        if (!string.IsNullOrEmpty(partialApiResquest?.TupleKeyUser)) {
+                            attributes.Add(new KeyValuePair<string, object>(AttributeFgaRequestUser,
+                                partialApiResquest.TupleKeyUser));
+                        }
+
+                        break;
+                    }
+                    case "ListObjects": {
+                        if (!string.IsNullOrEmpty(partialApiResquest?.User)) {
+                            attributes.Add(new KeyValuePair<string, object>(AttributeFgaRequestUser,
+                                partialApiResquest.User));
+                        }
+
+                        break;
+                    }
+                }
+            }
+            catch {
+            }
+        }
+
+
+        if (response.StatusCode != null) {
+            attributes.Add(new KeyValuePair<string, object>(AttributeHttpStatus, (int)response.StatusCode));
+            attributes.Add(new KeyValuePair<string, object>(AttributeHttpStatusOld, (int)response.StatusCode));
+        }
+
+        if (response.RequestMessage != null) {
+            if (response.RequestMessage.Method != null) {
+                attributes.Add(new KeyValuePair<string, object>(AttributeHttpMethod, response.RequestMessage.Method));
+                attributes.Add(new KeyValuePair<string, object>(AttributeHttpMethodOld,
+                    response.RequestMessage.Method));
+            }
+
+            if (response.RequestMessage.RequestUri != null) {
+                attributes.Add(new KeyValuePair<string, object>(AttributeHttpScheme,
+                    response.RequestMessage.RequestUri.Scheme));
+                attributes.Add(new KeyValuePair<string, object>(AttributeHttpHost,
+                    response.RequestMessage.RequestUri.Host));
+                attributes.Add(new KeyValuePair<string, object>(AttributeHttpUrl,
+                    response.RequestMessage.RequestUri.AbsoluteUri));
+            }
+
+            if (response.RequestMessage.Headers.UserAgent != null &&
+                !string.IsNullOrEmpty(response.RequestMessage.Headers.UserAgent.ToString())) {
+                attributes.Add(new KeyValuePair<string, object>(AttributeHttpUserAgent,
+                    response.RequestMessage.Headers.UserAgent.ToString()));
+            }
+        }
+
+        var responseModelId = GetHeaderValueIfValid(response.Headers, "openfga-authorization-model-id");
+        if (!string.IsNullOrEmpty(responseModelId)) {
+            attributes.Add(new KeyValuePair<string, object>(AttributeResponseModelId, responseModelId));
+        }
+        else {
+            responseModelId = GetHeaderValueIfValid(response.Headers, "fga-authorization-model-id");
+            if (!string.IsNullOrEmpty(responseModelId)) {
+                attributes.Add(new KeyValuePair<string, object>(AttributeResponseModelId, responseModelId));
+            }
+        }
+
+        if (credentials is { Method: CredentialsMethod.ClientCredentials, Config.ClientId: not null }) {
+            attributes.Add(new KeyValuePair<string, object>(AttributeRequestClientId, credentials.Config.ClientId));
+        }
+
+        var durationHeader = GetHeaderValueIfValid(response.Headers, "fga-query-duration-ms");
+        if (!string.IsNullOrEmpty(durationHeader)) {
+            var success = float.TryParse(durationHeader, out var durationFloat);
+            if (success) {
+                attributes.Add(new KeyValuePair<string, object>(AttributeHttpServerRequestDuration, durationFloat));
+            }
+        }
+
+        attributes.Add(new KeyValuePair<string, object>(AttributeHttpClientRequestDuration,
+            requestDuration.ElapsedMilliseconds));
+
+        attributes.Add(new KeyValuePair<string, object>(AttributeRequestRetryCount, retryCount));
+        attributes.Add(new KeyValuePair<string, object>(AttributeRequestRetryCountOld, retryCount));
+
+        return attributes.ToArray();
+    }
+}
\ No newline at end of file
diff --git a/config/clients/dotnet/template/Telemetry/Counters.cs.mustache b/config/clients/dotnet/template/Telemetry/Counters.cs.mustache
new file mode 100644
index 00000000..1727b218
--- /dev/null
+++ b/config/clients/dotnet/template/Telemetry/Counters.cs.mustache
@@ -0,0 +1,22 @@
+{{>partial_header}}
+
+using System.Diagnostics.Metrics;
+
+namespace {{packageName}}.Telemetry;
+
+public class TelemetryCounters {
+    // Meters
+    // The total number of times a new token was requested when using ClientCredentials
+    private const string TokenExchangeCountKey = "fga-client.credentials.request";
+
+    protected Meter meter;
+    public Counter<int> tokenExchangeCounter;
+
+    public TelemetryCounters(Meter meter) {
+        this.meter = meter;
+        tokenExchangeCounter = this.meter.CreateCounter<int>(TokenExchangeCountKey,
+            description: "The count of token exchange requests");
+    }
+
+    public void buildForResponse(KeyValuePair<string, object>[] attributes) => tokenExchangeCounter.Add(1, attributes!);
+}
\ No newline at end of file
diff --git a/config/clients/dotnet/template/Telemetry/Histograms.cs.mustache b/config/clients/dotnet/template/Telemetry/Histograms.cs.mustache
new file mode 100644
index 00000000..edb43f40
--- /dev/null
+++ b/config/clients/dotnet/template/Telemetry/Histograms.cs.mustache
@@ -0,0 +1,43 @@
+{{>partial_header}}
+
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+
+namespace {{packageName}}.Telemetry;
+
+public class TelemetryHistograms {
+    // Meters
+
+    // The total request time for FGA requests
+    private const string RequestDurationKey = "fga-client.request.duration";
+
+    // The amount of time the FGA server took to internally process nd evaluate the request
+    private const string QueryDurationKey = "fga-client.query.duration";
+
+    protected Meter meter;
+    public Histogram<float> queryDuration;
+    public Histogram<float> requestDurationHistogram;
+
+    public TelemetryHistograms(Meter meter) {
+        this.meter = meter;
+        requestDurationHistogram = this.meter.CreateHistogram<float>(RequestDurationKey,
+            description: "The duration of requests", unit: "milliseconds");
+        queryDuration = this.meter.CreateHistogram<float>(QueryDurationKey,
+            description: "The duration of queries on the FGA server", unit: "milliseconds");
+    }
+
+    public void buildForResponse(HttpResponseMessage response, KeyValuePair<string, object>[] attributes,
+        Stopwatch requestDuration) {
+        if (response.Headers.Contains("fga-query-duration-ms")) {
+            var durationHeader = response.Headers.GetValues("fga-query-duration-ms").ToString();
+            if (!string.IsNullOrEmpty(durationHeader)) {
+                var success = float.TryParse(durationHeader, out var durationFloat);
+                if (success) {
+                    queryDuration?.Record(durationFloat, attributes);
+                }
+            }
+        }
+
+        requestDurationHistogram.Record(requestDuration.ElapsedMilliseconds, attributes);
+    }
+}
\ No newline at end of file
diff --git a/config/clients/dotnet/template/Telemetry/Metrics.cs.mustache b/config/clients/dotnet/template/Telemetry/Metrics.cs.mustache
new file mode 100644
index 00000000..11ff9f53
--- /dev/null
+++ b/config/clients/dotnet/template/Telemetry/Metrics.cs.mustache
@@ -0,0 +1,31 @@
+{{>partial_header}}
+
+using {{packageName}}.ApiClient;
+using {{packageName}}.Configuration;
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+
+namespace {{packageName}}.Telemetry;
+
+public class Metrics {
+    public const string name = "{{packageName}}";
+    public TelemetryCounters counters;
+    public TelemetryHistograms histograms;
+    public Meter meter = new(name, Configuration.Configuration.Version);
+
+    public Metrics() {
+        histograms = new TelemetryHistograms(meter);
+        counters = new TelemetryCounters(meter);
+    }
+
+    public void buildForResponse(string apiName, HttpResponseMessage response, RequestBuilder requestBuilder,
+        Credentials? credentials, Stopwatch requestDuration, int retryCount) => histograms.buildForResponse(response,
+        Attributes.buildAttributesForResponse(apiName, response, requestBuilder, credentials, requestDuration,
+            retryCount),
+        requestDuration);
+
+    public void buildForClientCredentialsResponse(HttpResponseMessage response, RequestBuilder requestBuilder,
+        Credentials? credentials, Stopwatch requestDuration, int retryCount) => counters.buildForResponse(
+        Attributes.buildAttributesForResponse("ClientCredentialsExchange", response, requestBuilder, credentials,
+            requestDuration, retryCount));
+}
\ No newline at end of file
diff --git a/config/clients/dotnet/template/example/OpenTelemetryExample/.env.example b/config/clients/dotnet/template/example/OpenTelemetryExample/.env.example
new file mode 100644
index 00000000..db7ea750
--- /dev/null
+++ b/config/clients/dotnet/template/example/OpenTelemetryExample/.env.example
@@ -0,0 +1,11 @@
+# Configuration for OpenFGA
+FGA_CLIENT_ID=
+FGA_API_TOKEN_ISSUER=
+FGA_API_AUDIENCE=
+FGA_CLIENT_SECRET=
+FGA_STORE_ID=
+FGA_AUTHORIZATION_MODEL_ID=
+FGA_API_URL="http://localhost:8080"
+
+# Configuration for OpenTelemetry
+OTEL_SERVICE_NAME="openfga-otel-dotnet-example"
diff --git a/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.cs b/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.cs
new file mode 100644
index 00000000..8f4219d8
--- /dev/null
+++ b/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.cs
@@ -0,0 +1,248 @@
+using OpenFga.Sdk.Client;
+using OpenFga.Sdk.Client.Model;
+using OpenFga.Sdk.Configuration;
+using OpenFga.Sdk.Exceptions;
+using OpenFga.Sdk.Model;
+using OpenFga.Sdk.Telemetry;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+using System.Diagnostics;
+
+namespace OpenTelemetryExample;
+
+public class OpenTelemetryExample {
+    public static async Task Main() {
+        try {
+            var credentials = new Credentials();
+            if (Environment.GetEnvironmentVariable("FGA_CLIENT_ID") != null) {
+                credentials.Method = CredentialsMethod.ClientCredentials;
+                credentials.Config = new CredentialsConfig {
+                    ApiAudience = Environment.GetEnvironmentVariable("FGA_API_AUDIENCE"),
+                    ApiTokenIssuer = Environment.GetEnvironmentVariable("FGA_TOKEN_ISSUER"),
+                    ClientId = Environment.GetEnvironmentVariable("FGA_CLIENT_ID"),
+                    ClientSecret = Environment.GetEnvironmentVariable("FGA_CLIENT_SECRET")
+                };
+            } else if (Environment.GetEnvironmentVariable("FGA_API_TOKEN") != null) {
+                credentials.Method = CredentialsMethod.ApiToken;
+                credentials.Config = new CredentialsConfig {
+                    ApiToken = Environment.GetEnvironmentVariable("FGA_API_TOKEN")
+                };
+            }
+
+            var configuration = new ClientConfiguration {
+                ApiUrl =
+                    Environment.GetEnvironmentVariable("FGA_API_URL") ??
+                    "http://localhost:8080", // required, e.g. https://api.fga.example
+                StoreId =
+                    Environment.GetEnvironmentVariable(
+                        "FGA_STORE_ID"), // not needed when calling `CreateStore` or `ListStores`
+                AuthorizationModelId =
+                    Environment.GetEnvironmentVariable("FGA_MODEL_ID"), // Optional, can be overridden per request
+                Credentials = credentials
+            };
+            var fgaClient = new OpenFgaClient(configuration);
+
+            // Setup OpenTelemetry
+            // See: https://github.com/open-telemetry/opentelemetry-dotnet/blob/main/docs/metrics/customizing-the-sdk/README.md
+            using var meterProvider = Sdk.CreateMeterProviderBuilder()
+                .AddHttpClientInstrumentation()
+                .AddMeter(Metrics.name)
+                .ConfigureResource(resourceBuilder =>
+                    resourceBuilder.AddService(Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME") ??
+                                               "openfga-otel-dotnet-example"))
+                .AddOtlpExporter() // Required to export to an OTLP compatible endpoint
+                .AddConsoleExporter() // Only needed to export the metrics to the console (e.g. when debugging)
+                .Build();
+
+            // ListStores
+            Console.WriteLine("Listing Stores");
+            var stores1 = await fgaClient.ListStores();
+            Console.WriteLine("Stores Count: " + stores1.Stores?.Count());
+
+            // CreateStore
+            Console.WriteLine("Creating Test Store");
+            var store = await fgaClient.CreateStore(new ClientCreateStoreRequest { Name = "Test Store" });
+            Console.WriteLine("Test Store ID: " + store.Id);
+
+            // Set the store id
+            fgaClient.StoreId = store.Id;
+
+            // ListStores after Create
+            Console.WriteLine("Listing Stores");
+            var stores = await fgaClient.ListStores();
+            Console.WriteLine("Stores Count: " + stores.Stores?.Count());
+
+            // GetStore
+            Console.WriteLine("Getting Current Store");
+            var currentStore = await fgaClient.GetStore();
+            Console.WriteLine("Current Store Name: " + currentStore.Name);
+
+            // ReadAuthorizationModels
+            Console.WriteLine("Reading Authorization Models");
+            var models = await fgaClient.ReadAuthorizationModels();
+            Console.WriteLine("Models Count: " + models.AuthorizationModels?.Count());
+
+            // ReadLatestAuthorizationModel
+            var latestAuthorizationModel = await fgaClient.ReadLatestAuthorizationModel();
+            if (latestAuthorizationModel != null) {
+                Console.WriteLine("Latest Authorization Model ID " + latestAuthorizationModel.AuthorizationModel?.Id);
+            }
+            else {
+                Console.WriteLine("Latest Authorization Model not found");
+            }
+
+            // WriteAuthorizationModel
+            Console.WriteLine("Writing an Authorization Model");
+            var body = new ClientWriteAuthorizationModelRequest {
+                SchemaVersion = "1.1",
+                TypeDefinitions =
+                    new List<TypeDefinition> {
+                        new() { Type = "user", Relations = new Dictionary<string, Userset>() },
+                        new() {
+                            Type = "document",
+                            Relations =
+                                new Dictionary<string, Userset> {
+                                    { "writer", new Userset { This = new object() } }, {
+                                        "viewer",
+                                        new Userset {
+                                            Union = new Usersets {
+                                                Child = new List<Userset> {
+                                                    new() { This = new object() },
+                                                    new() {
+                                                        ComputedUserset = new ObjectRelation { Relation = "writer" }
+                                                    }
+                                                }
+                                            }
+                                        }
+                                    }
+                                },
+                            Metadata = new Metadata {
+                                Relations = new Dictionary<string, RelationMetadata> {
+                                    {
+                                        "writer",
+                                        new RelationMetadata {
+                                            DirectlyRelatedUserTypes = new List<RelationReference> {
+                                                new() { Type = "user" },
+                                                new() { Type = "user", Condition = "ViewCountLessThan200" }
+                                            }
+                                        }
+                                    }, {
+                                        "viewer",
+                                        new RelationMetadata {
+                                            DirectlyRelatedUserTypes = new List<RelationReference> {
+                                                new() { Type = "user" }
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    },
+                Conditions = new Dictionary<string, Condition> {
+                    ["ViewCountLessThan200"] = new() {
+                        Name = "ViewCountLessThan200",
+                        Expression = "ViewCount < 200",
+                        Parameters = new Dictionary<string, ConditionParamTypeRef> {
+                            ["ViewCount"] = new() { TypeName = TypeName.INT },
+                            ["Type"] = new() { TypeName = TypeName.STRING },
+                            ["Name"] = new() { TypeName = TypeName.STRING }
+                        }
+                    }
+                }
+            };
+            var authorizationModel = await fgaClient.WriteAuthorizationModel(body);
+            Console.WriteLine("Authorization Model ID " + authorizationModel.AuthorizationModelId);
+
+            // ReadAuthorizationModels - after Write
+            Console.WriteLine("Reading Authorization Models");
+            models = await fgaClient.ReadAuthorizationModels();
+            Console.WriteLine("Models Count: " + models.AuthorizationModels?.Count());
+
+            // ReadLatestAuthorizationModel - after Write
+            latestAuthorizationModel = await fgaClient.ReadLatestAuthorizationModel();
+            Console.WriteLine("Latest Authorization Model ID " + latestAuthorizationModel?.AuthorizationModel?.Id);
+
+            // Set the model ID
+            fgaClient.AuthorizationModelId = latestAuthorizationModel?.AuthorizationModel?.Id;
+
+            // Write
+            Console.WriteLine("Writing Tuples");
+            await fgaClient.Write(
+                new ClientWriteRequest {
+                    Writes = new List<ClientTupleKey> {
+                        new() {
+                            User = "user:anne",
+                            Relation = "writer",
+                            Object = "document:roadmap",
+                            Condition = new RelationshipCondition {
+                                Name = "ViewCountLessThan200", Context = new { Name = "Roadmap", Type = "document" }
+                            }
+                        }
+                    }
+                }, new ClientWriteOptions { AuthorizationModelId = authorizationModel.AuthorizationModelId });
+            Console.WriteLine("Done Writing Tuples");
+
+            // Read
+            Console.WriteLine("Reading Tuples");
+            var readTuples = await fgaClient.Read();
+            Console.WriteLine("Read Tuples" + readTuples.ToJson());
+
+            // ReadChanges
+            Console.WriteLine("Reading Tuple Changess");
+            var readChangesTuples = await fgaClient.ReadChanges();
+            Console.WriteLine("Read Changes Tuples" + readChangesTuples.ToJson());
+
+            // Check
+            Console.WriteLine("Checking for access");
+            try {
+                var failingCheckResponse = await fgaClient.Check(new ClientCheckRequest {
+                    User = "user:anne", Relation = "viewer", Object = "document:roadmap"
+                });
+                Console.WriteLine("Allowed: " + failingCheckResponse.Allowed);
+            }
+            catch (Exception e) {
+                Console.WriteLine("Failed due to: " + e.Message);
+            }
+
+            // Checking for access with context
+            Console.WriteLine("Checking for access with context");
+            var checkResponse = await fgaClient.Check(new ClientCheckRequest {
+                User = "user:anne", Relation = "viewer", Object = "document:roadmap", Context = new { ViewCount = 100 }
+            });
+            Console.WriteLine("Allowed: " + checkResponse.Allowed);
+
+            // WriteAssertions
+            await fgaClient.WriteAssertions(new List<ClientAssertion> {
+                new() { User = "user:carl", Relation = "writer", Object = "document:budget", Expectation = true },
+                new() { User = "user:anne", Relation = "viewer", Object = "document:roadmap", Expectation = false }
+            });
+            Console.WriteLine("Assertions updated");
+
+            // ReadAssertions
+            Console.WriteLine("Reading Assertions");
+            var assertions = await fgaClient.ReadAssertions();
+            Console.WriteLine("Assertions " + assertions.ToJson());
+
+            // Checking for access w/ context in a loop
+            Random rnd = new Random();
+            int randomNumber = rnd.Next(1, 1000);
+            Console.WriteLine($"Checking for access with context in a loop ({randomNumber} times)");
+            for (int index = 0; index < randomNumber; index++) {
+                checkResponse = await fgaClient.Check(new ClientCheckRequest {
+                    User = "user:anne", Relation = "viewer", Object = "document:roadmap", Context = new { ViewCount = 100 }
+                });
+                Console.WriteLine("Allowed: " + checkResponse.Allowed);
+            }
+
+            // DeleteStore
+            Console.WriteLine("Deleting Current Store");
+            await fgaClient.DeleteStore();
+            Console.WriteLine("Deleted Store: " + currentStore.Name);
+        }
+        catch (ApiException e) {
+            Console.WriteLine("Error: " + e);
+            Debug.Print("Error: " + e);
+        }
+    }
+}
\ No newline at end of file
diff --git a/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.csproj b/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.csproj
new file mode 100644
index 00000000..00bcfd0a
--- /dev/null
+++ b/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.csproj
@@ -0,0 +1,30 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>net6.0</TargetFramework>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+    <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
+    <RootNamespace>OpenTelemetryExample</RootNamespace>
+  </PropertyGroup>
+
+  <!--  To target the released version, uncomment this section -->
+  <!--  <ItemGroup>-->
+  <!--    <PackageReference Include="OpenFga.Sdk" Version="0.2.5"><PrivateAssets>all</PrivateAssets></PackageReference>-->
+  <!--  </ItemGroup>-->
+
+  <!--  To target the local build, uncomment this section (make sure to build that project first) -->
+  <ItemGroup>
+    <Reference Include="OpenFga.Sdk">
+      <HintPath>..\..\src\OpenFga.Sdk\bin\Debug\net6.0\OpenFga.Sdk.dll</HintPath>
+    </Reference>
+  </ItemGroup>
+  <ItemGroup>
+    <PackageReference Include="OpenTelemetry" Version="1.9.0"/>
+    <PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.9.0"/>
+    <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.9.0"/>
+    <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.9.0"/>
+  </ItemGroup>
+
+</Project>
diff --git a/config/clients/dotnet/template/netcore_project.mustache b/config/clients/dotnet/template/netcore_project.mustache
index 894167db..506beb40 100644
--- a/config/clients/dotnet/template/netcore_project.mustache
+++ b/config/clients/dotnet/template/netcore_project.mustache
@@ -30,9 +30,13 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <Folder Include="Models" />
-    <None Include="../../assets/FGAIcon.png" Pack="true" Visible="false" PackagePath="" />
-    <None Include="../../README.md" Pack="true" PackagePath="" />
+    <Folder Include="Models"/>
+    <None Include="../../assets/FGAIcon.png" Pack="true" Visible="false" PackagePath=""/>
+    <None Include="../../README.md" Pack="true" PackagePath=""/>
+  </ItemGroup>
+
+  <ItemGroup>
+    <PackageReference Include="OpenTelemetry.Api" Version="1.9.0"/>
   </ItemGroup>
 
 </Project>

From 16134a5e292965981a3a1bf7bda508ff14600f5f Mon Sep 17 00:00:00 2001
From: Raghd Hamzeh <raghd.hamzeh@auth0.com>
Date: Tue, 23 Jul 2024 02:54:31 -0400
Subject: [PATCH 02/12] fix(dotnet): drop deprecated otel metrics

Note: These metrics have never been released in the .NET SDK
---
 .../dotnet/template/OpenTelemetry.md.mustache    |  3 ---
 .../template/Telemetry/Attributes.cs.mustache    | 16 ----------------
 2 files changed, 19 deletions(-)

diff --git a/config/clients/dotnet/template/OpenTelemetry.md.mustache b/config/clients/dotnet/template/OpenTelemetry.md.mustache
index e3e7af51..86fa4601 100644
--- a/config/clients/dotnet/template/OpenTelemetry.md.mustache
+++ b/config/clients/dotnet/template/OpenTelemetry.md.mustache
@@ -26,11 +26,8 @@ In cases when metrics events are sent, they will not be viewable outside of infr
 | `fga-client.request.model_id`  | `string` | The authorization model ID that was sent as part of the request, if any                                                 |
 | `fga-client.request.client_id` | `string` | The client ID associated with the request, if any                                                                       |
 | `fga-client.user`              | `string` | The user that is associated with the action of the request for check and list objects                                   |
-| `fga-client.request.retries`   | `int`    | The number of retries attempted (starting from 1 for the original request). Deprecated, use `http.request.resend_count` |
 | `http.request.resend_count`    | `int`    | The number of retries attempted (starting from 1 for the original request)                                              |
-| `http.status_code`             | `int`    | The status code of the response. Deprecated, use `http.response.status_code`                                            |
 | `http.response.status_code`    | `int`    | The status code of the response                                                                                         |
-| `http.method`                  | `string` | The HTTP method for the request. Deprecated, use `http.request.method`                                                  |
 | `http.request.method`          | `string` | The HTTP method for the request                                                                                         |
 | `http.host`                    | `string` | Host identifier of the origin the request was sent to                                                                   |
 | `url.scheme`                   | `string` | HTTP Scheme of the request (`http`/`https`)                                                                             |
diff --git a/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
index 528d5846..83debfce 100644
--- a/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
+++ b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
@@ -64,17 +64,9 @@ public class Attributes {
     // The amount of time the FGA server took to internally process nd evaluate the request
     private const string AttributeHttpServerRequestDuration = "http.server.request.duration";
 
-    // The HTTP method for the request
-    [Obsolete("Deprecated - use AttributeHttpMethod.")]
-    private const string AttributeHttpMethodOld = "http.method";
-
     // The HTTP method for the request
     private const string AttributeHttpMethod = "http.request.method";
 
-    // The status code of the response
-    [Obsolete("Deprecated - use AttributeHttpStatus.")]
-    private const string AttributeHttpStatusOld = "http.status_code";
-
     // The status code of the response
     private const string AttributeHttpStatus = "http.response.status_code";
 
@@ -90,10 +82,6 @@ public class Attributes {
     // User Agent used in the query
     private const string AttributeHttpUserAgent = "user_agent.original";
 
-    // The number of retries attempted (starting from 1 for the original request)
-    [Obsolete("Deprecated - use AttributeRequestRetryCount.")]
-    private const string AttributeRequestRetryCountOld = "fga-client.request.retries";
-
     // The number of retries attempted (starting from 1 for the original request)
     private const string AttributeRequestRetryCount = "http.request.resend_count";
 
@@ -184,14 +172,11 @@ public class Attributes {
 
         if (response.StatusCode != null) {
             attributes.Add(new KeyValuePair<string, object>(AttributeHttpStatus, (int)response.StatusCode));
-            attributes.Add(new KeyValuePair<string, object>(AttributeHttpStatusOld, (int)response.StatusCode));
         }
 
         if (response.RequestMessage != null) {
             if (response.RequestMessage.Method != null) {
                 attributes.Add(new KeyValuePair<string, object>(AttributeHttpMethod, response.RequestMessage.Method));
-                attributes.Add(new KeyValuePair<string, object>(AttributeHttpMethodOld,
-                    response.RequestMessage.Method));
             }
 
             if (response.RequestMessage.RequestUri != null) {
@@ -237,7 +222,6 @@ public class Attributes {
             requestDuration.ElapsedMilliseconds));
 
         attributes.Add(new KeyValuePair<string, object>(AttributeRequestRetryCount, retryCount));
-        attributes.Add(new KeyValuePair<string, object>(AttributeRequestRetryCountOld, retryCount));
 
         return attributes.ToArray();
     }

From 03bb65d6121ff36cade2dd645dffebe11165c242 Mon Sep 17 00:00:00 2001
From: Raghd Hamzeh <raghd.hamzeh@auth0.com>
Date: Tue, 23 Jul 2024 03:00:34 -0400
Subject: [PATCH 03/12] fix(dotnet): switch `fga-client.request.method` in
 metrics to TitleCase

This makes it consistent with Proto naming
---
 config/clients/dotnet/template/OpenTelemetry.md.mustache       | 2 +-
 .../clients/dotnet/template/Telemetry/Attributes.cs.mustache   | 3 +--
 2 files changed, 2 insertions(+), 3 deletions(-)

diff --git a/config/clients/dotnet/template/OpenTelemetry.md.mustache b/config/clients/dotnet/template/OpenTelemetry.md.mustache
index 86fa4601..3085ce03 100644
--- a/config/clients/dotnet/template/OpenTelemetry.md.mustache
+++ b/config/clients/dotnet/template/OpenTelemetry.md.mustache
@@ -21,7 +21,7 @@ In cases when metrics events are sent, they will not be viewable outside of infr
 | Attribute Name                 | Type     | Description                                                                                                             |
 |--------------------------------|----------|-------------------------------------------------------------------------------------------------------------------------|
 | `fga-client.response.model_id` | `string` | The authorization model ID that the FGA server used                                                                     |
-| `fga-client.request.method`    | `string` | The FGA method/action that was performed (e.g. `check`, `listObjects`, ...) in camelCase                                |
+| `fga-client.request.method`    | `string` | The FGA method/action that was performed (e.g. `Check`, `ListObjects`, ...) in TitleCase                                |
 | `fga-client.request.store_id`  | `string` | The store ID that was sent as part of the request                                                                       |
 | `fga-client.request.model_id`  | `string` | The authorization model ID that was sent as part of the request, if any                                                 |
 | `fga-client.request.client_id` | `string` | The client ID associated with the request, if any                                                                       |
diff --git a/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
index 83debfce..83b8ca0e 100644
--- a/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
+++ b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
@@ -107,8 +107,7 @@ public class Attributes {
         var attributes = new List<KeyValuePair<string, object>>();
 
         // To match the JS SDK, we are converting the method name to camelCase
-        attributes.Add(new KeyValuePair<string, object>(AttributeRequestMethod,
-            char.ToLowerInvariant(apiName[0]) + apiName.Substring(1)));
+        attributes.Add(new KeyValuePair<string, object>(AttributeRequestMethod, apiName));
 
         if (requestBuilder.PathParameters.ContainsKey("store_id")) {
             var storeId = requestBuilder.PathParameters.GetValueOrDefault("store_id");

From 43101e78103c516d32af514b2bce1a23bcd7916e Mon Sep 17 00:00:00 2001
From: Raghd Hamzeh <raghd.hamzeh@auth0.com>
Date: Mon, 29 Jul 2024 18:34:46 -0400
Subject: [PATCH 04/12] chore(dotnet): convert attribute interface from
 KeyValue Pair List to TagList

---
 .../template/Telemetry/Attributes.cs.mustache | 68 +++++++++----------
 .../template/Telemetry/Counters.cs.mustache   |  3 +-
 .../template/Telemetry/Histograms.cs.mustache |  2 +-
 3 files changed, 36 insertions(+), 37 deletions(-)

diff --git a/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
index 83b8ca0e..4de46f21 100644
--- a/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
+++ b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
@@ -101,18 +101,16 @@ public class Attributes {
      * @param methodAttributes - Extra attributes that the method (i.e. check, listObjects) wishes to have included. Any custom attributes should use the common names.
      * @returns {Attributes}
      */
-    public static KeyValuePair<string, object>[] buildAttributesForResponse(string apiName,
+    public static TagList buildAttributesForResponse(string apiName,
         HttpResponseMessage response, RequestBuilder requestBuilder, Credentials? credentials,
         Stopwatch requestDuration, int retryCount) {
-        var attributes = new List<KeyValuePair<string, object>>();
-
-        // To match the JS SDK, we are converting the method name to camelCase
-        attributes.Add(new KeyValuePair<string, object>(AttributeRequestMethod, apiName));
+        var attributes = new TagList {
+            new (AttributeRequestMethod, apiName) };
 
         if (requestBuilder.PathParameters.ContainsKey("store_id")) {
             var storeId = requestBuilder.PathParameters.GetValueOrDefault("store_id");
             if (!string.IsNullOrEmpty(storeId)) {
-                attributes.Add(new KeyValuePair<string, object>(AttributeRequestStoreId, storeId));
+                attributes.Add(new KeyValuePair<string, object?>(AttributeRequestStoreId, storeId));
             }
         }
 
@@ -121,7 +119,7 @@ public class Attributes {
         if (requestBuilder.PathParameters.ContainsKey("authorization_model_id")) {
             modelId = requestBuilder.PathParameters.GetValueOrDefault("authorization_model_id");
             if (!string.IsNullOrEmpty(modelId)) {
-                attributes.Add(new KeyValuePair<string, object>(AttributeRequestModelId, modelId));
+                attributes.Add(new KeyValuePair<string, object?>(AttributeRequestModelId, modelId));
             }
         }
         // In the case of ReadAuthorizationModel, the path param is called ID
@@ -129,7 +127,7 @@ public class Attributes {
                  requestBuilder.PathParameters.ContainsKey("id")) {
             modelId = requestBuilder.PathParameters.GetValueOrDefault("id");
             if (!string.IsNullOrEmpty(modelId)) {
-                attributes.Add(new KeyValuePair<string, object>(AttributeRequestModelId, modelId));
+                attributes.Add(new KeyValuePair<string, object?>(AttributeRequestModelId, modelId));
             }
         }
         // In many endpoints authorization_model_id is sent as a field in the body
@@ -141,27 +139,27 @@ public class Attributes {
                     JsonSerializer.Deserialize<IPartialApiRequest>(requestBuilder.Body?.ToString()!);
 
                 if (!string.IsNullOrEmpty(partialApiResquest?.AuthorizationModelId)) {
-                    attributes.Add(new KeyValuePair<string, object>(AttributeRequestModelId,
+                    attributes.Add(new KeyValuePair<string, object?>(AttributeRequestModelId,
                         partialApiResquest.AuthorizationModelId));
                 }
 
                 switch (apiName) {
                     case "Check": {
-                        if (!string.IsNullOrEmpty(partialApiResquest?.TupleKeyUser)) {
-                            attributes.Add(new KeyValuePair<string, object>(AttributeFgaRequestUser,
-                                partialApiResquest.TupleKeyUser));
-                        }
+                            if (!string.IsNullOrEmpty(partialApiResquest?.TupleKeyUser)) {
+                                attributes.Add(new KeyValuePair<string, object?>(AttributeFgaRequestUser,
+                                    partialApiResquest.TupleKeyUser));
+                            }
 
-                        break;
-                    }
-                    case "ListObjects": {
-                        if (!string.IsNullOrEmpty(partialApiResquest?.User)) {
-                            attributes.Add(new KeyValuePair<string, object>(AttributeFgaRequestUser,
-                                partialApiResquest.User));
+                            break;
                         }
+                    case "ListObjects": {
+                            if (!string.IsNullOrEmpty(partialApiResquest?.User)) {
+                                attributes.Add(new KeyValuePair<string, object?>(AttributeFgaRequestUser,
+                                    partialApiResquest.User));
+                            }
 
-                        break;
-                    }
+                            break;
+                        }
                 }
             }
             catch {
@@ -170,58 +168,58 @@ public class Attributes {
 
 
         if (response.StatusCode != null) {
-            attributes.Add(new KeyValuePair<string, object>(AttributeHttpStatus, (int)response.StatusCode));
+            attributes.Add(new KeyValuePair<string, object?>(AttributeHttpStatus, (int)response.StatusCode));
         }
 
         if (response.RequestMessage != null) {
             if (response.RequestMessage.Method != null) {
-                attributes.Add(new KeyValuePair<string, object>(AttributeHttpMethod, response.RequestMessage.Method));
+                attributes.Add(new KeyValuePair<string, object?>(AttributeHttpMethod, response.RequestMessage.Method));
             }
 
             if (response.RequestMessage.RequestUri != null) {
-                attributes.Add(new KeyValuePair<string, object>(AttributeHttpScheme,
+                attributes.Add(new KeyValuePair<string, object?>(AttributeHttpScheme,
                     response.RequestMessage.RequestUri.Scheme));
-                attributes.Add(new KeyValuePair<string, object>(AttributeHttpHost,
+                attributes.Add(new KeyValuePair<string, object?>(AttributeHttpHost,
                     response.RequestMessage.RequestUri.Host));
-                attributes.Add(new KeyValuePair<string, object>(AttributeHttpUrl,
+                attributes.Add(new KeyValuePair<string, object?>(AttributeHttpUrl,
                     response.RequestMessage.RequestUri.AbsoluteUri));
             }
 
             if (response.RequestMessage.Headers.UserAgent != null &&
                 !string.IsNullOrEmpty(response.RequestMessage.Headers.UserAgent.ToString())) {
-                attributes.Add(new KeyValuePair<string, object>(AttributeHttpUserAgent,
+                attributes.Add(new KeyValuePair<string, object?>(AttributeHttpUserAgent,
                     response.RequestMessage.Headers.UserAgent.ToString()));
             }
         }
 
         var responseModelId = GetHeaderValueIfValid(response.Headers, "openfga-authorization-model-id");
         if (!string.IsNullOrEmpty(responseModelId)) {
-            attributes.Add(new KeyValuePair<string, object>(AttributeResponseModelId, responseModelId));
+            attributes.Add(new KeyValuePair<string, object?>(AttributeResponseModelId, responseModelId));
         }
         else {
             responseModelId = GetHeaderValueIfValid(response.Headers, "fga-authorization-model-id");
             if (!string.IsNullOrEmpty(responseModelId)) {
-                attributes.Add(new KeyValuePair<string, object>(AttributeResponseModelId, responseModelId));
+                attributes.Add(new KeyValuePair<string, object?>(AttributeResponseModelId, responseModelId));
             }
         }
 
         if (credentials is { Method: CredentialsMethod.ClientCredentials, Config.ClientId: not null }) {
-            attributes.Add(new KeyValuePair<string, object>(AttributeRequestClientId, credentials.Config.ClientId));
+            attributes.Add(new KeyValuePair<string, object?>(AttributeRequestClientId, credentials.Config.ClientId));
         }
 
         var durationHeader = GetHeaderValueIfValid(response.Headers, "fga-query-duration-ms");
         if (!string.IsNullOrEmpty(durationHeader)) {
             var success = float.TryParse(durationHeader, out var durationFloat);
             if (success) {
-                attributes.Add(new KeyValuePair<string, object>(AttributeHttpServerRequestDuration, durationFloat));
+                attributes.Add(new KeyValuePair<string, object?>(AttributeHttpServerRequestDuration, durationFloat));
             }
         }
 
-        attributes.Add(new KeyValuePair<string, object>(AttributeHttpClientRequestDuration,
+        attributes.Add(new KeyValuePair<string, object?>(AttributeHttpClientRequestDuration,
             requestDuration.ElapsedMilliseconds));
 
-        attributes.Add(new KeyValuePair<string, object>(AttributeRequestRetryCount, retryCount));
+        attributes.Add(new KeyValuePair<string, object?>(AttributeRequestRetryCount, retryCount));
 
-        return attributes.ToArray();
+        return attributes;
     }
-}
\ No newline at end of file
+}
diff --git a/config/clients/dotnet/template/Telemetry/Counters.cs.mustache b/config/clients/dotnet/template/Telemetry/Counters.cs.mustache
index 1727b218..e4776f5a 100644
--- a/config/clients/dotnet/template/Telemetry/Counters.cs.mustache
+++ b/config/clients/dotnet/template/Telemetry/Counters.cs.mustache
@@ -1,5 +1,6 @@
 {{>partial_header}}
 
+using System.Diagnostics;
 using System.Diagnostics.Metrics;
 
 namespace {{packageName}}.Telemetry;
@@ -18,5 +19,5 @@ public class TelemetryCounters {
             description: "The count of token exchange requests");
     }
 
-    public void buildForResponse(KeyValuePair<string, object>[] attributes) => tokenExchangeCounter.Add(1, attributes!);
+    public void buildForResponse(TagList attributes) => tokenExchangeCounter.Add(1, attributes!);
 }
\ No newline at end of file
diff --git a/config/clients/dotnet/template/Telemetry/Histograms.cs.mustache b/config/clients/dotnet/template/Telemetry/Histograms.cs.mustache
index edb43f40..98967e45 100644
--- a/config/clients/dotnet/template/Telemetry/Histograms.cs.mustache
+++ b/config/clients/dotnet/template/Telemetry/Histograms.cs.mustache
@@ -26,7 +26,7 @@ public class TelemetryHistograms {
             description: "The duration of queries on the FGA server", unit: "milliseconds");
     }
 
-    public void buildForResponse(HttpResponseMessage response, KeyValuePair<string, object>[] attributes,
+    public void buildForResponse(HttpResponseMessage response, TagList attributes,
         Stopwatch requestDuration) {
         if (response.Headers.Contains("fga-query-duration-ms")) {
             var durationHeader = response.Headers.GetValues("fga-query-duration-ms").ToString();

From ffde0600dfe2cb557a1eba84fdec87f89596885a Mon Sep 17 00:00:00 2001
From: Raghd Hamzeh <raghd.hamzeh@auth0.com>
Date: Mon, 29 Jul 2024 19:01:31 -0400
Subject: [PATCH 05/12] feat(dotnet): properly support exporting user and model
 id metrics

---
 .../dotnet/template/Client_ApiClient.mustache | 12 +--
 .../template/Client_BaseClient.mustache       |  9 +-
 .../template/Client_OAuth2Client.mustache     | 18 ++--
 .../template/Client_RequestBuilder.mustache   | 29 +++++--
 .../OpenTelemetryDocs_custom.mustache         |  2 +-
 .../dotnet/template/README_retries.mustache   |  6 +-
 .../template/Telemetry/Attributes.cs.mustache | 69 +++++++--------
 .../template/Telemetry/Histograms.cs.mustache |  5 +-
 .../template/Telemetry/Metrics.cs.mustache    |  4 +-
 config/clients/dotnet/template/api.mustache   | 16 ++--
 .../template/example/Example1/Example1.cs     |  2 +-
 .../OpenTelemetryExample.cs                   | 84 ++++++++++++-------
 12 files changed, 144 insertions(+), 112 deletions(-)

diff --git a/config/clients/dotnet/template/Client_ApiClient.mustache b/config/clients/dotnet/template/Client_ApiClient.mustache
index 85b6fbdf..a2f5c161 100644
--- a/config/clients/dotnet/template/Client_ApiClient.mustache
+++ b/config/clients/dotnet/template/Client_ApiClient.mustache
@@ -58,7 +58,7 @@ public class ApiClient : IDisposable {
     /// <typeparam name="T">Response Type</typeparam>
     /// <returns></returns>
     /// <exception cref="FgaApiAuthenticationError"></exception>
-    public async Task<T> SendRequestAsync<T>(RequestBuilder requestBuilder, string apiName,
+    public async Task<TRes> SendRequestAsync<TReq, TRes>(RequestBuilder<TReq> requestBuilder, string apiName,
         CancellationToken cancellationToken = default) {
         IDictionary<string, string> additionalHeaders = new Dictionary<string, string>();
 
@@ -77,7 +77,8 @@ public class ApiClient : IDisposable {
         }
 
         var response = await Retry(async () =>
-            await _baseClient.SendRequestAsync<T>(requestBuilder, additionalHeaders, apiName, cancellationToken));
+            await _baseClient.SendRequestAsync<TReq, TRes>(requestBuilder, additionalHeaders, apiName,
+                cancellationToken));
 
         sw.Stop();
         metrics.buildForResponse(apiName, response.rawResponse, requestBuilder, _configuration.Credentials, sw,
@@ -94,7 +95,7 @@ public class ApiClient : IDisposable {
     /// <param name="apiName"></param>
     /// <param name="cancellationToken"></param>
     /// <exception cref="FgaApiAuthenticationError"></exception>
-    public async Task SendRequestAsync(RequestBuilder requestBuilder, string apiName,
+    public async Task SendRequestAsync<TReq>(RequestBuilder<TReq> requestBuilder, string apiName,
         CancellationToken cancellationToken = default) {
         IDictionary<string, string> additionalHeaders = new Dictionary<string, string>();
 
@@ -113,7 +114,8 @@ public class ApiClient : IDisposable {
         }
 
         var response = await Retry(async () =>
-            await _baseClient.SendRequestAsync<object>(requestBuilder, additionalHeaders, apiName, cancellationToken));
+            await _baseClient.SendRequestAsync<TReq, object>(requestBuilder, additionalHeaders, apiName,
+                cancellationToken));
 
         sw.Stop();
         metrics.buildForResponse(apiName, response.rawResponse, requestBuilder, _configuration.Credentials, sw,
@@ -156,4 +158,4 @@ public class ApiClient : IDisposable {
     }
 
     public void Dispose() => _baseClient.Dispose();
-}
+}
\ No newline at end of file
diff --git a/config/clients/dotnet/template/Client_BaseClient.mustache b/config/clients/dotnet/template/Client_BaseClient.mustache
index 65974515..bc1c92f4 100644
--- a/config/clients/dotnet/template/Client_BaseClient.mustache
+++ b/config/clients/dotnet/template/Client_BaseClient.mustache
@@ -53,14 +53,15 @@ public class BaseClient : IDisposable {
     /// <param name="additionalHeaders"></param>
     /// <param name="apiName"></param>
     /// <param name="cancellationToken"></param>
-    /// <typeparam name="T"></typeparam>
+    /// <typeparam name="TReq"></typeparam>
+    /// <typeparam name="TRes"></typeparam>
     /// <returns></returns>
-    public async Task<ResponseWrapper<T>> SendRequestAsync<T>(RequestBuilder requestBuilder,
+    public async Task<ResponseWrapper<TRes>> SendRequestAsync<TReq, TRes>(RequestBuilder<TReq> requestBuilder,
         IDictionary<string, string>? additionalHeaders = null,
         string? apiName = null, CancellationToken cancellationToken = default) {
         var request = requestBuilder.BuildRequest();
 
-        return await SendRequestAsync<T>(request, additionalHeaders, apiName, cancellationToken);
+        return await SendRequestAsync<TRes>(request, additionalHeaders, apiName, cancellationToken);
     }
 
     // /// <summary>
@@ -136,4 +137,4 @@ public class BaseClient : IDisposable {
     ///     Disposes of any owned disposable resources such as the underlying <see cref="HttpClient" /> if owned.
     /// </summary>
     public void Dispose() => Dispose(true);
-}
\ No newline at end of file
+}
diff --git a/config/clients/dotnet/template/Client_OAuth2Client.mustache b/config/clients/dotnet/template/Client_OAuth2Client.mustache
index 2ed8d0c3..c5f1a159 100644
--- a/config/clients/dotnet/template/Client_OAuth2Client.mustache
+++ b/config/clients/dotnet/template/Client_OAuth2Client.mustache
@@ -1,6 +1,5 @@
 {{>partial_header}}
 
-
 using {{packageName}}.Client.Model;
 using {{packageName}}.Configuration;
 using {{packageName}}.Exceptions;
@@ -106,19 +105,20 @@ public class OAuth2Client {
     /// <exception cref="NullReferenceException"></exception>
     /// <exception cref="Exception"></exception>
     private async Task ExchangeTokenAsync(CancellationToken cancellationToken = default) {
-        var requestBuilder = new RequestBuilder {
+        var requestBuilder = new RequestBuilder<IDictionary<string, string>> {
             Method = HttpMethod.Post,
             BasePath = $"https://{_apiTokenIssuer}",
             PathTemplate = "/oauth/token",
-            Body = Utils.CreateFormEncodedConent(_authRequest)
+            Body = _authRequest
         };
 
         var sw = Stopwatch.StartNew();
-        var accessTokenResponse = await Retry(async () => await _httpClient.SendRequestAsync<AccessTokenResponse>(
-            requestBuilder,
-            null,
-            "ExchangeTokenAsync",
-            cancellationToken));
+        var accessTokenResponse = await Retry(async () =>
+            await _httpClient.SendRequestAsync<IDictionary<string, string>, AccessTokenResponse>(
+                requestBuilder,
+                null,
+                "ExchangeTokenAsync",
+                cancellationToken));
 
         sw.Stop();
         metrics.buildForClientCredentialsResponse(accessTokenResponse.rawResponse, requestBuilder, _credentialsConfig,
@@ -179,4 +179,4 @@ public class OAuth2Client {
     }
 
     #endregion
-}
\ No newline at end of file
+}
diff --git a/config/clients/dotnet/template/Client_RequestBuilder.mustache b/config/clients/dotnet/template/Client_RequestBuilder.mustache
index 67223e4f..663e16e7 100644
--- a/config/clients/dotnet/template/Client_RequestBuilder.mustache
+++ b/config/clients/dotnet/template/Client_RequestBuilder.mustache
@@ -1,12 +1,21 @@
 {{>partial_header}}
 
-using System.Web;
-
 using {{packageName}}.Exceptions;
+using System.Text;
+using System.Text.Json;
+using System.Web;
 
 namespace {{packageName}}.ApiClient;
 
-public class RequestBuilder {
+/// <summary>
+/// </summary>
+/// <typeparam name="TReq">Type of the Request Body</typeparam>
+public class RequestBuilder<TReq> {
+    public RequestBuilder() {
+        PathParameters = new Dictionary<string, string>();
+        QueryParameters = new Dictionary<string, string>();
+    }
+
     public HttpMethod Method { get; set; }
     public string BasePath { get; set; }
     public string PathTemplate { get; set; }
@@ -15,12 +24,12 @@ public class RequestBuilder {
 
     public Dictionary<string, string> QueryParameters { get; set; }
 
-    public HttpContent? Body { get; set; }
+    public TReq? Body { get; set; }
 
-    public RequestBuilder() {
-        PathParameters = new Dictionary<string, string>();
-        QueryParameters = new Dictionary<string, string>();
-    }
+    public string? JsonBody => Body == null ? null : JsonSerializer.Serialize(Body);
+
+    private HttpContent? HttpContentBody =>
+        Body == null ? null : new StringContent(JsonBody, Encoding.UTF8, "application/json");
 
     public string BuildPathString() {
         if (PathTemplate == null) {
@@ -56,6 +65,7 @@ public class RequestBuilder {
         if (BasePath == null) {
             throw new FgaRequiredParamError("RequestBuilder.BuildUri", nameof(BasePath));
         }
+
         var uriString = $"{BasePath}";
 
         uriString += BuildPathString();
@@ -68,6 +78,7 @@ public class RequestBuilder {
         if (Method == null) {
             throw new FgaRequiredParamError("RequestBuilder.BuildRequest", nameof(Method));
         }
-        return new HttpRequestMessage() { RequestUri = BuildUri(), Method = Method, Content = Body };
+
+        return new HttpRequestMessage { RequestUri = BuildUri(), Method = Method, Content = HttpContentBody };
     }
 }
diff --git a/config/clients/dotnet/template/OpenTelemetryDocs_custom.mustache b/config/clients/dotnet/template/OpenTelemetryDocs_custom.mustache
index 68caf90e..ba1442e5 100644
--- a/config/clients/dotnet/template/OpenTelemetryDocs_custom.mustache
+++ b/config/clients/dotnet/template/OpenTelemetryDocs_custom.mustache
@@ -19,7 +19,7 @@ namespace Example {
                 // Setup OpenTelemetry Metrics
                 using var meterProvider = Sdk.CreateMeterProviderBuilder()
                     .AddHttpClientInstrumentation() // To instrument the default http client
-                    .AddMeter(Metrics.name) // .AddMeter("OpenFga.Sdk") also works
+                    .AddMeter(Metrics.name) // .AddMeter("{{packageName}}") also works
                     .ConfigureResource(resourceBuilder => resourceBuilder.AddService("openfga-dotnet-example"))
                     .AddOtlpExporter() // Required to export to an OTLP compatible endpoint
                     .AddConsoleExporter() // Only needed to export the metrics to the console (e.g. when debugging)
diff --git a/config/clients/dotnet/template/README_retries.mustache b/config/clients/dotnet/template/README_retries.mustache
index af18dd41..4d0153b9 100644
--- a/config/clients/dotnet/template/README_retries.mustache
+++ b/config/clients/dotnet/template/README_retries.mustache
@@ -5,9 +5,9 @@ To customize this behavior, create a `RetryParams` instance and assign values to
 Apply your custom retry values by passing the object to the `ClientConfiguration` constructor's `RetryParams` parameter.
 
 ```csharp
-using OpenFga.Sdk.Client;
-using OpenFga.Sdk.Client.Model;
-using OpenFga.Sdk.Model;
+using {{packageName}}.Client;
+using {{packageName}}.Client.Model;
+using {{packageName}}.Model;
 
 namespace Example {
     public class Example {
diff --git a/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
index 4de46f21..c55054c6 100644
--- a/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
+++ b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
@@ -4,29 +4,10 @@ using {{packageName}}.ApiClient;
 using {{packageName}}.Configuration;
 using System.Diagnostics;
 using System.Net.Http.Headers;
-using System.Runtime.Serialization;
-using System.Text.Json;
-using System.Text.Json.Serialization;
+using System.Text.Json.Nodes;
 
 namespace {{packageName}}.Telemetry;
 
-internal interface IPartialApiRequest {
-    [DataMember(Name = "authorization_model_id", IsRequired = false, EmitDefaultValue = false)]
-    [JsonPropertyName("authorization_model_id")]
-    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
-    public string AuthorizationModelId { get; set; }
-
-    [DataMember(Name = "user", IsRequired = false, EmitDefaultValue = false)]
-    [JsonPropertyName("user")]
-    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
-    public string User { get; set; }
-
-    [DataMember(Name = "tuple_key.user", IsRequired = false, EmitDefaultValue = false)]
-    [JsonPropertyName("tuple_key.user")]
-    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
-    public string TupleKeyUser { get; set; }
-}
-
 public class Attributes {
     /**
      * Common attribute (tag) names
@@ -101,11 +82,10 @@ public class Attributes {
      * @param methodAttributes - Extra attributes that the method (i.e. check, listObjects) wishes to have included. Any custom attributes should use the common names.
      * @returns {Attributes}
      */
-    public static TagList buildAttributesForResponse(string apiName,
-        HttpResponseMessage response, RequestBuilder requestBuilder, Credentials? credentials,
+    public static TagList buildAttributesForResponse<T>(string apiName,
+        HttpResponseMessage response, RequestBuilder<T> requestBuilder, Credentials? credentials,
         Stopwatch requestDuration, int retryCount) {
-        var attributes = new TagList {
-            new (AttributeRequestMethod, apiName) };
+        var attributes = new TagList { new(AttributeRequestMethod, apiName) };
 
         if (requestBuilder.PathParameters.ContainsKey("store_id")) {
             var storeId = requestBuilder.PathParameters.GetValueOrDefault("store_id");
@@ -135,38 +115,47 @@ public class Attributes {
         // if the apiName is Write, Expand or ListUsers we want to parse it to get the model ID
         else if (apiName is "Check" or "ListObjects" or "Write" or "Expand" or "ListUsers") {
             try {
-                var partialApiResquest =
-                    JsonSerializer.Deserialize<IPartialApiRequest>(requestBuilder.Body?.ToString()!);
+                if (requestBuilder.JsonBody != null) {
+                    var apiRequest = JsonNode.Parse(requestBuilder.JsonBody!)!;
 
-                if (!string.IsNullOrEmpty(partialApiResquest?.AuthorizationModelId)) {
-                    attributes.Add(new KeyValuePair<string, object?>(AttributeRequestModelId,
-                        partialApiResquest.AuthorizationModelId));
-                }
+                    try {
+                        var authModelId = (string)apiRequest!["authorization_model_id"]!;
+
+                        if (!string.IsNullOrEmpty(authModelId)) {
+                            attributes.Add(new KeyValuePair<string, object?>(AttributeRequestModelId,
+                                authModelId));
+                        }
+                    }
+                    catch { }
 
-                switch (apiName) {
-                    case "Check": {
-                            if (!string.IsNullOrEmpty(partialApiResquest?.TupleKeyUser)) {
+                    switch (apiName) {
+                        case "Check": {
+                            var tupleKey = apiRequest!["tuple_key"]!;
+                            var fgaUser = (string)tupleKey!["user"]!;
+
+                            if (!string.IsNullOrEmpty(fgaUser)) {
                                 attributes.Add(new KeyValuePair<string, object?>(AttributeFgaRequestUser,
-                                    partialApiResquest.TupleKeyUser));
+                                    fgaUser));
                             }
 
                             break;
                         }
-                    case "ListObjects": {
-                            if (!string.IsNullOrEmpty(partialApiResquest?.User)) {
+                        case "ListObjects": {
+                            var fgaUser = (string)apiRequest!["user"]!;
+
+                            if (!string.IsNullOrEmpty(fgaUser)) {
                                 attributes.Add(new KeyValuePair<string, object?>(AttributeFgaRequestUser,
-                                    partialApiResquest.User));
+                                    fgaUser));
                             }
 
                             break;
                         }
+                    }
                 }
             }
-            catch {
-            }
+            catch { }
         }
 
-
         if (response.StatusCode != null) {
             attributes.Add(new KeyValuePair<string, object?>(AttributeHttpStatus, (int)response.StatusCode));
         }
diff --git a/config/clients/dotnet/template/Telemetry/Histograms.cs.mustache b/config/clients/dotnet/template/Telemetry/Histograms.cs.mustache
index 98967e45..9c218c7e 100644
--- a/config/clients/dotnet/template/Telemetry/Histograms.cs.mustache
+++ b/config/clients/dotnet/template/Telemetry/Histograms.cs.mustache
@@ -28,8 +28,9 @@ public class TelemetryHistograms {
 
     public void buildForResponse(HttpResponseMessage response, TagList attributes,
         Stopwatch requestDuration) {
-        if (response.Headers.Contains("fga-query-duration-ms")) {
-            var durationHeader = response.Headers.GetValues("fga-query-duration-ms").ToString();
+        if (response.Headers.Contains("fga-query-duration-ms") &&
+            response.Headers.GetValues("fga-query-duration-ms").Any()) {
+            var durationHeader = response.Headers.GetValues("fga-query-duration-ms").First();
             if (!string.IsNullOrEmpty(durationHeader)) {
                 var success = float.TryParse(durationHeader, out var durationFloat);
                 if (success) {
diff --git a/config/clients/dotnet/template/Telemetry/Metrics.cs.mustache b/config/clients/dotnet/template/Telemetry/Metrics.cs.mustache
index 11ff9f53..8753a324 100644
--- a/config/clients/dotnet/template/Telemetry/Metrics.cs.mustache
+++ b/config/clients/dotnet/template/Telemetry/Metrics.cs.mustache
@@ -18,13 +18,13 @@ public class Metrics {
         counters = new TelemetryCounters(meter);
     }
 
-    public void buildForResponse(string apiName, HttpResponseMessage response, RequestBuilder requestBuilder,
+    public void buildForResponse<T>(string apiName, HttpResponseMessage response, RequestBuilder<T> requestBuilder,
         Credentials? credentials, Stopwatch requestDuration, int retryCount) => histograms.buildForResponse(response,
         Attributes.buildAttributesForResponse(apiName, response, requestBuilder, credentials, requestDuration,
             retryCount),
         requestDuration);
 
-    public void buildForClientCredentialsResponse(HttpResponseMessage response, RequestBuilder requestBuilder,
+    public void buildForClientCredentialsResponse<T>(HttpResponseMessage response, RequestBuilder<T> requestBuilder,
         Credentials? credentials, Stopwatch requestDuration, int retryCount) => counters.buildForResponse(
         Attributes.buildAttributesForResponse("ClientCredentialsExchange", response, requestBuilder, credentials,
             requestDuration, retryCount));
diff --git a/config/clients/dotnet/template/api.mustache b/config/clients/dotnet/template/api.mustache
index e3b631bc..8e62088e 100644
--- a/config/clients/dotnet/template/api.mustache
+++ b/config/clients/dotnet/template/api.mustache
@@ -7,11 +7,11 @@ using {{packageName}}.{{modelPackage}};
 namespace {{packageName}}.{{apiPackage}};
 
 public class {{classname}} : IDisposable {
-    private Configuration.Configuration _configuration;
-    private ApiClient.ApiClient _apiClient;
+    private readonly ApiClient.ApiClient _apiClient;
+    private readonly Configuration.Configuration _configuration;
 
     /// <summary>
-    /// Initializes a new instance of the <see cref="{{classname}}"/> class.
+    ///     Initializes a new instance of the <see cref="{{classname}}"/> class.
     /// </summary>
     /// <param name="configuration"></param>
     /// <param name="httpClient"></param>
@@ -20,8 +20,8 @@ public class {{classname}} : IDisposable {
         HttpClient? httpClient = null
     ) {
         configuration.IsValid();
-        this._configuration = configuration;
-        this._apiClient = new ApiClient.ApiClient(_configuration, httpClient);
+        _configuration = configuration;
+        _apiClient = new ApiClient.ApiClient(_configuration, httpClient);
     }
 
     {{#operations}}
@@ -58,18 +58,18 @@ public class {{classname}} : IDisposable {
         }
         {{/queryParams}}
 
-        var requestBuilder = new RequestBuilder {
+        var requestBuilder = new RequestBuilder<{{#bodyParam}}{{{dataType}}}{{/bodyParam}}{{^bodyParam}}Any{{/bodyParam}}> {
             Method = new HttpMethod("{{httpMethod}}"),
             BasePath = _configuration.BasePath,
             PathTemplate = "{{path}}",
             PathParameters = pathParams,
             {{#bodyParam}}
-            Body = Utils.CreateJsonStringContent({{paramName}}),
+            Body = {{paramName}},
             {{/bodyParam}}
             QueryParameters = queryParams,
         };
 
-        {{#returnType}}return {{/returnType}}await this._apiClient.SendRequestAsync{{#returnType}}<{{{.}}}>{{/returnType}}(requestBuilder,
+        {{#returnType}}return {{/returnType}}await _apiClient.SendRequestAsync{{#returnType}}<{{#bodyParam}}{{{dataType}}}{{/bodyParam}}{{^bodyParam}}Any{{/bodyParam}}, {{{.}}}>{{/returnType}}(requestBuilder,
             "{{operationId}}", cancellationToken);
     }
 
diff --git a/config/clients/dotnet/template/example/Example1/Example1.cs b/config/clients/dotnet/template/example/Example1/Example1.cs
index f47bbea6..3324677f 100644
--- a/config/clients/dotnet/template/example/Example1/Example1.cs
+++ b/config/clients/dotnet/template/example/Example1/Example1.cs
@@ -15,7 +15,7 @@ public static async Task Main() {
                 credentials.Method = CredentialsMethod.ClientCredentials;
                 credentials.Config = new CredentialsConfig() {
                     ApiAudience = Environment.GetEnvironmentVariable("FGA_API_AUDIENCE"),
-                    ApiTokenIssuer = Environment.GetEnvironmentVariable("FGA_TOKEN_ISSUER"),
+                    ApiTokenIssuer = Environment.GetEnvironmentVariable("FGA_API_TOKEN_ISSUER"),
                     ClientId = Environment.GetEnvironmentVariable("FGA_CLIENT_ID"),
                     ClientSecret = Environment.GetEnvironmentVariable("FGA_CLIENT_SECRET")
                 };
diff --git a/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.cs b/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.cs
index 8f4219d8..16eeb238 100644
--- a/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.cs
+++ b/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.cs
@@ -19,11 +19,12 @@ public static async Task Main() {
                 credentials.Method = CredentialsMethod.ClientCredentials;
                 credentials.Config = new CredentialsConfig {
                     ApiAudience = Environment.GetEnvironmentVariable("FGA_API_AUDIENCE"),
-                    ApiTokenIssuer = Environment.GetEnvironmentVariable("FGA_TOKEN_ISSUER"),
+                    ApiTokenIssuer = Environment.GetEnvironmentVariable("FGA_API_TOKEN_ISSUER"),
                     ClientId = Environment.GetEnvironmentVariable("FGA_CLIENT_ID"),
                     ClientSecret = Environment.GetEnvironmentVariable("FGA_CLIENT_SECRET")
                 };
-            } else if (Environment.GetEnvironmentVariable("FGA_API_TOKEN") != null) {
+            }
+            else if (Environment.GetEnvironmentVariable("FGA_API_TOKEN") != null) {
                 credentials.Method = CredentialsMethod.ApiToken;
                 credentials.Config = new CredentialsConfig {
                     ApiToken = Environment.GetEnvironmentVariable("FGA_API_TOKEN")
@@ -55,28 +56,32 @@ public static async Task Main() {
                 .AddConsoleExporter() // Only needed to export the metrics to the console (e.g. when debugging)
                 .Build();
 
-            // ListStores
-            Console.WriteLine("Listing Stores");
-            var stores1 = await fgaClient.ListStores();
-            Console.WriteLine("Stores Count: " + stores1.Stores?.Count());
+            var performStoreActions = configuration.StoreId == null;
+            GetStoreResponse? currentStore = null;
+            if (performStoreActions) {
+                // ListStores
+                Console.WriteLine("Listing Stores");
+                var stores1 = await fgaClient.ListStores();
+                Console.WriteLine("Stores Count: " + stores1.Stores?.Count());
 
-            // CreateStore
-            Console.WriteLine("Creating Test Store");
-            var store = await fgaClient.CreateStore(new ClientCreateStoreRequest { Name = "Test Store" });
-            Console.WriteLine("Test Store ID: " + store.Id);
+                // CreateStore
+                Console.WriteLine("Creating Test Store");
+                var store = await fgaClient.CreateStore(new ClientCreateStoreRequest { Name = "Test Store" });
+                Console.WriteLine("Test Store ID: " + store.Id);
 
-            // Set the store id
-            fgaClient.StoreId = store.Id;
+                // Set the store id
+                fgaClient.StoreId = store.Id;
 
-            // ListStores after Create
-            Console.WriteLine("Listing Stores");
-            var stores = await fgaClient.ListStores();
-            Console.WriteLine("Stores Count: " + stores.Stores?.Count());
+                // ListStores after Create
+                Console.WriteLine("Listing Stores");
+                var stores = await fgaClient.ListStores();
+                Console.WriteLine("Stores Count: " + stores.Stores?.Count());
 
-            // GetStore
-            Console.WriteLine("Getting Current Store");
-            var currentStore = await fgaClient.GetStore();
-            Console.WriteLine("Current Store Name: " + currentStore.Name);
+                // GetStore
+                Console.WriteLine("Getting Current Store");
+                currentStore = await fgaClient.GetStore();
+                Console.WriteLine("Current Store Name: " + currentStore.Name);
+            }
 
             // ReadAuthorizationModels
             Console.WriteLine("Reading Authorization Models");
@@ -152,6 +157,7 @@ public static async Task Main() {
                 }
             };
             var authorizationModel = await fgaClient.WriteAuthorizationModel(body);
+            Thread.Sleep(1000);
             Console.WriteLine("Authorization Model ID " + authorizationModel.AuthorizationModelId);
 
             // ReadAuthorizationModels - after Write
@@ -166,6 +172,23 @@ public static async Task Main() {
             // Set the model ID
             fgaClient.AuthorizationModelId = latestAuthorizationModel?.AuthorizationModel?.Id;
 
+            var contToken = "";
+            do {
+                // Read All Tuples
+                Console.WriteLine("Reading All Tuples (paginated)");
+                var existingTuples =
+                    await fgaClient.Read(null, new ClientReadOptions { ContinuationToken = contToken });
+                contToken = existingTuples.ContinuationToken;
+
+                // Deleting All Tuples
+                Console.WriteLine("Deleting All Tuples (paginated)");
+                var tuplesToDelete = new List<ClientTupleKeyWithoutCondition>();
+                existingTuples.Tuples.ForEach(tuple => tuplesToDelete.Add(new ClientTupleKeyWithoutCondition {
+                    User = tuple.Key.User, Relation = tuple.Key.Relation, Object = tuple.Key.Object
+                }));
+                await fgaClient.DeleteTuples(tuplesToDelete);
+            } while (contToken != "");
+
             // Write
             Console.WriteLine("Writing Tuples");
             await fgaClient.Write(
@@ -225,20 +248,25 @@ await fgaClient.WriteAssertions(new List<ClientAssertion> {
             Console.WriteLine("Assertions " + assertions.ToJson());
 
             // Checking for access w/ context in a loop
-            Random rnd = new Random();
-            int randomNumber = rnd.Next(1, 1000);
+            var rnd = new Random();
+            var randomNumber = rnd.Next(1, 1000);
             Console.WriteLine($"Checking for access with context in a loop ({randomNumber} times)");
-            for (int index = 0; index < randomNumber; index++) {
+            for (var index = 0; index < randomNumber; index++) {
                 checkResponse = await fgaClient.Check(new ClientCheckRequest {
-                    User = "user:anne", Relation = "viewer", Object = "document:roadmap", Context = new { ViewCount = 100 }
+                    User = "user:anne",
+                    Relation = "viewer",
+                    Object = "document:roadmap",
+                    Context = new { ViewCount = 100 }
                 });
                 Console.WriteLine("Allowed: " + checkResponse.Allowed);
             }
 
-            // DeleteStore
-            Console.WriteLine("Deleting Current Store");
-            await fgaClient.DeleteStore();
-            Console.WriteLine("Deleted Store: " + currentStore.Name);
+            if (performStoreActions) {
+                // DeleteStore
+                Console.WriteLine("Deleting Current Store");
+                await fgaClient.DeleteStore();
+                Console.WriteLine("Deleted Store: " + currentStore?.Name);
+            }
         }
         catch (ApiException e) {
             Console.WriteLine("Error: " + e);

From 9ad57f0c98ffbbcec664304b25197f16a8ec4eb2 Mon Sep 17 00:00:00 2001
From: Raghd Hamzeh <raghd.hamzeh@auth0.com>
Date: Tue, 30 Jul 2024 09:02:22 -0400
Subject: [PATCH 06/12] fix(dotnet): set token exchange content type as
 "application/x-www-form-urlencode"

---
 .../template/Client_OAuth2Client.mustache     |  3 ++-
 .../template/Client_RequestBuilder.mustache   | 23 ++++++++++++++++++-
 2 files changed, 24 insertions(+), 2 deletions(-)

diff --git a/config/clients/dotnet/template/Client_OAuth2Client.mustache b/config/clients/dotnet/template/Client_OAuth2Client.mustache
index c5f1a159..b51ef15c 100644
--- a/config/clients/dotnet/template/Client_OAuth2Client.mustache
+++ b/config/clients/dotnet/template/Client_OAuth2Client.mustache
@@ -109,7 +109,8 @@ public class OAuth2Client {
             Method = HttpMethod.Post,
             BasePath = $"https://{_apiTokenIssuer}",
             PathTemplate = "/oauth/token",
-            Body = _authRequest
+            Body = _authRequest,
+            ContentType = "application/x-www-form-urlencode"
         };
 
         var sw = Stopwatch.StartNew();
diff --git a/config/clients/dotnet/template/Client_RequestBuilder.mustache b/config/clients/dotnet/template/Client_RequestBuilder.mustache
index 663e16e7..8e7ee1e0 100644
--- a/config/clients/dotnet/template/Client_RequestBuilder.mustache
+++ b/config/clients/dotnet/template/Client_RequestBuilder.mustache
@@ -28,8 +28,29 @@ public class RequestBuilder<TReq> {
 
     public string? JsonBody => Body == null ? null : JsonSerializer.Serialize(Body);
 
+    public HttpContent? FormEncodedBody {
+        get {
+            if (Body == null) {
+                return null;
+            }
+
+            if (ContentType != "application/x-www-form-urlencode") {
+                throw new Exception(
+                    "Content type must be \"application/x-www-form-urlencode\" in order to get the FormEncoded representation");
+            }
+
+            var body = (IDictionary<string, string>)Body;
+
+            return new FormUrlEncodedContent(body.Select(p =>
+                new KeyValuePair<string, string>(p.Key, p.Value ?? "")));
+        }
+    }
+
     private HttpContent? HttpContentBody =>
-        Body == null ? null : new StringContent(JsonBody, Encoding.UTF8, "application/json");
+        Body == null ? null :
+        ContentType == "application/json" ? new StringContent(JsonBody, Encoding.UTF8, ContentType) : FormEncodedBody;
+
+    public string ContentType { get; set; } = "application/json";
 
     public string BuildPathString() {
         if (PathTemplate == null) {

From 5564e5a4900bcd43a21e79a971328c4360b76a2e Mon Sep 17 00:00:00 2001
From: Raghd Hamzeh <raghd.hamzeh@auth0.com>
Date: Tue, 30 Jul 2024 09:43:32 -0400
Subject: [PATCH 07/12] fix(dotnet): `http.request.resend_count` metric no
 longer counts original request

---
 .../dotnet/template/Client_ApiClient.mustache | 10 +++---
 .../dotnet/template/OpenTelemetry.md.mustache | 34 +++++++++----------
 .../template/Telemetry/Attributes.cs.mustache |  7 ++--
 3 files changed, 27 insertions(+), 24 deletions(-)

diff --git a/config/clients/dotnet/template/Client_ApiClient.mustache b/config/clients/dotnet/template/Client_ApiClient.mustache
index a2f5c161..97e8753f 100644
--- a/config/clients/dotnet/template/Client_ApiClient.mustache
+++ b/config/clients/dotnet/template/Client_ApiClient.mustache
@@ -123,19 +123,19 @@ public class ApiClient : IDisposable {
     }
 
     private async Task<ResponseWrapper<TResult>> Retry<TResult>(Func<Task<ResponseWrapper<TResult>>> retryable) {
-        var numRetries = 0;
+        var requestCount = 0;
         while (true) {
             try {
-                numRetries++;
+                requestCount++;
 
                 var response = await retryable();
 
-                response.retryCount = numRetries;
+                response.retryCount = requestCount - 1; // OTEL spec specifies that the original request is not included in the count
 
                 return response;
             }
             catch (FgaApiRateLimitExceededError err) {
-                if (numRetries > _configuration.MaxRetry) {
+                if (requestCount > _configuration.MaxRetry) {
                     throw;
                 }
 
@@ -146,7 +146,7 @@ public class ApiClient : IDisposable {
                 await Task.Delay(waitInMs);
             }
             catch (FgaApiError err) {
-                if (!err.ShouldRetry || numRetries > _configuration.MaxRetry) {
+                if (!err.ShouldRetry || requestCount > _configuration.MaxRetry) {
                     throw;
                 }
 
diff --git a/config/clients/dotnet/template/OpenTelemetry.md.mustache b/config/clients/dotnet/template/OpenTelemetry.md.mustache
index 3085ce03..28d4e4d8 100644
--- a/config/clients/dotnet/template/OpenTelemetry.md.mustache
+++ b/config/clients/dotnet/template/OpenTelemetry.md.mustache
@@ -18,22 +18,22 @@ In cases when metrics events are sent, they will not be viewable outside of infr
 
 ### Supported attributes
 
-| Attribute Name                 | Type     | Description                                                                                                             |
-|--------------------------------|----------|-------------------------------------------------------------------------------------------------------------------------|
-| `fga-client.response.model_id` | `string` | The authorization model ID that the FGA server used                                                                     |
-| `fga-client.request.method`    | `string` | The FGA method/action that was performed (e.g. `Check`, `ListObjects`, ...) in TitleCase                                |
-| `fga-client.request.store_id`  | `string` | The store ID that was sent as part of the request                                                                       |
-| `fga-client.request.model_id`  | `string` | The authorization model ID that was sent as part of the request, if any                                                 |
-| `fga-client.request.client_id` | `string` | The client ID associated with the request, if any                                                                       |
-| `fga-client.user`              | `string` | The user that is associated with the action of the request for check and list objects                                   |
-| `http.request.resend_count`    | `int`    | The number of retries attempted (starting from 1 for the original request)                                              |
-| `http.response.status_code`    | `int`    | The status code of the response                                                                                         |
-| `http.request.method`          | `string` | The HTTP method for the request                                                                                         |
-| `http.host`                    | `string` | Host identifier of the origin the request was sent to                                                                   |
-| `url.scheme`                   | `string` | HTTP Scheme of the request (`http`/`https`)                                                                             |
-| `url.full`                     | `string` | Full URL of the request                                                                                                 |
-| `user_agent.original`          | `string` | User Agent used in the query                                                                                            |
-| `http.client.request.duration` | `int`    | The total request time for FGA requests                                                                                 |
-| `http.server.request.duration` | `int`    | The amount of time the FGA server took to internally process nd evaluate the request                                    |
+| Attribute Name                 | Type     | Description                                                                                                                                                 |
+|--------------------------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `fga-client.response.model_id` | `string` | The authorization model ID that the FGA server used                                                                                                         |
+| `fga-client.request.method`    | `string` | The FGA method/action that was performed (e.g. `Check`, `ListObjects`, ...) in TitleCase                                                                    |
+| `fga-client.request.store_id`  | `string` | The store ID that was sent as part of the request                                                                                                           |
+| `fga-client.request.model_id`  | `string` | The authorization model ID that was sent as part of the request, if any                                                                                     |
+| `fga-client.request.client_id` | `string` | The client ID associated with the request, if any                                                                                                           |
+| `fga-client.user`              | `string` | The user that is associated with the action of the request for check and list objects                                                                       |
+| `http.request.resend_count`    | `int`    | The number of retries attempted (Only sent if the request was retried. Count of `1` means the request was retried once in addition to the original request) |
+| `http.response.status_code`    | `int`    | The status code of the response                                                                                                                             |
+| `http.request.method`          | `string` | The HTTP method for the request                                                                                                                             |
+| `http.host`                    | `string` | Host identifier of the origin the request was sent to                                                                                                       |
+| `url.scheme`                   | `string` | HTTP Scheme of the request (`http`/`https`)                                                                                                                 |
+| `url.full`                     | `string` | Full URL of the request                                                                                                                                     |
+| `user_agent.original`          | `string` | User Agent used in the query                                                                                                                                |
+| `http.client.request.duration` | `int`    | The total request time for FGA requests                                                                                                                     |
+| `http.server.request.duration` | `int`    | The amount of time the FGA server took to internally process nd evaluate the request                                                                        |
 
 {{>OpenTelemetryDocs_custom}}
\ No newline at end of file
diff --git a/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
index c55054c6..068da137 100644
--- a/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
+++ b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
@@ -63,7 +63,7 @@ public class Attributes {
     // User Agent used in the query
     private const string AttributeHttpUserAgent = "user_agent.original";
 
-    // The number of retries attempted (starting from 1 for the original request)
+    // The number of retries attempted (Only sent if the request was retried. Count of `1` means the request was retried once in addition to the original request)
     private const string AttributeRequestRetryCount = "http.request.resend_count";
 
     private static string? GetHeaderValueIfValid(HttpResponseHeaders headers, string headerName) {
@@ -207,7 +207,10 @@ public class Attributes {
         attributes.Add(new KeyValuePair<string, object?>(AttributeHttpClientRequestDuration,
             requestDuration.ElapsedMilliseconds));
 
-        attributes.Add(new KeyValuePair<string, object?>(AttributeRequestRetryCount, retryCount));
+        // OTEL specifies that this value should be conditionally sent if a retry occurred
+        if (retryCount > 0) {
+            attributes.Add(new KeyValuePair<string, object?>(AttributeRequestRetryCount, retryCount));
+        }
 
         return attributes;
     }

From 6373183e073ac3c6c1a2ea82d5a9f6c96ae233f1 Mon Sep 17 00:00:00 2001
From: Raghd Hamzeh <raghd.hamzeh@auth0.com>
Date: Tue, 30 Jul 2024 11:29:00 -0400
Subject: [PATCH 08/12] chore(dotnet): use JsonDocument to parse body for
 attributes in metrics

---
 .../template/Telemetry/Attributes.cs.mustache | 58 +++++++++----------
 1 file changed, 27 insertions(+), 31 deletions(-)

diff --git a/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
index 068da137..cc4cd1e7 100644
--- a/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
+++ b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
@@ -4,7 +4,7 @@ using {{packageName}}.ApiClient;
 using {{packageName}}.Configuration;
 using System.Diagnostics;
 using System.Net.Http.Headers;
-using System.Text.Json.Nodes;
+using System.Text.Json;
 
 namespace {{packageName}}.Telemetry;
 
@@ -116,39 +116,35 @@ public class Attributes {
         else if (apiName is "Check" or "ListObjects" or "Write" or "Expand" or "ListUsers") {
             try {
                 if (requestBuilder.JsonBody != null) {
-                    var apiRequest = JsonNode.Parse(requestBuilder.JsonBody!)!;
-
-                    try {
-                        var authModelId = (string)apiRequest!["authorization_model_id"]!;
-
-                        if (!string.IsNullOrEmpty(authModelId)) {
-                            attributes.Add(new KeyValuePair<string, object?>(AttributeRequestModelId,
-                                authModelId));
-                        }
-                    }
-                    catch { }
-
-                    switch (apiName) {
-                        case "Check": {
-                            var tupleKey = apiRequest!["tuple_key"]!;
-                            var fgaUser = (string)tupleKey!["user"]!;
-
-                            if (!string.IsNullOrEmpty(fgaUser)) {
-                                attributes.Add(new KeyValuePair<string, object?>(AttributeFgaRequestUser,
-                                    fgaUser));
+                    using (var document = JsonDocument.Parse(requestBuilder.JsonBody)) {
+                        var root = document.RootElement;
+                        if (root.TryGetProperty("authorization_model_id", out var authModelId)) {
+                            if (!string.IsNullOrEmpty(authModelId.GetString())) {
+                                attributes.Add(new KeyValuePair<string, object?>(AttributeRequestModelId,
+                                    authModelId.GetString()));
                             }
 
-                            break;
-                        }
-                        case "ListObjects": {
-                            var fgaUser = (string)apiRequest!["user"]!;
-
-                            if (!string.IsNullOrEmpty(fgaUser)) {
-                                attributes.Add(new KeyValuePair<string, object?>(AttributeFgaRequestUser,
-                                    fgaUser));
+                            switch (apiName) {
+                                case "Check": {
+                                    if (root.TryGetProperty("tuple_key", out var tupleKey) &&
+                                        tupleKey.TryGetProperty("user", out var fgaUser) &&
+                                        !string.IsNullOrEmpty(fgaUser.GetString())) {
+                                        attributes.Add(new KeyValuePair<string, object?>(AttributeFgaRequestUser,
+                                            fgaUser.GetString()));
+                                    }
+
+                                    break;
+                                }
+                                case "ListObjects": {
+                                    if (root.TryGetProperty("user", out var fgaUser) &&
+                                        !string.IsNullOrEmpty(fgaUser.GetString())) {
+                                        attributes.Add(new KeyValuePair<string, object?>(AttributeFgaRequestUser,
+                                            fgaUser.GetString()));
+                                    }
+
+                                    break;
+                                }
                             }
-
-                            break;
                         }
                     }
                 }

From d2641d63376efa8428b9d825954c4b9d188efb1c Mon Sep 17 00:00:00 2001
From: Raghd Hamzeh <raghd.hamzeh@auth0.com>
Date: Tue, 30 Jul 2024 14:55:09 -0400
Subject: [PATCH 09/12] chore(dotnet): drop client and server request durations
 from attributes

Considering that we already have histograms for them, and the const impact of
having attributes with high variance, we're dropping the following two
attributes:
- `http.client.request.duration`
- `http.server.request.duration`
---
 .../clients/dotnet/template/OpenTelemetry.md.mustache |  4 +---
 .../dotnet/template/Telemetry/Attributes.cs.mustache  | 11 -----------
 2 files changed, 1 insertion(+), 14 deletions(-)

diff --git a/config/clients/dotnet/template/OpenTelemetry.md.mustache b/config/clients/dotnet/template/OpenTelemetry.md.mustache
index 28d4e4d8..8607aad1 100644
--- a/config/clients/dotnet/template/OpenTelemetry.md.mustache
+++ b/config/clients/dotnet/template/OpenTelemetry.md.mustache
@@ -25,7 +25,7 @@ In cases when metrics events are sent, they will not be viewable outside of infr
 | `fga-client.request.store_id`  | `string` | The store ID that was sent as part of the request                                                                                                           |
 | `fga-client.request.model_id`  | `string` | The authorization model ID that was sent as part of the request, if any                                                                                     |
 | `fga-client.request.client_id` | `string` | The client ID associated with the request, if any                                                                                                           |
-| `fga-client.user`              | `string` | The user that is associated with the action of the request for check and list objects                                                                       |
+| `fga-client.user`              | `string` | The user that is associated with the action of the request for `Check` and `ListObjects`                                                                    |
 | `http.request.resend_count`    | `int`    | The number of retries attempted (Only sent if the request was retried. Count of `1` means the request was retried once in addition to the original request) |
 | `http.response.status_code`    | `int`    | The status code of the response                                                                                                                             |
 | `http.request.method`          | `string` | The HTTP method for the request                                                                                                                             |
@@ -33,7 +33,5 @@ In cases when metrics events are sent, they will not be viewable outside of infr
 | `url.scheme`                   | `string` | HTTP Scheme of the request (`http`/`https`)                                                                                                                 |
 | `url.full`                     | `string` | Full URL of the request                                                                                                                                     |
 | `user_agent.original`          | `string` | User Agent used in the query                                                                                                                                |
-| `http.client.request.duration` | `int`    | The total request time for FGA requests                                                                                                                     |
-| `http.server.request.duration` | `int`    | The amount of time the FGA server took to internally process nd evaluate the request                                                                        |
 
 {{>OpenTelemetryDocs_custom}}
\ No newline at end of file
diff --git a/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
index cc4cd1e7..99f1e5a0 100644
--- a/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
+++ b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
@@ -192,17 +192,6 @@ public class Attributes {
             attributes.Add(new KeyValuePair<string, object?>(AttributeRequestClientId, credentials.Config.ClientId));
         }
 
-        var durationHeader = GetHeaderValueIfValid(response.Headers, "fga-query-duration-ms");
-        if (!string.IsNullOrEmpty(durationHeader)) {
-            var success = float.TryParse(durationHeader, out var durationFloat);
-            if (success) {
-                attributes.Add(new KeyValuePair<string, object?>(AttributeHttpServerRequestDuration, durationFloat));
-            }
-        }
-
-        attributes.Add(new KeyValuePair<string, object?>(AttributeHttpClientRequestDuration,
-            requestDuration.ElapsedMilliseconds));
-
         // OTEL specifies that this value should be conditionally sent if a retry occurred
         if (retryCount > 0) {
             attributes.Add(new KeyValuePair<string, object?>(AttributeRequestRetryCount, retryCount));

From 5fbcdf0daa17b8f7630a3a2aba027dd7ea8a8ebd Mon Sep 17 00:00:00 2001
From: Raghd Hamzeh <raghd.hamzeh@auth0.com>
Date: Tue, 30 Jul 2024 10:33:00 -0400
Subject: [PATCH 10/12] release(dotnet): v0.5.1

---
 config/clients/dotnet/CHANGELOG.md.mustache | 5 +++++
 config/clients/dotnet/config.overrides.json | 2 +-
 2 files changed, 6 insertions(+), 1 deletion(-)

diff --git a/config/clients/dotnet/CHANGELOG.md.mustache b/config/clients/dotnet/CHANGELOG.md.mustache
index 3195ec25..e44d6e8e 100644
--- a/config/clients/dotnet/CHANGELOG.md.mustache
+++ b/config/clients/dotnet/CHANGELOG.md.mustache
@@ -2,6 +2,11 @@
 
 ## [Unreleased](https://github.com/openfga/dotnet-sdk/compare/v{{packageVersion}}...HEAD)
 
+## v0.5.1
+
+### [0.5.1](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.4.0...v0.4.1) (2024-09-10)
+- feat: export OpenTelemetry metrics. Refer to the [https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/blob/main/OpenTelemetry.md](documentation) for more.
+
 ## v0.5.0
 
 ### [0.5.0](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.4.0...v0.5.0) (2024-08-28)
diff --git a/config/clients/dotnet/config.overrides.json b/config/clients/dotnet/config.overrides.json
index 2aad33cc..0ae2b1bb 100644
--- a/config/clients/dotnet/config.overrides.json
+++ b/config/clients/dotnet/config.overrides.json
@@ -10,7 +10,7 @@
   "packageGuid": "b8d9e3e9-0156-4948-9de7-5e0d3f9c4d9e",
   "testPackageGuid": "d119dfae-509a-4eba-a973-645b739356fc",
   "packageName": "OpenFga.Sdk",
-  "packageVersion": "0.5.0",
+  "packageVersion": "0.5.1",
   "licenseUrl": "https://github.com/openfga/dotnet-sdk/blob/main/LICENSE",
   "fossaComplianceNoticeId": "f8ac2ec4-84fc-44f4-a617-5800cd3d180e",
   "termsOfService": "",

From 8ebbe03007c71216ef578775b62281cd719e2c4c Mon Sep 17 00:00:00 2001
From: Ewan Harris <ewan.harris@okta.com>
Date: Tue, 5 Nov 2024 09:31:53 +0000
Subject: [PATCH 11/12] chore(dotnet-sdk): sync latest telemetry work back

---
 config/clients/dotnet/CHANGELOG.md.mustache   |   2 +-
 config/clients/dotnet/config.overrides.json   |   8 +
 .../dotnet/template/Client/Client.mustache    |   2 +-
 .../Client/ClientConfiguration.mustache       |  48 ++-
 .../dotnet/template/Client_ApiClient.mustache |  16 +-
 .../template/Client_OAuth2Client.mustache     |  24 +-
 .../Configuration_Configuration.mustache      |  60 +--
 .../Configuration_Credentials.mustache        |   7 +-
 .../Configuration_TelemetryConfig.mustache    | 105 +++++
 .../template/OpenFgaClientTests.mustache      |   2 +-
 .../dotnet/template/OpenTelemetry.md.mustache |  49 ++-
 .../OpenTelemetryDocs_custom.mustache         |  74 +++-
 .../template/Telemetry/Attributes.cs.mustache | 369 ++++++++++++------
 .../template/Telemetry/Counters.cs.mustache   |  31 +-
 .../template/Telemetry/Histograms.cs.mustache |  64 +--
 .../template/Telemetry/Meters.cs.mustache     |  45 +++
 .../template/Telemetry/Metrics.cs.mustache    | 106 ++++-
 config/clients/dotnet/template/api.mustache   |   2 +-
 .../clients/dotnet/template/api_test.mustache |  18 +-
 .../OpenTelemetryExample.cs                   |  56 ++-
 20 files changed, 800 insertions(+), 288 deletions(-)
 create mode 100644 config/clients/dotnet/template/Configuration_TelemetryConfig.mustache
 create mode 100644 config/clients/dotnet/template/Telemetry/Meters.cs.mustache

diff --git a/config/clients/dotnet/CHANGELOG.md.mustache b/config/clients/dotnet/CHANGELOG.md.mustache
index e44d6e8e..0d65388b 100644
--- a/config/clients/dotnet/CHANGELOG.md.mustache
+++ b/config/clients/dotnet/CHANGELOG.md.mustache
@@ -4,7 +4,7 @@
 
 ## v0.5.1
 
-### [0.5.1](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.4.0...v0.4.1) (2024-09-10)
+### [0.5.1](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.5.0...v0.5.1) (2024-09-09)
 - feat: export OpenTelemetry metrics. Refer to the [https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/blob/main/OpenTelemetry.md](documentation) for more.
 
 ## v0.5.0
diff --git a/config/clients/dotnet/config.overrides.json b/config/clients/dotnet/config.overrides.json
index 0ae2b1bb..667d46ac 100644
--- a/config/clients/dotnet/config.overrides.json
+++ b/config/clients/dotnet/config.overrides.json
@@ -239,6 +239,10 @@
       "destinationFilename": "src/OpenFga.Sdk/Telemetry/Histograms.cs",
       "templateType": "SupportingFiles"
     },
+    "Telemetry/Meters.cs.mustache": {
+      "destinationFilename": "src/OpenFga.Sdk/Telemetry/Meters.cs",
+      "templateType": "SupportingFiles"
+    },
     "Telemetry/Metrics.cs.mustache": {
       "destinationFilename": "src/OpenFga.Sdk/Telemetry/Metrics.cs",
       "templateType": "SupportingFiles"
@@ -247,6 +251,10 @@
       "destinationFilename": "src/OpenFga.Sdk/Configuration/Configuration.cs",
       "templateType": "SupportingFiles"
     },
+    "Configuration_TelemetryConfig.mustache": {
+      "destinationFilename": "src/OpenFga.Sdk/Configuration/TelemetryConfig.cs",
+      "templateType": "SupportingFiles"
+    },
     "Exceptions_Parsers_ApiErrorParser.mustache": {
       "destinationFilename": "src/OpenFga.Sdk/Exceptions/Parsers/ApiErrorParser.cs",
       "templateType": "SupportingFiles"
diff --git a/config/clients/dotnet/template/Client/Client.mustache b/config/clients/dotnet/template/Client/Client.mustache
index 849244c7..b0eae214 100644
--- a/config/clients/dotnet/template/Client/Client.mustache
+++ b/config/clients/dotnet/template/Client/Client.mustache
@@ -22,7 +22,7 @@ public class {{appShortName}}Client : IDisposable {
         ClientConfiguration configuration,
         HttpClient? httpClient = null
     ) {
-        configuration.IsValid();
+        configuration.EnsureValid();
         _configuration = configuration;
         api = new {{appShortName}}Api(_configuration, httpClient);
     }
diff --git a/config/clients/dotnet/template/Client/ClientConfiguration.mustache b/config/clients/dotnet/template/Client/ClientConfiguration.mustache
index 5eb0958f..96895052 100644
--- a/config/clients/dotnet/template/Client/ClientConfiguration.mustache
+++ b/config/clients/dotnet/template/Client/ClientConfiguration.mustache
@@ -7,7 +7,20 @@ using {{packageName}}.Exceptions;
 
 namespace {{packageName}}.Client;
 
+/// <summary>
+/// Class for managing telemetry settings.
+/// </summary>
+public class Telemetry {
+}
+
+/// <summary>
+/// Configuration class for the {{packageName}} client.
+/// </summary>
 public class ClientConfiguration : Configuration.Configuration {
+    /// <summary>
+    /// Initializes a new instance of the <see cref="ClientConfiguration"/> class with the specified configuration.
+    /// </summary>
+    /// <param name="config">The base configuration to copy settings from.</param>
     public ClientConfiguration(Configuration.Configuration config) {
         ApiScheme = config.ApiScheme;
         ApiHost = config.ApiHost;
@@ -15,42 +28,55 @@ public class ClientConfiguration : Configuration.Configuration {
         UserAgent = config.UserAgent;
         Credentials = config.Credentials;
         DefaultHeaders = config.DefaultHeaders;
+        Telemetry = config.Telemetry;
         RetryParams = new RetryParams {MaxRetry = config.MaxRetry, MinWaitInMs = config.MinWaitInMs};
     }
 
+    /// <summary>
+    /// Initializes a new instance of the <see cref="ClientConfiguration"/> class.
+    /// </summary>
     public ClientConfiguration() { }
 
     /// <summary>
-    ///     Gets or sets the Store  ID.
+    /// Gets or sets the Store ID.
     /// </summary>
-    /// <value>Store ID.</value>
+    /// <value>The Store ID.</value>
     public string? StoreId { get; set; }
 
     /// <summary>
-    ///     Gets or sets the Authorization Model ID.
+    /// Gets or sets the Authorization Model ID.
     /// </summary>
-    /// <value>Authorization Model ID.</value>
+    /// <value>The Authorization Model ID.</value>
     public string? AuthorizationModelId { get; set; }
 
+    /// <summary>
+    /// Gets or sets the retry parameters.
+    /// </summary>
+    /// <value>The retry parameters.</value>
     public RetryParams? RetryParams { get; set; } = new();
 
-    public new void IsValid() {
-        base.IsValid();
+    /// <summary>
+    /// Ensures the configuration is valid, otherwise throws an error.
+    /// </summary>
+    /// <exception cref="FgaValidationError">Thrown when the Store ID or Authorization Model ID is not in a valid ULID format.</exception>
+    public new void EnsureValid() {
+        base.EnsureValid();
 
         if (StoreId != null && !IsWellFormedUlidString(StoreId)) {
           throw new FgaValidationError("StoreId is not in a valid ulid format");
         }
 
-        if (AuthorizationModelId != null && AuthorizationModelId != "" && !IsWellFormedUlidString(AuthorizationModelId)) {
-          throw new FgaValidationError("AuthorizationModelId is not in a valid ulid format");
+        if (!string.IsNullOrEmpty(AuthorizationModelId) &&
+            !IsWellFormedUlidString(AuthorizationModelId)) {
+            throw new FgaValidationError("AuthorizationModelId is not in a valid ulid format");
         }
     }
 
     /// <summary>
-    /// Ensures that a string is in valid [ULID](https://github.com/ulid/spec) format
+    /// Ensures that a string is in valid [ULID](https://github.com/ulid/spec) format.
     /// </summary>
-    /// <param name="ulid"></param>
-    /// <returns></returns>
+    /// <param name="ulid">The string to validate as a ULID.</param>
+    /// <returns>True if the string is a valid ULID, otherwise false.</returns>
     public static bool IsWellFormedUlidString(string ulid) {
         var regex = new Regex("^[0-7][0-9A-HJKMNP-TV-Z]{25}$");
         return regex.IsMatch(ulid);
diff --git a/config/clients/dotnet/template/Client_ApiClient.mustache b/config/clients/dotnet/template/Client_ApiClient.mustache
index 97e8753f..1f9c4a24 100644
--- a/config/clients/dotnet/template/Client_ApiClient.mustache
+++ b/config/clients/dotnet/template/Client_ApiClient.mustache
@@ -15,7 +15,7 @@ public class ApiClient : IDisposable {
     private readonly BaseClient _baseClient;
     private readonly Configuration.Configuration _configuration;
     private readonly OAuth2Client? _oauth2Client;
-    private readonly Metrics metrics = new();
+    private readonly Metrics metrics;
 
     /// <summary>
     ///     Initializes a new instance of the <see cref="ApiClient" /> class.
@@ -23,10 +23,12 @@ public class ApiClient : IDisposable {
     /// <param name="configuration">Client Configuration</param>
     /// <param name="userHttpClient">User Http Client - Allows Http Client reuse</param>
     public ApiClient(Configuration.Configuration configuration, HttpClient? userHttpClient = null) {
-        configuration.IsValid();
+        configuration.EnsureValid();
         _configuration = configuration;
         _baseClient = new BaseClient(configuration, userHttpClient);
 
+        metrics = new Metrics(_configuration);
+
         if (_configuration.Credentials == null) {
             return;
         }
@@ -39,7 +41,8 @@ public class ApiClient : IDisposable {
                 break;
             case CredentialsMethod.ClientCredentials:
                 _oauth2Client = new OAuth2Client(_configuration.Credentials, _baseClient,
-                    new RetryParams { MaxRetry = _configuration.MaxRetry, MinWaitInMs = _configuration.MinWaitInMs });
+                    new RetryParams { MaxRetry = _configuration.MaxRetry, MinWaitInMs = _configuration.MinWaitInMs },
+                    metrics);
                 break;
             case CredentialsMethod.None:
             default:
@@ -81,7 +84,7 @@ public class ApiClient : IDisposable {
                 cancellationToken));
 
         sw.Stop();
-        metrics.buildForResponse(apiName, response.rawResponse, requestBuilder, _configuration.Credentials, sw,
+        metrics.BuildForResponse(apiName, response.rawResponse, requestBuilder, sw,
             response.retryCount);
 
         return response.responseContent;
@@ -118,7 +121,7 @@ public class ApiClient : IDisposable {
                 cancellationToken));
 
         sw.Stop();
-        metrics.buildForResponse(apiName, response.rawResponse, requestBuilder, _configuration.Credentials, sw,
+        metrics.BuildForResponse(apiName, response.rawResponse, requestBuilder, sw,
             response.retryCount);
     }
 
@@ -130,7 +133,8 @@ public class ApiClient : IDisposable {
 
                 var response = await retryable();
 
-                response.retryCount = requestCount - 1; // OTEL spec specifies that the original request is not included in the count
+                response.retryCount =
+                    requestCount - 1; // OTEL spec specifies that the original request is not included in the count
 
                 return response;
             }
diff --git a/config/clients/dotnet/template/Client_OAuth2Client.mustache b/config/clients/dotnet/template/Client_OAuth2Client.mustache
index b51ef15c..67b44944 100644
--- a/config/clients/dotnet/template/Client_OAuth2Client.mustache
+++ b/config/clients/dotnet/template/Client_OAuth2Client.mustache
@@ -19,7 +19,7 @@ public class OAuth2Client {
         TOKEN_EXPIRY_JITTER_IN_SEC = 300; // We add some jitter so that token refreshes are less likely to collide
 
     private static readonly Random _random = new();
-    private readonly Metrics metrics = new();
+    private readonly Metrics metrics;
 
     /// <summary>
     ///     Credentials Flow Response
@@ -65,7 +65,6 @@ public class OAuth2Client {
     private IDictionary<string, string> _authRequest { get; }
     private string _apiTokenIssuer { get; }
     private readonly RetryParams _retryParams;
-    private readonly Credentials _credentialsConfig;
 
     #endregion
 
@@ -77,7 +76,12 @@ public class OAuth2Client {
     /// <param name="credentialsConfig"></param>
     /// <param name="httpClient"></param>
     /// <exception cref="NullReferenceException"></exception>
-    public OAuth2Client(Credentials credentialsConfig, BaseClient httpClient, RetryParams retryParams) {
+    public OAuth2Client(Credentials credentialsConfig, BaseClient httpClient, RetryParams retryParams,
+        Metrics metrics) {
+      if (credentialsConfig == null) {
+            throw new Exception("Credentials are required for OAuth2Client");
+        }
+
         if (string.IsNullOrWhiteSpace(credentialsConfig.Config!.ClientId)) {
             throw new FgaRequiredParamError("OAuth2Client", "config.ClientId");
         }
@@ -86,17 +90,24 @@ public class OAuth2Client {
             throw new FgaRequiredParamError("OAuth2Client", "config.ClientSecret");
         }
 
-        _credentialsConfig = credentialsConfig;
+        if (string.IsNullOrWhiteSpace(credentialsConfig.Config.ApiTokenIssuer)) {
+            throw new FgaRequiredParamError("OAuth2Client", "config.ApiTokenIssuer");
+        }
+
         _httpClient = httpClient;
         _apiTokenIssuer = credentialsConfig.Config.ApiTokenIssuer;
         _authRequest = new Dictionary<string, string> {
             { "client_id", credentialsConfig.Config.ClientId },
             { "client_secret", credentialsConfig.Config.ClientSecret },
-            { "audience", credentialsConfig.Config.ApiAudience },
             { "grant_type", "client_credentials" }
         };
 
+        if (credentialsConfig.Config.ApiAudience != null) {
+            _authRequest["audience"] = credentialsConfig.Config.ApiAudience;
+        }
+
         _retryParams = retryParams;
+        this.metrics = metrics;
     }
 
     /// <summary>
@@ -122,7 +133,8 @@ public class OAuth2Client {
                 cancellationToken));
 
         sw.Stop();
-        metrics.buildForClientCredentialsResponse(accessTokenResponse.rawResponse, requestBuilder, _credentialsConfig,
+
+        metrics.BuildForClientCredentialsResponse(accessTokenResponse.rawResponse, requestBuilder,
             sw, accessTokenResponse.retryCount);
 
         _authToken = new AuthToken { AccessToken = accessTokenResponse.responseContent?.AccessToken };
diff --git a/config/clients/dotnet/template/Configuration_Configuration.mustache b/config/clients/dotnet/template/Configuration_Configuration.mustache
index 9e4e1a0c..901322cd 100644
--- a/config/clients/dotnet/template/Configuration_Configuration.mustache
+++ b/config/clients/dotnet/template/Configuration_Configuration.mustache
@@ -5,22 +5,38 @@ using {{packageName}}.Exceptions;
 namespace {{packageName}}.Configuration;
 
 /// <summary>
-/// Setup {{appName}} Configuration
+///     Setup {{packageName}} Configuration
 /// </summary>
 public class Configuration {
-    #region Methods
+    #region Constructors
+
+    /// <summary>
+    ///     Initializes a new instance of the <see cref="Configuration" /> class
+    /// </summary>
+    /// <exception cref="FgaRequiredParamError"></exception>
+    public Configuration() {
+        DefaultHeaders ??= new Dictionary<string, string>();
 
-    private static bool IsWellFormedUriString(string uri) {
-        return Uri.TryCreate(uri, UriKind.Absolute, out var uriResult) &&
-               ((uriResult.ToString().Equals(uri) || uriResult.ToString().Equals($"{uri}/")) &&
-                (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps));
+        if (!DefaultHeaders.ContainsKey("User-Agent")) {
+            DefaultHeaders.Add("User-Agent", DefaultUserAgent);
+        }
     }
 
+    #endregion Constructors
+
+    #region Methods
+
+    private static bool IsWellFormedUriString(string uri) =>
+        Uri.TryCreate(uri, UriKind.Absolute, out var uriResult) &&
+        (uriResult.ToString().Equals(uri) || uriResult.ToString().Equals($"{uri}/")) &&
+        (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
+
     /// <summary>
-    ///     Checks if the configuration is valid
+    ///     Ensures that the configuration is valid otherwise throws an error
     /// </summary>
     /// <exception cref="FgaRequiredParamError"></exception>
-    public void IsValid() {
+    /// <exception cref="FgaValidationError"></exception>
+    public void EnsureValid() {
         if (BasePath == null || BasePath == "") {
             throw new FgaRequiredParamError("Configuration", "ApiUrl");
         }
@@ -34,10 +50,11 @@ public class Configuration {
             throw new FgaValidationError("Configuration.MaxRetry exceeds maximum allowed limit of {{retryMaxAllowedNumber}}");
         }
 
-        Credentials?.IsValid();
+        Credentials?.EnsureValid();
+        Telemetry?.EnsureValid();
     }
 
-    #endregion
+    #endregion Methods
 
     #region Constants
 
@@ -51,23 +68,7 @@ public class Configuration {
 
     #endregion Constants
 
-    #region Constructors
-
-    /// <summary>
-    ///     Initializes a new instance of the <see cref="Configuration" /> class
-    /// </summary>
-    /// <exception cref="FgaRequiredParamError"></exception>
-    public Configuration() {
-        DefaultHeaders ??= new Dictionary<string, string>();
-
-        if (!DefaultHeaders.ContainsKey("User-Agent")) {
-            DefaultHeaders.Add("User-Agent", DefaultUserAgent);
-        }
-    }
-
-    #endregion Constructors
-
-
+    
     #region Properties
 
     /// <summary>
@@ -144,5 +145,10 @@ public class Configuration {
     /// <value>MinWaitInMs</value>
     public int MinWaitInMs { get; set; } = {{defaultMinWaitInMs}};
 
+    /// <summary>
+    ///     Gets or sets the telemetry configuration.
+    /// </summary>
+    public TelemetryConfig? Telemetry { get; set; }
+
     #endregion Properties
 }
diff --git a/config/clients/dotnet/template/Configuration_Credentials.mustache b/config/clients/dotnet/template/Configuration_Credentials.mustache
index 048d62f1..906fd9f6 100644
--- a/config/clients/dotnet/template/Configuration_Credentials.mustache
+++ b/config/clients/dotnet/template/Configuration_Credentials.mustache
@@ -97,10 +97,11 @@ public class Credentials: IAuthCredentialsConfig {
     }
 
     /// <summary>
-    ///     Checks if the credentials configuration is valid
+    ///     Ensures the credentials configuration is valid otherwise throws an error
     /// </summary>
     /// <exception cref="FgaRequiredParamError"></exception>
-    public void IsValid() {
+    /// <exception cref="FgaValidationError"></exception>
+    public void EnsureValid() {
         switch (Method) {
             case CredentialsMethod.ApiToken:
                 if (string.IsNullOrWhiteSpace(Config?.ApiToken)) {
@@ -141,7 +142,7 @@ public class Credentials: IAuthCredentialsConfig {
     /// </summary>
     /// <exception cref="FgaRequiredParamError"></exception>
     public Credentials() {
-        this.IsValid();
+        this.EnsureValid();
     }
 
     static Credentials Init(IAuthCredentialsConfig config) {
diff --git a/config/clients/dotnet/template/Configuration_TelemetryConfig.mustache b/config/clients/dotnet/template/Configuration_TelemetryConfig.mustache
new file mode 100644
index 00000000..f6c4d412
--- /dev/null
+++ b/config/clients/dotnet/template/Configuration_TelemetryConfig.mustache
@@ -0,0 +1,105 @@
+{{>partial_header}}
+
+using {{packageName}}.Exceptions;
+using {{packageName}}.Telemetry;
+
+namespace {{packageName}}.Configuration;
+
+/// <summary>
+///     Configuration for a specific metric, including its enabled attributes.
+/// </summary>
+public class MetricConfig {
+    /// <summary>
+    ///     List of enabled attributes associated with the metric.
+    /// </summary>
+    public HashSet<string> Attributes { get; set; } = new();
+}
+
+/// <summary>
+///     Configuration for telemetry, including metrics.
+/// </summary>
+public class TelemetryConfig {
+    /// <summary>
+    ///     Dictionary of metric configurations, keyed by metric name.
+    /// </summary>
+    public IDictionary<string, MetricConfig>? Metrics { get; set; } = new Dictionary<string, MetricConfig>();
+
+    /// <summary>
+    ///    Initializes a new instance of the <see cref="TelemetryConfig" /> class.
+    /// </summary>
+    /// <param name="metrics"></param>
+    public TelemetryConfig(IDictionary<string, MetricConfig>? metrics) {
+        Metrics = metrics;
+    }
+
+    /// <summary>
+    ///    Initializes a new instance of the <see cref="TelemetryConfig" /> class.
+    /// </summary>
+    public TelemetryConfig() {
+    }
+
+    /// <summary>
+    /// Sets the configuration to use the default metrics.
+    /// </summary>
+    public TelemetryConfig UseDefaultConfig() {
+        Metrics = GetDefaultMetricsConfiguration();
+        return this;
+    }
+
+    /// <summary>
+    ///     Returns the default metrics configuration.
+    /// </summary>
+    /// <returns></returns>
+    private static IDictionary<string, MetricConfig> GetDefaultMetricsConfiguration() {
+        var defaultAttributes = new HashSet<string> {
+            TelemetryAttribute.HttpHost,
+            TelemetryAttribute.HttpStatus,
+            TelemetryAttribute.HttpUserAgent,
+            TelemetryAttribute.RequestMethod,
+            TelemetryAttribute.RequestClientId,
+            TelemetryAttribute.RequestStoreId,
+            TelemetryAttribute.RequestModelId,
+            TelemetryAttribute.RequestRetryCount,
+            TelemetryAttribute.ResponseModelId
+
+            // These metrics are not included by default because they are usually less useful
+            // TelemetryAttribute.HttpScheme,
+            // TelemetryAttribute.HttpMethod,
+            // TelemetryAttribute.HttpUrl,
+
+            // This not included by default as it has a very high cardinality which could increase costs for users
+            // TelemetryAttribute.FgaRequestUser
+        };
+
+        return new Dictionary<string, MetricConfig> {
+            { TelemetryMeter.TokenExchangeCount, new MetricConfig { Attributes = defaultAttributes } },
+            { TelemetryMeter.RequestDuration, new MetricConfig { Attributes = defaultAttributes } },
+            { TelemetryMeter.QueryDuration, new MetricConfig { Attributes = defaultAttributes } },
+            // { TelemetryMeters.RequestCount, new MetricConfig { Attributes = defaultAttributes } }
+        };
+    }
+
+    /// <summary>
+    ///    Validates the telemetry configuration.
+    /// </summary>
+    /// <exception cref="FgaValidationError"></exception>
+    public void EnsureValid() {
+        if (Metrics == null) {
+            return;
+        }
+
+        var supportedMeters = TelemetryMeter.GetAllMeters();
+        var supportedAttributes = TelemetryAttribute.GetAllAttributes();
+        foreach (var metricName in Metrics.Keys) {
+            if (!supportedMeters.Contains(metricName)) {
+                throw new FgaValidationError($"Telemetry.Metrics[{metricName}] is not a supported metric");
+            }
+
+            foreach (var attribute in Metrics[metricName].Attributes) {
+                if (!supportedAttributes.Contains(attribute)) {
+                    throw new FgaValidationError($"Telemetry.Metrics[{metricName}].Attributes[{attribute}] is not a supported attribute");
+                }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/config/clients/dotnet/template/OpenFgaClientTests.mustache b/config/clients/dotnet/template/OpenFgaClientTests.mustache
index 3ae40459..3a745577 100644
--- a/config/clients/dotnet/template/OpenFgaClientTests.mustache
+++ b/config/clients/dotnet/template/OpenFgaClientTests.mustache
@@ -61,7 +61,7 @@ public class {{appShortName}}ClientTests {
         var config = new ClientConfiguration() {
             ApiUrl = _apiUrl, StoreId = "invalid-format"
         };
-        void ActionInvalidId() => config.IsValid();
+        void ActionInvalidId() => config.EnsureValid();
         var exception = Assert.Throws<FgaValidationError>(ActionInvalidId);
         Assert.Equal("StoreId is not in a valid ulid format", exception.Message);
     }
diff --git a/config/clients/dotnet/template/OpenTelemetry.md.mustache b/config/clients/dotnet/template/OpenTelemetry.md.mustache
index 8607aad1..63c17dff 100644
--- a/config/clients/dotnet/template/OpenTelemetry.md.mustache
+++ b/config/clients/dotnet/template/OpenTelemetry.md.mustache
@@ -10,28 +10,35 @@ In cases when metrics events are sent, they will not be viewable outside of infr
 
 ### Supported Metrics
 
-| Metric Name                     | Type      | Description                                                                          |
-|---------------------------------|-----------|--------------------------------------------------------------------------------------|
-| `fga-client.request.duration`   | Histogram | The total request time for FGA requests                                              |
-| `fga-client.query.duration`     | Histogram | The amount of time the FGA server took to internally process nd evaluate the request |
-|` fga-client.credentials.request`| Counter   | The total number of times a new token was requested when using ClientCredentials     |
+| Metric Name                     | Type      | Enabled by Default | Description                                                                          |
+|---------------------------------|-----------|--------------------|--------------------------------------------------------------------------------------|
+| `fga-client.request.duration`   | Histogram | Yes                | The total request time for FGA requests                                              |
+| `fga-client.query.duration`     | Histogram | Yes                | The amount of time the FGA server took to internally process nd evaluate the request |
+|` fga-client.credentials.request`| Counter   | Yes                | The total number of times a new token was requested when using ClientCredentials     |
+| `fga-client.request.count`      | Counter   | No                 | The total number of requests made to the FGA server                                  |
 
 ### Supported attributes
 
-| Attribute Name                 | Type     | Description                                                                                                                                                 |
-|--------------------------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `fga-client.response.model_id` | `string` | The authorization model ID that the FGA server used                                                                                                         |
-| `fga-client.request.method`    | `string` | The FGA method/action that was performed (e.g. `Check`, `ListObjects`, ...) in TitleCase                                                                    |
-| `fga-client.request.store_id`  | `string` | The store ID that was sent as part of the request                                                                                                           |
-| `fga-client.request.model_id`  | `string` | The authorization model ID that was sent as part of the request, if any                                                                                     |
-| `fga-client.request.client_id` | `string` | The client ID associated with the request, if any                                                                                                           |
-| `fga-client.user`              | `string` | The user that is associated with the action of the request for `Check` and `ListObjects`                                                                    |
-| `http.request.resend_count`    | `int`    | The number of retries attempted (Only sent if the request was retried. Count of `1` means the request was retried once in addition to the original request) |
-| `http.response.status_code`    | `int`    | The status code of the response                                                                                                                             |
-| `http.request.method`          | `string` | The HTTP method for the request                                                                                                                             |
-| `http.host`                    | `string` | Host identifier of the origin the request was sent to                                                                                                       |
-| `url.scheme`                   | `string` | HTTP Scheme of the request (`http`/`https`)                                                                                                                 |
-| `url.full`                     | `string` | Full URL of the request                                                                                                                                     |
-| `user_agent.original`          | `string` | User Agent used in the query                                                                                                                                |
-
+| Attribute Name                 | Type     | Enabled by Default | Description                                                                                                                                                 |
+|--------------------------------|----------|--------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `fga-client.response.model_id` | `string` | Yes                | The authorization model ID that the FGA server used                                                                                                         |
+| `fga-client.request.method`    | `string` | Yes                | The FGA method/action that was performed (e.g. `Check`, `ListObjects`, ...) in TitleCase                                                                    |
+| `fga-client.request.store_id`  | `string` | Yes                | The store ID that was sent as part of the request                                                                                                           |
+| `fga-client.request.model_id`  | `string` | Yes                | The authorization model ID that was sent as part of the request, if any                                                                                     |
+| `fga-client.request.client_id` | `string` | Yes                | The client ID associated with the request, if any                                                                                                           |
+| `fga-client.user`              | `string` | No                 | The user that is associated with the action of the request for check and list objects                                                                       |
+| `http.request.resend_count`    | `int`    | Yes                | The number of retries attempted (Only sent if the request was retried. Count of `1` means the request was retried once in addition to the original request) |
+| `http.response.status_code`    | `int`    | Yes                | The status code of the response                                                                                                                             |
+| `http.request.method`          | `string` | No                 | The HTTP method for the request                                                                                                                             |
+| `http.host`                    | `string` | Yes                | Host identifier of the origin the request was sent to                                                                                                       |
+| `url.scheme`                   | `string` | No                 | HTTP Scheme of the request (`http`/`https`)                                                                                                                 |
+| `url.full`                     | `string` | No                 | Full URL of the request                                                                                                                                     |
+| `user_agent.original`          | `string` | Yes                | User Agent used in the query                                                                                                                                |
+
+### Default Metrics
+
+Not all metrics and attributes are enabled by default.
+
+Some attributes, like `fga-client.user` have been disabled by default due to their high cardinality, which may result for very high costs when using some SaaS metric collectors.
+If you expect to have a high cardinality for a specific attribute, you can disable it by updating the `TelemetryConfig` accordingly.
 {{>OpenTelemetryDocs_custom}}
\ No newline at end of file
diff --git a/config/clients/dotnet/template/OpenTelemetryDocs_custom.mustache b/config/clients/dotnet/template/OpenTelemetryDocs_custom.mustache
index ba1442e5..e81f36c7 100644
--- a/config/clients/dotnet/template/OpenTelemetryDocs_custom.mustache
+++ b/config/clients/dotnet/template/OpenTelemetryDocs_custom.mustache
@@ -3,10 +3,10 @@
 See the OpenTelemetry docs on [Customizing the SDK](https://github.com/open-telemetry/opentelemetry-dotnet/blob/main/docs/metrics/customizing-the-sdk/README.md).
 
 ```csharp
-using {{packageName}}.Client;
-using {{packageName}}.Client.Model;
-using {{packageName}}.Model;
-using {{packageName}}.Telemetry;
+using OpenFga.Sdk.Client;
+using OpenFga.Sdk.Client.Model;
+using OpenFga.Sdk.Model;
+using OpenFga.Sdk.Telemetry;
 using OpenTelemetry;
 using OpenTelemetry.Metrics;
 using OpenTelemetry.Resources;
@@ -19,17 +19,17 @@ namespace Example {
                 // Setup OpenTelemetry Metrics
                 using var meterProvider = Sdk.CreateMeterProviderBuilder()
                     .AddHttpClientInstrumentation() // To instrument the default http client
-                    .AddMeter(Metrics.name) // .AddMeter("{{packageName}}") also works
+                    .AddMeter(Metrics.Name) // .AddMeter("OpenFga.Sdk") also works
                     .ConfigureResource(resourceBuilder => resourceBuilder.AddService("openfga-dotnet-example"))
                     .AddOtlpExporter() // Required to export to an OTLP compatible endpoint
                     .AddConsoleExporter() // Only needed to export the metrics to the console (e.g. when debugging)
                     .Build();
 
-                // Configure the OpenFGA SDK
+                // Configure the OpenFGA SDK with default configuration (default metrics and attributes will be enabled)
                 var configuration = new ClientConfiguration() {
-                    ApiUrl = Environment.GetEnvironmentVariable("FGA_API_URL") ?? "http://localhost:8080", // required, e.g. https://api.fga.example
-                    StoreId = Environment.GetEnvironmentVariable("FGA_STORE_ID"), // not needed when calling `CreateStore` or `ListStores`
-                    AuthorizationModelId = Environment.GetEnvironmentVariable("FGA_MODEL_ID"), // Optional, can be overridden per request
+                    ApiUrl = Environment.GetEnvironmentVariable("FGA_API_URL"),
+                    StoreId = Environment.GetEnvironmentVariable("FGA_STORE_ID"),
+                    AuthorizationModelId = Environment.GetEnvironmentVariable("FGA_MODEL_ID"),
                     // Credentials = ... // If needed
                 };
                 var fgaClient = new OpenFgaClient(configuration);
@@ -44,6 +44,62 @@ namespace Example {
 }
 ```
 
+#### Customize metrics
+You can can customize the metrics that are enabled and the attributes that are included in the metrics by setting the `TelemetryConfig` property on the `ClientConfiguration` object.
+If you do set the `Telemetry` property to anything other than `null`, the default configuration will be overridden.
+
+```csharp
+TelemetryConfig telemetryConfig = new () {
+    Metrics = new Dictionary<string, MetricConfig> {
+        [TelemetryMeters.TokenExchangeCount] = new () {
+            Attributes = new HashSet<string> {
+                TelemetryAttribute.HttpScheme,
+                TelemetryAttribute.HttpMethod,
+                TelemetryAttribute.HttpHost,
+                TelemetryAttribute.HttpStatus,
+                TelemetryAttribute.HttpUserAgent,
+                TelemetryAttribute.RequestMethod,
+                TelemetryAttribute.RequestClientId,
+                TelemetryAttribute.RequestStoreId,
+                TelemetryAttribute.RequestModelId,
+                TelemetryAttribute.RequestRetryCount,
+                TelemetryAttribute.ResponseModelId
+            }
+        },
+        [TelemetryMeters.QueryDuration] = new () {
+            Attributes = new HashSet<string> {
+                TelemetryAttribute.HttpStatus,
+                TelemetryAttribute.HttpUserAgent,
+                TelemetryAttribute.RequestMethod,
+                TelemetryAttribute.RequestClientId,
+                TelemetryAttribute.RequestStoreId,
+                TelemetryAttribute.RequestModelId,
+                TelemetryAttribute.RequestRetryCount,
+            }
+        },
+        [TelemetryMeters.QueryDuration] = new () {
+            Attributes = new HashSet<string> {
+                TelemetryAttribute.HttpStatus,
+                TelemetryAttribute.HttpUserAgent,
+                TelemetryAttribute.RequestMethod,
+                TelemetryAttribute.RequestClientId,
+                TelemetryAttribute.RequestStoreId,
+                TelemetryAttribute.RequestModelId,
+                TelemetryAttribute.RequestRetryCount,
+            }
+        },
+    }
+};
+
+var configuration = new ClientConfiguration() {
+    ApiUrl = Environment.GetEnvironmentVariable("FGA_API_URL"),
+    StoreId = Environment.GetEnvironmentVariable("FGA_STORE_ID"),
+    AuthorizationModelId = Environment.GetEnvironmentVariable("FGA_MODEL_ID"),
+    // Credentials = ... // If needed
+    Telemetry = telemetryConfig
+};
+```
+
 ### More Resources
 * [OpenTelemetry.Instrumentation.Http](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/blob/main/src/OpenTelemetry.Instrumentation.Http/README.md) for instrumenting the HttpClient.
 * If you are using .NET 8+, checkout the built-in metrics.
diff --git a/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
index 99f1e5a0..7b24554b 100644
--- a/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
+++ b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
@@ -8,64 +8,117 @@ using System.Text.Json;
 
 namespace {{packageName}}.Telemetry;
 
-public class Attributes {
-    /**
-     * Common attribute (tag) names
-     */
-
+/// <summary>
+///     Common attribute (tag) names.
+///     For why `static readonly` over `const`, see https://github.com/dotnet/aspnetcore/pull/12441/files
+/// </summary>
+public static class TelemetryAttribute {
     // Attributes (tags) associated with the request made //
 
-    // The FGA method/action that was performed (e.g. `check`, `listObjects`, ...) in camelCase
-    private const string AttributeRequestMethod = "fga-client.request.method";
+    /// <summary>
+    ///     The FGA method/action that was performed (e.g. `Check`, `ListObjects`, ...) in TitleCase.
+    /// </summary>
+    public static readonly string RequestMethod = "fga-client.request.method";
 
-    // The store ID that was sent as part of the request
-    private const string AttributeRequestStoreId = "fga-client.request.store_id";
+    /// <summary>
+    ///     The store ID that was sent as part of the request.
+    /// </summary>
+    public static readonly string RequestStoreId = "fga-client.request.store_id";
 
-    // The authorization model ID that was sent as part of the request, if any
-    private const string AttributeRequestModelId = "fga-client.request.model_id";
+    /// <summary>
+    ///     The authorization model ID that was sent as part of the request, if any.
+    /// </summary>
+    public static readonly string RequestModelId = "fga-client.request.model_id";
 
-    // The client ID associated with the request, if any
-    private const string AttributeRequestClientId = "fga-client.request.client_id";
+    /// <summary>
+    ///     The client ID associated with the request, if any.
+    /// </summary>
+    public static readonly string RequestClientId = "fga-client.request.client_id";
 
     // Attributes (tags) associated with the response //
 
-    // The authorization model ID that the FGA server used
-    private const string AttributeResponseModelId = "fga-client.response.model_id";
+    /// <summary>
+    ///     The authorization model ID that the FGA server used.
+    /// </summary>
+    public static readonly string ResponseModelId = "fga-client.response.model_id";
 
     // Attributes (tags) associated with specific actions //
 
-    // The user that is associated with the action of the request for check and list objects
-    private const string AttributeFgaRequestUser = "fga-client.user";
+    /// <summary>
+    ///     The user that is associated with the action of the request for check and list objects.
+    /// </summary>
+    public static readonly string FgaRequestUser = "fga-client.user";
 
     // OTEL Semantic Attributes (tags) //
 
-    // The total request time for FGA requests
-    private const string AttributeHttpClientRequestDuration = "http.client.request.duration";
-
-    // The amount of time the FGA server took to internally process nd evaluate the request
-    private const string AttributeHttpServerRequestDuration = "http.server.request.duration";
-
-    // The HTTP method for the request
-    private const string AttributeHttpMethod = "http.request.method";
-
-    // The status code of the response
-    private const string AttributeHttpStatus = "http.response.status_code";
-
-    // Host identifier of the origin the request was sent to
-    private const string AttributeHttpHost = "http.host";
-
-    // HTTP Scheme of the request (`http`/`https`)
-    private const string AttributeHttpScheme = "url.scheme";
-
-    // Full URL of the request
-    private const string AttributeHttpUrl = "url.full";
-
-    // User Agent used in the query
-    private const string AttributeHttpUserAgent = "user_agent.original";
-
-    // The number of retries attempted (Only sent if the request was retried. Count of `1` means the request was retried once in addition to the original request)
-    private const string AttributeRequestRetryCount = "http.request.resend_count";
+    /// <summary>
+    ///     The HTTP method for the request.
+    /// </summary>
+    public static readonly string HttpMethod = "http.request.method";
+
+    /// <summary>
+    ///     The status code of the response.
+    /// </summary>
+    public static readonly string HttpStatus = "http.response.status_code";
+
+    /// <summary>
+    ///     Host identifier of the origin the request was sent to.
+    /// </summary>
+    public static readonly string HttpHost = "http.host";
+
+    /// <summary>
+    ///     HTTP Scheme of the request (`http`/`https`).
+    /// </summary>
+    public static readonly string HttpScheme = "url.scheme";
+
+    /// <summary>
+    ///     Full URL of the request.
+    /// </summary>
+    public static readonly string HttpUrl = "url.full";
+
+    /// <summary>
+    ///     User Agent used in the query.
+    /// </summary>
+    public static readonly string HttpUserAgent = "user_agent.original";
+
+    /// <summary>
+    ///     The number of retries attempted (Only sent if the request was retried. Count of `1` means the request was retried
+    ///     once in addition to the original request).
+    /// </summary>
+    public static readonly string RequestRetryCount = "http.request.resend_count";
+
+    /// <summary>
+    /// Return all supported attributes
+    /// </summary>
+    public static HashSet<string> GetAllAttributes() {
+        return new() {
+            RequestMethod,
+            RequestStoreId,
+            RequestModelId,
+            RequestClientId,
+            ResponseModelId,
+            FgaRequestUser,
+            HttpMethod,
+            HttpStatus,
+            HttpHost,
+            HttpScheme,
+            HttpUrl,
+            HttpUserAgent,
+            RequestRetryCount
+        };
+    }
+}
 
+/// <summary>
+///     Class for building attributes for telemetry.
+/// </summary>
+public class Attributes {
+    /// <summary>
+    ///     Gets the header value if valid.
+    /// </summary>
+    /// <param name="headers">The HTTP response headers.</param>
+    /// <param name="headerName">The name of the header.</param>
+    /// <returns>The header value if valid, otherwise null.</returns>
     private static string? GetHeaderValueIfValid(HttpResponseHeaders headers, string headerName) {
         if (headers.Contains(headerName) && headers.GetValues(headerName).Any()) {
             return headers.GetValues(headerName).First();
@@ -74,129 +127,185 @@ public class Attributes {
         return null;
     }
 
-    /**
-     * Builds an object of attributes that can be used to report alongside an OpenTelemetry metric event.
-     *
-     * @param response - The Axios response object, used to add data like HTTP status, host, method, and headers.
-     * @param credentials - The credentials object, used to add data like the ClientID when using ClientCredentials.
-     * @param methodAttributes - Extra attributes that the method (i.e. check, listObjects) wishes to have included. Any custom attributes should use the common names.
-     * @returns {Attributes}
-     */
-    public static TagList buildAttributesForResponse<T>(string apiName,
-        HttpResponseMessage response, RequestBuilder<T> requestBuilder, Credentials? credentials,
+    /// <summary>
+    ///     Filters the attributes based on the enabled attributes.
+    /// </summary>
+    /// <param name="attributes">The list of attributes to filter.</param>
+    /// <param name="enabledAttributes">The dictionary of enabled attributes.</param>
+    /// <returns>A filtered list of attributes.</returns>
+    public static TagList FilterAttributes(TagList attributes, HashSet<string>? enabledAttributes) {
+        var filteredAttributes = new TagList();
+
+        if (enabledAttributes != null && enabledAttributes.Count != 0) {
+            foreach (var attribute in attributes) {
+                if (enabledAttributes.Contains(attribute.Key)) {
+                    filteredAttributes.Add(attribute);
+                }
+            }
+        }
+
+        return filteredAttributes;
+    }
+
+    /// <summary>
+    ///     Builds an object of attributes that can be used to report alongside an OpenTelemetry metric event.
+    /// </summary>
+    /// <typeparam name="T">The type of the request builder.</typeparam>
+    /// <param name="enabledAttributes">The list of enabled attributes.</param>
+    /// <param name="credentials">The credentials object.</param>
+    /// <param name="apiName">The GRPC method name.</param>
+    /// <param name="response">The HTTP response message.</param>
+    /// <param name="requestBuilder">The request builder.</param>
+    /// <param name="requestDuration">The stopwatch measuring the request duration.</param>
+    /// <param name="retryCount">The number of retries attempted.</param>
+    /// <returns>A TagList of attributes.</returns>
+    public static TagList BuildAttributesForResponse<T>(
+        HashSet<string> enabledAttributes, Credentials? credentials,
+        string apiName, HttpResponseMessage response, RequestBuilder<T> requestBuilder,
         Stopwatch requestDuration, int retryCount) {
-        var attributes = new TagList { new(AttributeRequestMethod, apiName) };
+        var attributes = new TagList();
+
+        attributes = AddRequestAttributes(enabledAttributes, apiName, requestBuilder, attributes);
+        attributes = AddResponseAttributes(enabledAttributes, response, attributes);
+        attributes = AddCommonAttributes(enabledAttributes, response, requestBuilder, credentials, retryCount, attributes);
+
+        return attributes;
+    }
+
+    private static TagList AddRequestAttributes<T>(
+        HashSet<string> enabledAttributes, string apiName, RequestBuilder<T> requestBuilder, TagList attributes) {
+        // var attributes = new TagList();
+        if (enabledAttributes.Contains(TelemetryAttribute.RequestMethod)) {
+            attributes.Add(new KeyValuePair<string, object?>(TelemetryAttribute.RequestMethod, apiName));
+        }
 
-        if (requestBuilder.PathParameters.ContainsKey("store_id")) {
+        if (enabledAttributes.Contains(TelemetryAttribute.RequestStoreId) &&
+            requestBuilder.PathParameters.ContainsKey("store_id")) {
             var storeId = requestBuilder.PathParameters.GetValueOrDefault("store_id");
             if (!string.IsNullOrEmpty(storeId)) {
-                attributes.Add(new KeyValuePair<string, object?>(AttributeRequestStoreId, storeId));
+                attributes.Add(new KeyValuePair<string, object?>(TelemetryAttribute.RequestStoreId, storeId));
             }
         }
 
+        if (enabledAttributes.Contains(TelemetryAttribute.RequestModelId)) {
+            attributes = AddRequestModelIdAttributes(requestBuilder, apiName, attributes);
+        }
+
+        return attributes;
+    }
+
+    private static TagList AddRequestModelIdAttributes<T>(
+        RequestBuilder<T> requestBuilder, string apiName, TagList attributes) {
         string? modelId = null;
-        // if the model id is in the path params try to get it from there
+
         if (requestBuilder.PathParameters.ContainsKey("authorization_model_id")) {
             modelId = requestBuilder.PathParameters.GetValueOrDefault("authorization_model_id");
-            if (!string.IsNullOrEmpty(modelId)) {
-                attributes.Add(new KeyValuePair<string, object?>(AttributeRequestModelId, modelId));
-            }
         }
-        // In the case of ReadAuthorizationModel, the path param is called ID
         else if (requestBuilder.PathTemplate == "/stores/{store_id}/authorization-models/{id}" &&
                  requestBuilder.PathParameters.ContainsKey("id")) {
             modelId = requestBuilder.PathParameters.GetValueOrDefault("id");
-            if (!string.IsNullOrEmpty(modelId)) {
-                attributes.Add(new KeyValuePair<string, object?>(AttributeRequestModelId, modelId));
-            }
         }
-        // In many endpoints authorization_model_id is sent as a field in the body
-        // if the apiName is Check or ListObjects, we always want to parse the request body to get the model ID and the user (subject)
-        // if the apiName is Write, Expand or ListUsers we want to parse it to get the model ID
-        else if (apiName is "Check" or "ListObjects" or "Write" or "Expand" or "ListUsers") {
-            try {
-                if (requestBuilder.JsonBody != null) {
-                    using (var document = JsonDocument.Parse(requestBuilder.JsonBody)) {
-                        var root = document.RootElement;
-                        if (root.TryGetProperty("authorization_model_id", out var authModelId)) {
-                            if (!string.IsNullOrEmpty(authModelId.GetString())) {
-                                attributes.Add(new KeyValuePair<string, object?>(AttributeRequestModelId,
-                                    authModelId.GetString()));
-                            }
-
-                            switch (apiName) {
-                                case "Check": {
-                                    if (root.TryGetProperty("tuple_key", out var tupleKey) &&
-                                        tupleKey.TryGetProperty("user", out var fgaUser) &&
-                                        !string.IsNullOrEmpty(fgaUser.GetString())) {
-                                        attributes.Add(new KeyValuePair<string, object?>(AttributeFgaRequestUser,
-                                            fgaUser.GetString()));
-                                    }
-
-                                    break;
-                                }
-                                case "ListObjects": {
-                                    if (root.TryGetProperty("user", out var fgaUser) &&
-                                        !string.IsNullOrEmpty(fgaUser.GetString())) {
-                                        attributes.Add(new KeyValuePair<string, object?>(AttributeFgaRequestUser,
-                                            fgaUser.GetString()));
-                                    }
-
-                                    break;
-                                }
-                            }
-                        }
+
+        if (!string.IsNullOrEmpty(modelId)) {
+            attributes.Add(new KeyValuePair<string, object?>(TelemetryAttribute.RequestModelId, modelId));
+        }
+
+        if (apiName is "Check" or "ListObjects" or "Write" or "Expand" or "ListUsers") {
+            AddRequestBodyAttributes(requestBuilder, apiName, attributes);
+        }
+
+        return attributes;
+    }
+
+    private static TagList AddRequestBodyAttributes<T>(
+        RequestBuilder<T> requestBuilder, string apiName, TagList attributes) {
+        try {
+            if (requestBuilder.JsonBody != null) {
+                using (var document = JsonDocument.Parse(requestBuilder.JsonBody)) {
+                    var root = document.RootElement;
+
+                    if (root.TryGetProperty("authorization_model_id", out var authModelId) &&
+                        !string.IsNullOrEmpty(authModelId.GetString())) {
+                        attributes.Add(new KeyValuePair<string, object?>(TelemetryAttribute.RequestModelId,
+                            authModelId.GetString()));
+                    }
+
+                    if (apiName is "Check" or "ListObjects" && root.TryGetProperty("user", out var fgaUser) &&
+                        !string.IsNullOrEmpty(fgaUser.GetString())) {
+                        attributes.Add(new KeyValuePair<string, object?>(TelemetryAttribute.FgaRequestUser,
+                            fgaUser.GetString()));
                     }
                 }
             }
-            catch { }
+        }
+        catch {
+            // Handle parsing errors if necessary
+        }
+
+        return attributes;
+    }
+
+    private static TagList AddResponseAttributes(
+        HashSet<string> enabledAttributes, HttpResponseMessage response, TagList attributes) {
+        if (enabledAttributes.Contains(TelemetryAttribute.HttpStatus) && response.StatusCode != null) {
+            attributes.Add(new KeyValuePair<string, object?>(TelemetryAttribute.HttpStatus, (int)response.StatusCode));
         }
 
-        if (response.StatusCode != null) {
-            attributes.Add(new KeyValuePair<string, object?>(AttributeHttpStatus, (int)response.StatusCode));
+        if (enabledAttributes.Contains(TelemetryAttribute.ResponseModelId)) {
+            var responseModelId = GetHeaderValueIfValid(response.Headers, "openfga-authorization-model-id") ??
+                                  GetHeaderValueIfValid(response.Headers, "fga-authorization-model-id");
+            if (!string.IsNullOrEmpty(responseModelId)) {
+                attributes.Add(new KeyValuePair<string, object?>(TelemetryAttribute.ResponseModelId, responseModelId));
+            }
         }
 
+        return attributes;
+    }
+
+    private static TagList AddCommonAttributes<T>(
+        HashSet<string> enabledAttributes, HttpResponseMessage response, RequestBuilder<T> requestBuilder,
+        Credentials? credentials, int retryCount, TagList attributes) {
         if (response.RequestMessage != null) {
-            if (response.RequestMessage.Method != null) {
-                attributes.Add(new KeyValuePair<string, object?>(AttributeHttpMethod, response.RequestMessage.Method));
+            if (enabledAttributes.Contains(TelemetryAttribute.HttpMethod) && response.RequestMessage.Method != null) {
+                attributes.Add(new KeyValuePair<string, object?>(TelemetryAttribute.HttpMethod,
+                    response.RequestMessage.Method));
             }
 
             if (response.RequestMessage.RequestUri != null) {
-                attributes.Add(new KeyValuePair<string, object?>(AttributeHttpScheme,
-                    response.RequestMessage.RequestUri.Scheme));
-                attributes.Add(new KeyValuePair<string, object?>(AttributeHttpHost,
-                    response.RequestMessage.RequestUri.Host));
-                attributes.Add(new KeyValuePair<string, object?>(AttributeHttpUrl,
-                    response.RequestMessage.RequestUri.AbsoluteUri));
+                if (enabledAttributes.Contains(TelemetryAttribute.HttpScheme)) {
+                    attributes.Add(new KeyValuePair<string, object?>(TelemetryAttribute.HttpScheme,
+                        response.RequestMessage.RequestUri.Scheme));
+                }
+
+                if (enabledAttributes.Contains(TelemetryAttribute.HttpHost)) {
+                    attributes.Add(new KeyValuePair<string, object?>(TelemetryAttribute.HttpHost,
+                        response.RequestMessage.RequestUri.Host));
+                }
+
+                if (enabledAttributes.Contains(TelemetryAttribute.HttpUrl)) {
+                    attributes.Add(new KeyValuePair<string, object?>(TelemetryAttribute.HttpUrl,
+                        response.RequestMessage.RequestUri.AbsoluteUri));
+                }
             }
 
-            if (response.RequestMessage.Headers.UserAgent != null &&
+            if (enabledAttributes.Contains(TelemetryAttribute.HttpUserAgent) &&
+                response.RequestMessage.Headers.UserAgent != null &&
                 !string.IsNullOrEmpty(response.RequestMessage.Headers.UserAgent.ToString())) {
-                attributes.Add(new KeyValuePair<string, object?>(AttributeHttpUserAgent,
+                attributes.Add(new KeyValuePair<string, object?>(TelemetryAttribute.HttpUserAgent,
                     response.RequestMessage.Headers.UserAgent.ToString()));
             }
         }
 
-        var responseModelId = GetHeaderValueIfValid(response.Headers, "openfga-authorization-model-id");
-        if (!string.IsNullOrEmpty(responseModelId)) {
-            attributes.Add(new KeyValuePair<string, object?>(AttributeResponseModelId, responseModelId));
-        }
-        else {
-            responseModelId = GetHeaderValueIfValid(response.Headers, "fga-authorization-model-id");
-            if (!string.IsNullOrEmpty(responseModelId)) {
-                attributes.Add(new KeyValuePair<string, object?>(AttributeResponseModelId, responseModelId));
-            }
-        }
-
-        if (credentials is { Method: CredentialsMethod.ClientCredentials, Config.ClientId: not null }) {
-            attributes.Add(new KeyValuePair<string, object?>(AttributeRequestClientId, credentials.Config.ClientId));
+        if (enabledAttributes.Contains(TelemetryAttribute.RequestClientId) && credentials is
+                { Method: CredentialsMethod.ClientCredentials, Config.ClientId: not null }) {
+            attributes.Add(new KeyValuePair<string, object?>(TelemetryAttribute.RequestClientId,
+                credentials.Config.ClientId));
         }
 
-        // OTEL specifies that this value should be conditionally sent if a retry occurred
-        if (retryCount > 0) {
-            attributes.Add(new KeyValuePair<string, object?>(AttributeRequestRetryCount, retryCount));
+        if (enabledAttributes.Contains(TelemetryAttribute.RequestRetryCount) && retryCount > 0) {
+            attributes.Add(new KeyValuePair<string, object?>(TelemetryAttribute.RequestRetryCount, retryCount));
         }
 
         return attributes;
     }
-}
+}
\ No newline at end of file
diff --git a/config/clients/dotnet/template/Telemetry/Counters.cs.mustache b/config/clients/dotnet/template/Telemetry/Counters.cs.mustache
index e4776f5a..cbeebe30 100644
--- a/config/clients/dotnet/template/Telemetry/Counters.cs.mustache
+++ b/config/clients/dotnet/template/Telemetry/Counters.cs.mustache
@@ -5,19 +5,34 @@ using System.Diagnostics.Metrics;
 
 namespace {{packageName}}.Telemetry;
 
+/// <summary>
+///     Class for managing telemetry counters.
+/// </summary>
 public class TelemetryCounters {
-    // Meters
-    // The total number of times a new token was requested when using ClientCredentials
-    private const string TokenExchangeCountKey = "fga-client.credentials.request";
+    public Counter<int> TokenExchangeCounter { get; }
 
-    protected Meter meter;
-    public Counter<int> tokenExchangeCounter;
+    public Counter<int> RequestCounter { get; private set; }
 
+    /// <summary>
+    ///     Initializes a new instance of the <see cref="TelemetryCounters" /> class.
+    /// </summary>
+    /// <param name="meter">The meter used to create counters.</param>
     public TelemetryCounters(Meter meter) {
-        this.meter = meter;
-        tokenExchangeCounter = this.meter.CreateCounter<int>(TokenExchangeCountKey,
+        TokenExchangeCounter = meter.CreateCounter<int>(TelemetryMeter.TokenExchangeCount,
             description: "The count of token exchange requests");
+        RequestCounter =
+            meter.CreateCounter<int>(TelemetryMeter.RequestCount, description: "The count of requests made");
     }
 
-    public void buildForResponse(TagList attributes) => tokenExchangeCounter.Add(1, attributes!);
+    /// <summary>
+    ///     Increments the counter for a token exchange.
+    /// </summary>
+    /// <param name="attributes">The attributes associated with the telemetry data.</param>
+    public void IncrementTokenExchangeCounter(TagList attributes) => TokenExchangeCounter.Add(1, attributes);
+
+    /// <summary>
+    ///     Increments the counter for an API request.
+    /// </summary>
+    /// <param name="attributes">The attributes associated with the telemetry data.</param>
+    public void IncrementRequestCounter(TagList attributes) => TokenExchangeCounter.Add(1, attributes);
 }
\ No newline at end of file
diff --git a/config/clients/dotnet/template/Telemetry/Histograms.cs.mustache b/config/clients/dotnet/template/Telemetry/Histograms.cs.mustache
index 9c218c7e..2b3aa9bc 100644
--- a/config/clients/dotnet/template/Telemetry/Histograms.cs.mustache
+++ b/config/clients/dotnet/template/Telemetry/Histograms.cs.mustache
@@ -5,40 +5,52 @@ using System.Diagnostics.Metrics;
 
 namespace {{packageName}}.Telemetry;
 
+/// <summary>
+///     Class for managing telemetry histograms.
+/// </summary>
 public class TelemetryHistograms {
-    // Meters
-
-    // The total request time for FGA requests
-    private const string RequestDurationKey = "fga-client.request.duration";
-
-    // The amount of time the FGA server took to internally process nd evaluate the request
-    private const string QueryDurationKey = "fga-client.query.duration";
-
-    protected Meter meter;
-    public Histogram<float> queryDuration;
-    public Histogram<float> requestDurationHistogram;
-
+    /// <summary>
+    /// Histogram for query duration.
+    /// </summary>
+    public Histogram<float> QueryDurationHistogram { get; }
+
+    /// <summary>
+    /// Histogram for request duration.
+    /// </summary>
+    public Histogram<float> RequestDurationHistogram { get; }
+
+    /// <summary>
+    ///     Initializes a new instance of the <see cref="TelemetryHistograms" /> class.
+    /// </summary>
+    /// <param name="meter">The meter used to create histograms.</param>
     public TelemetryHistograms(Meter meter) {
-        this.meter = meter;
-        requestDurationHistogram = this.meter.CreateHistogram<float>(RequestDurationKey,
-            description: "The duration of requests", unit: "milliseconds");
-        queryDuration = this.meter.CreateHistogram<float>(QueryDurationKey,
-            description: "The duration of queries on the FGA server", unit: "milliseconds");
+        RequestDurationHistogram =
+            meter.CreateHistogram<float>(TelemetryMeter.RequestDuration, "The duration of requests", "milliseconds");
+        QueryDurationHistogram = meter.CreateHistogram<float>(TelemetryMeter.QueryDuration,
+            "The duration of queries on the FGA server", "milliseconds");
     }
 
-    public void buildForResponse(HttpResponseMessage response, TagList attributes,
-        Stopwatch requestDuration) {
+    /// <summary>
+    ///     Records the server query duration if the header is present.
+    /// </summary>
+    /// <param name="response">The HTTP response message.</param>
+    /// <param name="attributes">The attributes associated with the telemetry data.</param>
+    public void RecordQueryDuration(HttpResponseMessage response, TagList attributes) {
         if (response.Headers.Contains("fga-query-duration-ms") &&
             response.Headers.GetValues("fga-query-duration-ms").Any()) {
             var durationHeader = response.Headers.GetValues("fga-query-duration-ms").First();
-            if (!string.IsNullOrEmpty(durationHeader)) {
-                var success = float.TryParse(durationHeader, out var durationFloat);
-                if (success) {
-                    queryDuration?.Record(durationFloat, attributes);
-                }
+            if (!string.IsNullOrEmpty(durationHeader) && float.TryParse(durationHeader, out var durationFloat)) {
+                QueryDurationHistogram?.Record(durationFloat, attributes);
             }
         }
-
-        requestDurationHistogram.Record(requestDuration.ElapsedMilliseconds, attributes);
     }
+
+    /// <summary>
+    ///     Records the total request duration (includes all requests needed till success such as credential exchange and
+    ///     retries).
+    /// </summary>
+    /// <param name="requestDuration">The stopwatch measuring the request duration.</param>
+    /// <param name="attributes">The attributes associated with the telemetry data.</param>
+    public void RecordRequestDuration(Stopwatch requestDuration, TagList attributes) =>
+        RequestDurationHistogram.Record(requestDuration.ElapsedMilliseconds, attributes);
 }
\ No newline at end of file
diff --git a/config/clients/dotnet/template/Telemetry/Meters.cs.mustache b/config/clients/dotnet/template/Telemetry/Meters.cs.mustache
new file mode 100644
index 00000000..5687e153
--- /dev/null
+++ b/config/clients/dotnet/template/Telemetry/Meters.cs.mustache
@@ -0,0 +1,45 @@
+{{>partial_header}}
+
+namespace {{packageName}}.Telemetry;
+
+/// <summary>
+///     Meter names for telemetry.
+///     For why `static readonly` over `const`, see https://github.com/dotnet/aspnetcore/pull/12441/files
+/// </summary>
+public static class TelemetryMeter {
+    // Histograms //
+
+    /// <summary>
+    ///     The total request time for FGA requests
+    /// </summary>
+    public static readonly string RequestDuration = "fga-client.request.duration";
+
+    /// <summary>
+    ///     The amount of time the FGA server took to internally process and evaluate the request
+    /// </summary>
+    public static readonly string QueryDuration = "fga-client.query.duration";
+
+    // Counters //
+
+    /// <summary>
+    ///     The total number of times a new token was requested when using ClientCredentials.
+    /// </summary>
+    public static readonly string TokenExchangeCount = "fga-client.credentials.request";
+
+    /// <summary>
+    ///     The total number of times a new token was requested when using ClientCredentials.
+    /// </summary>
+    public static readonly string RequestCount = "fga-client.request.count";
+
+    /// <summary>
+    /// Return all supported meter names
+    /// </summary>
+    public static HashSet<string> GetAllMeters() {
+        return new() {
+            RequestDuration,
+            QueryDuration,
+            TokenExchangeCount,
+            RequestCount
+        };
+    }
+}
\ No newline at end of file
diff --git a/config/clients/dotnet/template/Telemetry/Metrics.cs.mustache b/config/clients/dotnet/template/Telemetry/Metrics.cs.mustache
index 8753a324..767455c4 100644
--- a/config/clients/dotnet/template/Telemetry/Metrics.cs.mustache
+++ b/config/clients/dotnet/template/Telemetry/Metrics.cs.mustache
@@ -7,25 +7,97 @@ using System.Diagnostics.Metrics;
 
 namespace {{packageName}}.Telemetry;
 
+/// <summary>
+///     Class for managing metrics.
+/// </summary>
 public class Metrics {
-    public const string name = "{{packageName}}";
-    public TelemetryCounters counters;
-    public TelemetryHistograms histograms;
-    public Meter meter = new(name, Configuration.Configuration.Version);
-
-    public Metrics() {
-        histograms = new TelemetryHistograms(meter);
-        counters = new TelemetryCounters(meter);
+    public const string Name = "{{packageName}}";
+
+    // This is to store all the enabled attributes across all metrics so that they can be computed only once
+    private readonly HashSet<string> _allEnabledAttributes = new();
+    private readonly Credentials? _credentialsConfig;
+
+    private readonly TelemetryConfig _telemetryConfig;
+
+    private TelemetryCounters Counters { get; }
+    private TelemetryHistograms Histograms { get; }
+    public Meter Meter { get; } = new(Name, Configuration.Configuration.Version);
+
+    /// <summary>
+    ///     Initializes a new instance of the <see cref="Metrics" /> class.
+    /// </summary>
+    public Metrics(Configuration.Configuration config) {
+        Histograms = new TelemetryHistograms(Meter);
+        Counters = new TelemetryCounters(Meter);
+        _telemetryConfig = config.Telemetry ?? new TelemetryConfig().UseDefaultConfig();
+        _credentialsConfig = config.Credentials;
+
+        if (_telemetryConfig.Metrics?.Keys == null) {
+            return;
+        }
+
+        foreach (var metricName in _telemetryConfig.Metrics.Keys) {
+            var attributesHashSet = _telemetryConfig.Metrics[metricName]?.Attributes ?? new HashSet<string>();
+            foreach (var attribute in attributesHashSet) {
+                _allEnabledAttributes.Add(attribute);
+            }
+        }
     }
 
-    public void buildForResponse<T>(string apiName, HttpResponseMessage response, RequestBuilder<T> requestBuilder,
-        Credentials? credentials, Stopwatch requestDuration, int retryCount) => histograms.buildForResponse(response,
-        Attributes.buildAttributesForResponse(apiName, response, requestBuilder, credentials, requestDuration,
-            retryCount),
-        requestDuration);
+    /// <summary>
+    ///     Builds metrics for a given HTTP response.
+    /// </summary>
+    /// <typeparam name="T">The type of the request builder response.</typeparam>
+    /// <param name="apiName">The API name.</param>
+    /// <param name="response">The HTTP response message.</param>
+    /// <param name="requestBuilder">The request builder.</param>
+    /// <param name="requestDuration">The stopwatch measuring the request duration.</param>
+    /// <param name="retryCount">The number of retries attempted.</param>
+    public void BuildForResponse<T>(string apiName,
+        HttpResponseMessage response, RequestBuilder<T> requestBuilder, Stopwatch requestDuration, int retryCount) {
+        if (_telemetryConfig?.Metrics == null || _telemetryConfig?.Metrics.Count == 0) {
+            // No point processing if all metrics are disabled
+            return;
+        }
+
+        // Compute all enabled attributes once and then attached them to the metrics configured after
+        var attributes = Attributes.BuildAttributesForResponse(
+            _allEnabledAttributes, _credentialsConfig, apiName, response, requestBuilder, requestDuration, retryCount);
+
+        if (apiName == "ClientCredentialsExchange" &&
+            _telemetryConfig!.Metrics.TryGetValue(TelemetryMeter.TokenExchangeCount, out var exchangeCountConfig)) {
+            Counters.IncrementTokenExchangeCounter(Attributes.FilterAttributes(attributes,
+                exchangeCountConfig?.Attributes));
+        }
+        else {
+            // We only want to increment the request counter for non-token exchange requests
+            if (_telemetryConfig!.Metrics.TryGetValue(TelemetryMeter.RequestCount, out var requestCountConfig)) {
+                Counters.IncrementRequestCounter(Attributes.FilterAttributes(attributes,
+                    requestCountConfig?.Attributes));
+            }
+
+            // The query duration is only provided by OpenFGA servers
+            if (_telemetryConfig.Metrics.TryGetValue(TelemetryMeter.QueryDuration, out var queryDurationConfig)) {
+                Histograms.RecordQueryDuration(response,
+                    Attributes.FilterAttributes(attributes, queryDurationConfig?.Attributes));
+            }
+        }
+
+        if (_telemetryConfig.Metrics.TryGetValue(TelemetryMeter.RequestDuration, out var requestDurationConfig)) {
+            Histograms.RecordRequestDuration(requestDuration,
+                Attributes.FilterAttributes(attributes, requestDurationConfig?.Attributes));
+        }
+    }
 
-    public void buildForClientCredentialsResponse<T>(HttpResponseMessage response, RequestBuilder<T> requestBuilder,
-        Credentials? credentials, Stopwatch requestDuration, int retryCount) => counters.buildForResponse(
-        Attributes.buildAttributesForResponse("ClientCredentialsExchange", response, requestBuilder, credentials,
-            requestDuration, retryCount));
+    /// <summary>
+    ///     Builds metrics for a client credentials response.
+    /// </summary>
+    /// <typeparam name="T">The type of the request builder.</typeparam>
+    /// <param name="response">The HTTP response message.</param>
+    /// <param name="requestBuilder">The request builder.</param>
+    /// <param name="requestDuration">The stopwatch measuring the request duration.</param>
+    /// <param name="retryCount">The number of retries attempted.</param>
+    public void BuildForClientCredentialsResponse<T>(
+        HttpResponseMessage response, RequestBuilder<T> requestBuilder, Stopwatch requestDuration, int retryCount) =>
+        BuildForResponse("ClientCredentialsExchange", response, requestBuilder, requestDuration, retryCount);
 }
\ No newline at end of file
diff --git a/config/clients/dotnet/template/api.mustache b/config/clients/dotnet/template/api.mustache
index 8e62088e..267123a8 100644
--- a/config/clients/dotnet/template/api.mustache
+++ b/config/clients/dotnet/template/api.mustache
@@ -19,7 +19,7 @@ public class {{classname}} : IDisposable {
         Configuration.Configuration configuration,
         HttpClient? httpClient = null
     ) {
-        configuration.IsValid();
+        configuration.EnsureValid();
         _configuration = configuration;
         _apiClient = new ApiClient.ApiClient(_configuration, httpClient);
     }
diff --git a/config/clients/dotnet/template/api_test.mustache b/config/clients/dotnet/template/api_test.mustache
index 1fce008f..aa1882a7 100644
--- a/config/clients/dotnet/template/api_test.mustache
+++ b/config/clients/dotnet/template/api_test.mustache
@@ -61,7 +61,7 @@ namespace {{testPackageName}}.Api {
         [Fact]
         public void StoreIdNotRequired() {
             var storeIdRequiredConfig = new Configuration.Configuration() { ApiHost = _host };
-            storeIdRequiredConfig.IsValid();
+            storeIdRequiredConfig.EnsureValid();
         }
 
         /// <summary>
@@ -83,7 +83,7 @@ namespace {{testPackageName}}.Api {
         [Fact]
         public void ValidHostRequired() {
             var config = new Configuration.Configuration() {};
-            void ActionMissingHost() => config.IsValid();
+            void ActionMissingHost() => config.EnsureValid();
             var exception = Assert.Throws<FgaRequiredParamError>(ActionMissingHost);
             Assert.Equal("Required parameter ApiUrl was not defined when calling Configuration.", exception.Message);
         }
@@ -94,7 +94,7 @@ namespace {{testPackageName}}.Api {
         [Fact]
         public void ValidHostWellFormed() {
             var config = new Configuration.Configuration() {ApiHost = "https://api.{{sampleApiDomain}}"};
-            void ActionMalformedHost() => config.IsValid();
+            void ActionMalformedHost() => config.EnsureValid();
             var exception = Assert.Throws<FgaValidationError>(ActionMalformedHost);
             Assert.Equal("Configuration.ApiUrl (https://https://api.{{sampleApiDomain}}) does not form a valid URI (https://https://api.{{sampleApiDomain}})", exception.Message);
         }
@@ -111,7 +111,7 @@ namespace {{testPackageName}}.Api {
                 }
             };
             void ActionMissingApiToken() =>
-                missingApiTokenConfig.IsValid();
+                missingApiTokenConfig.EnsureValid();
             var exceptionMissingApiToken =
                 Assert.Throws<FgaRequiredParamError>(ActionMissingApiToken);
             Assert.Equal("Required parameter ApiToken was not defined when calling Configuration.",
@@ -135,7 +135,7 @@ namespace {{testPackageName}}.Api {
                     }
                 }
             };
-            void ActionMalformedApiTokenIssuer() => config.IsValid();
+            void ActionMalformedApiTokenIssuer() => config.EnsureValid();
             var exception = Assert.Throws<FgaValidationError>(ActionMalformedApiTokenIssuer);
             Assert.Equal("Configuration.ApiTokenIssuer does not form a valid URI (https://https://tokenissuer.{{sampleApiDomain}})", exception.Message);
         }
@@ -206,7 +206,7 @@ namespace {{testPackageName}}.Api {
                 }
             };
             void ActionMissingClientId() =>
-                missingClientIdConfig.IsValid();
+                missingClientIdConfig.EnsureValid();
             var exceptionMissingClientId =
                 Assert.Throws<FgaRequiredParamError>(ActionMissingClientId);
             Assert.Equal("Required parameter ClientId was not defined when calling Configuration.",
@@ -225,7 +225,7 @@ namespace {{testPackageName}}.Api {
                 }
             };
             void ActionMissingClientSecret() =>
-                missingClientSecretConfig.IsValid();
+                missingClientSecretConfig.EnsureValid();
             var exceptionMissingClientSecret =
                 Assert.Throws<FgaRequiredParamError>(ActionMissingClientSecret);
             Assert.Equal("Required parameter ClientSecret was not defined when calling Configuration.",
@@ -244,7 +244,7 @@ namespace {{testPackageName}}.Api {
                 }
             };
             void ActionMissingApiTokenIssuer() =>
-                missingApiTokenIssuerConfig.IsValid();
+                missingApiTokenIssuerConfig.EnsureValid();
             var exceptionMissingApiTokenIssuer =
                 Assert.Throws<FgaRequiredParamError>(ActionMissingApiTokenIssuer);
             Assert.Equal("Required parameter ApiTokenIssuer was not defined when calling Configuration.",
@@ -264,7 +264,7 @@ namespace {{testPackageName}}.Api {
             };
 
             void ActionMissingApiAudience() =>
-                missingApiAudienceConfig.IsValid();
+                missingApiAudienceConfig.EnsureValid();
             var exceptionMissingApiAudience =
                 Assert.Throws<FgaRequiredParamError>(ActionMissingApiAudience);
             Assert.Equal("Required parameter ApiAudience was not defined when calling Configuration.",
diff --git a/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.cs b/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.cs
index 16eeb238..c383e419 100644
--- a/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.cs
+++ b/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.cs
@@ -31,16 +31,48 @@ public static async Task Main() {
                 };
             }
 
+            // Customize the metrics and the attributes on each metric
+            TelemetryConfig telemetryConfig = new TelemetryConfig() {
+                Metrics = new Dictionary<string, MetricConfig> {
+                    [TelemetryMeter.TokenExchangeCount] = new() {
+                        Attributes = new HashSet<string> {
+                            TelemetryAttribute.HttpHost,
+                            TelemetryAttribute.HttpStatus,
+                            TelemetryAttribute.HttpUserAgent,
+                            TelemetryAttribute.RequestClientId,
+                        }
+                    },
+                    [TelemetryMeter.QueryDuration] = new () {
+                        Attributes = new HashSet<string> {
+                            TelemetryAttribute.HttpStatus,
+                            TelemetryAttribute.HttpUserAgent,
+                            TelemetryAttribute.RequestMethod,
+                            TelemetryAttribute.RequestClientId,
+                            TelemetryAttribute.RequestStoreId,
+                            TelemetryAttribute.RequestModelId,
+                            TelemetryAttribute.RequestRetryCount,
+                        }
+                    },
+                    [TelemetryMeter.RequestDuration] = new () {
+                        Attributes = new HashSet<string> {
+                            TelemetryAttribute.HttpStatus,
+                            TelemetryAttribute.HttpUserAgent,
+                            TelemetryAttribute.RequestMethod,
+                            TelemetryAttribute.RequestClientId,
+                            TelemetryAttribute.RequestStoreId,
+                            TelemetryAttribute.RequestModelId,
+                            TelemetryAttribute.RequestRetryCount,
+                        }
+                    },
+                }
+            };
+
             var configuration = new ClientConfiguration {
-                ApiUrl =
-                    Environment.GetEnvironmentVariable("FGA_API_URL") ??
-                    "http://localhost:8080", // required, e.g. https://api.fga.example
-                StoreId =
-                    Environment.GetEnvironmentVariable(
-                        "FGA_STORE_ID"), // not needed when calling `CreateStore` or `ListStores`
-                AuthorizationModelId =
-                    Environment.GetEnvironmentVariable("FGA_MODEL_ID"), // Optional, can be overridden per request
-                Credentials = credentials
+                ApiUrl = Environment.GetEnvironmentVariable("FGA_API_URL") ?? "http://localhost:8080",
+                StoreId = Environment.GetEnvironmentVariable("FGA_STORE_ID"),
+                AuthorizationModelId = Environment.GetEnvironmentVariable("FGA_MODEL_ID"),
+                Credentials = credentials,
+                Telemetry = telemetryConfig
             };
             var fgaClient = new OpenFgaClient(configuration);
 
@@ -48,7 +80,7 @@ public static async Task Main() {
             // See: https://github.com/open-telemetry/opentelemetry-dotnet/blob/main/docs/metrics/customizing-the-sdk/README.md
             using var meterProvider = Sdk.CreateMeterProviderBuilder()
                 .AddHttpClientInstrumentation()
-                .AddMeter(Metrics.name)
+                .AddMeter(Metrics.Name)
                 .ConfigureResource(resourceBuilder =>
                     resourceBuilder.AddService(Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME") ??
                                                "openfga-otel-dotnet-example"))
@@ -186,7 +218,9 @@ public static async Task Main() {
                 existingTuples.Tuples.ForEach(tuple => tuplesToDelete.Add(new ClientTupleKeyWithoutCondition {
                     User = tuple.Key.User, Relation = tuple.Key.Relation, Object = tuple.Key.Object
                 }));
-                await fgaClient.DeleteTuples(tuplesToDelete);
+                if (tuplesToDelete.Count > 0) {
+                    await fgaClient.DeleteTuples(tuplesToDelete);
+                }
             } while (contToken != "");
 
             // Write

From 534762665c79539b623b45387bafbe772e260e39 Mon Sep 17 00:00:00 2001
From: Ewan Harris <ewan.harris@okta.com>
Date: Tue, 5 Nov 2024 09:33:12 +0000
Subject: [PATCH 12/12] chore(dotnet-sdk): sync dependency updates

---
 config/clients/dotnet/template/netcore_testproject.mustache | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/config/clients/dotnet/template/netcore_testproject.mustache b/config/clients/dotnet/template/netcore_testproject.mustache
index 65c9d1ed..15023934 100644
--- a/config/clients/dotnet/template/netcore_testproject.mustache
+++ b/config/clients/dotnet/template/netcore_testproject.mustache
@@ -9,9 +9,9 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0"><PrivateAssets>all</PrivateAssets></PackageReference>
-    <PackageReference Include="Moq" Version="4.20.70"><PrivateAssets>all</PrivateAssets></PackageReference>
-    <PackageReference Include="xunit" Version="2.9.0"><PrivateAssets>all</PrivateAssets></PackageReference>
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1"><PrivateAssets>all</PrivateAssets></PackageReference>
+    <PackageReference Include="Moq" Version="4.20.72"><PrivateAssets>all</PrivateAssets></PackageReference>
+    <PackageReference Include="xunit" Version="2.9.2"><PrivateAssets>all</PrivateAssets></PackageReference>
     <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
       <PrivateAssets>all</PrivateAssets>
       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>