Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dotnet): export opentelemetry metrics #398

Merged
merged 12 commits into from
Nov 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config/clients/dotnet/CHANGELOG.md.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
34 changes: 33 additions & 1 deletion config/clients/dotnet/config.overrides.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand All @@ -33,6 +33,7 @@
"enablePostProcessFile": true,
"hashCodeBasePrimeNumber": 9661,
"hashCodeMultiplierPrimeNumber": 9923,
"supportsOpenTelemetry": true,
"files": {
"Client_OAuth2Client.mustache": {
"destinationFilename": "src/OpenFga.Sdk/ApiClient/OAuth2Client.cs",
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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": {}
}
Expand Down
2 changes: 1 addition & 1 deletion config/clients/dotnet/template/Client/Client.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,50 +7,76 @@ 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;
ApiUrl = config.ApiUrl;
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);
Expand Down
117 changes: 60 additions & 57 deletions config/clients/dotnet/template/Client_ApiClient.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,46 @@
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;

/// <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>
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:
Expand All @@ -44,119 +51,115 @@ 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>
/// <param name="cancellationToken"></param>
/// <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>();

var sw = Stopwatch.StartNew();
if (_oauth2Client != null) {
try {
var token = await _oauth2Client.GetAccessTokenAsync();

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<TReq, TRes>(requestBuilder, additionalHeaders, apiName,
cancellationToken));

sw.Stop();
metrics.BuildForResponse(apiName, response.rawResponse, requestBuilder, 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>
/// <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>();

var sw = Stopwatch.StartNew();
if (_oauth2Client != null) {
try {
var token = await _oauth2Client.GetAccessTokenAsync();

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<TReq, object>(requestBuilder, additionalHeaders, apiName,
cancellationToken));

sw.Stop();
metrics.BuildForResponse(apiName, response.rawResponse, requestBuilder, sw,
response.retryCount);
}

private async Task<TResult> Retry<TResult>(Func<Task<TResult>> retryable) {
var numRetries = 0;
private async Task<ResponseWrapper<TResult>> Retry<TResult>(Func<Task<ResponseWrapper<TResult>>> 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<Task> 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();
}
Loading
Loading