diff --git a/config/clients/python/CHANGELOG.md.mustache b/config/clients/python/CHANGELOG.md.mustache index 7100496e..209bfac0 100644 --- a/config/clients/python/CHANGELOG.md.mustache +++ b/config/clients/python/CHANGELOG.md.mustache @@ -1,5 +1,19 @@ # Changelog +## v0.7.2 + +### [0.7.2](https://github.com/openfga/python-sdk/compare/v0.7.1...v0.7.2) (2024-09-22) + +This release includes improvements to the OpenTelemetry configuration API introduced in the previous releases + +- refactor: improve OpenTelemetry configuration (#127) + +This release also includes fixes for several bugs identified in previous releases: + +- fix: ensure max_parallel_requests is an int value in batch_check (#132) +- fix: inconsistency in 429 handling between sync/async client (#131) +- fix: ensure telemetry is reported when API exceptions are raised (#127) + ## v0.7.1 ### [0.7.1](https://github.com/openfga/python-sdk/compare/v0.7.0...v0.7.1) (2024-09-16) diff --git a/config/clients/python/config.overrides.json b/config/clients/python/config.overrides.json index 15f9d5d8..5ccb5925 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.7.1", + "packageVersion": "0.7.2", "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", @@ -188,6 +188,10 @@ "destinationFilename": "openfga_sdk/telemetry/telemetry.py", "templateType": "SupportingFiles" }, + "src/telemetry/utilities.py.mustache": { + "destinationFilename": "openfga_sdk/telemetry/utilities.py", + "templateType": "SupportingFiles" + }, "src/__init__.py.mustache": { "destinationFilename": "openfga_sdk/__init__.py", "templateType": "SupportingFiles" @@ -281,6 +285,10 @@ "destinationFilename": "test/telemetry/telemetry_test.py", "templateType": "SupportingFiles" }, + "test/telemetry/utilities_test.py.mustache": { + "destinationFilename": "test/telemetry/utilities_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 9eadc263..bd971aff 100644 --- a/config/clients/python/template/.github/workflows/main.yaml.mustache +++ b/config/clients/python/template/.github/workflows/main.yaml.mustache @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: fetch-depth: 0 @@ -60,7 +60,7 @@ jobs: - if: matrix.python-version == '3.10' name: Upload coverage to Codecov - uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0 continue-on-error: true with: token: ${{ secrets.CODECOV_TOKEN }} @@ -75,7 +75,7 @@ jobs: id-token: write # Required for PyPI trusted publishing steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: fetch-depth: 0 @@ -98,7 +98,7 @@ jobs: python setup.py sdist bdist_wheel - name: Publish package - uses: pypa/gh-action-pypi-publish@8a08d616893759ef8e1aa1f2785787c0b97e20d6 # v1.10.0 + uses: pypa/gh-action-pypi-publish@f7600683efdcb7656dec5b29656edb7bc586e597 # v1.10.3 create-release: runs-on: ubuntu-latest @@ -106,7 +106,7 @@ jobs: needs: [publish] steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: fetch-depth: 0 diff --git a/config/clients/python/template/docs/opentelemetry.md.mustache b/config/clients/python/template/docs/opentelemetry.md.mustache index 4b14c769..7d25b5a1 100644 --- a/config/clients/python/template/docs/opentelemetry.md.mustache +++ b/config/clients/python/template/docs/opentelemetry.md.mustache @@ -26,6 +26,7 @@ If you configure the OpenTelemetry SDK, these metrics will be exported and sent | `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 | +| `fga-client.request` | Counter | No | Total number of requests made to the FGA server | ### Supported Attributes @@ -42,7 +43,7 @@ If you configure the OpenTelemetry SDK, these metrics will be exported and sent | `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 | +| `http.server.request.duration` | int | No | 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 | @@ -102,15 +103,15 @@ configuration = ClientConfiguration( 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, - ), - ), - ), + # If you are comfortable with the default configuration outlined in the tables above, you can omit providing your own TelemetryConfiguration object, as one will be created for you. + telemetry={ + "metrics": { + "fga-client.request.duration": { + "fga-client.request.method": True, + "http.response.status_code": True, + }, + }, + }, ) fga = OpenFgaClient(configuration) diff --git a/config/clients/python/template/example/opentelemetry/main.py.mustache b/config/clients/python/template/example/opentelemetry/main.py.mustache index 76c924d6..63c34678 100644 --- a/config/clients/python/template/example/opentelemetry/main.py.mustache +++ b/config/clients/python/template/example/opentelemetry/main.py.mustache @@ -15,12 +15,6 @@ 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) @@ -36,7 +30,7 @@ from {{packageName}}.credentials import ( Credentials, ) from {{packageName}}.exceptions import FgaValidationException - +from {{packageName}}.telemetry.configuration import TelemetryConfiguration class app: """ @@ -88,62 +82,62 @@ class app: 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, - ), - ), - ) + # Note: the following represents the default configuration values, so unless you want to customize what's reported, you can omit this. + self._telemetry = { + "metrics": { + "fga-client.credentials.request": { + "fga-client.request.client_id": True, + "fga-client.request.method": True, + "fga-client.request.model_id": True, + "fga-client.request.store_id": True, + "fga-client.response.model_id": True, + "fga-client.user": False, + "http.client.request.duration": False, + "http.host": True, + "http.request.method": True, + "http.request.resend_count": True, + "http.response.status_code": True, + "http.server.request.duration": False, + "url.scheme": True, + "url.full": True, + "user_agent.original": True, + }, + "fga-client.request.duration": { + "fga-client.request.client_id": True, + "fga-client.request.method": True, + "fga-client.request.model_id": True, + "fga-client.request.store_id": True, + "fga-client.response.model_id": True, + "fga-client.user": False, + "http.client.request.duration": False, + "http.host": True, + "http.request.method": True, + "http.request.resend_count": True, + "http.response.status_code": True, + "http.server.request.duration": False, + "url.scheme": True, + "url.full": True, + "user_agent.original": True, + }, + "fga-client.query.duration": { + "fga-client.request.client_id": True, + "fga-client.request.method": True, + "fga-client.request.model_id": True, + "fga-client.request.store_id": True, + "fga-client.response.model_id": True, + "fga-client.user": False, + "http.client.request.duration": False, + "http.host": True, + "http.request.method": True, + "http.request.resend_count": True, + "http.response.status_code": True, + "http.server.request.duration": False, + "url.scheme": True, + "url.full": True, + "user_agent.original": True, + }, + } + } self._configuration.telemetry = self._telemetry diff --git a/config/clients/python/template/src/__init__.py.mustache b/config/clients/python/template/src/__init__.py.mustache index 109f027b..13699158 100644 --- a/config/clients/python/template/src/__init__.py.mustache +++ b/config/clients/python/template/src/__init__.py.mustache @@ -20,6 +20,8 @@ from {{packageName}}.exceptions import ApiException {{/model}}{{/models}} from {{packageName}}.telemetry.configuration import ( TelemetryConfiguration, + TelemetryConfigurations, + TelemetryConfigurationType, TelemetryMetricConfiguration, TelemetryMetricsConfiguration, ) diff --git a/config/clients/python/template/src/api.py.mustache b/config/clients/python/template/src/api.py.mustache index ca901b57..ea383120 100644 --- a/config/clients/python/template/src/api.py.mustache +++ b/config/clients/python/template/src/api.py.mustache @@ -25,7 +25,9 @@ class {{classname}}: if api_client.configuration is not None: credentials = api_client.configuration.credentials if credentials is not None and credentials.method == "client_credentials": - self._oauth2_client = OAuth2Client(credentials) + self._oauth2_client = OAuth2Client( + credentials, api_client.configuration + ) self._telemetry = Telemetry() @@ -329,38 +331,14 @@ class {{classname}}: response_types_map = {} {{/returnType}} - telemetry_attributes: dict[TelemetryAttribute, str] = { - TelemetryAttributes.fga_client_request_method: "{{operationId}}" + telemetry_attributes: dict[TelemetryAttribute, str | int] = { + TelemetryAttributes.fga_client_request_method: "{{operationId}}", + TelemetryAttributes.fga_client_request_store_id: self.api_client.get_store_id(), + TelemetryAttributes.fga_client_request_model_id: local_var_params.get( + "authorization_model_id", "" + ), } - try: - if store_id: - telemetry_attributes[ - TelemetryAttributes.fga_client_request_store_id - ] = store_id - except: - pass - - try: - if body_params.tuple_key: - telemetry_attributes[TelemetryAttributes.fga_client_user] = ( - body_params.tuple_key.user - ) - except: - pass - - try: - if "authorization_model_id" in local_var_params: - telemetry_attributes[ - TelemetryAttributes.fga_client_request_model_id - ] = local_var_params["authorization_model_id"] - elif body_params.authorization_model_id: - telemetry_attributes[ - TelemetryAttributes.fga_client_request_model_id - ] = body_params.authorization_model_id - except: - pass - return {{#asyncio}}await ({{/asyncio}}self.api_client.call_api( '{{{path}}}'.replace('{store_id}', store_id), '{{httpMethod}}', path_params, diff --git a/config/clients/python/template/src/api_client.py.mustache b/config/clients/python/template/src/api_client.py.mustache index 6a102132..07509f95 100644 --- a/config/clients/python/template/src/api_client.py.mustache +++ b/config/clients/python/template/src/api_client.py.mustache @@ -175,7 +175,7 @@ class ApiClient: _request_auth=None, _retry_params=None, _oauth2_client=None, - _telemetry_attributes: dict[TelemetryAttribute | str, str] = None, + _telemetry_attributes: dict[TelemetryAttribute, str | int] = None, ): self.configuration.is_valid() @@ -244,7 +244,21 @@ class ApiClient: max_retry = _retry_params.max_retry if _retry_params.min_wait_in_ms is not None: max_retry = _retry_params.min_wait_in_ms + + _telemetry_attributes = TelemetryAttributes.fromRequest( + user_agent=self.user_agent, + fga_method=resource_path, + http_method=method, + url=url, + resend_count=0, + start=start, + credentials=self.configuration.credentials, + attributes=_telemetry_attributes, + ) + for retry in range(max_retry + 1): + _telemetry_attributes[TelemetryAttributes.http_request_resend_count] = retry + try: # perform request and return response response_data = {{#asyncio}}await {{/asyncio}}{{#tornado}}yield {{/tornado}}self.request( @@ -254,6 +268,17 @@ class ApiClient: _request_timeout=_request_timeout) except (RateLimitExceededError, ServiceException) as e: if retry < max_retry and e.status != 501: + _telemetry_attributes = TelemetryAttributes.fromResponse( + response=e.body.decode("utf-8"), + credentials=self.configuration.credentials, + attributes=_telemetry_attributes, + ) + + self._telemetry.metrics.request( + attributes=_telemetry_attributes, + configuration=self.configuration.telemetry, + ) + {{#asyncio}}await asyncio.sleep(random_time(retry, min_wait_in_ms)){{/asyncio}} {{^asyncio}}time.sleep(random_time(retry, min_wait_in_ms)){{/asyncio}} continue @@ -271,35 +296,50 @@ class ApiClient: e.parsed_exception = self.__deserialize( json.loads(e.body), response_type) e.body = None + + _telemetry_attributes = TelemetryAttributes.fromResponse( + response=e, + credentials=self.configuration.credentials, + attributes=_telemetry_attributes, + ) + + self._telemetry.metrics.request( + attributes=_telemetry_attributes, + configuration=self.configuration.telemetry, + ) + + self._telemetry.metrics.queryDuration( + attributes=_telemetry_attributes, + configuration=self.configuration.telemetry, + ) + + self._telemetry.metrics.requestDuration( + attributes=_telemetry_attributes, + configuration=self.configuration.telemetry, + ) raise e self.last_response = response_data return_data = 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, + _telemetry_attributes = TelemetryAttributes.fromResponse( + response=response_data, credentials=self.configuration.credentials, attributes=_telemetry_attributes, ) - _telemetry_attributes = TelemetryAttributes().fromResponse( - response=response_data, - credentials=self.configuration.credentials, + self._telemetry.metrics.request( attributes=_telemetry_attributes, + configuration=self.configuration.telemetry, ) - self._telemetry.metrics().queryDuration( + self._telemetry.metrics.queryDuration( attributes=_telemetry_attributes, configuration=self.configuration.telemetry, ) - self._telemetry.metrics().requestDuration( + self._telemetry.metrics.requestDuration( attributes=_telemetry_attributes, configuration=self.configuration.telemetry, ) @@ -464,7 +504,7 @@ class ApiClient: _request_auth=None, _retry_params=None, _oauth2_client=None, - _telemetry_attributes: dict[TelemetryAttribute, str] = None, + _telemetry_attributes: dict[TelemetryAttribute, str | int] = None, ): """Makes the HTTP request (synchronous) and returns deserialized data. @@ -721,7 +761,7 @@ class ApiClient: headers['Authorization'] = f'Bearer {credentials.configuration.api_token}' if credentials.method == 'client_credentials': if oauth2_client is None: - oauth2_client = oauth2.OAuth2Client(credentials) + oauth2_client = oauth2.OAuth2Client(credentials, self.configuration) oauth2_headers = await oauth2_client.get_authentication_header(self.rest_client) for key, value in oauth2_headers.items(): headers[key] = value diff --git a/config/clients/python/template/src/client/client.py.mustache b/config/clients/python/template/src/client/client.py.mustache index 6c41fee0..70e128f0 100644 --- a/config/clients/python/template/src/client/client.py.mustache +++ b/config/clients/python/template/src/client/client.py.mustache @@ -549,7 +549,7 @@ class OpenFgaClient: semaphore.release() {{/asyncio}} - {{#asyncio}}async {{/asyncio}}def batch_check(self, body: list[ClientCheckRequest], options: dict[str, str] = None): + {{#asyncio}}async {{/asyncio}}def batch_check(self, body: list[ClientCheckRequest], options: dict[str, str | int] = None): """ Run a set of checks :param body - list of ClientCheckRequest defining check request @@ -565,7 +565,13 @@ class OpenFgaClient: max_parallel_requests = {{ clientMaxMethodParallelRequests }} if options is not None and "max_parallel_requests" in options: - max_parallel_requests = options["max_parallel_requests"] + if ( + isinstance(options["max_parallel_requests"], str) + and options["max_parallel_requests"].isdigit() + ): + max_parallel_requests = int(options["max_parallel_requests"]) + elif isinstance(options["max_parallel_requests"], int): + max_parallel_requests = options["max_parallel_requests"] {{#asyncio}} sem = asyncio.Semaphore(max_parallel_requests) diff --git a/config/clients/python/template/src/configuration.py.mustache b/config/clients/python/template/src/configuration.py.mustache index 439ee763..be1027c8 100644 --- a/config/clients/python/template/src/configuration.py.mustache +++ b/config/clients/python/template/src/configuration.py.mustache @@ -9,9 +9,18 @@ import sys import http import urllib import urllib3 +from typing import Optional from {{packageName}}.exceptions import FgaValidationException, ApiValueError -from {{packageName}}.telemetry.configuration import TelemetryConfiguration +from {{packageName}}.telemetry.attributes import TelemetryAttribute +from {{packageName}}.telemetry.configuration import ( + TelemetryConfiguration, + TelemetryConfigurationType, + TelemetryMetricConfiguration, + TelemetryMetricsConfiguration, +) +from {{packageName}}.telemetry.counters import TelemetryCounter +from {{packageName}}.telemetry.histograms import TelemetryHistogram from {{packageName}}.validation import is_well_formed_ulid_string @@ -155,7 +164,19 @@ 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, + telemetry: Optional[ + dict[ + TelemetryConfigurationType | str, + TelemetryMetricsConfiguration + | dict[ + TelemetryHistogram | TelemetryCounter, + TelemetryMetricConfiguration + | dict[TelemetryAttribute, bool] + | None, + ] + | None, + ] + ] = None, ): """Constructor """ @@ -278,10 +299,16 @@ class Configuration: """Options to pass down to the underlying urllib3 socket """ + self._telemetry: TelemetryConfiguration | None = None if telemetry is None: - telemetry = TelemetryConfiguration() + self._telemetry = TelemetryConfiguration( + TelemetryConfiguration.getSdkDefaults() + ) + elif isinstance(telemetry, dict): + self._telemetry = TelemetryConfiguration(telemetry) + elif isinstance(telemetry, TelemetryConfiguration): + self._telemetry = telemetry - self.telemetry = telemetry """Telemetry configuration """ @@ -409,6 +436,41 @@ class Configuration: self.__logger_format = value self.logger_formatter = logging.Formatter(self.__logger_format) + @property + def telemetry(self) -> TelemetryConfiguration | None: + """Return the telemetry configuration""" + return self._telemetry + + @telemetry.setter + def telemetry( + self, + value: ( + dict[ + TelemetryConfigurationType | str, + TelemetryMetricsConfiguration + | dict[ + TelemetryHistogram | TelemetryCounter, + TelemetryMetricConfiguration + | dict[TelemetryAttribute, bool] + | None, + ] + | None, + ] + | None + ), + ) -> None: + """Set the telemetry configuration""" + if value is not None: + if isinstance(value, dict): + self._telemetry = TelemetryConfiguration(value) + return + elif isinstance(value, TelemetryConfiguration): + self._telemetry = value + return + + self._telemetry = None + + def get_api_key_with_prefix(self, identifier, alias=None): """Gets API key (with prefix if set). diff --git a/config/clients/python/template/src/oauth2.py.mustache b/config/clients/python/template/src/oauth2.py.mustache index 01d558b1..dc75bc29 100644 --- a/config/clients/python/template/src/oauth2.py.mustache +++ b/config/clients/python/template/src/oauth2.py.mustache @@ -110,11 +110,11 @@ class OAuth2Client: seconds=int(api_response.get("expires_in")) ) self._access_token = api_response.get("access_token") - self._telemetry.metrics().credentialsRequest( - 1, - { + self._telemetry.metrics.credentialsRequest( + attributes={ TelemetryAttributes.fga_client_request_client_id: configuration.client_id }, + configuration=self.configuration.telemetry, ) break diff --git a/config/clients/python/template/src/rest.py.mustache b/config/clients/python/template/src/rest.py.mustache index 543359be..55a910b4 100644 --- a/config/clients/python/template/src/rest.py.mustache +++ b/config/clients/python/template/src/rest.py.mustache @@ -8,7 +8,7 @@ import ssl import urllib import aiohttp -from {{packageName}}.exceptions import ApiException, UnauthorizedException, ForbiddenException, NotFoundException, RateLimitExceededError, ServiceException, ApiValueError +from {{packageName}}.exceptions import ApiException, UnauthorizedException, ForbiddenException, NotFoundException, RateLimitExceededError, ServiceException, ApiValueError, ValidationException logger = logging.getLogger(__name__) @@ -162,6 +162,9 @@ class RESTClientObject: logger.debug("response body: %s", r.data) if not 200 <= r.status <= 299: + if r.status == 400: + raise ValidationException(http_resp=r) + if r.status == 401: raise UnauthorizedException(http_resp=r) diff --git a/config/clients/python/template/src/sync/api.py.mustache b/config/clients/python/template/src/sync/api.py.mustache index 3d2cd37a..63bc4763 100644 --- a/config/clients/python/template/src/sync/api.py.mustache +++ b/config/clients/python/template/src/sync/api.py.mustache @@ -319,38 +319,14 @@ class {{classname}}: response_types_map = {} {{/returnType}} - telemetry_attributes: dict[TelemetryAttribute, str] = { - TelemetryAttributes.fga_client_request_method: "{{operationId}}" + telemetry_attributes: dict[TelemetryAttribute, str | int] = { + TelemetryAttributes.fga_client_request_method: "{{operationId}}", + TelemetryAttributes.fga_client_request_store_id: self.api_client.get_store_id(), + TelemetryAttributes.fga_client_request_model_id: local_var_params.get( + "authorization_model_id", "" + ), } - try: - if store_id: - telemetry_attributes[ - TelemetryAttributes.fga_client_request_store_id - ] = store_id - except: - pass - - try: - if body_params.tuple_key: - telemetry_attributes[TelemetryAttributes.fga_client_user] = ( - body_params.tuple_key.user - ) - except: - pass - - try: - if "authorization_model_id" in local_var_params: - telemetry_attributes[ - TelemetryAttributes.fga_client_request_model_id - ] = local_var_params["authorization_model_id"] - elif body_params.authorization_model_id: - telemetry_attributes[ - TelemetryAttributes.fga_client_request_model_id - ] = body_params.authorization_model_id - except: - pass - return self.api_client.call_api( '{{{path}}}'.replace('{store_id}', store_id), '{{httpMethod}}', path_params, 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 d09b3a8a..ecc16100 100644 --- a/config/clients/python/template/src/sync/api_client.py.mustache +++ b/config/clients/python/template/src/sync/api_client.py.mustache @@ -160,7 +160,7 @@ class ApiClient: _request_auth=None, _retry_params=None, _oauth2_client=None, - _telemetry_attributes: dict[TelemetryAttribute | str, str] = None, + _telemetry_attributes: dict[TelemetryAttribute, str | int] = None, ): self.configuration.is_valid() @@ -228,7 +228,21 @@ class ApiClient: max_retry = _retry_params.max_retry if _retry_params.min_wait_in_ms is not None: max_retry = _retry_params.min_wait_in_ms + + _telemetry_attributes = TelemetryAttributes.fromRequest( + user_agent=self.user_agent, + fga_method=resource_path, + http_method=method, + url=url, + resend_count=0, + start=start, + credentials=self.configuration.credentials, + attributes=_telemetry_attributes, + ) + for retry in range(max_retry+1): + _telemetry_attributes[TelemetryAttributes.http_request_resend_count] = retry + try: # perform request and return response response_data = {{#tornado}}yield {{/tornado}}self.request( @@ -238,7 +252,19 @@ class ApiClient: _request_timeout=_request_timeout) except (RateLimitExceededError, ServiceException) as e: if retry < max_retry and e.status != 501: + _telemetry_attributes = TelemetryAttributes.fromResponse( + response=e.body.decode("utf-8"), + credentials=self.configuration.credentials, + attributes=_telemetry_attributes, + ) + + self._telemetry.metrics.request( + attributes=_telemetry_attributes, + configuration=self.configuration.telemetry, + ) + time.sleep(random_time(retry, min_wait_in_ms)) + continue e.body = e.body.decode('utf-8') response_type = response_types_map.get(e.status, None) @@ -252,35 +278,51 @@ class ApiClient: if response_type is not None: e.parsed_exception = self.__deserialize(json.loads(e.body), response_type) e.body = None + + _telemetry_attributes = TelemetryAttributes.fromResponse( + response=e, + credentials=self.configuration.credentials, + attributes=_telemetry_attributes, + ) + + self._telemetry.metrics.request( + attributes=_telemetry_attributes, + configuration=self.configuration.telemetry, + ) + + self._telemetry.metrics.queryDuration( + attributes=_telemetry_attributes, + configuration=self.configuration.telemetry, + ) + + self._telemetry.metrics.requestDuration( + attributes=_telemetry_attributes, + configuration=self.configuration.telemetry, + ) raise e self.last_response = response_data return_data = 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, + _telemetry_attributes = TelemetryAttributes.fromResponse( + response=response_data, credentials=self.configuration.credentials, attributes=_telemetry_attributes, ) - _telemetry_attributes = TelemetryAttributes().fromResponse( - response=response_data, - credentials=self.configuration.credentials, + self._telemetry.metrics.request( attributes=_telemetry_attributes, + configuration=self.configuration.telemetry, ) - self._telemetry.metrics().queryDuration( + + self._telemetry.metrics.queryDuration( attributes=_telemetry_attributes, configuration=self.configuration.telemetry, ) - self._telemetry.metrics().requestDuration( + self._telemetry.metrics.requestDuration( attributes=_telemetry_attributes, configuration=self.configuration.telemetry, ) @@ -445,7 +487,7 @@ class ApiClient: _request_auth=None, _retry_params=None, _oauth2_client=None, - _telemetry_attributes: dict[TelemetryAttribute, str] = None, + _telemetry_attributes: dict[TelemetryAttribute, str | int] = None, ): """Makes the HTTP request (synchronous) and returns deserialized data. @@ -680,7 +722,7 @@ class ApiClient: headers['Authorization'] = f'Bearer {credentials.configuration.api_token}' if credentials.method == 'client_credentials': if oauth2_client is None: - oauth2_client = oauth2.OAuth2Client(credentials) + oauth2_client = oauth2.OAuth2Client(credentials, self.configuration) oauth2_headers = oauth2_client.get_authentication_header(self.rest_client) for key, value in oauth2_headers.items(): headers[key] = value diff --git a/config/clients/python/template/src/sync/client/client.py.mustache b/config/clients/python/template/src/sync/client/client.py.mustache index 2183525c..3175468c 100644 --- a/config/clients/python/template/src/sync/client/client.py.mustache +++ b/config/clients/python/template/src/sync/client/client.py.mustache @@ -524,7 +524,7 @@ class OpenFgaClient: except Exception as err: return BatchCheckResponse(allowed=False, request=body, response=None, error=err) - def batch_check(self, body: list[ClientCheckRequest], options: dict[str, str] = None): + def batch_check(self, body: list[ClientCheckRequest], options: dict[str, str | int] = None): """ Run a set of checks :param body - list of ClientCheckRequest defining check request @@ -541,7 +541,13 @@ class OpenFgaClient: max_parallel_requests = {{ clientMaxMethodParallelRequests }} if options is not None and "max_parallel_requests" in options: - max_parallel_requests = options["max_parallel_requests"] + if ( + isinstance(options["max_parallel_requests"], str) + and options["max_parallel_requests"].isdigit() + ): + max_parallel_requests = int(options["max_parallel_requests"]) + elif isinstance(options["max_parallel_requests"], int): + max_parallel_requests = options["max_parallel_requests"] batch_check_response = [] diff --git a/config/clients/python/template/src/sync/oauth2.py.mustache b/config/clients/python/template/src/sync/oauth2.py.mustache index 6b744820..57faf83d 100644 --- a/config/clients/python/template/src/sync/oauth2.py.mustache +++ b/config/clients/python/template/src/sync/oauth2.py.mustache @@ -110,11 +110,11 @@ class OAuth2Client: seconds=int(api_response.get("expires_in")) ) self._access_token = api_response.get("access_token") - self._telemetry.metrics().credentialsRequest( - 1, - { + self._telemetry.metrics.credentialsRequest( + attributes={ TelemetryAttributes.fga_client_request_client_id: configuration.client_id }, + configuration=self.configuration.telemetry, ) break diff --git a/config/clients/python/template/src/sync/rest.py.mustache b/config/clients/python/template/src/sync/rest.py.mustache index d12f44d6..698db6df 100644 --- a/config/clients/python/template/src/sync/rest.py.mustache +++ b/config/clients/python/template/src/sync/rest.py.mustache @@ -8,7 +8,7 @@ import ssl import urllib import urllib3 -from {{packageName}}.exceptions import ApiException, UnauthorizedException, ForbiddenException, NotFoundException, ServiceException, ApiValueError, ValidationException +from {{packageName}}.exceptions import ApiException, UnauthorizedException, ForbiddenException, NotFoundException, ServiceException, ApiValueError, ValidationException, RateLimitExceededError logger = logging.getLogger(__name__) @@ -216,6 +216,9 @@ class RESTClientObject: if r.status == 404: raise NotFoundException(http_resp=r) + if r.status == 429: + raise RateLimitExceededError(http_resp=r) + if 500 <= r.status <= 599: raise ServiceException(http_resp=r) diff --git a/config/clients/python/template/src/telemetry/__init__.py.mustache b/config/clients/python/template/src/telemetry/__init__.py.mustache index fb6ab4fa..73c1c462 100644 --- a/config/clients/python/template/src/telemetry/__init__.py.mustache +++ b/config/clients/python/template/src/telemetry/__init__.py.mustache @@ -1,9 +1,11 @@ from {{packageName}}.telemetry.attributes import TelemetryAttribute, TelemetryAttributes from {{packageName}}.telemetry.configuration import ( TelemetryConfiguration, + TelemetryConfigurations, + TelemetryConfigurationType, TelemetryMetricConfiguration, TelemetryMetricsConfiguration, ) from {{packageName}}.telemetry.histograms import TelemetryHistogram, TelemetryHistograms -from {{packageName}}.telemetry.metrics import MetricsTelemetry +from {{packageName}}.telemetry.metrics import TelemetryMetrics 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 fb5c35f1..a1596d75 100644 --- a/config/clients/python/template/src/telemetry/attributes.py.mustache +++ b/config/clients/python/template/src/telemetry/attributes.py.mustache @@ -1,43 +1,40 @@ import time import urllib -from typing import NamedTuple +from typing import NamedTuple, Optional from aiohttp import ClientResponse from urllib3 import HTTPResponse from {{packageName}}.credentials import Credentials +from {{packageName}}.exceptions import ApiException from {{packageName}}.rest import RESTResponse - +from openfga_sdk.telemetry.utilities import ( + doesInstanceHaveCallable, +) class TelemetryAttribute(NamedTuple): name: str - format: str = None + format: str = "string" class TelemetryAttributes: fga_client_request_client_id: TelemetryAttribute = TelemetryAttribute( name="fga-client.request.client_id", - format="string", ) fga_client_request_method: TelemetryAttribute = TelemetryAttribute( name="fga-client.request.method", - format="string", ) fga_client_request_model_id: TelemetryAttribute = TelemetryAttribute( name="fga-client.request.model_id", - format="string", ) fga_client_request_store_id: TelemetryAttribute = TelemetryAttribute( name="fga-client.request.store_id", - format="string", ) fga_client_response_model_id: TelemetryAttribute = TelemetryAttribute( name="fga-client.response.model_id", - format="string", ) fga_client_user: TelemetryAttribute = TelemetryAttribute( name="fga-client.user", - format="string", ) http_client_request_duration: TelemetryAttribute = TelemetryAttribute( name="http.client.request.duration", @@ -45,11 +42,9 @@ class TelemetryAttributes: ) 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", @@ -65,21 +60,49 @@ class TelemetryAttributes: ) url_scheme: TelemetryAttribute = TelemetryAttribute( name="url.scheme", - format="string", ) url_full: TelemetryAttribute = TelemetryAttribute( name="url.full", - format="string", ) user_agent_original: TelemetryAttribute = TelemetryAttribute( name="user_agent.original", - format="string", ) + _attributes: list[TelemetryAttribute] = [ + fga_client_request_client_id, + fga_client_request_method, + fga_client_request_model_id, + fga_client_request_store_id, + fga_client_response_model_id, + fga_client_user, + http_client_request_duration, + http_host, + http_request_method, + http_request_resend_count, + http_response_status_code, + http_server_request_duration, + url_scheme, + url_full, + user_agent_original, + ] + + @staticmethod + def get( + name: Optional[str] = None, + ) -> list[TelemetryAttribute] | TelemetryAttribute | None: + if name is None: + return TelemetryAttributes._attributes + + for attribute in TelemetryAttributes._attributes: + if attribute.name == name: + return attribute + + return None + + @staticmethod def prepare( - self, - attributes: dict[TelemetryAttribute | str, str | int] | None, - filter: list[TelemetryAttribute | str] | None = None, + attributes: dict[TelemetryAttribute, str | int] | None, + filter: list[TelemetryAttribute] | None = None, ) -> dict[str, str | int]: response = {} @@ -95,7 +118,9 @@ class TelemetryAttributes: attributeTranslated = ( attribute.lower().replace("-", "_").replace(".", "_") ) - attributeInstance = getattr(self, attributeTranslated, None) + attributeInstance = getattr( + TelemetryAttributes, attributeTranslated, None + ) if attributeInstance is None: raise ValueError("Invalid attribute specified: %s" % attribute) @@ -107,11 +132,7 @@ class TelemetryAttributes: "Invalid attribute specified: %s" % type(attribute) ) - if ( - filter is not None - and attribute.name not in filter - and attribute not in filter - ): + if filter is not None and attribute not in filter: continue if attribute.format == "string": @@ -131,13 +152,20 @@ class TelemetryAttributes: except ValueError: continue + if attribute.format == "float": + if not isinstance(value, float): + try: + value = float(value) + except ValueError: + continue + response[attribute.name] = value continue return response + @staticmethod def fromRequest( - self, user_agent: str = None, fga_method: str = None, http_method: str = None, @@ -145,90 +173,148 @@ class TelemetryAttributes: resend_count: int = None, start: float = None, credentials: Credentials = None, - attributes: dict[TelemetryAttribute | str, str | int] = None, - ) -> dict[TelemetryAttribute | str, str | int]: + attributes: dict[TelemetryAttribute, str | int] = None, + ) -> dict[TelemetryAttribute, str | int]: if attributes is None: attributes = {} - if fga_method is not None: - attributes[self.fga_client_request_method.name] = fga_method + if ( + TelemetryAttributes.fga_client_request_method not in attributes + and fga_method is not None + ): + fga_method = fga_method.rsplit("/", 1)[-1] + + if fga_method: + attributes[TelemetryAttributes.fga_client_request_method] = ( + fga_method.rsplit("/", 1)[-1] + ) + + if TelemetryAttributes.fga_client_request_method in attributes: + fga_method = attributes[TelemetryAttributes.fga_client_request_method] + fga_method = ( + fga_method.lower().replace("_", " ").title().replace(" ", "").strip() + ) + + if fga_method: + attributes[TelemetryAttributes.fga_client_request_method] = fga_method + else: + del attributes[TelemetryAttributes.fga_client_request_method] if user_agent is not None: - attributes[self.user_agent_original.name] = user_agent + attributes[TelemetryAttributes.user_agent_original] = user_agent if http_method is not None: - attributes[self.http_request_method.name] = http_method + attributes[TelemetryAttributes.http_request_method] = 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 + attributes[TelemetryAttributes.http_host] = urllib.parse.urlparse( + url + ).hostname + attributes[TelemetryAttributes.url_scheme] = urllib.parse.urlparse( + url + ).scheme + attributes[TelemetryAttributes.url_full] = url if start is not None and start > 0: - attributes[self.http_client_request_duration.name] = int( + attributes[TelemetryAttributes.http_client_request_duration] = int( (time.time() - start) * 1000 ) - if resend_count is not None: - attributes[self.http_request_resend_count.name] = resend_count + if resend_count is not None and resend_count > 0: + attributes[TelemetryAttributes.http_request_resend_count] = resend_count if credentials is not None: if credentials.method == "client_credentials": - attributes[self.fga_client_request_client_id.name] = ( + attributes[TelemetryAttributes.fga_client_request_client_id] = ( credentials.configuration.client_id ) return attributes + @staticmethod def fromResponse( - self, - response: HTTPResponse | RESTResponse | ClientResponse = None, - credentials: Credentials = None, - attributes: dict[TelemetryAttribute | str, str | int] = None, - ) -> dict[TelemetryAttribute | str, str | int]: + response: Optional[ + HTTPResponse | RESTResponse | ClientResponse | ApiException + ] = None, + credentials: Optional[Credentials] = None, + attributes: Optional[dict[TelemetryAttribute, str | int]] = None, + ) -> dict[TelemetryAttribute, str | int]: response_model_id = None response_query_duration = None if attributes is None: attributes = {} + if isinstance(response, ApiException): + if response.status is not None: + attributes[TelemetryAttributes.http_response_status_code] = int( + response.status + ) + + if response.body is not None: + response_model_id = response.body.get( + "openfga-authorization-model-id" + ) or response.body.get("openfga_authorization_model_id") + response_query_duration = response.body.get("fga-query-duration-ms") + if response is not None: - if self.instanceHasAttribute(response, "status"): - attributes[self.http_response_status_code.name] = int(response.status) + if hasattr(response, "status"): + attributes[TelemetryAttributes.http_response_status_code] = int( + response.status + ) - if self.instanceHasCallable(response, "getheader"): + if doesInstanceHaveCallable(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"): + if doesInstanceHaveCallable(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.fga_client_response_model_id.name] = response_model_id + if response_model_id is not None: + attributes[TelemetryAttributes.fga_client_response_model_id] = ( + response_model_id + ) - if response_query_duration is not None: - attributes[self.http_server_request_duration.name] = ( - response_query_duration - ) + if response_query_duration is not None: + attributes[TelemetryAttributes.http_server_request_duration] = ( + response_query_duration + ) - if isinstance(credentials, Credentials): - if credentials.method == "client_credentials": - attributes[self.fga_client_request_client_id.name] = ( - credentials.configuration.client_id - ) + if isinstance(credentials, Credentials): + if credentials.method == "client_credentials": + attributes[TelemetryAttributes.fga_client_request_client_id] = ( + 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) + @staticmethod + def coalesceAttributeValue( + attribute: TelemetryAttribute, + value: Optional[int | float] = None, + attributes: Optional[dict[TelemetryAttribute, str | int]] = None, + ) -> int | float | None: + if value is None: + if attribute in attributes: + value = attributes[attribute] + + if value is not None: + if attribute.format == "int": + try: + value = int(value) + except ValueError: + value = None + + if attribute.format == "float": + try: + value = float(value) + except ValueError: + value = None + + if attribute.format == "string": + value = str(value) + + return value \ No newline at end of file diff --git a/config/clients/python/template/src/telemetry/configuration.py.mustache b/config/clients/python/template/src/telemetry/configuration.py.mustache index 0737e808..568c967c 100644 --- a/config/clients/python/template/src/telemetry/configuration.py.mustache +++ b/config/clients/python/template/src/telemetry/configuration.py.mustache @@ -1,168 +1,1125 @@ -from typing import Optional - -from {{packageName}}.telemetry.attributes import TelemetryAttributes +from typing import NamedTuple, Optional +from {{packageName}}.telemetry.attributes import TelemetryAttribute, TelemetryAttributes +from {{packageName}}.telemetry.counters import TelemetryCounter, TelemetryCounters +from {{packageName}}.telemetry.histograms import TelemetryHistogram, TelemetryHistograms class TelemetryMetricConfiguration: - _enabled: bool + """ + Telemetry configuration for an individual histogram or counter. When instantiated directly, all attributes are disabled by default. + Use the `getDefaultTelemetryMetricConfiguration` method to get an instance of TelemetryMetricConfiguration pre-configured with reasonable defaults enabled. + """ + + _state: dict[TelemetryAttribute, bool] = {} + _valid: bool | None = None 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, + config: Optional[dict[TelemetryAttribute, bool]] = None, + fga_client_request_client_id: Optional[bool] = None, + fga_client_request_method: Optional[bool] = None, + fga_client_request_model_id: Optional[bool] = None, + fga_client_request_store_id: Optional[bool] = None, + fga_client_response_model_id: Optional[bool] = None, + fga_client_user: Optional[bool] = None, + http_client_request_duration: Optional[bool] = None, + http_host: Optional[bool] = None, + http_request_method: Optional[bool] = None, + http_request_resend_count: Optional[bool] = None, + http_response_status_code: Optional[bool] = None, + http_server_request_duration: Optional[bool] = None, + url_scheme: Optional[bool] = None, + url_full: Optional[bool] = None, + user_agent_original: Optional[bool] = None, ): - 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 + """ + Initialize a new instance of the `TelemetryMetricConfiguration` class. + + :param config: A dictionary containing the configuration for the telemetry metrics. + :param fga_client_request_client_id: The `fga-client.request.client_id` attribute includes the client ID associated with the request, if any. + :param fga_client_request_method: The `fga-client.request.method` attribute includes the FGA method/action that was performed. + :param fga_client_request_model_id: The `fga-client.request.model_id` attribute includes the authorization model ID that was sent as part of the request, if any. + :param fga_client_request_store_id: The `fga-client.request.store_id` attribute includes the store ID that was sent as part of the request, if any. + :param fga_client_response_model_id: The `fga-client.response.model_id` attribute includes the authorization model ID that the FGA server used. + :param fga_client_user: The `fga-client.user` attribute includes the user associated with the request, if any. + :param http_client_request_duration: The `http.client.request.duration` attribute includes the duration of the request from the client's perspective. + :param http_host: The `http.host` attribute includes the host name of the request. + :param http_request_method: The `http.request.method` attribute includes the HTTP method of the request. + :param http_request_resend_count: The `http.request.resend_count` attribute includes the number of times the request was resent. + :param http_response_status_code: The `http.response.status_code` attribute includes the HTTP status code of the response. + :param http_server_request_duration: The `http.server.request.duration` attribute includes the duration of the request from the server's perspective. + :param url_scheme: The `url.scheme` attribute includes the scheme of the request URL. + :param url_full: The `url.full` attribute includes the full URL of the request. + :param user_agent_original: The `user_agent.original` attribute includes the original user agent string of the request. + """ + + self.configure( + config=config, + clear=True, + ) + + if fga_client_request_client_id is not None: + self._state[TelemetryAttributes.fga_client_request_client_id] = ( + fga_client_request_client_id + ) + + if fga_client_request_method is not None: + self._state[TelemetryAttributes.fga_client_request_method] = ( + fga_client_request_method + ) + + if fga_client_request_model_id is not None: + self._state[TelemetryAttributes.fga_client_request_model_id] = ( + fga_client_request_model_id + ) + + if fga_client_request_store_id is not None: + self._state[TelemetryAttributes.fga_client_request_store_id] = ( + fga_client_request_store_id + ) + + if fga_client_response_model_id is not None: + self._state[TelemetryAttributes.fga_client_response_model_id] = ( + fga_client_response_model_id + ) + + if fga_client_user is not None: + self._state[TelemetryAttributes.fga_client_user] = fga_client_user + + if http_client_request_duration is not None: + self._state[TelemetryAttributes.http_client_request_duration] = ( + http_client_request_duration + ) + + if http_host is not None: + self._state[TelemetryAttributes.http_host] = http_host + + if http_request_method is not None: + self._state[TelemetryAttributes.http_request_method] = http_request_method + + if http_request_resend_count is not None: + self._state[TelemetryAttributes.http_request_resend_count] = ( + http_request_resend_count + ) + + if http_response_status_code is not None: + self._state[TelemetryAttributes.http_response_status_code] = ( + http_response_status_code + ) + + if http_server_request_duration is not None: + self._state[TelemetryAttributes.http_server_request_duration] = ( + http_server_request_duration + ) + + if url_scheme is not None: + self._state[TelemetryAttributes.url_scheme] = url_scheme + + if url_full is not None: + self._state[TelemetryAttributes.url_full] = url_full + + if user_agent_original is not None: + self._state[TelemetryAttributes.user_agent_original] = user_agent_original + + self._valid = None # Reset the validation state + + @property + def fga_client_request_client_id(self) -> bool: + """ + Get the configuration for the `fga-client.request.client_id` attribute. + + :return: The configuration for the `fga-client.request.client_id` attribute. + """ + + return self._state[TelemetryAttributes.fga_client_request_client_id] + + @fga_client_request_client_id.setter + def fga_client_request_client_id(self, value: bool): + """ + Set the configuration for the `fga-client.request.client_id` attribute. + + :param value: The configuration for the `fga-client.request.client_id` attribute. + """ + + self._valid = None # Reset the validation state + self._state[TelemetryAttributes.fga_client_request_client_id] = value + + @property + def fga_client_request_method(self) -> bool: + """ + Get the configuration for the `fga-client.request.method` attribute. + + :return: The configuration for the `fga-client.request.method` attribute. + """ + + return self._state[TelemetryAttributes.fga_client_request_method] + + @fga_client_request_method.setter + def fga_client_request_method(self, value: bool): + """ + Set the configuration for the `fga-client.request.method` attribute. + + :param value: The configuration for the `fga-client.request.method` attribute. + """ + + self._valid = None # Reset the validation state + self._state[TelemetryAttributes.fga_client_request_method] = value + + @property + def fga_client_request_model_id(self) -> bool: + """ + Get the configuration for the `fga-client.request.model_id` attribute. + + :return: The configuration for the `fga-client.request.model_id` attribute. + """ + + return self._state[TelemetryAttributes.fga_client_request_model_id] + + @fga_client_request_model_id.setter + def fga_client_request_model_id(self, value: bool): + """ + Set the configuration for the `fga-client.request.model_id` attribute. + + :param value: The configuration for the `fga-client.request.model_id` attribute. + """ + + self._valid = None # Reset the validation state + self._state[TelemetryAttributes.fga_client_request_model_id] = value + + @property + def fga_client_request_store_id(self) -> bool: + """ + Get the configuration for the `fga-client.request.store_id` attribute. + + :return: The configuration for the `fga-client.request.store_id` attribute. + """ + + return self._state[TelemetryAttributes.fga_client_request_store_id] + + @fga_client_request_store_id.setter + def fga_client_request_store_id(self, value: bool): + """ + Set the configuration for the `fga-client.request.store_id` attribute. + + :param value: The configuration for the `fga-client.request.store_id` attribute. + """ + + self._valid = None # Reset the validation state + self._state[TelemetryAttributes.fga_client_request_store_id] = value + + @property + def fga_client_response_model_id(self) -> bool: + """ + Get the configuration for the `fga-client.response.model_id` attribute. + + :return: The configuration for the `fga-client.response.model_id` attribute. + """ + + return self._state[TelemetryAttributes.fga_client_response_model_id] + + @fga_client_response_model_id.setter + def fga_client_response_model_id(self, value: bool): + """ + Set the configuration for the `fga-client.response.model_id` attribute. + + :param value: The configuration for the `fga-client.response.model_id` attribute. + """ + + self._valid = None # Reset the validation state + self._state[TelemetryAttributes.fga_client_response_model_id] = value + + @property + def fga_client_user(self) -> bool: + """ + Get the configuration for the `fga-client.user` attribute. + + :return: The configuration for the `fga-client.user` attribute. + """ + + return self._state[TelemetryAttributes.fga_client_user] + + @fga_client_user.setter + def fga_client_user(self, value: bool): + """ + Set the configuration for the `fga-client.user` attribute. + + :param value: The configuration for the `fga-client.user` attribute. + """ + + self._valid = None # Reset the validation state + self._state[TelemetryAttributes.fga_client_user] = value @property - def enabled(self) -> bool: - return self._enabled + def http_client_request_duration(self) -> bool: + """ + Get the configuration for the `http.client.request.duration` attribute. + + :return: The configuration for the `http.client.request.duration` attribute. + """ + + return self._state[TelemetryAttributes.http_client_request_duration] + + @http_client_request_duration.setter + def http_client_request_duration(self, value: bool): + """ + Set the configuration for the `http.client.request.duration` attribute. + + :param value: The configuration for the `http.client.request.duration` attribute. + """ + + self._valid = None # Reset the validation state + self._state[TelemetryAttributes.http_client_request_duration] = value + + @property + def http_host(self) -> bool: + """ + Get the configuration for the `http.host` attribute. + + :return: The configuration for the `http.host` attribute. + """ + + return self._state[TelemetryAttributes.http_host] + + @http_host.setter + def http_host(self, value: bool): + """ + Set the configuration for the `http.host` attribute. + + :param value: The configuration for the `http.host` attribute. + """ + + self._valid = None # Reset the validation state + self._state[TelemetryAttributes.http_host] = value + + @property + def http_request_method(self) -> bool: + """ + Get the configuration for the `http.request.method` attribute. + + :return: The configuration for the `http.request.method` attribute. + """ + + return self._state[TelemetryAttributes.http_request_method] + + @http_request_method.setter + def http_request_method(self, value: bool): + """ + Set the configuration for the `http.request.method` attribute. + + :param value: The configuration for the `http.request.method` attribute. + """ + + self._valid = None # Reset the validation state + self._http_request_method = value + + @property + def http_request_resend_count(self) -> bool: + """ + Get the configuration for the `http.request.resend_count` attribute. + + :return: The configuration for the `http.request.resend_count` attribute. + """ + + return self._state[TelemetryAttributes.http_request_resend_count] + + @http_request_resend_count.setter + def http_request_resend_count(self, value: bool): + """ + Set the configuration for the `http.request.resend_count` attribute. + + :param value: The configuration for the `http.request.resend_count` attribute. + """ + + self._valid = None # Reset the validation state + self._state[TelemetryAttributes.http_request_resend_count] = value + + @property + def http_response_status_code(self) -> bool: + """ + Get the configuration for the `http.response.status_code` attribute. + + :return: The configuration for the `http.response.status_code` attribute. + """ + + return self._state[TelemetryAttributes.http_response_status_code] + + @http_response_status_code.setter + def http_response_status_code(self, value: bool): + """ + Set the configuration for the `http.response.status_code` attribute. + + :param value: The configuration for the `http.response.status_code` attribute. + """ + + self._valid = None # Reset the validation state + self._state[TelemetryAttributes.http_response_status_code] = value + + @property + def http_server_request_duration(self) -> bool: + """ + Get the configuration for the `http.server.request.duration` attribute. + + :return: The configuration for the `http.server.request.duration` attribute. + """ + + return self._state[TelemetryAttributes.http_server_request_duration] + + @http_server_request_duration.setter + def http_server_request_duration(self, value: bool): + """ + Set the configuration for the `http.server.request.duration` attribute. + + :param value: The configuration for the `http.server.request.duration` attribute. + """ + + self._valid = None # Reset the validation state + self._state[TelemetryAttributes.http_server_request_duration] = value + + @property + def url_scheme(self) -> bool: + """ + Get the configuration for the `url.scheme` attribute. + + :return: The configuration for the `url.scheme` attribute. + """ + + return self._state[TelemetryAttributes.url_scheme] + + @url_scheme.setter + def url_scheme(self, value: bool): + """ + Set the configuration for the `url.scheme` attribute. + + :param value: The configuration for the `url.scheme` attribute. + """ + + self._valid = None # Reset the validation state + self._state[TelemetryAttributes.url_scheme] = value + + @property + def url_full(self) -> bool: + """ + Get the configuration for the `url.full` attribute. + + :return: The configuration for the `url.full` attribute. + """ + + return self._state[TelemetryAttributes.url_full] + + @url_full.setter + def url_full(self, value: bool): + """ + Set the configuration for the `url.full` attribute. + + :param value: The configuration for the `url.full` attribute. + """ + + self._valid = None # Reset the validation state + self._state[TelemetryAttributes.url_full] = value + + @property + def user_agent_original(self) -> bool: + """ + Get the configuration for the `user_agent.original` attribute. + + :return: The configuration for the `user_agent.original` attribute. + """ + + return self._state[TelemetryAttributes.user_agent_original] + + @user_agent_original.setter + def user_agent_original(self, value: bool): + """ + Set the configuration for the `user_agent.original` attribute. + + :param value: The configuration for the `user_agent.original` attribute. + """ + + self._valid = None # Reset the validation state + self._state[TelemetryAttributes.user_agent_original] = value + + def clear(self) -> None: + """ + Reset the configuration to the default state (all attributes disabled). + """ - @enabled.setter - def enabled(self, value: bool): - self._enabled = value + # Reset the configuration to the default state + self._state = { + TelemetryAttributes.fga_client_request_client_id: False, + TelemetryAttributes.fga_client_request_method: False, + TelemetryAttributes.fga_client_request_model_id: False, + TelemetryAttributes.fga_client_request_store_id: False, + TelemetryAttributes.fga_client_response_model_id: False, + TelemetryAttributes.fga_client_user: False, + TelemetryAttributes.http_client_request_duration: False, + TelemetryAttributes.http_host: False, + TelemetryAttributes.http_request_method: False, + TelemetryAttributes.http_request_resend_count: False, + TelemetryAttributes.http_response_status_code: False, + TelemetryAttributes.http_server_request_duration: False, + TelemetryAttributes.url_scheme: False, + TelemetryAttributes.url_full: False, + TelemetryAttributes.user_agent_original: False, + } - def attributes(self) -> dict[str]: - enabled = {} + # Reset the validation state + self._valid = True - if self.attr_fga_client_request_client_id: - enabled[TelemetryAttributes.fga_client_request_client_id.name] = True + def configure( + self, + config: Optional[dict[TelemetryAttribute | str, bool]] = None, + clear: Optional[bool] = False, + ) -> None: + """ + Configure the telemetry metric. + """ + + # Reset the configuration to the default state + if clear is True: + self.clear() + + # Apply an incoming configuration, if provided + if isinstance(config, dict): + for attribute, enabled in config.items(): + if isinstance(attribute, str): + attribute = TelemetryAttributes.get(name=attribute) + + if not isinstance(attribute, TelemetryAttribute): + raise ValueError( + f"Invalid attribute type provided in `TelemetryMetricConfiguration`; `TelemetryAttribute` expected, but `{type(attribute)}` was provided.", + attribute, + ) + + if not isinstance(enabled, bool): + raise ValueError( + f"Invalid attribute value provided in `TelemetryMetricConfiguration`; `bool` expected, but `{type(enabled)}` was provided.", + attribute, + ) + + if attribute not in self._state: + raise ValueError( + f"Invalid attribute provided in `TelemetryMetricConfiguration`; `{attribute.name}` is not a supported attribute type for this context.", + attribute, + ) + + self._state[attribute] = enabled + + # Reset the validation state + self._valid = None - if self.attr_fga_client_request_method: - enabled[TelemetryAttributes.fga_client_request_method.name] = True + def getAttributes( + self, filter_enabled: Optional[bool] = True + ) -> dict[TelemetryAttribute, bool]: + """ + Returns a list of supported attributes. If `filter_enabled` is `True`, only enabled attributes are returned. - if self.attr_fga_client_request_model_id: - enabled[TelemetryAttributes.fga_client_request_model_id.name] = True + :param filter_enabled: A boolean indicating whether to filter the list to only include enabled attributes. - if self.attr_fga_client_request_store_id: - enabled[TelemetryAttributes.fga_client_request_store_id.name] = True + :return: A list of enabled attributes. + """ - if self.attr_fga_client_response_model_id: - enabled[TelemetryAttributes.fga_client_response_model_id.name] = True + attributes = self._state - if self.attr_fga_client_user: - enabled[TelemetryAttributes.fga_client_user.name] = True + if filter_enabled is True: + return [ + attribute + for attribute, enabled in attributes.items() + if enabled is True + ] - if self.attr_http_client_request_duration: - enabled[TelemetryAttributes.http_client_request_duration.name] = True + return attributes - if self.attr_http_host: - enabled[TelemetryAttributes.http_host.name] = True + def isEnabled(self, attribute: Optional[TelemetryAttribute] = None) -> bool: + """ + Check if this metric is enabled for telemetry, based on whether any attributes are enabled. - if self.attr_http_request_method: - enabled[TelemetryAttributes.http_request_method.name] = True + :return: A boolean indicating whether any attributes are enabled for the metric. + """ - if self.attr_http_request_resend_count: - enabled[TelemetryAttributes.http_request_resend_count.name] = True + # If no attribute is specified, check if any attributes are enabled + if attribute is None: + return True if any(self.getAttributes(filter_enabled=True)) else False - if self.attr_http_response_status_code: - enabled[TelemetryAttributes.http_response_status_code.name] = True + # Check if the specified attribute is enabled + if attribute in self._state: + return self._state[attribute] - if self.attr_http_server_request_duration: - enabled[TelemetryAttributes.http_server_request_duration.name] = True + return False - if self.attr_http_url_scheme: - enabled[TelemetryAttributes.url_scheme.name] = True + def isValid(self, raise_exception: Optional[bool] = False) -> bool: + """ + Validate the telemetry metric configuration. - if self.attr_http_url_full: - enabled[TelemetryAttributes.url_full.name] = True + :param raise_exception: A boolean indicating whether to raise an exception if the configuration is invalid. - if self.attr_user_agent_original: - enabled[TelemetryAttributes.user_agent_original.name] = True + :return: A boolean indicating whether the metric configuration is valid. + """ - return enabled + # Check if the validation state is already cached + if self._valid is not None: + return self._valid + + # Validate the configuration + self._valid = all([isinstance(value, bool) for value in self._state.values()]) + + # If requested, raise an exception if the configuration is invalid + if self._valid is False and raise_exception is True: + raise ValueError("Invalid TelemetryMetricConfiguration.") + + # Return the validation state + return self._valid + + @staticmethod + def getSdkDefaults() -> dict[TelemetryAttribute, bool]: + """ + Get the default SDK configuration for the telemetry metric. + + :return: The default SDK configuration for the telemetry metric. + """ + return { + TelemetryAttributes.fga_client_request_client_id: True, + TelemetryAttributes.fga_client_request_method: True, + TelemetryAttributes.fga_client_request_model_id: True, + TelemetryAttributes.fga_client_request_store_id: True, + TelemetryAttributes.fga_client_response_model_id: True, + TelemetryAttributes.fga_client_user: False, + TelemetryAttributes.http_client_request_duration: False, + TelemetryAttributes.http_host: True, + TelemetryAttributes.http_request_method: True, + TelemetryAttributes.http_request_resend_count: True, + TelemetryAttributes.http_response_status_code: True, + TelemetryAttributes.http_server_request_duration: False, + TelemetryAttributes.url_scheme: True, + TelemetryAttributes.url_full: True, + TelemetryAttributes.user_agent_original: True, + } class TelemetryMetricsConfiguration: - _counter_credentials_request: TelemetryMetricConfiguration - _histogram_request_duration: TelemetryMetricConfiguration - _histogram_query_duration: TelemetryMetricConfiguration + _state: dict[ + TelemetryHistogram | TelemetryCounter, TelemetryMetricConfiguration | None + ] = {} + _valid: bool | None = None def __init__( self, - counter_credentials_request: Optional[TelemetryMetricConfiguration] = None, - histogram_request_duration: Optional[TelemetryMetricConfiguration] = None, - histogram_query_duration: Optional[TelemetryMetricConfiguration] = None, + config: Optional[ + dict[ + TelemetryHistogram | TelemetryCounter, + TelemetryMetricConfiguration | None, + ] + ] = None, + fga_client_credentials_request: Optional[TelemetryMetricConfiguration] = None, + fga_client_request_duration: Optional[TelemetryMetricConfiguration] = None, + fga_client_query_duration: Optional[TelemetryMetricConfiguration] = None, + fga_client_request: Optional[TelemetryMetricConfiguration] = None, ): - if counter_credentials_request is None: - counter_credentials_request = TelemetryMetricConfiguration() + """ + Initialize a new instance of the `TelemetryMetricsConfiguration` class. + + :param config: A dictionary containing the configuration for the telemetry metrics. + :param fga_client_credentials_request: The `fga-client.credentials.request` counter collects the number of times a new token is requested using ClientCredentials. + :param fga_client_request_duration: The `fga-client.query.duration` histogram tracks how long requests take to complete from the client's perspective. + :param fga_client_query_duration: The `fga-client.request.duration` histogram tracks how long requests take to process from the server's perspective. + :param fga_client_request: The `fga-client.request` counter collects the number of requests made to the FGA server. + """ + + # Instantiate with default state, and apply the incoming configuration, if one was provided + self.configure(config=config, clear=True) + + if fga_client_credentials_request is not None: + self._state[TelemetryCounters.fga_client_credentials_request] = ( + fga_client_credentials_request + ) - if histogram_request_duration is None: - histogram_request_duration = TelemetryMetricConfiguration() + if fga_client_request_duration is not None: + self._state[TelemetryHistograms.fga_client_request_duration] = ( + fga_client_request_duration + ) - if histogram_query_duration is None: - histogram_query_duration = TelemetryMetricConfiguration() + if fga_client_query_duration is not None: + self._state[TelemetryHistograms.fga_client_query_duration] = ( + fga_client_query_duration + ) - self._counter_credentials_request = counter_credentials_request - self._histogram_request_duration = histogram_request_duration - self._histogram_query_duration = histogram_query_duration + if fga_client_request is not None: + self._state[TelemetryCounters.fga_client_request] = fga_client_request + + # Reset the validation state + self._valid = None @property - def counter_credentials_request(self) -> TelemetryMetricConfiguration: - return self._counter_credentials_request + def fga_client_request(self) -> TelemetryMetricConfiguration | None: + """ + Get the configuration for the `fga-client.request` counter. + + :return: The configuration for the `fga-client.request` counter. + """ + + return self._state[TelemetryCounters.fga_client_request] + + @fga_client_request.setter + def fga_client_request(self, value: TelemetryMetricConfiguration | None): + """ + Set the configuration for the `fga-client.request` counter. - @counter_credentials_request.setter - def counter_credentials_request(self, value: TelemetryMetricConfiguration): - self._counter_credentials_request = value + :param value: The configuration for the `fga-client.request` counter. + """ + + self._valid = None # Reset the validation state + self._state[TelemetryCounters.fga_client_request] = value @property - def histogram_request_duration(self) -> TelemetryMetricConfiguration: - return self._histogram_request_duration + def fga_client_credentials_request(self) -> TelemetryMetricConfiguration | None: + """ + Get the configuration for the `fga-client.credentials.request` counter. + + :return: The configuration for the `fga-client.credentials.request` counter. + """ + + return self._state[TelemetryCounters.fga_client_credentials_request] + + @fga_client_credentials_request.setter + def fga_client_credentials_request( + self, value: TelemetryMetricConfiguration | None + ): + """ + Set the configuration for the `fga-client.credentials.request` counter. + + :param value: The configuration for the `fga-client.credentials.request` counter. + """ + + self._valid = None # Reset the validation state + self._state[TelemetryCounters.fga_client_credentials_request] = value + + @property + def fga_client_request_duration(self) -> TelemetryMetricConfiguration | None: + """ + Get the configuration for the `fga-client.query.duration` histogram. + + :return: The configuration for the `fga-client.query.duration` histogram. + """ + + return self._state[TelemetryHistograms.fga_client_request_duration] - @histogram_request_duration.setter - def histogram_request_duration(self, value: TelemetryMetricConfiguration): - self._histogram_request_duration = value + @fga_client_request_duration.setter + def fga_client_request_duration(self, value: TelemetryMetricConfiguration | None): + """ + Set the configuration for the `fga-client.query.duration` histogram. + + :param value: The configuration for the `fga-client.query.duration` histogram. + """ + + self._valid = None # Reset the validation state + self._state[TelemetryHistograms.fga_client_request_duration] = value @property - def histogram_query_duration(self) -> TelemetryMetricConfiguration: - return self._histogram_query_duration + def fga_client_query_duration(self) -> TelemetryMetricConfiguration | None: + """ + Get the configuration for the `fga-client.request.duration` histogram. + + :return: The configuration for the `fga-client.request.duration` histogram. + """ + + return self._state[TelemetryHistograms.fga_client_query_duration] + + @fga_client_query_duration.setter + def fga_client_query_duration(self, value: TelemetryMetricConfiguration | None): + """ + Set the configuration for the `fga-client.request.duration` histogram. + + :param value: The configuration for the `fga-client.request.duration` histogram. + """ + + self._valid = None # Reset the validation state + self._state[TelemetryHistograms.fga_client_query_duration] = value + + def clear(self) -> None: + """ + Reset the configuration to the default state (all attributes disabled). + """ + self._state = { + TelemetryCounters.fga_client_request: None, + TelemetryCounters.fga_client_credentials_request: None, + TelemetryHistograms.fga_client_request_duration: None, + TelemetryHistograms.fga_client_query_duration: None, + } + self._valid = True + + def configure( + self, + config: Optional[ + dict[ + TelemetryHistogram | TelemetryCounter | str, + TelemetryMetricConfiguration | dict[TelemetryAttribute, bool] | None, + ] + ] = None, + clear: Optional[bool] = False, + ) -> None: + """ + Configure metrics reporting for telemetry. + """ + if clear is True: + self.clear() + + if isinstance(config, dict): + for metric, configuration in config.items(): + if isinstance(metric, str): + metric = TelemetryCounters.get( + name=metric + ) or TelemetryHistograms.get(name=metric) + + if not isinstance(metric, TelemetryCounter) and not isinstance( + metric, TelemetryHistogram + ): + raise ValueError( + f"Invalid metric type provided in `TelemetryMetricsConfiguration`; `TelemetryHistogram` or `TelemetryCounter` was expected, but `{type(metric)}` was provided.", + metric, + ) + + if isinstance(configuration, dict): + configuration = TelemetryMetricConfiguration(configuration) + + if ( + not isinstance(configuration, TelemetryMetricConfiguration) + and configuration is not None + ): + raise ValueError( + f"Invalid metric configuration type provided in `TelemetryMetricsConfiguration`; `TelemetryMetricConfiguration` or `None` was expected, but `{type(configuration)}` was provided.", + configuration, + ) + + if metric not in self._state: + raise ValueError( + f"Invalid metric provided in `TelemetryMetricsConfiguration`; `{metric.name}` is not a supported metric type for this context.", + metric, + ) + + self._state[metric] = configuration + + self._valid = None + + def getMetrics( + self, filter_enabled: Optional[bool] = True + ) -> dict[ + TelemetryHistogram | TelemetryCounter, TelemetryMetricConfiguration | None + ]: + """ + Returns a list of supported metrics. If `filter_enabled` is `True`, only enabled metrics are returned. + + :param filter_enabled: A boolean indicating whether to filter the list to only include enabled metrics. + + :return: A list of enabled metrics. + """ + + metrics = self._state + + if filter_enabled is True: + return [ + metric + for metric, configuration in metrics.items() + if configuration is not None and configuration.isEnabled() + ] + + return metrics + + def isEnabled( + self, metric: Optional[TelemetryCounter | TelemetryHistogram] = None + ) -> bool: + """ + Check if a metric is enabled for telemetry. + + :return: A boolean indicating whether any metric is enabled. + """ + + # If no metric is specified, check if any metrics are enabled + if metric is None: + return True if any(self.getMetrics(filter_enabled=True)) else False + + # Check if the specified metric is enabled + if metric in self._state: + if ( + self._state[metric] is not None + and self._state[metric].isEnabled() is True + ): + return True + + return False + + def isValid(self, raise_exception: Optional[bool] = False) -> bool: + """ + Validate the telemetry metrics configuration. + + :param raise_exception: A boolean indicating whether to raise an exception if the configuration is invalid. + + :return: A boolean indicating whether the metrics configuration is valid, including all sub-configurations. + """ - @histogram_query_duration.setter - def histogram_query_duration(self, value: TelemetryMetricConfiguration): - self._histogram_query_duration = value + # Check if the validation state is already cached + if self._valid is not None: + return self._valid + + enabled = self.getMetrics(filter_enabled=True) + + # Validate all sub-configurations and cache the result + self._valid = [configuration.isValid() for configuration in enabled.values()] + + # If requested, raise an exception if the configuration is invalid + if self._valid is False and raise_exception is True: + raise ValueError("Invalid TelemetryMetricsConfiguration.") + + # Return the validation state + return self._valid + + @staticmethod + def getSdkDefaults() -> ( + dict[TelemetryHistogram | TelemetryCounter, TelemetryMetricConfiguration | None] + ): + """ + Get the default SDK configuration for telemetry metrics. + + :return: The default SDK configuration for telemetry metrics. + """ + return { + TelemetryCounters.fga_client_credentials_request: TelemetryMetricConfiguration.getSdkDefaults(), + TelemetryHistograms.fga_client_query_duration: TelemetryMetricConfiguration.getSdkDefaults(), + TelemetryHistograms.fga_client_request_duration: TelemetryMetricConfiguration.getSdkDefaults(), + } + + +class TelemetryConfigurationType(NamedTuple): + name: str + configurationClass: object + + +class TelemetryConfigurations: + metrics: TelemetryConfigurationType = TelemetryConfigurationType( + name="metrics", + configurationClass=TelemetryMetricsConfiguration, + ) + + _configurations: list[TelemetryConfigurationType] = [metrics] + + @staticmethod + def get( + name: Optional[str] = None, + ) -> list[TelemetryConfigurationType] | TelemetryConfigurationType | None: + if name is None: + return TelemetryConfigurations._configurations + + for configuration in TelemetryConfigurations._configurations: + if configuration.name == name: + return configuration + + return None class TelemetryConfiguration: - _metrics: TelemetryMetricsConfiguration + _state: dict[str, TelemetryMetricsConfiguration | None] = {} + _valid: bool | None = None - def __init__(self, metrics: Optional[TelemetryMetricsConfiguration] = None): - if metrics is None: - metrics = TelemetryMetricsConfiguration() + def __init__( + self, + config: Optional[dict[str, TelemetryMetricsConfiguration | None]] = None, + metrics: Optional[TelemetryMetricsConfiguration] = None, + ): + """ + Initialize a new instance of the `TelemetryConfiguration` class. + + :param config: A dictionary containing the configuration for telemetry. + :param metrics: Customize which metrics and attributes are included in telemetry collection. + """ + + # Instantiate with default state, and apply the incoming configuration, if one was provided + self.configure(config=config, clear=True) - self._metrics = metrics + if metrics is not None: + self._state[TelemetryConfigurations.metrics] = metrics @property - def metrics(self) -> TelemetryMetricsConfiguration: - return self._metrics + def metrics(self) -> TelemetryMetricsConfiguration | None: + """ + Get the metrics configuration for telemetry. + + :return: The metrics configuration for telemetry. + """ + + return self._state[TelemetryConfigurations.metrics] @metrics.setter - def metrics(self, value: TelemetryMetricsConfiguration): - self._metrics = value + def metrics(self, value: TelemetryMetricsConfiguration | None): + """ + Set the metrics configuration for telemetry. + + :param value: The metrics configuration for telemetry. + """ + + if value is not None and not isinstance(value, TelemetryMetricsConfiguration): + raise ValueError( + "A `metrics` configuration must be an instance of `TelemetryMetricsConfiguration` or `None`." + ) + + self._valid = None + self._state[TelemetryConfigurations.metrics] = value + + def clear(self) -> None: + """ + Reset the configuration to the default state (all attributes disabled). + """ + self._state = { + TelemetryConfigurations.metrics: None, + } + self._valid = True + + def configure( + self, + config: Optional[ + dict[ + TelemetryConfigurationType | str, + TelemetryMetricsConfiguration + | dict[ + TelemetryHistogram | TelemetryCounter, + TelemetryMetricConfiguration + | dict[TelemetryAttribute, bool] + | None, + ] + | None, + ] + ] = None, + clear: Optional[bool] = False, + ) -> None: + """ + Configure telemetry reporting. + """ + if clear is True: + self.clear() + + if isinstance(config, dict): + for context, configuration in config.items(): + if isinstance(context, str): + context = TelemetryConfigurations.get(context) + + if not isinstance(context, TelemetryConfigurationType): + raise ValueError( + f"Invalid context provided in `TelemetryConfiguration`; a valid string or an `TelemetryConfigurationType` instance was expected, but `{type(context)}` was provided.", + context, + ) + + if isinstance(configuration, dict): + configuration = TelemetryMetricsConfiguration(configuration) + + if ( + not isinstance(configuration, context.configurationClass) + and configuration is not None + ): + raise ValueError( + f"Invalid context configuration provided in `TelemetryConfiguration`; a {type(context.configurationClass)} was expected, but `{type(configuration)}` was provided.", + configuration, + ) + + if context not in self._state: + raise ValueError( + f"Invalid context provided in `TelemetryConfiguration`; `{context.name}` is not a supported context type for this configuration context.", + context, + ) + + self._state[context] = configuration + + self._valid = None + + def getConfigurations( + self, filter_enabled: Optional[bool] = True + ) -> dict[TelemetryConfigurationType, TelemetryMetricsConfiguration | None]: + """ + Returns a list of supported reporting contexts. If `filter_enabled` is `True`, only enabled contexts are returned. + + :param filter_enabled: A boolean indicating whether to filter the list to only include enabled contexts. + + :return: A list of enabled contexts. + """ + + contexts = self._state + + if filter_enabled is True: + return { + context: configuration + for context, configuration in contexts.items() + if configuration is not None and configuration.isEnabled() + } + + return contexts + + def isEnabled( + self, configuration: Optional[TelemetryConfigurationType] = None + ) -> bool: + """ + Check if telemetry is enabled. + + :return: A boolean indicating whether telemetry is enabled. + """ + + if configuration is None: + return True if any(self.getConfigurations(filter_enabled=True)) else False + + if configuration in self._state: + if ( + self._state[configuration] is not None + or self._state[configuration].isEnabled() is True + ): + return self._state[configuration].isEnabled() + + return False + + def isValid(self, raise_exception: Optional[bool] = False) -> bool: + """ + Validate the telemetry configuration. + + :param raise_exception: A boolean indicating whether to raise an exception if the configuration is invalid. + + :return: A boolean indicating whether the telemetry configuration is valid, including all sub-configurations. + """ + + if self._valid is not None: + return self._valid + + enabled = self.getConfigurations(filter_enabled=True) + + self._valid = all( + [configuration.isValid() for configuration in enabled.values()] + ) + + if self._valid is False and raise_exception is True: + raise ValueError("Invalid TelemetryConfiguration.") + + return self._valid + + @staticmethod + def getSdkDefaults() -> ( + dict[TelemetryConfigurationType, TelemetryMetricsConfiguration | None] + ): + """ + Get the default SDK configuration for telemetry. + + :return: The default SDK configuration for telemetry. + """ + return { + TelemetryConfigurations.metrics: TelemetryMetricsConfiguration.getSdkDefaults(), + } + + +def isMetricEnabled( + config: TelemetryConfiguration | TelemetryMetricsConfiguration, + metric: TelemetryCounter | TelemetryHistogram, +) -> bool: + """ + Check if a particular metric is enabled for telemetry collection. + """ + + if config is not None and metric is not None: + if isinstance(config, TelemetryConfiguration): + config = config.metrics + + if isinstance(config, TelemetryMetricsConfiguration): + return config.isEnabled(metric) + + return False \ No newline at end of file diff --git a/config/clients/python/template/src/telemetry/counters.py.mustache b/config/clients/python/template/src/telemetry/counters.py.mustache index bd856347..96249c84 100644 --- a/config/clients/python/template/src/telemetry/counters.py.mustache +++ b/config/clients/python/template/src/telemetry/counters.py.mustache @@ -1,15 +1,37 @@ -from typing import NamedTuple +from typing import NamedTuple, Optional class TelemetryCounter(NamedTuple): name: str - unit: str description: str + unit: str = "" class TelemetryCounters: - credentials_request: TelemetryCounter = TelemetryCounter( + fga_client_credentials_request: TelemetryCounter = TelemetryCounter( name="fga-client.credentials.request", - unit="milliseconds", - description="The number of times an access token is requested.", + description="Total number of new token requests initiated using the Client Credentials flow.", ) + + fga_client_request: TelemetryCounter = TelemetryCounter( + name="fga-client.request", + description="Total number of requests made to the FGA server.", + ) + + _counters: list[TelemetryCounter] = [ + fga_client_credentials_request, + fga_client_request, + ] + + @staticmethod + def get( + name: Optional[str] = None, + ) -> list[TelemetryCounter] | TelemetryCounter | None: + if name is None: + return TelemetryCounters._counters + + for counter in TelemetryCounters._counters: + if counter.name == name: + return counter + + return None \ No newline at end of file diff --git a/config/clients/python/template/src/telemetry/histograms.py.mustache b/config/clients/python/template/src/telemetry/histograms.py.mustache index b4539087..7c7c4c97 100644 --- a/config/clients/python/template/src/telemetry/histograms.py.mustache +++ b/config/clients/python/template/src/telemetry/histograms.py.mustache @@ -1,20 +1,36 @@ -from typing import NamedTuple +from typing import NamedTuple, Optional class TelemetryHistogram(NamedTuple): name: str - unit: str description: str + unit: str = "milliseconds" class TelemetryHistograms: - request_duration: TelemetryHistogram = TelemetryHistogram( + fga_client_request_duration: TelemetryHistogram = TelemetryHistogram( name="fga-client.request.duration", - unit="milliseconds", - description="How long it took for a request to be fulfilled.", + description="Total request time for FGA requests, in milliseconds.", ) - query_duration: TelemetryHistogram = TelemetryHistogram( + fga_client_query_duration: TelemetryHistogram = TelemetryHistogram( name="fga-client.query.duration", - unit="milliseconds", - description="How long it took to perform a query request.", + description="Time taken by the FGA server to process and evaluate the request, in milliseconds.", ) + + _histograms: list[TelemetryHistogram] = [ + fga_client_request_duration, + fga_client_query_duration, + ] + + @staticmethod + def get( + name: Optional[str] = None, + ) -> list[TelemetryHistogram] | TelemetryHistogram | None: + if name is None: + return TelemetryHistograms._histograms + + for histogram in TelemetryHistograms._histograms: + if histogram.name == name: + return histogram + + return None \ No newline at end of file diff --git a/config/clients/python/template/src/telemetry/metrics.py.mustache b/config/clients/python/template/src/telemetry/metrics.py.mustache index d5471278..a6e748f9 100644 --- a/config/clients/python/template/src/telemetry/metrics.py.mustache +++ b/config/clients/python/template/src/telemetry/metrics.py.mustache @@ -2,17 +2,19 @@ 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.attributes import ( + TelemetryAttribute, + TelemetryAttributes, +) from {{packageName}}.telemetry.configuration import ( TelemetryConfiguration, - TelemetryMetricsConfiguration, + isMetricEnabled, ) from {{packageName}}.telemetry.counters import TelemetryCounter, TelemetryCounters from {{packageName}}.telemetry.histograms import TelemetryHistogram, TelemetryHistograms -class MetricsTelemetry: +class TelemetryMetrics: _meter: Meter = None _histograms: dict[str, Histogram] = {} _counters: dict[str, Counter] = {} @@ -29,22 +31,11 @@ class MetricsTelemetry: def meter(self) -> Meter: if self._meter is None: - self._meter = get_meter("openfga-sdk", __version__) + self._meter = get_meter("openfga-sdk") return self._meter - def counter( - self, - counter: str | TelemetryCounter, - value: int = None, - attributes: dict[TelemetryAttribute | str, str | int] | None = None, - ) -> Counter: - if isinstance(counter, str): - try: - counter = TelemetryCounters[counter] - except (KeyError, TypeError): - raise KeyError(f"Invalid counter key: {counter}") - + def counter(self, counter: TelemetryCounter) -> Counter: if not isinstance(counter, TelemetryCounter): raise ValueError( "counter must be a TelemetryCounter, or a string that is a key in TelemetryCounters" @@ -55,23 +46,9 @@ class MetricsTelemetry: name=counter.name, unit=counter.unit, description=counter.description ) - if value is not None: - self._counters[counter.name].add(amount=value, attributes=attributes) - return self._counters[counter.name] - def histogram( - self, - histogram: str | TelemetryHistogram, - value: int | float = None, - attributes: dict[TelemetryAttribute | str, str | int] | None = None, - ) -> Histogram: - if isinstance(histogram, str): - try: - histogram = TelemetryHistograms[histogram] - except (KeyError, TypeError): - raise KeyError(f"Invalid histogram key: {histogram}") - + def histogram(self, histogram: TelemetryHistogram) -> Histogram: if not isinstance(histogram, TelemetryHistogram): raise ValueError( "histogram must be a TelemetryHistogram, or a string that is a key in TelemetryHistograms" @@ -84,120 +61,114 @@ class MetricsTelemetry: description=histogram.description, ) - if value is not None: - self._histograms[histogram.name].record(amount=value, attributes=attributes) - return self._histograms[histogram.name] + def request( + self, + value: int = 1, + attributes: dict[TelemetryAttribute, str | int] | None = None, + configuration: TelemetryConfiguration | None = None, + ) -> Counter: + """ + Record a request made by the client. + """ + counter = self.counter(TelemetryCounters.fga_client_request) + + if isMetricEnabled(configuration, TelemetryCounters.fga_client_request): + attributes = TelemetryAttributes.prepare( + attributes, + filter=configuration.metrics.fga_client_request.getAttributes(), + ) + + if value is not None: + counter.add(amount=value, attributes=attributes) + + return counter + def credentialsRequest( self, - value: int | float | None = None, - attributes: dict[TelemetryAttribute | str, str | int] | None = None, + value: int = 1, + attributes: dict[TelemetryAttribute, str | int] | None = None, configuration: TelemetryConfiguration | None = None, ) -> Counter: - if configuration is None: - configuration = TelemetryConfiguration() + """ + Record a client credentials request made by the client. + """ + counter = self.counter(TelemetryCounters.fga_client_credentials_request) - if ( - isinstance(configuration, TelemetryMetricsConfiguration) is False - or configuration.metrics.counter_credentials_request.enabled is False - or configuration.metrics.counter_credentials_request.attributes() == {} + if isMetricEnabled( + configuration, TelemetryCounters.fga_client_credentials_request ): - return self.counter(TelemetryCounters.credentials_request) + attributes = TelemetryAttributes.prepare( + attributes, + filter=configuration.metrics.fga_client_credentials_request.getAttributes(), + ) - attributes = TelemetryAttributes().prepare( - attributes, - filter=configuration.metrics.counter_credentials_request.attributes(), - ) + if value is not None: + counter.add(amount=value, attributes=attributes) - return self.counter(TelemetryCounters.credentials_request, value, attributes) + return counter def requestDuration( self, value: int | float | None = None, - attributes: dict[TelemetryAttribute | str, str | int] | None = None, + attributes: dict[TelemetryAttribute, str | int] | None = None, configuration: TelemetryConfiguration | None = None, ) -> Histogram: - if configuration is None: - configuration = TelemetryConfiguration() + """ + Record the duration of a request made by the client. + """ + histogram = self.histogram(TelemetryHistograms.fga_client_request_duration) - if ( - isinstance(configuration, TelemetryConfiguration) is False - or configuration.metrics.histogram_request_duration.enabled is False - or configuration.metrics.histogram_request_duration.attributes() == {} + if isMetricEnabled( + configuration, TelemetryHistograms.fga_client_request_duration ): - 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 + attributes[TelemetryAttributes.http_client_request_duration] = value = ( + TelemetryAttributes.coalesceAttributeValue( + TelemetryAttributes.http_client_request_duration, + value, + attributes, ) - 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] + attributes = TelemetryAttributes.prepare( + attributes, + filter=configuration.metrics.fga_client_request_duration.getAttributes(), + ) - if value is None: - return self.histogram(TelemetryHistograms.request_duration) + if value is not None: + histogram.record(amount=value, attributes=attributes) - return self.histogram(TelemetryHistograms.request_duration, value, attributes) + return histogram def queryDuration( self, value: int | float | None = None, - attributes: dict[TelemetryAttribute | str, str | int] | None = None, + attributes: dict[TelemetryAttribute, str | int] | None = None, configuration: TelemetryConfiguration | None = None, ) -> Histogram: - if configuration is None: - configuration = TelemetryConfiguration() + """ + Record the duration of a query made by the client, as reported by the server. + """ + histogram = self.histogram(TelemetryHistograms.fga_client_query_duration) - if ( - isinstance(configuration, TelemetryConfiguration) is False - or configuration.metrics.histogram_query_duration.enabled is False - or configuration.metrics.histogram_query_duration.attributes() == {} + if isMetricEnabled( + configuration, TelemetryHistograms.fga_client_query_duration ): - 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 + attributes[TelemetryAttributes.http_server_request_duration] = value = ( + TelemetryAttributes.coalesceAttributeValue( + TelemetryAttributes.http_server_request_duration, + value, + attributes, ) - except ValueError: - value = None + ) - attributes = TelemetryAttributes().prepare( - attributes, - filter=configuration.metrics.histogram_query_duration.attributes(), - ) + attributes = TelemetryAttributes.prepare( + attributes, + filter=configuration.metrics.fga_client_query_duration.getAttributes(), + ) - if value is None: - return self.histogram(TelemetryHistograms.query_duration) + if value is not None: + histogram.record(amount=value, attributes=attributes) - return self.histogram(TelemetryHistograms.query_duration, value, attributes) + return histogram \ No newline at end of file diff --git a/config/clients/python/template/src/telemetry/telemetry.py.mustache b/config/clients/python/template/src/telemetry/telemetry.py.mustache index 4de41703..25d78cfb 100644 --- a/config/clients/python/template/src/telemetry/telemetry.py.mustache +++ b/config/clients/python/template/src/telemetry/telemetry.py.mustache @@ -1,11 +1,12 @@ -from {{packageName}}.telemetry.metrics import MetricsTelemetry +from {{packageName}}.telemetry.metrics import TelemetryMetrics class Telemetry: - _metrics: MetricsTelemetry = None + _metrics: TelemetryMetrics | None = None - def metrics(self) -> MetricsTelemetry: + @property + def metrics(self) -> TelemetryMetrics: if self._metrics is None: - self._metrics = MetricsTelemetry() + self._metrics = TelemetryMetrics() return self._metrics diff --git a/config/clients/python/template/src/telemetry/utilities.py.mustache b/config/clients/python/template/src/telemetry/utilities.py.mustache new file mode 100644 index 00000000..de52ec7c --- /dev/null +++ b/config/clients/python/template/src/telemetry/utilities.py.mustache @@ -0,0 +1,7 @@ +def doesInstanceHaveCallable(instance: object, callableName: str) -> bool: + instanceCallable = getattr(instance, callableName, None) + + if instanceCallable is None: + return False + + return callable(instanceCallable) \ No newline at end of file diff --git a/config/clients/python/template/test/client/client_test.py.mustache b/config/clients/python/template/test/client/client_test.py.mustache index dca9522c..eaf0d726 100644 --- a/config/clients/python/template/test/client/client_test.py.mustache +++ b/config/clients/python/template/test/client/client_test.py.mustache @@ -2027,8 +2027,6 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): ) mock_request.assert_called() - self.assertEqual(mock_request.call_count, 3) - {{#asyncio}}await {{/asyncio}}api_client.close() diff --git a/config/clients/python/template/test/sync/client/client_test.py.mustache b/config/clients/python/template/test/sync/client/client_test.py.mustache index cbd2a311..e21f683b 100644 --- a/config/clients/python/template/test/sync/client/client_test.py.mustache +++ b/config/clients/python/template/test/sync/client/client_test.py.mustache @@ -2039,7 +2039,6 @@ class TestOpenFgaClient(IsolatedAsyncioTestCase): self.assertIsInstance(api_exception.exception, UnauthorizedException) mock_request.assert_called() - self.assertEqual(mock_request.call_count, 3) api_client.close() diff --git a/config/clients/python/template/test/telemetry/attributes_test.py.mustache b/config/clients/python/template/test/telemetry/attributes_test.py.mustache index 7d494064..95a429d1 100644 --- a/config/clients/python/template/test/telemetry/attributes_test.py.mustache +++ b/config/clients/python/template/test/telemetry/attributes_test.py.mustache @@ -6,7 +6,9 @@ from urllib3 import HTTPResponse from {{packageName}}.credentials import CredentialConfiguration, Credentials from {{packageName}}.rest import RESTResponse -from {{packageName}}.telemetry.attributes import TelemetryAttributes +from {{packageName}}.telemetry.attributes import ( + TelemetryAttributes, +) @pytest.fixture @@ -67,15 +69,16 @@ def test_from_request_with_all_params(telemetry_attributes): 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" + assert attributes[TelemetryAttributes.fga_client_request_method] == "FgaMethod" + assert attributes[TelemetryAttributes.user_agent_original] == "TestAgent" + assert attributes[TelemetryAttributes.http_host] == "example.com" + assert attributes[TelemetryAttributes.http_request_method] == "POST" + assert attributes[TelemetryAttributes.url_scheme] == "https" + assert attributes[TelemetryAttributes.url_full] == "https://example.com/api" + + assert TelemetryAttributes.http_client_request_duration in attributes + assert attributes[TelemetryAttributes.http_request_resend_count] == 2 + assert attributes[TelemetryAttributes.fga_client_request_client_id] == "client_123" def test_from_request_without_optional_params(telemetry_attributes): @@ -86,15 +89,16 @@ def test_from_request_without_optional_params(telemetry_attributes): 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 + assert attributes[TelemetryAttributes.fga_client_request_method] == "FgaMethod" + assert attributes[TelemetryAttributes.user_agent_original] == "MinimalAgent" + assert attributes[TelemetryAttributes.http_host] == "minimal.com" + assert attributes[TelemetryAttributes.http_request_method] == "GET" + assert attributes[TelemetryAttributes.url_scheme] == "http" + assert attributes[TelemetryAttributes.url_full] == "http://minimal.com" + + assert TelemetryAttributes.http_client_request_duration not in attributes + assert TelemetryAttributes.http_request_resend_count not in attributes + assert TelemetryAttributes.fga_client_request_client_id not in attributes def test_from_response_with_http_response(telemetry_attributes): @@ -113,10 +117,10 @@ def test_from_response_with_http_response(telemetry_attributes): 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" + assert attributes[TelemetryAttributes.http_response_status_code] == 200 + assert attributes[TelemetryAttributes.fga_client_response_model_id] == "model_123" + assert attributes[TelemetryAttributes.http_server_request_duration] == "50" + assert attributes[TelemetryAttributes.fga_client_request_client_id] == "client_123" def test_from_response_with_rest_response(telemetry_attributes): @@ -137,30 +141,7 @@ def test_from_response_with_rest_response(telemetry_attributes): 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") + assert attributes[TelemetryAttributes.http_response_status_code] == 404 + assert attributes[TelemetryAttributes.fga_client_response_model_id] == "model_404" + assert attributes[TelemetryAttributes.http_server_request_duration] == "100" + assert attributes[TelemetryAttributes.fga_client_request_client_id] == "client_456" \ No newline at end of file diff --git a/config/clients/python/template/test/telemetry/configuration_test.py.mustache b/config/clients/python/template/test/telemetry/configuration_test.py.mustache index 34da3605..412a221a 100644 --- a/config/clients/python/template/test/telemetry/configuration_test.py.mustache +++ b/config/clients/python/template/test/telemetry/configuration_test.py.mustache @@ -1,108 +1,197 @@ from {{packageName}}.telemetry.attributes import TelemetryAttributes from {{packageName}}.telemetry.configuration import ( TelemetryConfiguration, + TelemetryConfigurations, TelemetryMetricConfiguration, TelemetryMetricsConfiguration, ) +from {{packageName}}.telemetry.counters import TelemetryCounters +from {{packageName}}.telemetry.histograms import TelemetryHistograms 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 + assert config.fga_client_request_client_id is False + assert config.fga_client_request_method is False + assert config.fga_client_request_model_id is False + assert config.fga_client_request_store_id is False + assert config.fga_client_response_model_id is False + assert config.fga_client_user is False + assert config.http_client_request_duration is False + assert config.http_host is False + assert config.http_request_method is False + assert config.http_request_resend_count is False + assert config.http_response_status_code is False + assert config.http_server_request_duration is False + assert config.url_scheme is False + assert config.url_full is False + assert config.user_agent_original is False def test_telemetry_metric_configuration_custom_initialization(): config = TelemetryMetricConfiguration( - enabled=False, - attr_fga_client_request_client_id=False, - attr_http_request_method=False, + fga_client_request_client_id=False, + http_request_method=True, ) - # 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 + assert config.fga_client_request_client_id is False + assert config.http_request_method is True + assert config.fga_client_request_method is False + assert config.user_agent_original is False -def test_telemetry_metric_configuration_attributes_method(): +def test_telemetry_metric_configuration_custom_dict_object_keys_initialization(): config = TelemetryMetricConfiguration( - enabled=True, - attr_fga_client_request_client_id=True, - attr_http_request_method=False, + { + "fga-client.request.client_id": False, + "http.request.method": True, + } ) - enabled_attributes = config.attributes() + assert config.fga_client_request_client_id is False + assert config.http_request_method is True + assert config.fga_client_request_method is False + assert config.user_agent_original is False + assert config.fga_client_response_model_id is False - # Check only enabled attributes are returned - assert ( - enabled_attributes[TelemetryAttributes.fga_client_request_client_id.name] - is True + +def test_telemetry_metric_configuration_custom_dict_string_keys_initialization(): + config = TelemetryMetricConfiguration( + { + TelemetryAttributes.fga_client_request_client_id: False, + TelemetryAttributes.http_request_method: True, + } + ) + + assert config.fga_client_request_client_id is False + assert config.http_request_method is True + assert config.fga_client_request_method is False + assert config.user_agent_original is False + assert config.fga_client_response_model_id is False + + +def test_telemetry_metric_configuration_custom_mixed_initialization(): + config = TelemetryMetricConfiguration( + { + TelemetryAttributes.fga_client_request_client_id: False, + "http.request.method": True, + }, + fga_client_response_model_id=True, + ) + + assert config.fga_client_request_client_id is False + assert config.http_request_method is True + assert config.fga_client_request_method is False + assert config.user_agent_original is False + assert config.fga_client_response_model_id is True + + +def test_telemetry_metric_configuration_custom_overwritten_initialization(): + config = TelemetryMetricConfiguration( + { + TelemetryAttributes.fga_client_request_client_id: True, + }, + fga_client_request_client_id=False, ) - assert TelemetryAttributes.http_request_method.name not in enabled_attributes + + assert config.fga_client_request_client_id is False + + +def test_telemetry_metric_configuration_attributes_method(): + config = TelemetryMetricConfiguration( + fga_client_request_client_id=True, + http_request_method=False, + ) + + enabled_attributes = config.getAttributes() + + assert len(enabled_attributes) == 1 + assert TelemetryAttributes.fga_client_request_client_id in enabled_attributes + assert TelemetryAttributes.http_request_method 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 metrics_config.fga_client_credentials_request is None + assert metrics_config.fga_client_query_duration is None + assert metrics_config.fga_client_request_duration is None + + +def test_telemetry_metrics_configuration_custom_initialization(): + custom_config = TelemetryMetricConfiguration() + metrics_config = TelemetryMetricsConfiguration( + fga_client_credentials_request=custom_config ) - assert isinstance( - metrics_config.histogram_request_duration, TelemetryMetricConfiguration + + assert metrics_config.fga_client_credentials_request is custom_config + assert metrics_config.fga_client_request_duration is None + assert metrics_config.fga_client_query_duration is None + + +def test_telemetry_metrics_configuration_custom_dict_object_keys_initialization(): + custom_config = TelemetryMetricConfiguration() + metrics_config = TelemetryMetricsConfiguration( + {TelemetryHistograms.fga_client_query_duration: custom_config} ) - assert isinstance( - metrics_config.histogram_query_duration, TelemetryMetricConfiguration + + assert metrics_config.fga_client_credentials_request is None + assert metrics_config.fga_client_request_duration is None + assert metrics_config.fga_client_query_duration is custom_config + + +def test_telemetry_metrics_configuration_custom_dict_string_keys_initialization(): + custom_config = TelemetryMetricConfiguration() + metrics_config = TelemetryMetricsConfiguration( + {"fga-client.request.duration": custom_config} ) + assert metrics_config.fga_client_credentials_request is None + assert metrics_config.fga_client_request_duration is custom_config + assert metrics_config.fga_client_query_duration is None -def test_telemetry_metrics_configuration_custom_initialization(): - custom_config = TelemetryMetricConfiguration(enabled=False) + +def test_telemetry_metrics_configuration_custom_mixed_initialization(): + custom_config = TelemetryMetricConfiguration() + metrics_config = TelemetryMetricsConfiguration( + { + TelemetryHistograms.fga_client_query_duration: custom_config, + "fga-client.request.duration": custom_config, + }, + fga_client_credentials_request=custom_config, + ) + + assert metrics_config.fga_client_credentials_request is custom_config + assert metrics_config.fga_client_request_duration is custom_config + assert metrics_config.fga_client_query_duration is custom_config + + +def test_telemetry_metrics_configuration_custom_overwritten_initialization(): + custom_config = TelemetryMetricConfiguration() metrics_config = TelemetryMetricsConfiguration( - counter_credentials_request=custom_config + { + TelemetryHistograms.fga_client_query_duration: None, + }, + fga_client_query_duration=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 + assert metrics_config.fga_client_query_duration is custom_config def test_telemetry_metrics_configuration_setters(): metrics_config = TelemetryMetricsConfiguration() - new_config = TelemetryMetricConfiguration(enabled=False) - metrics_config.counter_credentials_request = new_config + new_config = TelemetryMetricConfiguration() + metrics_config.fga_client_credentials_request = new_config - assert metrics_config.counter_credentials_request is new_config + assert metrics_config.fga_client_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) + assert telemetry_config.metrics is None def test_telemetry_configuration_custom_initialization(): @@ -112,9 +201,111 @@ def test_telemetry_configuration_custom_initialization(): assert telemetry_config.metrics is custom_metrics +def test_telemetry_configuration_custom_dict_object_keys_initialization(): + custom_metrics = TelemetryMetricsConfiguration() + telemetry_config = TelemetryConfiguration( + {TelemetryConfigurations.metrics: custom_metrics} + ) + + assert telemetry_config.metrics is custom_metrics + + +def test_telemetry_configuration_custom_dict_string_keys_initialization(): + custom_metrics = TelemetryMetricsConfiguration() + telemetry_config = TelemetryConfiguration({"metrics": custom_metrics}) + + assert telemetry_config.metrics is custom_metrics + + +def test_telemetry_configuration_custom_overwritten_initialization(): + custom_metrics = TelemetryMetricsConfiguration() + telemetry_config = TelemetryConfiguration( + {TelemetryConfigurations.metrics: None}, metrics=custom_metrics + ) + + assert telemetry_config.metrics is custom_metrics + + +def test_telemetry_configuration_custom_full_initialization(): + telemetry_config = TelemetryConfiguration( + { + "metrics": { + "fga-client.credentials.request": { + "fga-client.request.client_id": False, + "http.request.method": True, + }, + "fga-client.request.duration": { + "fga-client.request.client_id": True, + "http.request.method": False, + }, + } + } + ) + + assert ( + telemetry_config.metrics.fga_client_credentials_request.fga_client_request_client_id + is False + ) + assert ( + telemetry_config.metrics.fga_client_credentials_request.http_request_method + is True + ) + assert ( + telemetry_config.metrics.fga_client_request_duration.fga_client_request_client_id + is True + ) + assert ( + telemetry_config.metrics.fga_client_request_duration.http_request_method + is False + ) + + def test_telemetry_configuration_setter(): telemetry_config = TelemetryConfiguration() new_metrics = TelemetryMetricsConfiguration() telemetry_config.metrics = new_metrics assert telemetry_config.metrics is new_metrics + + +def test_default_telemetry_configuration(): + config = TelemetryConfiguration.getSdkDefaults() + + assert isinstance(config, dict) + assert len(config) == 1 + + assert TelemetryConfigurations.metrics in config + + +def test_default_telemetry_metrics_configuration(): + metrics_config = TelemetryMetricsConfiguration.getSdkDefaults() + + assert isinstance(metrics_config, dict) + assert len(metrics_config) == 3 + + assert TelemetryCounters.fga_client_credentials_request in metrics_config + assert TelemetryHistograms.fga_client_query_duration in metrics_config + assert TelemetryHistograms.fga_client_request_duration in metrics_config + + +def test_default_telemetry_metric_configuration(): + metric_config = TelemetryMetricConfiguration.getSdkDefaults() + + assert isinstance(metric_config, dict) + assert len(metric_config) == 15 + + assert metric_config[TelemetryAttributes.fga_client_request_client_id] is True + assert metric_config[TelemetryAttributes.fga_client_request_method] is True + assert metric_config[TelemetryAttributes.fga_client_request_model_id] is True + assert metric_config[TelemetryAttributes.fga_client_request_store_id] is True + assert metric_config[TelemetryAttributes.fga_client_response_model_id] is True + assert metric_config[TelemetryAttributes.fga_client_user] is False + assert metric_config[TelemetryAttributes.http_client_request_duration] is False + assert metric_config[TelemetryAttributes.http_host] is True + assert metric_config[TelemetryAttributes.http_request_method] is True + assert metric_config[TelemetryAttributes.http_request_resend_count] is True + assert metric_config[TelemetryAttributes.http_response_status_code] is True + assert metric_config[TelemetryAttributes.http_server_request_duration] is False + assert metric_config[TelemetryAttributes.url_scheme] is True + assert metric_config[TelemetryAttributes.url_full] is True + assert metric_config[TelemetryAttributes.user_agent_original] is True \ No newline at end of file diff --git a/config/clients/python/template/test/telemetry/counters_test.py.mustache b/config/clients/python/template/test/telemetry/counters_test.py.mustache index ba53563e..1dff0f33 100644 --- a/config/clients/python/template/test/telemetry/counters_test.py.mustache +++ b/config/clients/python/template/test/telemetry/counters_test.py.mustache @@ -4,23 +4,22 @@ from {{packageName}}.telemetry.counters import TelemetryCounter, TelemetryCounte 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." + counters.fga_client_credentials_request.name == "fga-client.credentials.request" + ) + assert ( + counters.fga_client_credentials_request.description + == "Total number of new token requests initiated using the Client Credentials flow." ) @@ -33,4 +32,4 @@ def test_telemetry_counters_custom_counter(): assert custom_counter.name == "fga-client.custom.counter" assert custom_counter.unit == "operations" - assert custom_counter.description == "A custom counter for specific operations." + assert custom_counter.description == "A custom counter for specific operations." \ No newline at end of file diff --git a/config/clients/python/template/test/telemetry/histograms_test.py.mustache b/config/clients/python/template/test/telemetry/histograms_test.py.mustache index 42a98f2e..df700568 100644 --- a/config/clients/python/template/test/telemetry/histograms_test.py.mustache +++ b/config/clients/python/template/test/telemetry/histograms_test.py.mustache @@ -16,18 +16,18 @@ def test_telemetry_histogram_initialization(): 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.fga_client_request_duration.name == "fga-client.request.duration" + assert histograms.fga_client_request_duration.unit == "milliseconds" assert ( - histograms.request_duration.description - == "How long it took for a request to be fulfilled." + histograms.fga_client_request_duration.description + == "Total request time for FGA requests, in milliseconds." ) - assert histograms.query_duration.name == "fga-client.query.duration" - assert histograms.query_duration.unit == "milliseconds" + assert histograms.fga_client_query_duration.name == "fga-client.query.duration" + assert histograms.fga_client_query_duration.unit == "milliseconds" assert ( - histograms.query_duration.description - == "How long it took to perform a query request." + histograms.fga_client_query_duration.description + == "Time taken by the FGA server to process and evaluate the request, in milliseconds." ) @@ -40,4 +40,4 @@ def test_telemetry_histograms_custom_histogram(): assert custom_histogram.name == "fga-client.custom.histogram" assert custom_histogram.unit == "operations" - assert custom_histogram.description == "A custom histogram for specific operations." + assert custom_histogram.description == "A custom histogram for specific operations." \ No newline at end of file diff --git a/config/clients/python/template/test/telemetry/metrics_test.py.mustache b/config/clients/python/template/test/telemetry/metrics_test.py.mustache index f91c3598..aa4d2b24 100644 --- a/config/clients/python/template/test/telemetry/metrics_test.py.mustache +++ b/config/clients/python/template/test/telemetry/metrics_test.py.mustache @@ -3,19 +3,18 @@ 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 +from {{packageName}}.telemetry.metrics import TelemetryMetrics -@patch("openfga_sdk.telemetry.metrics.get_meter") +@patch("{{packageName}}.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() + telemetry = TelemetryMetrics() # Ensure _meter is initially None assert telemetry._meter is None @@ -23,7 +22,7 @@ def test_meter_lazy_initialization(mock_get_meter): # 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__) + mock_get_meter.assert_called_once_with("openfga-sdk") # Access the meter property again, no new instance should be created meter_again = telemetry.meter() @@ -31,71 +30,63 @@ def test_meter_lazy_initialization(mock_get_meter): mock_get_meter.assert_called_once() -@patch("openfga_sdk.telemetry.metrics.get_meter") -def test_counter_creation_and_add(mock_get_meter): +@patch("{{packageName}}.telemetry.metrics.get_meter") +def test_counter_creation(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() + telemetry = TelemetryMetrics() attributes = { TelemetryAttributes.fga_client_request_model_id.name: "model_123", "custom_attribute": "custom_value", } - counter = telemetry.counter( - TelemetryCounters.credentials_request, value=5, attributes=attributes - ) + counter = telemetry.counter(TelemetryCounters.fga_client_credentials_request) 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, + name=TelemetryCounters.fga_client_credentials_request.name, + unit=TelemetryCounters.fga_client_credentials_request.unit, + description=TelemetryCounters.fga_client_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): +@patch("{{packageName}}.telemetry.metrics.get_meter") +def test_histogram_creation(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() + telemetry = TelemetryMetrics() 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 - ) + histogram = telemetry.histogram(TelemetryHistograms.fga_client_request_duration) 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, + name=TelemetryHistograms.fga_client_request_duration.name, + unit=TelemetryHistograms.fga_client_request_duration.unit, + description=TelemetryHistograms.fga_client_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 = TelemetryMetrics() + with pytest.raises(ValueError): telemetry.counter("invalid_counter_key") def test_invalid_histogram_key(): - telemetry = MetricsTelemetry() - with pytest.raises(KeyError): - telemetry.histogram("invalid_histogram_key") + telemetry = TelemetryMetrics() + with pytest.raises(ValueError): + telemetry.histogram("invalid_histogram_key") \ No newline at end of file diff --git a/config/clients/python/template/test/telemetry/telemetry_test.py.mustache b/config/clients/python/template/test/telemetry/telemetry_test.py.mustache index f2cd729b..3a3122a1 100644 --- a/config/clients/python/template/test/telemetry/telemetry_test.py.mustache +++ b/config/clients/python/template/test/telemetry/telemetry_test.py.mustache @@ -1,15 +1,15 @@ from unittest.mock import patch from {{packageName}}.telemetry.metrics import ( - MetricsTelemetry, + TelemetryMetrics, ) def test_metrics_lazy_initialization(): with patch( - "openfga_sdk.telemetry.telemetry.MetricsTelemetry" + "{{packageName}}.telemetry.telemetry.TelemetryMetrics" ) as mock_metrics_telemetry: - from openfga_sdk.telemetry import Telemetry # Import inside the patch context + from {{packageName}}.telemetry import Telemetry # Import inside the patch context telemetry = Telemetry() @@ -17,29 +17,29 @@ def test_metrics_lazy_initialization(): assert telemetry._metrics is None # Access the metrics property, which should trigger lazy initialization - metrics = telemetry.metrics() + metrics = telemetry.metrics - # Verify that a MetricsTelemetry object was created and returned + # Verify that a TelemetryMetrics 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() + 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 + from {{packageName}}.telemetry import Telemetry # Import the Telemetry class directly telemetry = Telemetry() # Access the metrics property, which should trigger lazy initialization - metrics = telemetry.metrics() + metrics = telemetry.metrics - # Verify that a real MetricsTelemetry object was created and returned - assert isinstance(metrics, MetricsTelemetry) + # Verify that a real TelemetryMetrics object was created and returned + assert isinstance(metrics, TelemetryMetrics) # Access the metrics property again, no new instance should be created - metrics_again = telemetry.metrics() - assert metrics_again == metrics + metrics_again = telemetry.metrics + assert metrics_again == metrics \ No newline at end of file diff --git a/config/clients/python/template/test/telemetry/utilities_test.py.mustache b/config/clients/python/template/test/telemetry/utilities_test.py.mustache new file mode 100644 index 00000000..7a3a23df --- /dev/null +++ b/config/clients/python/template/test/telemetry/utilities_test.py.mustache @@ -0,0 +1,15 @@ +from mock import MagicMock + +from {{packageName}}.telemetry.utilities import doesInstanceHaveCallable + + +def test_instance_has_callable(): + mock_instance = MagicMock(spec_set=["some_callable", "some_attribute"]) + mock_instance.some_callable = lambda: "I am callable" + + assert doesInstanceHaveCallable(mock_instance, "some_callable") + + assert not doesInstanceHaveCallable(mock_instance, "missing_callable") + + mock_instance.some_attribute = "not callable" + assert not doesInstanceHaveCallable(mock_instance, "some_attribute")