diff --git a/config/clients/dotnet/CHANGELOG.md.mustache b/config/clients/dotnet/CHANGELOG.md.mustache
index 3195ec257..0d65388be 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.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
### [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 3e01faefa..667d46ac3 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": "",
@@ -33,6 +33,7 @@
"enablePostProcessFile": true,
"hashCodeBasePrimeNumber": 9661,
"hashCodeMultiplierPrimeNumber": 9923,
+ "supportsOpenTelemetry": true,
"files": {
"Client_OAuth2Client.mustache": {
"destinationFilename": "src/OpenFga.Sdk/ApiClient/OAuth2Client.cs",
@@ -226,10 +227,34 @@
"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/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"
+ },
"Configuration_Configuration.mustache": {
"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"
@@ -294,10 +319,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/Client.mustache b/config/clients/dotnet/template/Client/Client.mustache
index 849244c74..b0eae2146 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 5eb0958fe..96895052b 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;
+///
+/// Class for managing telemetry settings.
+///
+public class Telemetry {
+}
+
+///
+/// Configuration class for the {{packageName}} client.
+///
public class ClientConfiguration : Configuration.Configuration {
+ ///
+ /// Initializes a new instance of the class with the specified configuration.
+ ///
+ /// The base configuration to copy settings from.
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};
}
+ ///
+ /// Initializes a new instance of the class.
+ ///
public ClientConfiguration() { }
///
- /// Gets or sets the Store ID.
+ /// Gets or sets the Store ID.
///
- /// Store ID.
+ /// The Store ID.
public string? StoreId { get; set; }
///
- /// Gets or sets the Authorization Model ID.
+ /// Gets or sets the Authorization Model ID.
///
- /// Authorization Model ID.
+ /// The Authorization Model ID.
public string? AuthorizationModelId { get; set; }
+ ///
+ /// Gets or sets the retry parameters.
+ ///
+ /// The retry parameters.
public RetryParams? RetryParams { get; set; } = new();
- public new void IsValid() {
- base.IsValid();
+ ///
+ /// Ensures the configuration is valid, otherwise throws an error.
+ ///
+ /// Thrown when the Store ID or Authorization Model ID is not in a valid ULID format.
+ 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");
}
}
///
- /// 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.
///
- ///
- ///
+ /// The string to validate as a ULID.
+ /// True if the string is a valid ULID, otherwise false.
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 97e5bb84c..1f9c4a248 100644
--- a/config/clients/dotnet/template/Client_ApiClient.mustache
+++ b/config/clients/dotnet/template/Client_ApiClient.mustache
@@ -3,39 +3,46 @@
using {{packageName}}.Client.Model;
using {{packageName}}.Configuration;
using {{packageName}}.Exceptions;
+using {{packageName}}.Telemetry;
+using System.Diagnostics;
namespace {{packageName}}.ApiClient;
///
-/// 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.
///
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;
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class.
///
/// Client Configuration
/// User Http Client - Allows Http Client reuse
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;
}
- 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 },
+ metrics);
break;
case CredentialsMethod.None:
default:
@@ -44,8 +51,9 @@ public class ApiClient : IDisposable {
}
///
- /// 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
///
///
///
@@ -53,10 +61,11 @@ public class ApiClient : IDisposable {
/// Response Type
///
///
- public async Task SendRequestAsync(RequestBuilder requestBuilder, string apiName,
+ public async Task SendRequestAsync(RequestBuilder requestBuilder, string apiName,
CancellationToken cancellationToken = default) {
IDictionary additionalHeaders = new Dictionary();
+ var sw = Stopwatch.StartNew();
if (_oauth2Client != null) {
try {
var token = await _oauth2Client.GetAccessTokenAsync();
@@ -64,25 +73,36 @@ 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(requestBuilder, additionalHeaders, apiName, cancellationToken));
+ var response = await Retry(async () =>
+ await _baseClient.SendRequestAsync(requestBuilder, additionalHeaders, apiName,
+ cancellationToken));
+
+ sw.Stop();
+ metrics.BuildForResponse(apiName, response.rawResponse, requestBuilder, sw,
+ response.retryCount);
+
+ return response.responseContent;
}
///
- /// 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)
///
///
///
///
///
- public async Task SendRequestAsync(RequestBuilder requestBuilder, string apiName,
+ public async Task SendRequestAsync(RequestBuilder requestBuilder, string apiName,
CancellationToken cancellationToken = default) {
IDictionary additionalHeaders = new Dictionary();
+ var sw = Stopwatch.StartNew();
if (_oauth2Client != null) {
try {
var token = await _oauth2Client.GetAccessTokenAsync();
@@ -90,73 +110,56 @@ 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(requestBuilder, additionalHeaders, apiName,
+ cancellationToken));
+
+ sw.Stop();
+ metrics.BuildForResponse(apiName, response.rawResponse, requestBuilder, sw,
+ response.retryCount);
}
- private async Task Retry(Func> retryable) {
- var numRetries = 0;
+ private async Task> Retry(Func>> retryable) {
+ var requestCount = 0;
while (true) {
try {
- numRetries++;
+ requestCount++;
- 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 =
+ requestCount - 1; // OTEL spec specifies that the original request is not included in the count
- await Task.Delay(waitInMs);
+ return response;
}
- }
- }
-
- private async Task Retry(Func retryable) {
- var numRetries = 0;
- while (true) {
- try {
- numRetries++;
-
- await retryable();
-
- return;
- } catch (FgaApiRateLimitExceededError err) {
- if (numRetries > _configuration.MaxRetry) {
+ catch (FgaApiRateLimitExceededError err) {
+ if (requestCount > _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);
await Task.Delay(waitInMs);
}
catch (FgaApiError err) {
- if (!err.ShouldRetry || numRetries > _configuration.MaxRetry) {
+ if (!err.ShouldRetry || requestCount > _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();
+}
\ 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 27d718bc3..bc1c92f43 100644
--- a/config/clients/dotnet/template/Client_BaseClient.mustache
+++ b/config/clients/dotnet/template/Client_BaseClient.mustache
@@ -1,79 +1,87 @@
{{>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 {
+ public HttpResponseMessage rawResponse;
+ public T? responseContent;
+
+ public int retryCount;
+}
+
///
-/// Base Client, used by the API and OAuth Clients
+/// Base Client, used by the API and OAuth Clients
///
public class BaseClient : IDisposable {
private readonly HttpClient _httpClient;
private bool _shouldDisposeWhenDone;
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class.
///
///
- /// Optional to use when sending requests.
+ /// Optional to use when sending requests.
///
- /// If you supply a it is your responsibility to manage its lifecycle and
- /// dispose it when appropriate.
- /// If you do not supply a one will be created automatically and disposed
- /// of when this object is disposed.
+ /// If you supply a it is your responsibility to manage its lifecycle and
+ /// dispose it when appropriate.
+ /// If you do not supply a one will be created automatically and disposed
+ /// of when this object is disposed.
///
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);
}
}
}
///
- /// Handles calling the API
+ /// Handles calling the API
///
///
///
///
///
- ///
+ ///
+ ///
///
- public async Task SendRequestAsync(RequestBuilder requestBuilder,
+ public async Task> SendRequestAsync(RequestBuilder requestBuilder,
IDictionary? additionalHeaders = null,
string? apiName = null, CancellationToken cancellationToken = default) {
var request = requestBuilder.BuildRequest();
- return await SendRequestAsync(request, additionalHeaders, apiName, cancellationToken);
+ return await SendRequestAsync(request, additionalHeaders, apiName, cancellationToken);
}
- ///
- /// Handles calling the API for requests that are expected to return no content
- ///
- ///
- ///
- ///
- ///
- ///
- public async Task SendRequestAsync(RequestBuilder requestBuilder,
- IDictionary? additionalHeaders = null,
- string? apiName = null, CancellationToken cancellationToken = default) {
- var request = requestBuilder.BuildRequest();
-
- await this.SendRequestAsync(request, additionalHeaders, apiName, cancellationToken);
- }
+ // ///
+ // /// Handles calling the API for requests that are expected to return no content
+ // ///
+ // ///
+ // ///
+ // ///
+ // ///
+ // ///
+ // public async Task SendRequestAsync(RequestBuilder requestBuilder,
+ // IDictionary? additionalHeaders = null,
+ // string? apiName = null, CancellationToken cancellationToken = default) {
+ // var request = requestBuilder.BuildRequest();
+ //
+ // await this.SendRequestAsync(request, additionalHeaders, apiName, cancellationToken);
+ // }
///
- /// Handles calling the API
+ /// Handles calling the API
///
///
///
@@ -83,7 +91,7 @@ public class BaseClient : IDisposable {
///
///
///
- public async Task SendRequestAsync(HttpRequestMessage request,
+ public async Task> SendRequestAsync(HttpRequestMessage request,
IDictionary? additionalHeaders = null,
string? apiName = null, CancellationToken cancellationToken = default) {
if (additionalHeaders != null) {
@@ -96,48 +104,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(cancellationToken: cancellationToken).ConfigureAwait(false) ??
- throw new FgaError();
- }
- }
-
- ///
- /// Handles calling the API for requests that are expected to return no content
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- public async Task SendRequestAsync(HttpRequestMessage request,
- IDictionary? 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(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 { rawResponse = response, responseContent = responseContent };
}
}
///
- /// Disposes of any owned disposable resources such as the underlying if owned.
+ /// Disposes of any owned disposable resources such as the underlying if owned.
///
- /// Whether we are actually disposing () or not ().
+ /// Whether we are actually disposing () or not ().
protected virtual void Dispose(bool disposing) {
if (disposing && _shouldDisposeWhenDone) {
_httpClient.Dispose();
@@ -146,9 +134,7 @@ public class BaseClient : IDisposable {
}
///
- /// Disposes of any owned disposable resources such as the underlying if owned.
+ /// Disposes of any owned disposable resources such as the underlying if owned.
///
- public void Dispose() {
- Dispose(true);
- }
+ public void Dispose() => Dispose(true);
}
diff --git a/config/clients/dotnet/template/Client_OAuth2Client.mustache b/config/clients/dotnet/template/Client_OAuth2Client.mustache
index 17b0edd2b..67b44944e 100644
--- a/config/clients/dotnet/template/Client_OAuth2Client.mustache
+++ b/config/clients/dotnet/template/Client_OAuth2Client.mustache
@@ -1,44 +1,45 @@
{{>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;
///
-/// 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
///
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;
///
- /// 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
///
public class AccessTokenResponse {
///
- /// Time period after which the token will expire (in ms)
+ /// Time period after which the token will expire (in ms)
///
[JsonPropertyName("expires_in")]
public long ExpiresIn { get; set; }
///
- /// Token Type
+ /// Token Type
///
[JsonPropertyName("token_type")]
public string? TokenType { get; set; }
///
- /// Access token to use
+ /// Access token to use
///
[JsonPropertyName("access_token")]
public string? AccessToken { get; set; }
@@ -49,34 +50,38 @@ 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 _authRequest { get; set; }
- private string _apiTokenIssuer { get; set; }
- private RetryParams _retryParams;
+ private IDictionary _authRequest { get; }
+ private string _apiTokenIssuer { get; }
+ private readonly RetryParams _retryParams;
#endregion
#region Methods
///
- /// Initializes a new instance of the class
+ /// Initializes a new instance of the class
///
///
///
///
- 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");
}
@@ -85,41 +90,58 @@ public class OAuth2Client {
throw new FgaRequiredParamError("OAuth2Client", "config.ClientSecret");
}
- this._httpClient = httpClient;
- this._apiTokenIssuer = credentialsConfig.Config.ApiTokenIssuer;
- this._authRequest = new Dictionary() {
+ if (string.IsNullOrWhiteSpace(credentialsConfig.Config.ApiTokenIssuer)) {
+ throw new FgaRequiredParamError("OAuth2Client", "config.ApiTokenIssuer");
+ }
+
+ _httpClient = httpClient;
+ _apiTokenIssuer = credentialsConfig.Config.ApiTokenIssuer;
+ _authRequest = new Dictionary {
{ "client_id", credentialsConfig.Config.ClientId },
{ "client_secret", credentialsConfig.Config.ClientSecret },
- { "audience", credentialsConfig.Config.ApiAudience },
{ "grant_type", "client_credentials" }
};
- this._retryParams = retryParams;
+ if (credentialsConfig.Config.ApiAudience != null) {
+ _authRequest["audience"] = credentialsConfig.Config.ApiAudience;
+ }
+
+ _retryParams = retryParams;
+ this.metrics = metrics;
}
///
- /// 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
///
///
///
private async Task ExchangeTokenAsync(CancellationToken cancellationToken = default) {
- var requestBuilder = new RequestBuilder {
+ var requestBuilder = new RequestBuilder> {
Method = HttpMethod.Post,
- BasePath = $"https://{this._apiTokenIssuer}",
+ BasePath = $"https://{_apiTokenIssuer}",
PathTemplate = "/oauth/token",
- Body = Utils.CreateFormEncodedConent(this._authRequest),
+ Body = _authRequest,
+ ContentType = "application/x-www-form-urlencode"
};
- var accessTokenResponse = await Retry(async () => await _httpClient.SendRequestAsync(
- requestBuilder,
- null,
- "ExchangeTokenAsync",
- cancellationToken));
+ 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,
+ 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 Retry(Func> retryable) {
@@ -134,7 +156,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 +167,7 @@ public class OAuth2Client {
if (!err.ShouldRetry || numRetries > _retryParams.MaxRetry) {
throw;
}
+
var waitInMs = _retryParams.MinWaitInMs;
await Task.Delay(waitInMs);
@@ -152,7 +176,7 @@ public class OAuth2Client {
}
///
- /// 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
///
///
///
diff --git a/config/clients/dotnet/template/Client_RequestBuilder.mustache b/config/clients/dotnet/template/Client_RequestBuilder.mustache
index 67223e4ff..8e7ee1e09 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 {
+///
+///
+/// Type of the Request Body
+public class RequestBuilder {
+ public RequestBuilder() {
+ PathParameters = new Dictionary();
+ QueryParameters = new Dictionary();
+ }
+
public HttpMethod Method { get; set; }
public string BasePath { get; set; }
public string PathTemplate { get; set; }
@@ -15,13 +24,34 @@ public class RequestBuilder {
public Dictionary QueryParameters { get; set; }
- public HttpContent? Body { get; set; }
+ public TReq? Body { get; set; }
- public RequestBuilder() {
- PathParameters = new Dictionary();
- QueryParameters = new Dictionary();
+ 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)Body;
+
+ return new FormUrlEncodedContent(body.Select(p =>
+ new KeyValuePair(p.Key, p.Value ?? "")));
+ }
}
+ private HttpContent? HttpContentBody =>
+ 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) {
throw new FgaRequiredParamError("RequestBuilder.BuildUri", nameof(PathTemplate));
@@ -56,6 +86,7 @@ public class RequestBuilder {
if (BasePath == null) {
throw new FgaRequiredParamError("RequestBuilder.BuildUri", nameof(BasePath));
}
+
var uriString = $"{BasePath}";
uriString += BuildPathString();
@@ -68,6 +99,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/Configuration_Configuration.mustache b/config/clients/dotnet/template/Configuration_Configuration.mustache
index 9e4e1a0c6..901322cdf 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;
///
-/// Setup {{appName}} Configuration
+/// Setup {{packageName}} Configuration
///
public class Configuration {
- #region Methods
+ #region Constructors
+
+ ///
+ /// Initializes a new instance of the class
+ ///
+ ///
+ public Configuration() {
+ DefaultHeaders ??= new Dictionary();
- 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);
+
///
- /// Checks if the configuration is valid
+ /// Ensures that the configuration is valid otherwise throws an error
///
///
- public void IsValid() {
+ ///
+ 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
-
- ///
- /// Initializes a new instance of the class
- ///
- ///
- public Configuration() {
- DefaultHeaders ??= new Dictionary();
-
- if (!DefaultHeaders.ContainsKey("User-Agent")) {
- DefaultHeaders.Add("User-Agent", DefaultUserAgent);
- }
- }
-
- #endregion Constructors
-
-
+
#region Properties
///
@@ -144,5 +145,10 @@ public class Configuration {
/// MinWaitInMs
public int MinWaitInMs { get; set; } = {{defaultMinWaitInMs}};
+ ///
+ /// Gets or sets the telemetry configuration.
+ ///
+ 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 048d62f1e..906fd9f66 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 {
}
///
- /// Checks if the credentials configuration is valid
+ /// Ensures the credentials configuration is valid otherwise throws an error
///
///
- public void IsValid() {
+ ///
+ public void EnsureValid() {
switch (Method) {
case CredentialsMethod.ApiToken:
if (string.IsNullOrWhiteSpace(Config?.ApiToken)) {
@@ -141,7 +142,7 @@ public class Credentials: IAuthCredentialsConfig {
///
///
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 000000000..f6c4d4121
--- /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;
+
+///
+/// Configuration for a specific metric, including its enabled attributes.
+///
+public class MetricConfig {
+ ///
+ /// List of enabled attributes associated with the metric.
+ ///
+ public HashSet Attributes { get; set; } = new();
+}
+
+///
+/// Configuration for telemetry, including metrics.
+///
+public class TelemetryConfig {
+ ///
+ /// Dictionary of metric configurations, keyed by metric name.
+ ///
+ public IDictionary? Metrics { get; set; } = new Dictionary();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ public TelemetryConfig(IDictionary? metrics) {
+ Metrics = metrics;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public TelemetryConfig() {
+ }
+
+ ///
+ /// Sets the configuration to use the default metrics.
+ ///
+ public TelemetryConfig UseDefaultConfig() {
+ Metrics = GetDefaultMetricsConfiguration();
+ return this;
+ }
+
+ ///
+ /// Returns the default metrics configuration.
+ ///
+ ///
+ private static IDictionary GetDefaultMetricsConfiguration() {
+ var defaultAttributes = new HashSet {
+ 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 {
+ { TelemetryMeter.TokenExchangeCount, new MetricConfig { Attributes = defaultAttributes } },
+ { TelemetryMeter.RequestDuration, new MetricConfig { Attributes = defaultAttributes } },
+ { TelemetryMeter.QueryDuration, new MetricConfig { Attributes = defaultAttributes } },
+ // { TelemetryMeters.RequestCount, new MetricConfig { Attributes = defaultAttributes } }
+ };
+ }
+
+ ///
+ /// Validates the telemetry configuration.
+ ///
+ ///
+ 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 3ae404596..3a7455779 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(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
new file mode 100644
index 000000000..63c17dffa
--- /dev/null
+++ b/config/clients/dotnet/template/OpenTelemetry.md.mustache
@@ -0,0 +1,44 @@
+# 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 | 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 | 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
new file mode 100644
index 000000000..e81f36c7a
--- /dev/null
+++ b/config/clients/dotnet/template/OpenTelemetryDocs_custom.mustache
@@ -0,0 +1,111 @@
+## 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 OpenFga.Sdk.Client;
+using OpenFga.Sdk.Client.Model;
+using OpenFga.Sdk.Model;
+using OpenFga.Sdk.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 with default configuration (default metrics and attributes will be enabled)
+ 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
+ };
+ var fgaClient = new OpenFgaClient(configuration);
+
+ // Call the SDK normally
+ var response = await fgaClient.ReadAuthorizationModels();
+ } catch (ApiException e) {
+ Debug.Print("Error: "+ e);
+ }
+ }
+ }
+}
+```
+
+#### 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 {
+ [TelemetryMeters.TokenExchangeCount] = new () {
+ Attributes = new HashSet {
+ 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 {
+ TelemetryAttribute.HttpStatus,
+ TelemetryAttribute.HttpUserAgent,
+ TelemetryAttribute.RequestMethod,
+ TelemetryAttribute.RequestClientId,
+ TelemetryAttribute.RequestStoreId,
+ TelemetryAttribute.RequestModelId,
+ TelemetryAttribute.RequestRetryCount,
+ }
+ },
+ [TelemetryMeters.QueryDuration] = new () {
+ Attributes = new HashSet {
+ 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.
+
+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 000000000..0399d8add
--- /dev/null
+++ b/config/clients/dotnet/template/README.mustache
@@ -0,0 +1,139 @@
+# {{packageDescription}}
+
+{{>README_custom_badges}}[](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/releases)
+{{#licenseBadgeId}}[](./LICENSE){{/licenseBadgeId}}
+[](https://app.fossa.com/projects/git%2B{{gitHost}}%2F{{gitUserId}}%2F{{gitRepoId}}?ref=badge_shield)
+[](https://openfga.dev/community)
+{{#twitterUserName}}[](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/README_retries.mustache b/config/clients/dotnet/template/README_retries.mustache
index af18dd413..4d0153b9d 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
new file mode 100644
index 000000000..7b24554bc
--- /dev/null
+++ b/config/clients/dotnet/template/Telemetry/Attributes.cs.mustache
@@ -0,0 +1,311 @@
+{{>partial_header}}
+
+using {{packageName}}.ApiClient;
+using {{packageName}}.Configuration;
+using System.Diagnostics;
+using System.Net.Http.Headers;
+using System.Text.Json;
+
+namespace {{packageName}}.Telemetry;
+
+///
+/// Common attribute (tag) names.
+/// For why `static readonly` over `const`, see https://github.com/dotnet/aspnetcore/pull/12441/files
+///
+public static class TelemetryAttribute {
+ // Attributes (tags) associated with the request made //
+
+ ///
+ /// The FGA method/action that was performed (e.g. `Check`, `ListObjects`, ...) in TitleCase.
+ ///
+ public static readonly string RequestMethod = "fga-client.request.method";
+
+ ///
+ /// The store ID that was sent as part of the request.
+ ///
+ public static readonly string RequestStoreId = "fga-client.request.store_id";
+
+ ///
+ /// The authorization model ID that was sent as part of the request, if any.
+ ///
+ public static readonly string RequestModelId = "fga-client.request.model_id";
+
+ ///
+ /// The client ID associated with the request, if any.
+ ///
+ 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.
+ ///
+ 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.
+ ///
+ public static readonly string FgaRequestUser = "fga-client.user";
+
+ // OTEL Semantic Attributes (tags) //
+
+ ///
+ /// The HTTP method for the request.
+ ///
+ public static readonly string HttpMethod = "http.request.method";
+
+ ///
+ /// The status code of the response.
+ ///
+ public static readonly string HttpStatus = "http.response.status_code";
+
+ ///
+ /// Host identifier of the origin the request was sent to.
+ ///
+ public static readonly string HttpHost = "http.host";
+
+ ///
+ /// HTTP Scheme of the request (`http`/`https`).
+ ///
+ public static readonly string HttpScheme = "url.scheme";
+
+ ///
+ /// Full URL of the request.
+ ///
+ public static readonly string HttpUrl = "url.full";
+
+ ///
+ /// User Agent used in the query.
+ ///
+ public static readonly string HttpUserAgent = "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).
+ ///
+ public static readonly string RequestRetryCount = "http.request.resend_count";
+
+ ///
+ /// Return all supported attributes
+ ///
+ public static HashSet GetAllAttributes() {
+ return new() {
+ RequestMethod,
+ RequestStoreId,
+ RequestModelId,
+ RequestClientId,
+ ResponseModelId,
+ FgaRequestUser,
+ HttpMethod,
+ HttpStatus,
+ HttpHost,
+ HttpScheme,
+ HttpUrl,
+ HttpUserAgent,
+ RequestRetryCount
+ };
+ }
+}
+
+///
+/// Class for building attributes for telemetry.
+///
+public class Attributes {
+ ///
+ /// Gets the header value if valid.
+ ///
+ /// The HTTP response headers.
+ /// The name of the header.
+ /// The header value if valid, otherwise null.
+ private static string? GetHeaderValueIfValid(HttpResponseHeaders headers, string headerName) {
+ if (headers.Contains(headerName) && headers.GetValues(headerName).Any()) {
+ return headers.GetValues(headerName).First();
+ }
+
+ return null;
+ }
+
+ ///
+ /// Filters the attributes based on the enabled attributes.
+ ///
+ /// The list of attributes to filter.
+ /// The dictionary of enabled attributes.
+ /// A filtered list of attributes.
+ public static TagList FilterAttributes(TagList attributes, HashSet? 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;
+ }
+
+ ///
+ /// Builds an object of attributes that can be used to report alongside an OpenTelemetry metric event.
+ ///
+ /// The type of the request builder.
+ /// The list of enabled attributes.
+ /// The credentials object.
+ /// The GRPC method name.
+ /// The HTTP response message.
+ /// The request builder.
+ /// The stopwatch measuring the request duration.
+ /// The number of retries attempted.
+ /// A TagList of attributes.
+ public static TagList BuildAttributesForResponse(
+ HashSet enabledAttributes, Credentials? credentials,
+ string apiName, HttpResponseMessage response, RequestBuilder requestBuilder,
+ Stopwatch requestDuration, int retryCount) {
+ 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(
+ HashSet enabledAttributes, string apiName, RequestBuilder requestBuilder, TagList attributes) {
+ // var attributes = new TagList();
+ if (enabledAttributes.Contains(TelemetryAttribute.RequestMethod)) {
+ attributes.Add(new KeyValuePair(TelemetryAttribute.RequestMethod, apiName));
+ }
+
+ 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(TelemetryAttribute.RequestStoreId, storeId));
+ }
+ }
+
+ if (enabledAttributes.Contains(TelemetryAttribute.RequestModelId)) {
+ attributes = AddRequestModelIdAttributes(requestBuilder, apiName, attributes);
+ }
+
+ return attributes;
+ }
+
+ private static TagList AddRequestModelIdAttributes(
+ RequestBuilder requestBuilder, string apiName, TagList attributes) {
+ string? modelId = null;
+
+ if (requestBuilder.PathParameters.ContainsKey("authorization_model_id")) {
+ modelId = requestBuilder.PathParameters.GetValueOrDefault("authorization_model_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(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(
+ RequestBuilder 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(TelemetryAttribute.RequestModelId,
+ authModelId.GetString()));
+ }
+
+ if (apiName is "Check" or "ListObjects" && root.TryGetProperty("user", out var fgaUser) &&
+ !string.IsNullOrEmpty(fgaUser.GetString())) {
+ attributes.Add(new KeyValuePair(TelemetryAttribute.FgaRequestUser,
+ fgaUser.GetString()));
+ }
+ }
+ }
+ }
+ catch {
+ // Handle parsing errors if necessary
+ }
+
+ return attributes;
+ }
+
+ private static TagList AddResponseAttributes(
+ HashSet enabledAttributes, HttpResponseMessage response, TagList attributes) {
+ if (enabledAttributes.Contains(TelemetryAttribute.HttpStatus) && response.StatusCode != null) {
+ attributes.Add(new KeyValuePair(TelemetryAttribute.HttpStatus, (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(TelemetryAttribute.ResponseModelId, responseModelId));
+ }
+ }
+
+ return attributes;
+ }
+
+ private static TagList AddCommonAttributes(
+ HashSet enabledAttributes, HttpResponseMessage response, RequestBuilder requestBuilder,
+ Credentials? credentials, int retryCount, TagList attributes) {
+ if (response.RequestMessage != null) {
+ if (enabledAttributes.Contains(TelemetryAttribute.HttpMethod) && response.RequestMessage.Method != null) {
+ attributes.Add(new KeyValuePair(TelemetryAttribute.HttpMethod,
+ response.RequestMessage.Method));
+ }
+
+ if (response.RequestMessage.RequestUri != null) {
+ if (enabledAttributes.Contains(TelemetryAttribute.HttpScheme)) {
+ attributes.Add(new KeyValuePair(TelemetryAttribute.HttpScheme,
+ response.RequestMessage.RequestUri.Scheme));
+ }
+
+ if (enabledAttributes.Contains(TelemetryAttribute.HttpHost)) {
+ attributes.Add(new KeyValuePair(TelemetryAttribute.HttpHost,
+ response.RequestMessage.RequestUri.Host));
+ }
+
+ if (enabledAttributes.Contains(TelemetryAttribute.HttpUrl)) {
+ attributes.Add(new KeyValuePair(TelemetryAttribute.HttpUrl,
+ response.RequestMessage.RequestUri.AbsoluteUri));
+ }
+ }
+
+ if (enabledAttributes.Contains(TelemetryAttribute.HttpUserAgent) &&
+ response.RequestMessage.Headers.UserAgent != null &&
+ !string.IsNullOrEmpty(response.RequestMessage.Headers.UserAgent.ToString())) {
+ attributes.Add(new KeyValuePair(TelemetryAttribute.HttpUserAgent,
+ response.RequestMessage.Headers.UserAgent.ToString()));
+ }
+ }
+
+ if (enabledAttributes.Contains(TelemetryAttribute.RequestClientId) && credentials is
+ { Method: CredentialsMethod.ClientCredentials, Config.ClientId: not null }) {
+ attributes.Add(new KeyValuePair(TelemetryAttribute.RequestClientId,
+ credentials.Config.ClientId));
+ }
+
+ if (enabledAttributes.Contains(TelemetryAttribute.RequestRetryCount) && retryCount > 0) {
+ attributes.Add(new KeyValuePair(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
new file mode 100644
index 000000000..cbeebe302
--- /dev/null
+++ b/config/clients/dotnet/template/Telemetry/Counters.cs.mustache
@@ -0,0 +1,38 @@
+{{>partial_header}}
+
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+
+namespace {{packageName}}.Telemetry;
+
+///
+/// Class for managing telemetry counters.
+///
+public class TelemetryCounters {
+ public Counter TokenExchangeCounter { get; }
+
+ public Counter RequestCounter { get; private set; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The meter used to create counters.
+ public TelemetryCounters(Meter meter) {
+ TokenExchangeCounter = meter.CreateCounter(TelemetryMeter.TokenExchangeCount,
+ description: "The count of token exchange requests");
+ RequestCounter =
+ meter.CreateCounter(TelemetryMeter.RequestCount, description: "The count of requests made");
+ }
+
+ ///
+ /// Increments the counter for a token exchange.
+ ///
+ /// The attributes associated with the telemetry data.
+ public void IncrementTokenExchangeCounter(TagList attributes) => TokenExchangeCounter.Add(1, attributes);
+
+ ///
+ /// Increments the counter for an API request.
+ ///
+ /// The attributes associated with the telemetry data.
+ 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
new file mode 100644
index 000000000..2b3aa9bc7
--- /dev/null
+++ b/config/clients/dotnet/template/Telemetry/Histograms.cs.mustache
@@ -0,0 +1,56 @@
+{{>partial_header}}
+
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+
+namespace {{packageName}}.Telemetry;
+
+///
+/// Class for managing telemetry histograms.
+///
+public class TelemetryHistograms {
+ ///
+ /// Histogram for query duration.
+ ///
+ public Histogram QueryDurationHistogram { get; }
+
+ ///
+ /// Histogram for request duration.
+ ///
+ public Histogram RequestDurationHistogram { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The meter used to create histograms.
+ public TelemetryHistograms(Meter meter) {
+ RequestDurationHistogram =
+ meter.CreateHistogram(TelemetryMeter.RequestDuration, "The duration of requests", "milliseconds");
+ QueryDurationHistogram = meter.CreateHistogram(TelemetryMeter.QueryDuration,
+ "The duration of queries on the FGA server", "milliseconds");
+ }
+
+ ///
+ /// Records the server query duration if the header is present.
+ ///
+ /// The HTTP response message.
+ /// The attributes associated with the telemetry data.
+ 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) && float.TryParse(durationHeader, out var durationFloat)) {
+ QueryDurationHistogram?.Record(durationFloat, attributes);
+ }
+ }
+ }
+
+ ///
+ /// Records the total request duration (includes all requests needed till success such as credential exchange and
+ /// retries).
+ ///
+ /// The stopwatch measuring the request duration.
+ /// The attributes associated with the telemetry data.
+ 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 000000000..5687e153e
--- /dev/null
+++ b/config/clients/dotnet/template/Telemetry/Meters.cs.mustache
@@ -0,0 +1,45 @@
+{{>partial_header}}
+
+namespace {{packageName}}.Telemetry;
+
+///
+/// Meter names for telemetry.
+/// For why `static readonly` over `const`, see https://github.com/dotnet/aspnetcore/pull/12441/files
+///
+public static class TelemetryMeter {
+ // Histograms //
+
+ ///
+ /// The total request time for FGA requests
+ ///
+ public static readonly string RequestDuration = "fga-client.request.duration";
+
+ ///
+ /// The amount of time the FGA server took to internally process and evaluate the request
+ ///
+ public static readonly string QueryDuration = "fga-client.query.duration";
+
+ // Counters //
+
+ ///
+ /// The total number of times a new token was requested when using ClientCredentials.
+ ///
+ public static readonly string TokenExchangeCount = "fga-client.credentials.request";
+
+ ///
+ /// The total number of times a new token was requested when using ClientCredentials.
+ ///
+ public static readonly string RequestCount = "fga-client.request.count";
+
+ ///
+ /// Return all supported meter names
+ ///
+ public static HashSet 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
new file mode 100644
index 000000000..767455c4e
--- /dev/null
+++ b/config/clients/dotnet/template/Telemetry/Metrics.cs.mustache
@@ -0,0 +1,103 @@
+{{>partial_header}}
+
+using {{packageName}}.ApiClient;
+using {{packageName}}.Configuration;
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+
+namespace {{packageName}}.Telemetry;
+
+///
+/// Class for managing metrics.
+///
+public class Metrics {
+ 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 _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);
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ 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();
+ foreach (var attribute in attributesHashSet) {
+ _allEnabledAttributes.Add(attribute);
+ }
+ }
+ }
+
+ ///
+ /// Builds metrics for a given HTTP response.
+ ///
+ /// The type of the request builder response.
+ /// The API name.
+ /// The HTTP response message.
+ /// The request builder.
+ /// The stopwatch measuring the request duration.
+ /// The number of retries attempted.
+ public void BuildForResponse(string apiName,
+ HttpResponseMessage response, RequestBuilder 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));
+ }
+ }
+
+ ///
+ /// Builds metrics for a client credentials response.
+ ///
+ /// The type of the request builder.
+ /// The HTTP response message.
+ /// The request builder.
+ /// The stopwatch measuring the request duration.
+ /// The number of retries attempted.
+ public void BuildForClientCredentialsResponse(
+ HttpResponseMessage response, RequestBuilder 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 e3b631bc8..267123a84 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;
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class.
///
///
///
@@ -19,9 +19,9 @@ public class {{classname}} : IDisposable {
Configuration.Configuration configuration,
HttpClient? httpClient = null
) {
- configuration.IsValid();
- this._configuration = configuration;
- this._apiClient = new ApiClient.ApiClient(_configuration, httpClient);
+ configuration.EnsureValid();
+ _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/api_test.mustache b/config/clients/dotnet/template/api_test.mustache
index 1fce008f8..aa1882a7d 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();
}
///
@@ -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(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(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(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(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(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(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(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(ActionMissingApiAudience);
Assert.Equal("Required parameter ApiAudience was not defined when calling Configuration.",
diff --git a/config/clients/dotnet/template/example/Example1/Example1.cs b/config/clients/dotnet/template/example/Example1/Example1.cs
index f47bbea64..3324677f4 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/.env.example b/config/clients/dotnet/template/example/OpenTelemetryExample/.env.example
new file mode 100644
index 000000000..db7ea750b
--- /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 000000000..c383e4192
--- /dev/null
+++ b/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.cs
@@ -0,0 +1,310 @@
+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_API_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")
+ };
+ }
+
+ // Customize the metrics and the attributes on each metric
+ TelemetryConfig telemetryConfig = new TelemetryConfig() {
+ Metrics = new Dictionary {
+ [TelemetryMeter.TokenExchangeCount] = new() {
+ Attributes = new HashSet {
+ TelemetryAttribute.HttpHost,
+ TelemetryAttribute.HttpStatus,
+ TelemetryAttribute.HttpUserAgent,
+ TelemetryAttribute.RequestClientId,
+ }
+ },
+ [TelemetryMeter.QueryDuration] = new () {
+ Attributes = new HashSet {
+ TelemetryAttribute.HttpStatus,
+ TelemetryAttribute.HttpUserAgent,
+ TelemetryAttribute.RequestMethod,
+ TelemetryAttribute.RequestClientId,
+ TelemetryAttribute.RequestStoreId,
+ TelemetryAttribute.RequestModelId,
+ TelemetryAttribute.RequestRetryCount,
+ }
+ },
+ [TelemetryMeter.RequestDuration] = new () {
+ Attributes = new HashSet {
+ 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",
+ StoreId = Environment.GetEnvironmentVariable("FGA_STORE_ID"),
+ AuthorizationModelId = Environment.GetEnvironmentVariable("FGA_MODEL_ID"),
+ Credentials = credentials,
+ Telemetry = telemetryConfig
+ };
+ 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();
+
+ 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);
+
+ // 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");
+ 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 {
+ new() { Type = "user", Relations = new Dictionary() },
+ new() {
+ Type = "document",
+ Relations =
+ new Dictionary {
+ { "writer", new Userset { This = new object() } }, {
+ "viewer",
+ new Userset {
+ Union = new Usersets {
+ Child = new List {
+ new() { This = new object() },
+ new() {
+ ComputedUserset = new ObjectRelation { Relation = "writer" }
+ }
+ }
+ }
+ }
+ }
+ },
+ Metadata = new Metadata {
+ Relations = new Dictionary {
+ {
+ "writer",
+ new RelationMetadata {
+ DirectlyRelatedUserTypes = new List {
+ new() { Type = "user" },
+ new() { Type = "user", Condition = "ViewCountLessThan200" }
+ }
+ }
+ }, {
+ "viewer",
+ new RelationMetadata {
+ DirectlyRelatedUserTypes = new List {
+ new() { Type = "user" }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ Conditions = new Dictionary {
+ ["ViewCountLessThan200"] = new() {
+ Name = "ViewCountLessThan200",
+ Expression = "ViewCount < 200",
+ Parameters = new Dictionary {
+ ["ViewCount"] = new() { TypeName = TypeName.INT },
+ ["Type"] = new() { TypeName = TypeName.STRING },
+ ["Name"] = new() { TypeName = TypeName.STRING }
+ }
+ }
+ }
+ };
+ var authorizationModel = await fgaClient.WriteAuthorizationModel(body);
+ Thread.Sleep(1000);
+ 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;
+
+ 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();
+ existingTuples.Tuples.ForEach(tuple => tuplesToDelete.Add(new ClientTupleKeyWithoutCondition {
+ User = tuple.Key.User, Relation = tuple.Key.Relation, Object = tuple.Key.Object
+ }));
+ if (tuplesToDelete.Count > 0) {
+ await fgaClient.DeleteTuples(tuplesToDelete);
+ }
+ } while (contToken != "");
+
+ // Write
+ Console.WriteLine("Writing Tuples");
+ await fgaClient.Write(
+ new ClientWriteRequest {
+ Writes = new List {
+ 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 {
+ 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
+ var rnd = new Random();
+ var randomNumber = rnd.Next(1, 1000);
+ Console.WriteLine($"Checking for access with context in a loop ({randomNumber} times)");
+ 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 }
+ });
+ Console.WriteLine("Allowed: " + checkResponse.Allowed);
+ }
+
+ if (performStoreActions) {
+ // 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 000000000..00bcfd0a1
--- /dev/null
+++ b/config/clients/dotnet/template/example/OpenTelemetryExample/OpenTelemetryExample.csproj
@@ -0,0 +1,30 @@
+
+
+
+ Exe
+ net6.0
+ enable
+ enable
+ Linux
+ OpenTelemetryExample
+
+
+
+
+
+
+
+
+
+
+ ..\..\src\OpenFga.Sdk\bin\Debug\net6.0\OpenFga.Sdk.dll
+
+
+
+
+
+
+
+
+
+
diff --git a/config/clients/dotnet/template/netcore_project.mustache b/config/clients/dotnet/template/netcore_project.mustache
index 894167db2..506beb406 100644
--- a/config/clients/dotnet/template/netcore_project.mustache
+++ b/config/clients/dotnet/template/netcore_project.mustache
@@ -30,9 +30,13 @@
-
-
-
+
+
+
+
+
+
+
diff --git a/config/clients/dotnet/template/netcore_testproject.mustache b/config/clients/dotnet/template/netcore_testproject.mustache
index 65c9d1ed8..150239347 100644
--- a/config/clients/dotnet/template/netcore_testproject.mustache
+++ b/config/clients/dotnet/template/netcore_testproject.mustache
@@ -9,9 +9,9 @@
- all
- all
- all
+ all
+ all
+ all
all
runtime; build; native; contentfiles; analyzers; buildtransitive