From 079b6e30846d53d63a5797c0ab2033e33c96080c Mon Sep 17 00:00:00 2001 From: Evan Sims Date: Mon, 16 Sep 2024 23:45:28 -0500 Subject: [PATCH] release(python-sdl): v0.7.1 --- config/clients/python/CHANGELOG.md.mustache | 28 +++ config/clients/python/config.overrides.json | 30 ++- .../.github/workflows/main.yaml.mustache | 6 +- .../template/docs/opentelemetry.md.mustache | 138 ++++++++--- .../example/opentelemetry/main.py.mustache | 69 ++++++ .../python/template/src/__init__.py.mustache | 5 + .../python/template/src/api.py.mustache | 20 +- .../template/src/api_client.py.mustache | 50 ++-- .../template/src/configuration.py.mustache | 9 + .../python/template/src/oauth2.py.mustache | 2 +- .../python/template/src/sync/api.py.mustache | 20 +- .../template/src/sync/api_client.py.mustache | 50 ++-- .../template/src/sync/oauth2.py.mustache | 2 +- .../src/telemetry/__init__.py.mustache | 5 + .../src/telemetry/attributes.py.mustache | 220 +++++++++++++++--- .../src/telemetry/configuration.py.mustache | 168 +++++++++++++ .../src/telemetry/histograms.py.mustache | 2 +- .../src/telemetry/metrics.py.mustache | 143 ++++++++++-- .../src/telemetry/telemetry.py.mustache | 2 +- .../template/test-requirements.mustache | 4 +- .../telemetry/attributes_test.py.mustache | 166 +++++++++++++ .../telemetry/configuration_test.py.mustache | 120 ++++++++++ .../test/telemetry/counters_test.py.mustache | 36 +++ .../telemetry/histograms_test.py.mustache | 43 ++++ .../test/telemetry/metrics_test.py.mustache | 101 ++++++++ .../test/telemetry/telemetry_test.py.mustache | 45 ++++ 26 files changed, 1323 insertions(+), 161 deletions(-) create mode 100644 config/clients/python/template/src/telemetry/configuration.py.mustache create mode 100644 config/clients/python/template/test/telemetry/attributes_test.py.mustache create mode 100644 config/clients/python/template/test/telemetry/configuration_test.py.mustache create mode 100644 config/clients/python/template/test/telemetry/counters_test.py.mustache create mode 100644 config/clients/python/template/test/telemetry/histograms_test.py.mustache create mode 100644 config/clients/python/template/test/telemetry/metrics_test.py.mustache create mode 100644 config/clients/python/template/test/telemetry/telemetry_test.py.mustache diff --git a/config/clients/python/CHANGELOG.md.mustache b/config/clients/python/CHANGELOG.md.mustache index 52fd68a5..7100496e 100644 --- a/config/clients/python/CHANGELOG.md.mustache +++ b/config/clients/python/CHANGELOG.md.mustache @@ -1,5 +1,33 @@ # Changelog +## v0.7.1 + +### [0.7.1](https://github.com/openfga/python-sdk/compare/v0.7.0...v0.7.1) (2024-09-16) + +This release includes fixes for several bugs identified in the previous release related to OpenTelemetry metrics reporting: (#124) + +- fix: attribute values are now correctly exported as their intended types (previously, these were all sent as string values) +- fix: `http_client_request_duration` being reported in seconds rather than the intended milliseconds +- fix: sync client mistakenly passing the entire configuration (rather than just the OpenTelemetry configuration as intended) to `queryDuration()` and `requestDuration()` +- fix: some attributes may not have been exported as expected under some conditions +- fix: `queryDuration()` and `requestDuration()` may not have updated their histograms reliably when `attr_http_client_request_duration` or `attr_http_server_request_duration` (respectively) were not enabled (which is the default) + +Please note that if you use third-party OpenTelemetry tooling to visualize the attributes mentioned above, you may need to update your queries to account for these changes. + +## v0.7.0 + +### [0.7.0](https://github.com/openfga/python-sdk/compare/v0.6.1...v0.7.0) (2024-08-30) + +- feat: enhancements to OpenTelemetry support (#120) + +Note this introduces some breaking changes to our metrics: +1. `fga-client.request.method` is now in TitleCase to match the naming conventions in the Protos, e.g. `Check`, `ListObjects`, etc.. +2. Due to possible high costs for attributes with high cardinality, we are no longer including the following attributes by default: + * `fga-client.user` + * `http.client.request.duration` + * `http.server.request.duration` + We added configuration options to allow you to set which specific metrics and attributes you care about in case the defaults don't work for your use-case + ## v0.6.1 ### [0.6.1](https://github.com/openfga/python-sdk/compare/v0.6.0...v0.6.1) (2024-07-31) diff --git a/config/clients/python/config.overrides.json b/config/clients/python/config.overrides.json index 01c06878..15f9d5d8 100644 --- a/config/clients/python/config.overrides.json +++ b/config/clients/python/config.overrides.json @@ -2,7 +2,7 @@ "sdkId": "python", "gitRepoId": "python-sdk", "packageName": "openfga_sdk", - "packageVersion": "0.6.1", + "packageVersion": "0.7.1", "packageDescription": "Python SDK for OpenFGA", "packageDetailedDescription": "This is an autogenerated python SDK for OpenFGA. It provides a wrapper around the [OpenFGA API definition](https://openfga.dev/api).", "fossaComplianceNoticeId": "2f8a8629-b46c-435e-b8cd-1174a674fb4b", @@ -168,6 +168,10 @@ "destinationFilename": "openfga_sdk/telemetry/attributes.py", "templateType": "SupportingFiles" }, + "src/telemetry/configuration.py.mustache": { + "destinationFilename": "openfga_sdk/telemetry/configuration.py", + "templateType": "SupportingFiles" + }, "src/telemetry/counters.py.mustache": { "destinationFilename": "openfga_sdk/telemetry/counters.py", "templateType": "SupportingFiles" @@ -253,6 +257,30 @@ "destinationFilename": "test/sync/oauth2_test.py", "templateType": "SupportingFiles" }, + "test/telemetry/attributes_test.py.mustache": { + "destinationFilename": "test/telemetry/attributes_test.py", + "templateType": "SupportingFiles" + }, + "test/telemetry/configuration_test.py.mustache": { + "destinationFilename": "test/telemetry/configuration_test.py", + "templateType": "SupportingFiles" + }, + "test/telemetry/counters_test.py.mustache": { + "destinationFilename": "test/telemetry/counters_test.py", + "templateType": "SupportingFiles" + }, + "test/telemetry/histograms_test.py.mustache": { + "destinationFilename": "test/telemetry/histograms_test.py", + "templateType": "SupportingFiles" + }, + "test/telemetry/metrics_test.py.mustache": { + "destinationFilename": "test/telemetry/metrics_test.py", + "templateType": "SupportingFiles" + }, + "test/telemetry/telemetry_test.py.mustache": { + "destinationFilename": "test/telemetry/telemetry_test.py", + "templateType": "SupportingFiles" + }, "test/__init__.py.mustache": { "destinationFilename": "test/__init__.py", "templateType": "SupportingFiles" diff --git a/config/clients/python/template/.github/workflows/main.yaml.mustache b/config/clients/python/template/.github/workflows/main.yaml.mustache index 315a26b7..9eadc263 100644 --- a/config/clients/python/template/.github/workflows/main.yaml.mustache +++ b/config/clients/python/template/.github/workflows/main.yaml.mustache @@ -27,7 +27,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 + uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 with: python-version: ${{ matrix.python-version }} cache: "pip" @@ -80,7 +80,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 + uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 with: python-version: "3.10" cache: "pip" @@ -98,7 +98,7 @@ jobs: python setup.py sdist bdist_wheel - name: Publish package - uses: pypa/gh-action-pypi-publish@ec4db0b4ddc65acdf4bff5fa45ac92d78b56bdf0 # v1.9.0 + uses: pypa/gh-action-pypi-publish@8a08d616893759ef8e1aa1f2785787c0b97e20d6 # v1.10.0 create-release: runs-on: ubuntu-latest diff --git a/config/clients/python/template/docs/opentelemetry.md.mustache b/config/clients/python/template/docs/opentelemetry.md.mustache index 803d5597..4b14c769 100644 --- a/config/clients/python/template/docs/opentelemetry.md.mustache +++ b/config/clients/python/template/docs/opentelemetry.md.mustache @@ -1,35 +1,121 @@ # 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. +- [Overview](#overview) +- [Metrics](#metrics) + - [Supported Metrics](#supported-metrics) + - [Supported Attributes](#supported-attributes) +- [Customizing Reporting](#customizing-reporting) +- [Usage](#usage) + - [Installation](#1-install-dependencies) + - [Configure OpenTelemetry](#2-configure-opentelemetry) + - [Configure OpenFGA](#3-configure-openfga) +- [Example Integration](#example-integration) -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. +## Overview -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. +This SDK supports [OpenTelemetry](https://opentelemetry.io/) to export [metrics](https://opentelemetry.io/docs/concepts/signals/metrics/) that provide insights into your application's performance, such as request timings. These metrics include attributes like model and store IDs, and the API called, which you can use to build detailed reports and dashboards. + +If you configure the OpenTelemetry SDK, these metrics will be exported and sent to a collector as specified in your application's configuration. If OpenTelemetry is not configured, metrics functionality is disabled, and no events are sent. ## Metrics ### Supported Metrics -| Metric Name | Type | Description | -| --------------------------------- | --------- | -------------------------------------------------------------------------------- | -| `fga-client.request.duration` | Histogram | The total request time for FGA requests | -| `fga-client.query.duration` | Histogram | The amount of time the FGA server took to process the request | -| ` fga-client.credentials.request` | Counter | The total number of times a new token was requested when using ClientCredentials | - -### Supported attributes - -| Attribute Name | Type | Description | -| ------------------------------ | -------- | ----------------------------------------------------------------------------------- | -| `fga-client.response.model_id` | `string` | The authorization model ID that the FGA server used | -| `fga-client.request.method` | `string` | The FGA method/action that was performed | -| `fga-client.request.store_id` | `string` | The store ID that was sent as part of the request | -| `fga-client.request.model_id` | `string` | The authorization model ID that was sent as part of the request, if any | -| `fga-client.request.client_id` | `string` | The client ID associated with the request, if any | -| `fga-client.user` | `string` | The user that is associated with the action of the request for check and list users | -| `http.status_code ` | `int` | The status code of the response | -| `http.method` | `string` | The HTTP method for the request | -| `http.host` | `string` | Host identifier of the origin the request was sent to | - -## Example - -There is an [example project](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/blob/main/example/opentelemetry) that provides some guidance on how to configure OpenTelemetry available in the examples directory. \ No newline at end of file +| Metric Name | Type | Enabled by Default | Description | +| -------------------------------- | --------- | ------------------ | --------------------------------------------------------------------------------- | +| `fga-client.request.duration` | Histogram | Yes | Total request time for FGA requests, in milliseconds | +| `fga-client.query.duration` | Histogram | Yes | Time taken by the FGA server to process and evaluate the request, in milliseconds | +| `fga-client.credentials.request` | Counter | Yes | Total number of new token requests initiated using the Client Credentials flow | + +### Supported Attributes + +| Attribute Name | Type | Enabled by Default | Description | +| ------------------------------ | ------ | ------------------ | --------------------------------------------------------------------------------- | +| `fga-client.request.client_id` | string | Yes | Client ID associated with the request, if any | +| `fga-client.request.method` | string | Yes | FGA method/action that was performed (e.g., Check, ListObjects) in TitleCase | +| `fga-client.request.model_id` | string | Yes | Authorization model ID that was sent as part of the request, if any | +| `fga-client.request.store_id` | string | Yes | Store ID that was sent as part of the request | +| `fga-client.response.model_id` | string | Yes | Authorization model ID that the FGA server used | +| `fga-client.user` | string | No | User associated with the action of the request for check and list users | +| `http.client.request.duration` | int | No | Duration for the SDK to complete the request, in milliseconds | +| `http.host` | string | Yes | Host identifier of the origin the request was sent to | +| `http.request.method` | string | Yes | HTTP method for the request | +| `http.request.resend_count` | int | Yes | Number of retries attempted, if any | +| `http.response.status_code` | int | Yes | Status code of the response (e.g., `200` for success) | +| `http.server.request.duration` | int | Yes | Time taken by the FGA server to process and evaluate the request, in milliseconds | +| `url.scheme` | string | Yes | HTTP scheme of the request (`http`/`https`) | +| `url.full` | string | Yes | Full URL of the request | +| `user_agent.original` | string | Yes | User Agent used in the query | + +## Customizing Reporting + +To control which metrics and attributes are reported by the SDK, you can provide your own `TelemetryConfiguration` instance during initialization, as shown in the example above. The `TelemetryConfiguration` class allows you to configure the metrics and attributes that are reported by the SDK, as outlined in [the tables above](#metrics). + +## Usage + +### 1. Install Dependencies + +Install the OpenFGA SDK and OpenTelemetry SDK in your application using `pip`: + +```sh +pip install openfga opentelemetry-sdk +``` + +You must also install an OpenTelemetry exporter; for example, the OTLP gRPC exporter: + +```sh +pip install opentelemetry-exporter-otlp-proto-grpc +``` + +### 2. Configure OpenTelemetry + +Configure your application to use OpenTelemetry, and set up the metrics provider to export metrics using an exporter: + +```python +from opentelemetry import metrics +from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter +from opentelemetry.sdk.metrics import MeterProvider + +# Configure OpenTelemetry +metrics.set_meter_provider( + MeterProvider( + resource=Resource(attributes={SERVICE_NAME: "openfga-example"}), + metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())], + ) +) +``` + +### 3. Configure OpenFGA + +Configure the OpenFGA client, and (optionally) customize what metrics and attributes are reported: + +```python +from {{packageName}}.telemetry.configuration import ( + TelemetryConfiguration, + TelemetryMetricConfiguration, + TelemetryMetricsConfiguration, +) +from {{packageName}} import ClientConfiguration, OpenFgaClient + +configuration = ClientConfiguration( + api_url=os.getenv("FGA_API_URL"), + store_id=os.getenv("FGA_STORE_ID"), + authorization_model_id=os.getenv("FGA_AUTHORIZATION_MODEL_ID"), + + # If you are comfortable with the default configuration outlined in the tables above, you can omit providing your own TelemetryConfiguration object. + telemetry=TelemetryConfiguration( + metrics=TelemetryMetricsConfiguration( + histogram_request_duration=TelemetryMetricConfiguration( + attr_fga_client_request_method=True, + attr_http_response_status_code=True, + ), + ), + ), +) + +fga = OpenFgaClient(configuration) +``` + +## Example Integration + +An [example integration](../example/opentelemetry) is provided that also demonstrates how to configure an application with OpenFGA and OpenTelemetry. Please refer to [the README](../example/opentelemetry/README.md) for more information. diff --git a/config/clients/python/template/example/opentelemetry/main.py.mustache b/config/clients/python/template/example/opentelemetry/main.py.mustache index a9226267..76c924d6 100644 --- a/config/clients/python/template/example/opentelemetry/main.py.mustache +++ b/config/clients/python/template/example/opentelemetry/main.py.mustache @@ -15,6 +15,12 @@ from opentelemetry.sdk.metrics.export import ( ) from opentelemetry.sdk.resources import SERVICE_NAME, Resource +from {{packageName}}.telemetry.configuration import ( + TelemetryConfiguration, + TelemetryMetricConfiguration, + TelemetryMetricsConfiguration, +) + # For usage convenience of this example, we will import the OpenFGA SDK from the parent directory. sdk_path = os.path.realpath(os.path.join(os.path.abspath(__file__), "..", "..", "..")) sys.path.insert(0, sdk_path) @@ -42,6 +48,7 @@ class app: client: OpenFgaClient = None, credentials: Credentials = None, configuration: ClientConfiguration = None, + telemetry: TelemetryConfiguration = None, ): """ Initialize the example with the provided client, credentials, and configuration. @@ -50,6 +57,7 @@ class app: self._client = client self._credentials = credentials self._configuration = configuration + self._telemetry = telemetry async def fga_client(self, env: dict[str, str] = {}) -> OpenFgaClient: """ @@ -78,6 +86,67 @@ class app: credentials=self._credentials, ) + if not self._telemetry: + # Configure the telemetry metrics to be collected. + # Note: the following represents the default configuration values, so unless you want to change them, you can omit this step. + self._telemetry = TelemetryConfiguration( + metrics=TelemetryMetricsConfiguration( + counter_credentials_request=TelemetryMetricConfiguration( + attr_fga_client_request_client_id=True, + attr_fga_client_request_method=True, + attr_fga_client_request_model_id=True, + attr_fga_client_request_store_id=True, + attr_fga_client_response_model_id=True, + attr_fga_client_user=False, + attr_http_client_request_duration=False, + attr_http_host=True, + attr_http_request_method=True, + attr_http_request_resend_count=True, + attr_http_response_status_code=True, + attr_http_server_request_duration=False, + attr_http_url_scheme=True, + attr_http_url_full=True, + attr_user_agent_original=True, + ), + histogram_request_duration=TelemetryMetricConfiguration( + attr_fga_client_request_client_id=True, + attr_fga_client_request_method=True, + attr_fga_client_request_model_id=True, + attr_fga_client_request_store_id=True, + attr_fga_client_response_model_id=True, + attr_fga_client_user=False, + attr_http_client_request_duration=False, + attr_http_host=True, + attr_http_request_method=True, + attr_http_request_resend_count=True, + attr_http_response_status_code=True, + attr_http_server_request_duration=False, + attr_http_url_scheme=True, + attr_http_url_full=True, + attr_user_agent_original=True, + ), + histogram_query_duration=TelemetryMetricConfiguration( + attr_fga_client_request_client_id=True, + attr_fga_client_request_method=True, + attr_fga_client_request_model_id=True, + attr_fga_client_request_store_id=True, + attr_fga_client_response_model_id=True, + attr_fga_client_user=False, + attr_http_client_request_duration=False, + attr_http_host=True, + attr_http_request_method=True, + attr_http_request_resend_count=True, + attr_http_response_status_code=True, + attr_http_server_request_duration=False, + attr_http_url_scheme=True, + attr_http_url_full=True, + attr_user_agent_original=True, + ), + ), + ) + + self._configuration.telemetry = self._telemetry + if not self._client: return OpenFgaClient(self._configuration) diff --git a/config/clients/python/template/src/__init__.py.mustache b/config/clients/python/template/src/__init__.py.mustache index aff6d2d3..109f027b 100644 --- a/config/clients/python/template/src/__init__.py.mustache +++ b/config/clients/python/template/src/__init__.py.mustache @@ -18,6 +18,11 @@ from {{packageName}}.exceptions import ApiException {{#models}}{{#model}}from {{modelPackage}}.{{classFilename}} import {{classname}} {{/model}}{{/models}} +from {{packageName}}.telemetry.configuration import ( + TelemetryConfiguration, + TelemetryMetricConfiguration, + TelemetryMetricsConfiguration, +) {{#recursionLimit}} __import__('sys').setrecursionlimit({{{.}}}) diff --git a/config/clients/python/template/src/api.py.mustache b/config/clients/python/template/src/api.py.mustache index d6563b95..ca901b57 100644 --- a/config/clients/python/template/src/api.py.mustache +++ b/config/clients/python/template/src/api.py.mustache @@ -330,18 +330,20 @@ class {{classname}}: {{/returnType}} telemetry_attributes: dict[TelemetryAttribute, str] = { - TelemetryAttributes.request_method: "{{operationId}}" + TelemetryAttributes.fga_client_request_method: "{{operationId}}" } try: if store_id: - telemetry_attributes[TelemetryAttributes.request_store_id] = store_id + telemetry_attributes[ + TelemetryAttributes.fga_client_request_store_id + ] = store_id except: pass try: if body_params.tuple_key: - telemetry_attributes[TelemetryAttributes.client_user] = ( + telemetry_attributes[TelemetryAttributes.fga_client_user] = ( body_params.tuple_key.user ) except: @@ -349,13 +351,13 @@ class {{classname}}: try: if "authorization_model_id" in local_var_params: - telemetry_attributes[TelemetryAttributes.request_model_id] = local_var_params[ - "authorization_model_id" - ] + telemetry_attributes[ + TelemetryAttributes.fga_client_request_model_id + ] = local_var_params["authorization_model_id"] elif body_params.authorization_model_id: - telemetry_attributes[TelemetryAttributes.request_model_id] = ( - body_params.authorization_model_id - ) + telemetry_attributes[ + TelemetryAttributes.fga_client_request_model_id + ] = body_params.authorization_model_id except: pass diff --git a/config/clients/python/template/src/api_client.py.mustache b/config/clients/python/template/src/api_client.py.mustache index 4b2a0387..6a102132 100644 --- a/config/clients/python/template/src/api_client.py.mustache +++ b/config/clients/python/template/src/api_client.py.mustache @@ -180,14 +180,7 @@ class ApiClient: self.configuration.is_valid() config = self.configuration - - benchmark: dict[str, float] = {"start": float(time.time())} - _telemetry_attributes = _telemetry_attributes or {} - - _telemetry_attributes[TelemetryAttributes().http_host] = urllib.parse.urlparse( - config.api_url - ).hostname - _telemetry_attributes[TelemetryAttributes().http_method] = method + start = float(time.time()) # header parameters header_params = header_params or {} @@ -284,30 +277,31 @@ class ApiClient: return_data = response_data - benchmark["end"] = float(time.time()) - - _telemetry_attributes[TelemetryAttributes().request_retries] = retry - - _telemetry_attributes.update( - TelemetryAttributes().fromResponse(response_data) + _telemetry_attributes = TelemetryAttributes().fromRequest( + user_agent=self.user_agent, + fga_method=resource_path, + http_method=method, + url=url, + resend_count=retry, + start=start, + credentials=self.configuration.credentials, + attributes=_telemetry_attributes, ) - try: - if self.configuration.credentials.configuration.client_id is not None: - _telemetry_attributes[TelemetryAttributes().request_client_id] = ( - self.configuration.credentials.configuration.client_id - ) - except AttributeError: - pass - - duration = response_data.getheader("fga-query-duration-ms") - attributes = TelemetryAttributes().prepare(_telemetry_attributes) + _telemetry_attributes = TelemetryAttributes().fromResponse( + response=response_data, + credentials=self.configuration.credentials, + attributes=_telemetry_attributes, + ) - if duration is not None: - self._telemetry.metrics().queryDuration(int(duration, 10), attributes) + self._telemetry.metrics().queryDuration( + attributes=_telemetry_attributes, + configuration=self.configuration.telemetry, + ) - self._telemetry.metrics().requestDuration().record( - float(benchmark["end"] - benchmark["start"]), attributes + self._telemetry.metrics().requestDuration( + attributes=_telemetry_attributes, + configuration=self.configuration.telemetry, ) if not _preload_content: diff --git a/config/clients/python/template/src/configuration.py.mustache b/config/clients/python/template/src/configuration.py.mustache index 19f5b621..439ee763 100644 --- a/config/clients/python/template/src/configuration.py.mustache +++ b/config/clients/python/template/src/configuration.py.mustache @@ -11,6 +11,7 @@ import urllib import urllib3 from {{packageName}}.exceptions import FgaValidationException, ApiValueError +from {{packageName}}.telemetry.configuration import TelemetryConfiguration from {{packageName}}.validation import is_well_formed_ulid_string @@ -154,6 +155,7 @@ class Configuration: server_operation_index=None, server_operation_variables=None, ssl_ca_cert=None, api_url=None, # TODO: restructure when removing api_scheme/api_host + telemetry: TelemetryConfiguration | None = None, ): """Constructor """ @@ -276,6 +278,13 @@ class Configuration: """Options to pass down to the underlying urllib3 socket """ + if telemetry is None: + telemetry = TelemetryConfiguration() + + self.telemetry = telemetry + """Telemetry configuration + """ + def __deepcopy__(self, memo): cls = self.__class__ result = cls.__new__(cls) diff --git a/config/clients/python/template/src/oauth2.py.mustache b/config/clients/python/template/src/oauth2.py.mustache index 3ea23250..01d558b1 100644 --- a/config/clients/python/template/src/oauth2.py.mustache +++ b/config/clients/python/template/src/oauth2.py.mustache @@ -113,7 +113,7 @@ class OAuth2Client: self._telemetry.metrics().credentialsRequest( 1, { - TelemetryAttributes.request_client_id: configuration.client_id + TelemetryAttributes.fga_client_request_client_id: configuration.client_id }, ) break diff --git a/config/clients/python/template/src/sync/api.py.mustache b/config/clients/python/template/src/sync/api.py.mustache index ec994c61..3d2cd37a 100644 --- a/config/clients/python/template/src/sync/api.py.mustache +++ b/config/clients/python/template/src/sync/api.py.mustache @@ -320,18 +320,20 @@ class {{classname}}: {{/returnType}} telemetry_attributes: dict[TelemetryAttribute, str] = { - TelemetryAttributes.request_method: "{{operationId}}" + TelemetryAttributes.fga_client_request_method: "{{operationId}}" } try: if store_id: - telemetry_attributes[TelemetryAttributes.request_store_id] = store_id + telemetry_attributes[ + TelemetryAttributes.fga_client_request_store_id + ] = store_id except: pass try: if body_params.tuple_key: - telemetry_attributes[TelemetryAttributes.client_user] = ( + telemetry_attributes[TelemetryAttributes.fga_client_user] = ( body_params.tuple_key.user ) except: @@ -339,13 +341,13 @@ class {{classname}}: try: if "authorization_model_id" in local_var_params: - telemetry_attributes[TelemetryAttributes.request_model_id] = local_var_params[ - "authorization_model_id" - ] + telemetry_attributes[ + TelemetryAttributes.fga_client_request_model_id + ] = local_var_params["authorization_model_id"] elif body_params.authorization_model_id: - telemetry_attributes[TelemetryAttributes.request_model_id] = ( - body_params.authorization_model_id - ) + telemetry_attributes[ + TelemetryAttributes.fga_client_request_model_id + ] = body_params.authorization_model_id except: pass diff --git a/config/clients/python/template/src/sync/api_client.py.mustache b/config/clients/python/template/src/sync/api_client.py.mustache index 9deccc98..d09b3a8a 100644 --- a/config/clients/python/template/src/sync/api_client.py.mustache +++ b/config/clients/python/template/src/sync/api_client.py.mustache @@ -165,14 +165,7 @@ class ApiClient: self.configuration.is_valid() config = self.configuration - - benchmark: dict[str, float] = {"start": float(time.time())} - _telemetry_attributes = _telemetry_attributes or {} - - _telemetry_attributes[TelemetryAttributes().http_host] = urllib.parse.urlparse( - config.api_url - ).hostname - _telemetry_attributes[TelemetryAttributes().http_method] = method + start = float(time.time()) # header parameters header_params = header_params or {} @@ -265,30 +258,31 @@ class ApiClient: return_data = response_data - benchmark["end"] = float(time.time()) - - _telemetry_attributes[TelemetryAttributes().request_retries] = retry - - _telemetry_attributes.update( - TelemetryAttributes().fromResponse(response_data) + _telemetry_attributes = TelemetryAttributes().fromRequest( + user_agent=self.user_agent, + fga_method=resource_path, + http_method=method, + url=url, + resend_count=retry, + start=start, + credentials=self.configuration.credentials, + attributes=_telemetry_attributes, ) - try: - if self.configuration.credentials.configuration.client_id is not None: - _telemetry_attributes[TelemetryAttributes().request_client_id] = ( - self.configuration.credentials.configuration.client_id - ) - except AttributeError: - pass - - duration = response_data.getheader("fga-query-duration-ms") - attributes = TelemetryAttributes().prepare(_telemetry_attributes) + _telemetry_attributes = TelemetryAttributes().fromResponse( + response=response_data, + credentials=self.configuration.credentials, + attributes=_telemetry_attributes, + ) - if duration is not None: - self._telemetry.metrics().queryDuration(int(duration, 10), attributes) + self._telemetry.metrics().queryDuration( + attributes=_telemetry_attributes, + configuration=self.configuration.telemetry, + ) - self._telemetry.metrics().requestDuration().record( - float(benchmark["end"] - benchmark["start"]), attributes + self._telemetry.metrics().requestDuration( + attributes=_telemetry_attributes, + configuration=self.configuration.telemetry, ) if not _preload_content: diff --git a/config/clients/python/template/src/sync/oauth2.py.mustache b/config/clients/python/template/src/sync/oauth2.py.mustache index 8fb41c78..6b744820 100644 --- a/config/clients/python/template/src/sync/oauth2.py.mustache +++ b/config/clients/python/template/src/sync/oauth2.py.mustache @@ -113,7 +113,7 @@ class OAuth2Client: self._telemetry.metrics().credentialsRequest( 1, { - TelemetryAttributes.request_client_id: configuration.client_id + TelemetryAttributes.fga_client_request_client_id: configuration.client_id }, ) break diff --git a/config/clients/python/template/src/telemetry/__init__.py.mustache b/config/clients/python/template/src/telemetry/__init__.py.mustache index e23e60a2..fb6ab4fa 100644 --- a/config/clients/python/template/src/telemetry/__init__.py.mustache +++ b/config/clients/python/template/src/telemetry/__init__.py.mustache @@ -1,4 +1,9 @@ from {{packageName}}.telemetry.attributes import TelemetryAttribute, TelemetryAttributes +from {{packageName}}.telemetry.configuration import ( + TelemetryConfiguration, + TelemetryMetricConfiguration, + TelemetryMetricsConfiguration, +) from {{packageName}}.telemetry.histograms import TelemetryHistogram, TelemetryHistograms from {{packageName}}.telemetry.metrics import MetricsTelemetry from {{packageName}}.telemetry.telemetry import Telemetry diff --git a/config/clients/python/template/src/telemetry/attributes.py.mustache b/config/clients/python/template/src/telemetry/attributes.py.mustache index 4219da25..fb5c35f1 100644 --- a/config/clients/python/template/src/telemetry/attributes.py.mustache +++ b/config/clients/python/template/src/telemetry/attributes.py.mustache @@ -1,3 +1,5 @@ +import time +import urllib from typing import NamedTuple from aiohttp import ClientResponse @@ -9,74 +11,224 @@ from {{packageName}}.rest import RESTResponse class TelemetryAttribute(NamedTuple): name: str + format: str = None class TelemetryAttributes: - request_model_id: TelemetryAttribute = TelemetryAttribute( - name="fga-client.request.model_id", + fga_client_request_client_id: TelemetryAttribute = TelemetryAttribute( + name="fga-client.request.client_id", + format="string", ) - request_method: TelemetryAttribute = TelemetryAttribute( + fga_client_request_method: TelemetryAttribute = TelemetryAttribute( name="fga-client.request.method", + format="string", ) - request_store_id: TelemetryAttribute = TelemetryAttribute( - name="fga-client.request.store_id", - ) - request_client_id: TelemetryAttribute = TelemetryAttribute( - name="fga-client.request.client_id", + fga_client_request_model_id: TelemetryAttribute = TelemetryAttribute( + name="fga-client.request.model_id", + format="string", ) - request_retries: TelemetryAttribute = TelemetryAttribute( - name="fga-client.request.retries", + fga_client_request_store_id: TelemetryAttribute = TelemetryAttribute( + name="fga-client.request.store_id", + format="string", ) - response_model_id: TelemetryAttribute = TelemetryAttribute( + fga_client_response_model_id: TelemetryAttribute = TelemetryAttribute( name="fga-client.response.model_id", + format="string", ) - client_user: TelemetryAttribute = TelemetryAttribute( + fga_client_user: TelemetryAttribute = TelemetryAttribute( name="fga-client.user", + format="string", + ) + http_client_request_duration: TelemetryAttribute = TelemetryAttribute( + name="http.client.request.duration", + format="int", ) http_host: TelemetryAttribute = TelemetryAttribute( name="http.host", + format="string", + ) + http_request_method: TelemetryAttribute = TelemetryAttribute( + name="http.request.method", + format="string", + ) + http_request_resend_count: TelemetryAttribute = TelemetryAttribute( + name="http.request.resend_count", + format="int", + ) + http_response_status_code: TelemetryAttribute = TelemetryAttribute( + name="http.response.status_code", + format="int", + ) + http_server_request_duration: TelemetryAttribute = TelemetryAttribute( + name="http.server.request.duration", + format="int", + ) + url_scheme: TelemetryAttribute = TelemetryAttribute( + name="url.scheme", + format="string", ) - http_method: TelemetryAttribute = TelemetryAttribute( - name="http.method", + url_full: TelemetryAttribute = TelemetryAttribute( + name="url.full", + format="string", ) - http_status_code: TelemetryAttribute = TelemetryAttribute( - name="http.status_code", + user_agent_original: TelemetryAttribute = TelemetryAttribute( + name="user_agent.original", + format="string", ) def prepare( - self, attributes: dict[TelemetryAttribute | str, str | int] | None - ) -> dict: + self, + attributes: dict[TelemetryAttribute | str, str | int] | None, + filter: list[TelemetryAttribute | str] | None = None, + ) -> dict[str, str | int]: response = {} + if filter is None or filter == []: + return response + if attributes is not None: for attribute, value in attributes.items(): - if isinstance(attribute, TelemetryAttribute): - response[attribute.name] = value - else: - response[attribute] = value + if value is None: + continue + + if isinstance(attribute, str): + attributeTranslated = ( + attribute.lower().replace("-", "_").replace(".", "_") + ) + attributeInstance = getattr(self, attributeTranslated, None) + + if attributeInstance is None: + raise ValueError("Invalid attribute specified: %s" % attribute) + + attribute = attributeInstance + + if not isinstance(attribute, TelemetryAttribute): + raise ValueError( + "Invalid attribute specified: %s" % type(attribute) + ) - return dict(sorted(response.items())) + if ( + filter is not None + and attribute.name not in filter + and attribute not in filter + ): + continue + + if attribute.format == "string": + if not isinstance(value, str): + try: + value = str(value) + except ValueError: + continue + + if value == "": + continue + + if attribute.format == "int": + if not isinstance(value, int): + try: + value = int(value) + except ValueError: + continue + + response[attribute.name] = value + continue + + return response + + def fromRequest( + self, + user_agent: str = None, + fga_method: str = None, + http_method: str = None, + url: str = None, + resend_count: int = None, + start: float = None, + credentials: Credentials = None, + attributes: dict[TelemetryAttribute | str, str | int] = None, + ) -> dict[TelemetryAttribute | str, str | int]: + if attributes is None: + attributes = {} + + if fga_method is not None: + attributes[self.fga_client_request_method.name] = fga_method + + if user_agent is not None: + attributes[self.user_agent_original.name] = user_agent + + if http_method is not None: + attributes[self.http_request_method.name] = http_method + + if url is not None: + attributes[self.http_host.name] = urllib.parse.urlparse(url).hostname + attributes[self.url_scheme.name] = urllib.parse.urlparse(url).scheme + attributes[self.url_full.name] = url + + if start is not None and start > 0: + attributes[self.http_client_request_duration.name] = int( + (time.time() - start) * 1000 + ) + + if resend_count is not None: + attributes[self.http_request_resend_count.name] = resend_count + + if credentials is not None: + if credentials.method == "client_credentials": + attributes[self.fga_client_request_client_id.name] = ( + credentials.configuration.client_id + ) + + return attributes def fromResponse( self, response: HTTPResponse | RESTResponse | ClientResponse = None, credentials: Credentials = None, - ): - attributes: dict[TelemetryAttribute | str, str | int] = {} + attributes: dict[TelemetryAttribute | str, str | int] = None, + ) -> dict[TelemetryAttribute | str, str | int]: + response_model_id = None + response_query_duration = None + + if attributes is None: + attributes = {} - if response: - if response.status: - attributes[self.http_status_code] = int(response.status) + if response is not None: + if self.instanceHasAttribute(response, "status"): + attributes[self.http_response_status_code.name] = int(response.status) - response_model_id = response.getheader("openfga-authorization-model-id") + if self.instanceHasCallable(response, "getheader"): + response_model_id = response.getheader("openfga-authorization-model-id") + response_query_duration = response.getheader("fga-query-duration-ms") + + if self.instanceHasCallable(response, "headers"): + response_model_id = response.headers.get( + "openfga-authorization-model-id" + ) + response_query_duration = response.headers.get("fga-query-duration-ms") if response_model_id is not None: - attributes[self.response_model_id.name] = response_model_id + attributes[self.fga_client_response_model_id.name] = response_model_id - if isinstance(credentials, Credentials): - if credentials.method == "client_credentials": - attributes[self.request_client_id.name] = ( - credentials.configuration.client_id + if response_query_duration is not None: + attributes[self.http_server_request_duration.name] = ( + response_query_duration ) + if isinstance(credentials, Credentials): + if credentials.method == "client_credentials": + attributes[self.fga_client_request_client_id.name] = ( + credentials.configuration.client_id + ) + return attributes + + def instanceHasAttribute(self, instance: object, attributeName: str) -> bool: + return hasattr(instance, attributeName) + + def instanceHasCallable(self, instance: object, callableName: str) -> bool: + instanceCallable = getattr(instance, callableName, None) + + if instanceCallable is None: + return False + + return callable(instanceCallable) diff --git a/config/clients/python/template/src/telemetry/configuration.py.mustache b/config/clients/python/template/src/telemetry/configuration.py.mustache new file mode 100644 index 00000000..0737e808 --- /dev/null +++ b/config/clients/python/template/src/telemetry/configuration.py.mustache @@ -0,0 +1,168 @@ +from typing import Optional + +from {{packageName}}.telemetry.attributes import TelemetryAttributes + + +class TelemetryMetricConfiguration: + _enabled: bool + + def __init__( + self, + enabled: Optional[bool] = True, + attr_fga_client_request_client_id: Optional[bool] = True, + attr_fga_client_request_method: Optional[bool] = True, + attr_fga_client_request_model_id: Optional[bool] = True, + attr_fga_client_request_store_id: Optional[bool] = True, + attr_fga_client_response_model_id: Optional[bool] = True, + attr_fga_client_user: Optional[bool] = False, + attr_http_client_request_duration: Optional[bool] = False, + attr_http_host: Optional[bool] = True, + attr_http_request_method: Optional[bool] = True, + attr_http_request_resend_count: Optional[bool] = True, + attr_http_response_status_code: Optional[bool] = True, + attr_http_server_request_duration: Optional[bool] = False, + attr_http_url_scheme: Optional[bool] = True, + attr_http_url_full: Optional[bool] = True, + attr_user_agent_original: Optional[bool] = True, + ): + self._enabled = enabled + self.attr_fga_client_request_client_id = attr_fga_client_request_client_id + self.attr_fga_client_request_method = attr_fga_client_request_method + self.attr_fga_client_request_model_id = attr_fga_client_request_model_id + self.attr_fga_client_request_store_id = attr_fga_client_request_store_id + self.attr_fga_client_response_model_id = attr_fga_client_response_model_id + self.attr_fga_client_user = attr_fga_client_user + self.attr_http_client_request_duration = attr_http_client_request_duration + self.attr_http_host = attr_http_host + self.attr_http_request_method = attr_http_request_method + self.attr_http_request_resend_count = attr_http_request_resend_count + self.attr_http_response_status_code = attr_http_response_status_code + self.attr_http_server_request_duration = attr_http_server_request_duration + self.attr_http_url_scheme = attr_http_url_scheme + self.attr_http_url_full = attr_http_url_full + self.attr_user_agent_original = attr_user_agent_original + + @property + def enabled(self) -> bool: + return self._enabled + + @enabled.setter + def enabled(self, value: bool): + self._enabled = value + + def attributes(self) -> dict[str]: + enabled = {} + + if self.attr_fga_client_request_client_id: + enabled[TelemetryAttributes.fga_client_request_client_id.name] = True + + if self.attr_fga_client_request_method: + enabled[TelemetryAttributes.fga_client_request_method.name] = True + + if self.attr_fga_client_request_model_id: + enabled[TelemetryAttributes.fga_client_request_model_id.name] = True + + if self.attr_fga_client_request_store_id: + enabled[TelemetryAttributes.fga_client_request_store_id.name] = True + + if self.attr_fga_client_response_model_id: + enabled[TelemetryAttributes.fga_client_response_model_id.name] = True + + if self.attr_fga_client_user: + enabled[TelemetryAttributes.fga_client_user.name] = True + + if self.attr_http_client_request_duration: + enabled[TelemetryAttributes.http_client_request_duration.name] = True + + if self.attr_http_host: + enabled[TelemetryAttributes.http_host.name] = True + + if self.attr_http_request_method: + enabled[TelemetryAttributes.http_request_method.name] = True + + if self.attr_http_request_resend_count: + enabled[TelemetryAttributes.http_request_resend_count.name] = True + + if self.attr_http_response_status_code: + enabled[TelemetryAttributes.http_response_status_code.name] = True + + if self.attr_http_server_request_duration: + enabled[TelemetryAttributes.http_server_request_duration.name] = True + + if self.attr_http_url_scheme: + enabled[TelemetryAttributes.url_scheme.name] = True + + if self.attr_http_url_full: + enabled[TelemetryAttributes.url_full.name] = True + + if self.attr_user_agent_original: + enabled[TelemetryAttributes.user_agent_original.name] = True + + return enabled + + +class TelemetryMetricsConfiguration: + _counter_credentials_request: TelemetryMetricConfiguration + _histogram_request_duration: TelemetryMetricConfiguration + _histogram_query_duration: TelemetryMetricConfiguration + + def __init__( + self, + counter_credentials_request: Optional[TelemetryMetricConfiguration] = None, + histogram_request_duration: Optional[TelemetryMetricConfiguration] = None, + histogram_query_duration: Optional[TelemetryMetricConfiguration] = None, + ): + if counter_credentials_request is None: + counter_credentials_request = TelemetryMetricConfiguration() + + if histogram_request_duration is None: + histogram_request_duration = TelemetryMetricConfiguration() + + if histogram_query_duration is None: + histogram_query_duration = TelemetryMetricConfiguration() + + self._counter_credentials_request = counter_credentials_request + self._histogram_request_duration = histogram_request_duration + self._histogram_query_duration = histogram_query_duration + + @property + def counter_credentials_request(self) -> TelemetryMetricConfiguration: + return self._counter_credentials_request + + @counter_credentials_request.setter + def counter_credentials_request(self, value: TelemetryMetricConfiguration): + self._counter_credentials_request = value + + @property + def histogram_request_duration(self) -> TelemetryMetricConfiguration: + return self._histogram_request_duration + + @histogram_request_duration.setter + def histogram_request_duration(self, value: TelemetryMetricConfiguration): + self._histogram_request_duration = value + + @property + def histogram_query_duration(self) -> TelemetryMetricConfiguration: + return self._histogram_query_duration + + @histogram_query_duration.setter + def histogram_query_duration(self, value: TelemetryMetricConfiguration): + self._histogram_query_duration = value + + +class TelemetryConfiguration: + _metrics: TelemetryMetricsConfiguration + + def __init__(self, metrics: Optional[TelemetryMetricsConfiguration] = None): + if metrics is None: + metrics = TelemetryMetricsConfiguration() + + self._metrics = metrics + + @property + def metrics(self) -> TelemetryMetricsConfiguration: + return self._metrics + + @metrics.setter + def metrics(self, value: TelemetryMetricsConfiguration): + self._metrics = value diff --git a/config/clients/python/template/src/telemetry/histograms.py.mustache b/config/clients/python/template/src/telemetry/histograms.py.mustache index 51966775..b4539087 100644 --- a/config/clients/python/template/src/telemetry/histograms.py.mustache +++ b/config/clients/python/template/src/telemetry/histograms.py.mustache @@ -8,7 +8,7 @@ class TelemetryHistogram(NamedTuple): class TelemetryHistograms: - duration: TelemetryHistogram = TelemetryHistogram( + request_duration: TelemetryHistogram = TelemetryHistogram( name="fga-client.request.duration", unit="milliseconds", description="How long it took for a request to be fulfilled.", diff --git a/config/clients/python/template/src/telemetry/metrics.py.mustache b/config/clients/python/template/src/telemetry/metrics.py.mustache index cb5ed76f..d5471278 100644 --- a/config/clients/python/template/src/telemetry/metrics.py.mustache +++ b/config/clients/python/template/src/telemetry/metrics.py.mustache @@ -1,7 +1,13 @@ +from typing import Optional + from opentelemetry.metrics import Counter, Histogram, Meter, get_meter from {{packageName}} import __version__ from {{packageName}}.telemetry.attributes import TelemetryAttribute, TelemetryAttributes +from {{packageName}}.telemetry.configuration import ( + TelemetryConfiguration, + TelemetryMetricsConfiguration, +) from {{packageName}}.telemetry.counters import TelemetryCounter, TelemetryCounters from {{packageName}}.telemetry.histograms import TelemetryHistogram, TelemetryHistograms @@ -11,11 +17,20 @@ class MetricsTelemetry: _histograms: dict[str, Histogram] = {} _counters: dict[str, Counter] = {} - def __init__(self): - self._meter = get_meter("openfga-sdk", __version__) - self._histograms = {} + def __init__( + self, + meter: Optional[Meter] = None, + counters: Optional[dict[str, Counter]] = None, + histograms: Optional[dict[str, Histogram]] = None, + ): + self._meter = meter + self._counters = counters or {} + self._histograms = histograms or {} def meter(self) -> Meter: + if self._meter is None: + self._meter = get_meter("openfga-sdk", __version__) + return self._meter def counter( @@ -25,7 +40,10 @@ class MetricsTelemetry: attributes: dict[TelemetryAttribute | str, str | int] | None = None, ) -> Counter: if isinstance(counter, str): - counter = TelemetryCounters[counter] + try: + counter = TelemetryCounters[counter] + except (KeyError, TypeError): + raise KeyError(f"Invalid counter key: {counter}") if not isinstance(counter, TelemetryCounter): raise ValueError( @@ -33,14 +51,12 @@ class MetricsTelemetry: ) if counter.name not in self._counters: - self._counters[counter.name] = self._meter.create_counter( + self._counters[counter.name] = self.meter().create_counter( name=counter.name, unit=counter.unit, description=counter.description ) if value is not None: - self._counters[counter.name].add( - amount=value, attributes=TelemetryAttributes().prepare(attributes) - ) + self._counters[counter.name].add(amount=value, attributes=attributes) return self._counters[counter.name] @@ -51,7 +67,10 @@ class MetricsTelemetry: attributes: dict[TelemetryAttribute | str, str | int] | None = None, ) -> Histogram: if isinstance(histogram, str): - histogram = TelemetryHistograms[histogram] + try: + histogram = TelemetryHistograms[histogram] + except (KeyError, TypeError): + raise KeyError(f"Invalid histogram key: {histogram}") if not isinstance(histogram, TelemetryHistogram): raise ValueError( @@ -59,36 +78,126 @@ class MetricsTelemetry: ) if histogram.name not in self._histograms: - self._histograms[histogram.name] = self._meter.create_histogram( + self._histograms[histogram.name] = self.meter().create_histogram( name=histogram.name, unit=histogram.unit, description=histogram.description, ) if value is not None: - self._histograms[histogram.name].record( - amount=value, attributes=TelemetryAttributes().prepare(attributes) - ) + self._histograms[histogram.name].record(amount=value, attributes=attributes) return self._histograms[histogram.name] def credentialsRequest( self, - value: int | float = None, + value: int | float | None = None, attributes: dict[TelemetryAttribute | str, str | int] | None = None, + configuration: TelemetryConfiguration | None = None, ) -> Counter: + if configuration is None: + configuration = TelemetryConfiguration() + + if ( + isinstance(configuration, TelemetryMetricsConfiguration) is False + or configuration.metrics.counter_credentials_request.enabled is False + or configuration.metrics.counter_credentials_request.attributes() == {} + ): + return self.counter(TelemetryCounters.credentials_request) + + attributes = TelemetryAttributes().prepare( + attributes, + filter=configuration.metrics.counter_credentials_request.attributes(), + ) + return self.counter(TelemetryCounters.credentials_request, value, attributes) def requestDuration( self, - value: int | float = None, + value: int | float | None = None, attributes: dict[TelemetryAttribute | str, str | int] | None = None, + configuration: TelemetryConfiguration | None = None, ) -> Histogram: - return self.histogram(TelemetryHistograms.duration, value, attributes) + if configuration is None: + configuration = TelemetryConfiguration() + + if ( + isinstance(configuration, TelemetryConfiguration) is False + or configuration.metrics.histogram_request_duration.enabled is False + or configuration.metrics.histogram_request_duration.attributes() == {} + ): + return self.histogram(TelemetryHistograms.request_duration) + + if ( + value is None + and TelemetryAttributes.http_client_request_duration.name in attributes + ): + value = attributes[TelemetryAttributes.http_client_request_duration.name] + attributes.pop(TelemetryAttributes.http_client_request_duration.name, None) + + if value is not None: + try: + value = int(value) + attributes[TelemetryAttributes.http_client_request_duration.name] = ( + value + ) + except ValueError: + value = None + + attributes = TelemetryAttributes().prepare( + attributes, + filter=configuration.metrics.histogram_request_duration.attributes(), + ) + + if ( + value is None + and TelemetryAttributes.http_client_request_duration.name in attributes + ): + value = attributes[TelemetryAttributes.http_client_request_duration.name] + + if value is None: + return self.histogram(TelemetryHistograms.request_duration) + + return self.histogram(TelemetryHistograms.request_duration, value, attributes) def queryDuration( self, - value: int | float = None, + value: int | float | None = None, attributes: dict[TelemetryAttribute | str, str | int] | None = None, + configuration: TelemetryConfiguration | None = None, ) -> Histogram: + if configuration is None: + configuration = TelemetryConfiguration() + + if ( + isinstance(configuration, TelemetryConfiguration) is False + or configuration.metrics.histogram_query_duration.enabled is False + or configuration.metrics.histogram_query_duration.attributes() == {} + ): + return self.histogram(TelemetryHistograms.query_duration) + + if ( + value is None + and TelemetryAttributes.http_server_request_duration.name in attributes + ): + value = attributes[TelemetryAttributes.http_server_request_duration.name] + attributes.pop(TelemetryAttributes.http_server_request_duration.name, None) + + if value is not None: + try: + value = int(value) + attributes[TelemetryAttributes.http_server_request_duration.name] = ( + value + ) + except ValueError: + value = None + + attributes = TelemetryAttributes().prepare( + attributes, + filter=configuration.metrics.histogram_query_duration.attributes(), + ) + + if value is None: + return self.histogram(TelemetryHistograms.query_duration) + return self.histogram(TelemetryHistograms.query_duration, value, attributes) diff --git a/config/clients/python/template/src/telemetry/telemetry.py.mustache b/config/clients/python/template/src/telemetry/telemetry.py.mustache index d01033b0..4de41703 100644 --- a/config/clients/python/template/src/telemetry/telemetry.py.mustache +++ b/config/clients/python/template/src/telemetry/telemetry.py.mustache @@ -5,7 +5,7 @@ class Telemetry: _metrics: MetricsTelemetry = None def metrics(self) -> MetricsTelemetry: - if not self._metrics: + if self._metrics is None: self._metrics = MetricsTelemetry() return self._metrics diff --git a/config/clients/python/template/test-requirements.mustache b/config/clients/python/template/test-requirements.mustache index 5f98b515..25bea3de 100644 --- a/config/clients/python/template/test-requirements.mustache +++ b/config/clients/python/template/test-requirements.mustache @@ -2,9 +2,9 @@ mock >= 5.1.0, < 6 autoflake==2.3.1 -black==24.4.2 +black==24.8.0 flake8 >= 7.0.0, < 8 -griffe >= 0.41.2, < 1 +griffe >= 0.41.2, < 2 isort==5.13.2 pytest-cov >= 5, < 6 pyupgrade==3.17.0 diff --git a/config/clients/python/template/test/telemetry/attributes_test.py.mustache b/config/clients/python/template/test/telemetry/attributes_test.py.mustache new file mode 100644 index 00000000..7d494064 --- /dev/null +++ b/config/clients/python/template/test/telemetry/attributes_test.py.mustache @@ -0,0 +1,166 @@ +import time +from unittest.mock import MagicMock + +import pytest +from urllib3 import HTTPResponse + +from {{packageName}}.credentials import CredentialConfiguration, Credentials +from {{packageName}}.rest import RESTResponse +from {{packageName}}.telemetry.attributes import TelemetryAttributes + + +@pytest.fixture +def telemetry_attributes(): + return TelemetryAttributes() + + +def test_prepare_with_valid_attributes(telemetry_attributes): + attributes = { + telemetry_attributes.fga_client_request_client_id: "client_123", + telemetry_attributes.http_request_method: "GET", + } + filter_attributes = [telemetry_attributes.fga_client_request_client_id] + + prepared = telemetry_attributes.prepare(attributes, filter=filter_attributes) + + # Assert that only filtered attributes are returned + assert prepared == { + "fga-client.request.client_id": "client_123", + } + + +def test_prepare_with_empty_attributes(telemetry_attributes): + attributes = {} + prepared = telemetry_attributes.prepare(attributes) + assert prepared == {} + + +def test_prepare_with_none_attributes(telemetry_attributes): + prepared = telemetry_attributes.prepare(None) + assert prepared == {} + + +def test_prepare_with_invalid_attributes(telemetry_attributes): + attributes = { + "invalid_attribute": "value", + } + filters = [telemetry_attributes.fga_client_request_client_id] + + with pytest.raises(ValueError): + telemetry_attributes.prepare(attributes, filter=filters) + + +def test_from_request_with_all_params(telemetry_attributes): + credentials = Credentials( + method="client_credentials", + configuration=CredentialConfiguration(client_id="client_123"), + ) + start_time = time.time() - 5 # Simulate a request started 5 seconds ago + + attributes = telemetry_attributes.fromRequest( + user_agent="TestAgent", + fga_method="FGA_METHOD", + http_method="POST", + url="https://example.com/api", + resend_count=2, + start=start_time, + credentials=credentials, + ) + + assert attributes["fga-client.request.method"] == "FGA_METHOD" + assert attributes["user_agent.original"] == "TestAgent" + assert attributes["http.host"] == "example.com" + assert attributes["http.request.method"] == "POST" + assert attributes["url.scheme"] == "https" + assert attributes["url.full"] == "https://example.com/api" + assert "http.client.request.duration" in attributes + assert attributes["http.request.resend_count"] == 2 + assert attributes["fga-client.request.client_id"] == "client_123" + + +def test_from_request_without_optional_params(telemetry_attributes): + attributes = telemetry_attributes.fromRequest( + user_agent="MinimalAgent", + fga_method="FGA_METHOD", + http_method="GET", + url="http://minimal.com", + ) + + assert attributes["fga-client.request.method"] == "FGA_METHOD" + assert attributes["user_agent.original"] == "MinimalAgent" + assert attributes["http.host"] == "minimal.com" + assert attributes["http.request.method"] == "GET" + assert attributes["url.scheme"] == "http" + assert attributes["url.full"] == "http://minimal.com" + assert "http.client.request.duration" not in attributes + assert "http.request.resend_count" not in attributes + assert "fga-client.request.client_id" not in attributes + + +def test_from_response_with_http_response(telemetry_attributes): + response = MagicMock(spec=HTTPResponse) + response.status = 200 + response.getheader.side_effect = lambda header: { + "openfga-authorization-model-id": "model_123", + "fga-query-duration-ms": "50", + }.get(header) + + credentials = Credentials( + method="client_credentials", + configuration=CredentialConfiguration(client_id="client_123"), + ) + attributes = telemetry_attributes.fromResponse( + response=response, credentials=credentials + ) + + assert attributes["http.response.status_code"] == 200 + assert attributes["fga-client.response.model_id"] == "model_123" + assert attributes["http.server.request.duration"] == "50" + assert attributes["fga-client.request.client_id"] == "client_123" + + +def test_from_response_with_rest_response(telemetry_attributes): + response = MagicMock(spec=RESTResponse) + response.status = 404 + response.headers = { + "openfga-authorization-model-id": "model_404", + "fga-query-duration-ms": "100", + } + + response.getheader = lambda key: response.headers.get(key) + + credentials = Credentials( + method="client_credentials", + configuration=CredentialConfiguration(client_id="client_456"), + ) + attributes = telemetry_attributes.fromResponse( + response=response, credentials=credentials + ) + + assert attributes["http.response.status_code"] == 404 + assert attributes["fga-client.response.model_id"] == "model_404" + assert attributes["http.server.request.duration"] == "100" + assert attributes["fga-client.request.client_id"] == "client_456" + + +def test_instance_has_attribute(telemetry_attributes): + mock_instance = MagicMock(spec_set=["some_attribute"]) + mock_instance.some_attribute = "value" + assert telemetry_attributes.instanceHasAttribute(mock_instance, "some_attribute") + assert not telemetry_attributes.instanceHasAttribute( + mock_instance, "missing_attribute" + ) + + +def test_instance_has_callable(telemetry_attributes): + mock_instance = MagicMock(spec_set=["some_callable", "some_attribute"]) + mock_instance.some_callable = lambda: "I am callable" + + assert telemetry_attributes.instanceHasCallable(mock_instance, "some_callable") + + assert not telemetry_attributes.instanceHasCallable( + mock_instance, "missing_callable" + ) + + mock_instance.some_attribute = "not callable" + assert not telemetry_attributes.instanceHasCallable(mock_instance, "some_attribute") diff --git a/config/clients/python/template/test/telemetry/configuration_test.py.mustache b/config/clients/python/template/test/telemetry/configuration_test.py.mustache new file mode 100644 index 00000000..34da3605 --- /dev/null +++ b/config/clients/python/template/test/telemetry/configuration_test.py.mustache @@ -0,0 +1,120 @@ +from {{packageName}}.telemetry.attributes import TelemetryAttributes +from {{packageName}}.telemetry.configuration import ( + TelemetryConfiguration, + TelemetryMetricConfiguration, + TelemetryMetricsConfiguration, +) + + +def test_telemetry_metric_configuration_default_initialization(): + config = TelemetryMetricConfiguration() + + # Assert default enabled value + assert config.enabled is True + + # Assert all attributes are True by default + assert config.attr_fga_client_request_client_id is True + assert config.attr_fga_client_request_method is True + assert config.attr_fga_client_request_model_id is True + assert config.attr_fga_client_request_store_id is True + assert config.attr_fga_client_response_model_id is True + assert config.attr_fga_client_user is False + assert config.attr_http_client_request_duration is False + assert config.attr_http_host is True + assert config.attr_http_request_method is True + assert config.attr_http_request_resend_count is True + assert config.attr_http_response_status_code is True + assert config.attr_http_server_request_duration is False + assert config.attr_http_url_scheme is True + assert config.attr_http_url_full is True + assert config.attr_user_agent_original is True + + +def test_telemetry_metric_configuration_custom_initialization(): + config = TelemetryMetricConfiguration( + enabled=False, + attr_fga_client_request_client_id=False, + attr_http_request_method=False, + ) + + # Assert custom initialization values + assert config.enabled is False + assert config.attr_fga_client_request_client_id is False + assert config.attr_http_request_method is False + # Check default values for other attributes + assert config.attr_fga_client_request_method is True + assert config.attr_http_host is True + + +def test_telemetry_metric_configuration_attributes_method(): + config = TelemetryMetricConfiguration( + enabled=True, + attr_fga_client_request_client_id=True, + attr_http_request_method=False, + ) + + enabled_attributes = config.attributes() + + # Check only enabled attributes are returned + assert ( + enabled_attributes[TelemetryAttributes.fga_client_request_client_id.name] + is True + ) + assert TelemetryAttributes.http_request_method.name not in enabled_attributes + + +def test_telemetry_metrics_configuration_default_initialization(): + metrics_config = TelemetryMetricsConfiguration() + + # Assert default metric configurations + assert isinstance( + metrics_config.counter_credentials_request, TelemetryMetricConfiguration + ) + assert isinstance( + metrics_config.histogram_request_duration, TelemetryMetricConfiguration + ) + assert isinstance( + metrics_config.histogram_query_duration, TelemetryMetricConfiguration + ) + + +def test_telemetry_metrics_configuration_custom_initialization(): + custom_config = TelemetryMetricConfiguration(enabled=False) + metrics_config = TelemetryMetricsConfiguration( + counter_credentials_request=custom_config + ) + + # Check that custom configuration is used + assert metrics_config.counter_credentials_request is custom_config + assert metrics_config.histogram_request_duration.enabled is True + + +def test_telemetry_metrics_configuration_setters(): + metrics_config = TelemetryMetricsConfiguration() + + new_config = TelemetryMetricConfiguration(enabled=False) + metrics_config.counter_credentials_request = new_config + + assert metrics_config.counter_credentials_request is new_config + + +def test_telemetry_configuration_default_initialization(): + telemetry_config = TelemetryConfiguration() + + # Check the default metrics configuration is used + assert isinstance(telemetry_config.metrics, TelemetryMetricsConfiguration) + + +def test_telemetry_configuration_custom_initialization(): + custom_metrics = TelemetryMetricsConfiguration() + telemetry_config = TelemetryConfiguration(metrics=custom_metrics) + + assert telemetry_config.metrics is custom_metrics + + +def test_telemetry_configuration_setter(): + telemetry_config = TelemetryConfiguration() + new_metrics = TelemetryMetricsConfiguration() + telemetry_config.metrics = new_metrics + + assert telemetry_config.metrics is new_metrics diff --git a/config/clients/python/template/test/telemetry/counters_test.py.mustache b/config/clients/python/template/test/telemetry/counters_test.py.mustache new file mode 100644 index 00000000..ba53563e --- /dev/null +++ b/config/clients/python/template/test/telemetry/counters_test.py.mustache @@ -0,0 +1,36 @@ +from {{packageName}}.telemetry.counters import TelemetryCounter, TelemetryCounters + + +def test_telemetry_counter_initialization(): + counter = TelemetryCounter( + name="fga-client.test.counter", + unit="seconds", + description="A test counter for unit testing.", + ) + + assert counter.name == "fga-client.test.counter" + assert counter.unit == "seconds" + assert counter.description == "A test counter for unit testing." + + +def test_telemetry_counters_default_values(): + counters = TelemetryCounters() + + assert counters.credentials_request.name == "fga-client.credentials.request" + assert counters.credentials_request.unit == "milliseconds" + assert ( + counters.credentials_request.description + == "The number of times an access token is requested." + ) + + +def test_telemetry_counters_custom_counter(): + custom_counter = TelemetryCounter( + name="fga-client.custom.counter", + unit="operations", + description="A custom counter for specific operations.", + ) + + assert custom_counter.name == "fga-client.custom.counter" + assert custom_counter.unit == "operations" + assert custom_counter.description == "A custom counter for specific operations." diff --git a/config/clients/python/template/test/telemetry/histograms_test.py.mustache b/config/clients/python/template/test/telemetry/histograms_test.py.mustache new file mode 100644 index 00000000..42a98f2e --- /dev/null +++ b/config/clients/python/template/test/telemetry/histograms_test.py.mustache @@ -0,0 +1,43 @@ +from {{packageName}}.telemetry.histograms import TelemetryHistogram, TelemetryHistograms + + +def test_telemetry_histogram_initialization(): + histogram = TelemetryHistogram( + name="fga-client.test.histogram", + unit="seconds", + description="A test histogram for unit testing.", + ) + + assert histogram.name == "fga-client.test.histogram" + assert histogram.unit == "seconds" + assert histogram.description == "A test histogram for unit testing." + + +def test_telemetry_histograms_default_values(): + histograms = TelemetryHistograms() + + assert histograms.request_duration.name == "fga-client.request.duration" + assert histograms.request_duration.unit == "milliseconds" + assert ( + histograms.request_duration.description + == "How long it took for a request to be fulfilled." + ) + + assert histograms.query_duration.name == "fga-client.query.duration" + assert histograms.query_duration.unit == "milliseconds" + assert ( + histograms.query_duration.description + == "How long it took to perform a query request." + ) + + +def test_telemetry_histograms_custom_histogram(): + custom_histogram = TelemetryHistogram( + name="fga-client.custom.histogram", + unit="operations", + description="A custom histogram for specific operations.", + ) + + assert custom_histogram.name == "fga-client.custom.histogram" + assert custom_histogram.unit == "operations" + assert custom_histogram.description == "A custom histogram for specific operations." diff --git a/config/clients/python/template/test/telemetry/metrics_test.py.mustache b/config/clients/python/template/test/telemetry/metrics_test.py.mustache new file mode 100644 index 00000000..f91c3598 --- /dev/null +++ b/config/clients/python/template/test/telemetry/metrics_test.py.mustache @@ -0,0 +1,101 @@ +from unittest.mock import MagicMock, patch + +import pytest +from opentelemetry.metrics import Counter, Histogram, Meter + +from {{packageName}} import __version__ +from {{packageName}}.telemetry.attributes import TelemetryAttributes +from {{packageName}}.telemetry.counters import TelemetryCounters +from {{packageName}}.telemetry.histograms import TelemetryHistograms +from {{packageName}}.telemetry.metrics import MetricsTelemetry + + +@patch("openfga_sdk.telemetry.metrics.get_meter") +def test_meter_lazy_initialization(mock_get_meter): + mock_meter = MagicMock(spec=Meter) + mock_get_meter.return_value = mock_meter + + telemetry = MetricsTelemetry() + + # Ensure _meter is initially None + assert telemetry._meter is None + + # Access the meter property, which should trigger lazy initialization + meter = telemetry.meter() + assert meter == mock_meter + mock_get_meter.assert_called_once_with("openfga-sdk", __version__) + + # Access the meter property again, no new instance should be created + meter_again = telemetry.meter() + assert meter_again == meter + mock_get_meter.assert_called_once() + + +@patch("openfga_sdk.telemetry.metrics.get_meter") +def test_counter_creation_and_add(mock_get_meter): + mock_meter = MagicMock(spec=Meter) + mock_counter = MagicMock(spec=Counter) + mock_get_meter.return_value = mock_meter + mock_meter.create_counter.return_value = mock_counter + + telemetry = MetricsTelemetry() + + attributes = { + TelemetryAttributes.fga_client_request_model_id.name: "model_123", + "custom_attribute": "custom_value", + } + + counter = telemetry.counter( + TelemetryCounters.credentials_request, value=5, attributes=attributes + ) + + assert counter == mock_counter + + telemetry._meter.create_counter.assert_called_once_with( + name=TelemetryCounters.credentials_request.name, + unit=TelemetryCounters.credentials_request.unit, + description=TelemetryCounters.credentials_request.description, + ) + + mock_counter.add.assert_called_once_with(amount=5, attributes=attributes) + + +@patch("openfga_sdk.telemetry.metrics.get_meter") +def test_histogram_creation_and_record(mock_get_meter): + mock_meter = MagicMock(spec=Meter) + mock_histogram = MagicMock(spec=Histogram) + mock_get_meter.return_value = mock_meter + mock_meter.create_histogram.return_value = mock_histogram + + telemetry = MetricsTelemetry() + + attributes = { + TelemetryAttributes.fga_client_request_model_id.name: "model_123", + "custom_attribute": "custom_value", + } + + histogram = telemetry.histogram( + TelemetryHistograms.request_duration, value=200.5, attributes=attributes + ) + + assert histogram == mock_histogram + + telemetry._meter.create_histogram.assert_called_once_with( + name=TelemetryHistograms.request_duration.name, + unit=TelemetryHistograms.request_duration.unit, + description=TelemetryHistograms.request_duration.description, + ) + + mock_histogram.record.assert_called_once_with(amount=200.5, attributes=attributes) + + +def test_invalid_counter_key(): + telemetry = MetricsTelemetry() + with pytest.raises(KeyError): + telemetry.counter("invalid_counter_key") + + +def test_invalid_histogram_key(): + telemetry = MetricsTelemetry() + with pytest.raises(KeyError): + telemetry.histogram("invalid_histogram_key") diff --git a/config/clients/python/template/test/telemetry/telemetry_test.py.mustache b/config/clients/python/template/test/telemetry/telemetry_test.py.mustache new file mode 100644 index 00000000..f2cd729b --- /dev/null +++ b/config/clients/python/template/test/telemetry/telemetry_test.py.mustache @@ -0,0 +1,45 @@ +from unittest.mock import patch + +from {{packageName}}.telemetry.metrics import ( + MetricsTelemetry, +) + + +def test_metrics_lazy_initialization(): + with patch( + "openfga_sdk.telemetry.telemetry.MetricsTelemetry" + ) as mock_metrics_telemetry: + from openfga_sdk.telemetry import Telemetry # Import inside the patch context + + telemetry = Telemetry() + + # Ensure _metrics is initially None + assert telemetry._metrics is None + + # Access the metrics property, which should trigger lazy initialization + metrics = telemetry.metrics() + + # Verify that a MetricsTelemetry object was created and returned + assert metrics == mock_metrics_telemetry.return_value + mock_metrics_telemetry.assert_called_once() + + # Access the metrics property again, no new instance should be created + metrics_again = telemetry.metrics() + assert metrics_again == metrics + mock_metrics_telemetry.assert_called_once() # Should still be only called once + + +def test_metrics_initialization_without_patch(): + from openfga_sdk.telemetry import Telemetry # Import the Telemetry class directly + + telemetry = Telemetry() + + # Access the metrics property, which should trigger lazy initialization + metrics = telemetry.metrics() + + # Verify that a real MetricsTelemetry object was created and returned + assert isinstance(metrics, MetricsTelemetry) + + # Access the metrics property again, no new instance should be created + metrics_again = telemetry.metrics() + assert metrics_again == metrics