diff --git a/api.ts b/api.ts index 381ab64..189841a 100644 --- a/api.ts +++ b/api.ts @@ -24,7 +24,6 @@ import { CallResult, PromiseResult } from "./common"; -import { attributeNames } from "./telemetry"; import { Configuration } from "./configuration"; import { Credentials } from "./credentials"; import { assertParamExists } from "./validation"; @@ -110,6 +109,7 @@ import { WriteRequestDeletes, WriteRequestWrites, } from "./apiModel"; +import { TelemetryAttributes } from "./telemetry/attributes"; /** @@ -759,10 +759,10 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: async check(storeId: string, body: CheckRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { const localVarAxiosArgs = localVarAxiosParamCreator.check(storeId, body, options); return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { - [attributeNames.requestMethod]: "check", - [attributeNames.requestStoreId]: storeId, - [attributeNames.requestModelId]: body.authorization_model_id, - [attributeNames.user]: body.tuple_key.user + [TelemetryAttributes.fgaClientRequestMethod.name]: "Check", + [TelemetryAttributes.fgaClientRequestStoreId.name]: storeId ?? "", + [TelemetryAttributes.fgaClientRequestModelId.name]: body.authorization_model_id ?? "", + [TelemetryAttributes.fgaClientUser.name]: body.tuple_key.user }); }, /** @@ -775,7 +775,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: async createStore(body: CreateStoreRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { const localVarAxiosArgs = localVarAxiosParamCreator.createStore(body, options); return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { - [attributeNames.requestMethod]: "createStore", + [TelemetryAttributes.fgaClientRequestMethod.name]: "createStore", }); }, /** @@ -788,8 +788,8 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: async deleteStore(storeId: string, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { const localVarAxiosArgs = localVarAxiosParamCreator.deleteStore(storeId, options); return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { - [attributeNames.requestMethod]: "deleteStore", - [attributeNames.requestStoreId]: storeId, + [TelemetryAttributes.fgaClientRequestMethod.name]: "deleteStore", + [TelemetryAttributes.fgaClientRequestStoreId.name]: storeId, }); }, /** @@ -803,9 +803,9 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: async expand(storeId: string, body: ExpandRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { const localVarAxiosArgs = localVarAxiosParamCreator.expand(storeId, body, options); return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { - [attributeNames.requestMethod]: "expand", - [attributeNames.requestModelId]: body.authorization_model_id, - [attributeNames.requestStoreId]: storeId, + [TelemetryAttributes.fgaClientRequestMethod.name]: "expand", + [TelemetryAttributes.fgaClientRequestModelId.name]: body.authorization_model_id ?? "", + [TelemetryAttributes.fgaClientRequestStoreId.name]: storeId ?? "", }); }, /** @@ -818,8 +818,8 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: async getStore(storeId: string, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { const localVarAxiosArgs = localVarAxiosParamCreator.getStore(storeId, options); return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { - [attributeNames.requestMethod]: "getStore", - [attributeNames.requestStoreId]: storeId, + [TelemetryAttributes.fgaClientRequestMethod.name]: "getStore", + [TelemetryAttributes.fgaClientRequestStoreId.name]: storeId, }); }, /** @@ -833,14 +833,14 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: async listObjects(storeId: string, body: ListObjectsRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { const localVarAxiosArgs = localVarAxiosParamCreator.listObjects(storeId, body, options); return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { - [attributeNames.requestMethod]: "listObjects", - [attributeNames.requestStoreId]: storeId, - [attributeNames.requestModelId]: body.authorization_model_id, - [attributeNames.user]: body.user + [TelemetryAttributes.fgaClientRequestMethod.name]: "listObjects", + [TelemetryAttributes.fgaClientRequestStoreId.name]: storeId ?? "", + [TelemetryAttributes.fgaClientRequestModelId.name]: body.authorization_model_id ?? "", + [TelemetryAttributes.fgaClientUser.name]: body.user }); }, /** - * Returns a paginated list of OpenFGA stores and a continuation token to get additional stores. The continuation token will be empty if there are no more stores. + * Returns a paginated list of OpenFGA stores and a continuation token to get additional stores. The continuation token will be empty if there are no more stores. * @summary List all stores * @param {number} [pageSize] * @param {string} [continuationToken] @@ -850,7 +850,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: async listStores(pageSize?: number, continuationToken?: string, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { const localVarAxiosArgs = localVarAxiosParamCreator.listStores(pageSize, continuationToken, options); return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { - [attributeNames.requestMethod]: "listStores", + [TelemetryAttributes.fgaClientRequestMethod.name]: "listStores", }); }, /** @@ -864,9 +864,9 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: async listUsers(storeId: string, body: ListUsersRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { const localVarAxiosArgs = localVarAxiosParamCreator.listUsers(storeId, body, options); return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { - [attributeNames.requestMethod]: "listUsers", - [attributeNames.requestStoreId]: storeId, - [attributeNames.requestModelId]: body.authorization_model_id, + [TelemetryAttributes.fgaClientRequestMethod.name]: "listUsers", + [TelemetryAttributes.fgaClientRequestStoreId.name]: storeId ?? "", + [TelemetryAttributes.fgaClientRequestModelId.name]: body.authorization_model_id ?? "", }); }, /** @@ -880,12 +880,12 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: async read(storeId: string, body: ReadRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { const localVarAxiosArgs = localVarAxiosParamCreator.read(storeId, body, options); return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { - [attributeNames.requestMethod]: "read", - [attributeNames.requestStoreId]: storeId, + [TelemetryAttributes.fgaClientRequestMethod.name]: "read", + [TelemetryAttributes.fgaClientRequestStoreId.name]: storeId, }); }, /** - * The ReadAssertions API will return, for a given authorization model id, all the assertions stored for it. An assertion is an object that contains a tuple key, and the expectation of whether a call to the Check API of that tuple key will return true or false. + * The ReadAssertions API will return, for a given authorization model id, all the assertions stored for it. An assertion is an object that contains a tuple key, and the expectation of whether a call to the Check API of that tuple key will return true or false. * @summary Read assertions for an authorization model ID * @param {string} storeId * @param {string} authorizationModelId @@ -895,9 +895,9 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: async readAssertions(storeId: string, authorizationModelId: string, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { const localVarAxiosArgs = localVarAxiosParamCreator.readAssertions(storeId, authorizationModelId, options); return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { - [attributeNames.requestMethod]: "readAssertions", - [attributeNames.requestStoreId]: storeId, - [attributeNames.requestModelId]: authorizationModelId, + [TelemetryAttributes.fgaClientRequestMethod.name]: "readAssertions", + [TelemetryAttributes.fgaClientRequestStoreId.name]: storeId, + [TelemetryAttributes.fgaClientRequestModelId.name]: authorizationModelId, }); }, /** @@ -911,8 +911,8 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: async readAuthorizationModel(storeId: string, id: string, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { const localVarAxiosArgs = localVarAxiosParamCreator.readAuthorizationModel(storeId, id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { - [attributeNames.requestMethod]: "readAuthorizationModel", - [attributeNames.requestStoreId]: storeId, + [TelemetryAttributes.fgaClientRequestMethod.name]: "readAuthorizationModel", + [TelemetryAttributes.fgaClientRequestStoreId.name]: storeId, }); }, /** @@ -927,8 +927,8 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: async readAuthorizationModels(storeId: string, pageSize?: number, continuationToken?: string, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { const localVarAxiosArgs = localVarAxiosParamCreator.readAuthorizationModels(storeId, pageSize, continuationToken, options); return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { - [attributeNames.requestMethod]: "readAuthorizationModels", - [attributeNames.requestStoreId]: storeId, + [TelemetryAttributes.fgaClientRequestMethod.name]: "readAuthorizationModels", + [TelemetryAttributes.fgaClientRequestStoreId.name]: storeId, }); }, /** @@ -944,8 +944,8 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: async readChanges(storeId: string, type?: string, pageSize?: number, continuationToken?: string, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { const localVarAxiosArgs = localVarAxiosParamCreator.readChanges(storeId, type, pageSize, continuationToken, options); return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { - [attributeNames.requestMethod]: "readChanges", - [attributeNames.requestStoreId]: storeId, + [TelemetryAttributes.fgaClientRequestMethod.name]: "readChanges", + [TelemetryAttributes.fgaClientRequestStoreId.name]: storeId, }); }, /** @@ -959,9 +959,9 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: async write(storeId: string, body: WriteRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { const localVarAxiosArgs = localVarAxiosParamCreator.write(storeId, body, options); return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { - [attributeNames.requestMethod]: "write", - [attributeNames.requestStoreId]: storeId, - [attributeNames.requestModelId]: body.authorization_model_id, + [TelemetryAttributes.fgaClientRequestMethod.name]: "write", + [TelemetryAttributes.fgaClientRequestStoreId.name]: storeId ?? "", + [TelemetryAttributes.fgaClientRequestModelId.name]: body.authorization_model_id ?? "", }); }, /** @@ -976,9 +976,9 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: async writeAssertions(storeId: string, authorizationModelId: string, body: WriteAssertionsRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { const localVarAxiosArgs = localVarAxiosParamCreator.writeAssertions(storeId, authorizationModelId, body, options); return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { - [attributeNames.requestMethod]: "writeAssertions", - [attributeNames.requestStoreId]: storeId, - [attributeNames.requestModelId]: authorizationModelId, + [TelemetryAttributes.fgaClientRequestMethod.name]: "writeAssertions", + [TelemetryAttributes.fgaClientRequestStoreId.name]: storeId, + [TelemetryAttributes.fgaClientRequestModelId.name]: authorizationModelId, }); }, /** @@ -992,8 +992,8 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: async writeAuthorizationModel(storeId: string, body: WriteAuthorizationModelRequest, options?: any): Promise<(axios?: AxiosInstance) => PromiseResult> { const localVarAxiosArgs = localVarAxiosParamCreator.writeAuthorizationModel(storeId, body, options); return createRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { - [attributeNames.requestMethod]: "writeAuthorizationModel", - [attributeNames.requestStoreId]: storeId, + [TelemetryAttributes.fgaClientRequestMethod.name]: "writeAuthorizationModel", + [TelemetryAttributes.fgaClientRequestStoreId.name]: storeId, }); }, }; diff --git a/common.ts b/common.ts index f3415e2..6b71eca 100644 --- a/common.ts +++ b/common.ts @@ -12,7 +12,6 @@ import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; -import { metrics } from "@opentelemetry/api"; import { Configuration } from "./configuration"; import type { Credentials } from "./credentials"; @@ -26,17 +25,9 @@ import { FgaError } from "./errors"; import { setNotEnumerableProperty } from "./utils"; -import { buildAttributes } from "./telemetry"; - -const meter = metrics.getMeter("@openfga/sdk", "0.6.3"); -const durationHist = meter.createHistogram("fga-client.request.duration", { - description: "The duration of requests", - unit: "milliseconds", -}); -const queryDurationHist = meter.createHistogram("fga-client.query.duration", { - description: "The duration of queries on the FGA server", - unit: "milliseconds", -}); +import { TelemetryAttributes } from "./telemetry/attributes"; +import { TelemetryMetrics } from "./telemetry/metrics"; +import { TelemetryHistograms } from "./telemetry/histograms"; /** * @@ -127,6 +118,10 @@ export const toPathString = function (url: URL) { type ObjectOrVoid = object | void; +interface StringIndexable { + [key: string]: any; +} + export type CallResult = T & { $response: AxiosResponse }; @@ -192,7 +187,7 @@ export async function attemptHttpRequest( /** * creates an axios request function */ -export const createRequestFunction = function (axiosArgs: RequestArgs, axiosInstance: AxiosInstance, configuration: Configuration, credentials: Credentials, methodAttributes: Record = {}) { +export const createRequestFunction = function (axiosArgs: RequestArgs, axiosInstance: AxiosInstance, configuration: Configuration, credentials: Credentials, methodAttributes: Record = {}) { configuration.isValid(); const retryParams = axiosArgs.options?.retryParams ? axiosArgs.options?.retryParams : configuration.retryParams; @@ -209,22 +204,49 @@ export const createRequestFunction = function (axiosArgs: RequestArgs, axiosInst maxRetry, minWaitInMs, }, axios); - const executionTime = Date.now() - start; const data = typeof response?.data === "undefined" ? {} : response?.data; const result: CallResult = { ...data }; setNotEnumerableProperty(result, "$response", response); - const attributes = buildAttributes(response, configuration.credentials, methodAttributes); - - if (response?.headers) { - const duration = response.headers["fga-query-duration-ms"]; - if (duration !== undefined) { - queryDurationHist.record(parseInt(duration, 10), attributes); - } + const telemetryAttributes = new TelemetryAttributes(); + const telemetryMetrics = new TelemetryMetrics(); + + let attributes: StringIndexable = {}; + + attributes = telemetryAttributes.fromRequest({ + start: start, + credentials: credentials, + attributes: methodAttributes, + }); + + attributes = telemetryAttributes.fromResponse({ + response, + credentials, + attributes, + }); + + if (attributes[TelemetryAttributes.httpServerRequestDuration.name]) { + telemetryMetrics.histogram( + TelemetryHistograms.queryDuration, + parseInt(attributes[TelemetryAttributes.httpServerRequestDuration.name] as string, 10), + telemetryAttributes.prepare( + attributes, + Object.keys(configuration.telemetryConfig.metrics.histogramQueryDuration.attributes()) + ) + ); } - durationHist.record(executionTime, attributes); + if (attributes[TelemetryAttributes.httpClientRequestDuration.name]) { + telemetryMetrics.histogram( + TelemetryHistograms.requestDuration, + Date.now() - start, + telemetryAttributes.prepare( + attributes, + Object.keys(configuration.telemetryConfig.metrics.histogramRequestDuration.attributes()) + ) + ); + } return result; }; diff --git a/configuration.ts b/configuration.ts index 1c844bc..6e70aaa 100644 --- a/configuration.ts +++ b/configuration.ts @@ -14,6 +14,7 @@ import { ApiTokenConfig, AuthCredentialsConfig, ClientCredentialsConfig, CredentialsMethod } from "./credentials/types"; import { FgaValidationError, } from "./errors"; import { assertParamExists, isWellFormedUlidString, isWellFormedUriString } from "./validation"; +import { TelemetryConfiguration } from './telemetry/configuration'; // default maximum number of retry const DEFAULT_MAX_RETRY = 15; @@ -41,6 +42,7 @@ export interface UserConfigurationParams { credentials?: CredentialsConfig; baseOptions?: any; retryParams?: RetryParams; + telemetryConfig?: TelemetryConfiguration; } export function GetDefaultRetryParams (maxRetry = DEFAULT_MAX_RETRY, minWaitInMs = DEFAULT_MIN_WAIT_MS) { @@ -120,6 +122,13 @@ export class Configuration { * @memberof Configuration */ retryParams?: RetryParams; + /** + * telemetry configuration + * + * @type {TelemetryConfiguration} + * @memberof Configuration + */ + telemetryConfig: TelemetryConfiguration; constructor(params: UserConfigurationParams = {} as unknown as UserConfigurationParams) { this.apiScheme = params.apiScheme || this.apiScheme; @@ -168,6 +177,7 @@ export class Configuration { this.baseOptions = baseOptions; this.retryParams = params.retryParams; + this.telemetryConfig = params.telemetryConfig || new TelemetryConfiguration(); } /** diff --git a/credentials/credentials.ts b/credentials/credentials.ts index 096e0f7..930992f 100644 --- a/credentials/credentials.ts +++ b/credentials/credentials.ts @@ -16,14 +16,14 @@ import globalAxios, { AxiosInstance } from "axios"; import { assertParamExists, isWellFormedUriString } from "../validation"; import { FgaApiAuthenticationError, FgaApiError, FgaError, FgaValidationError } from "../errors"; import { attemptHttpRequest } from "../common"; -import { buildAttributes } from "../telemetry"; import { ApiTokenConfig, AuthCredentialsConfig, ClientCredentialsConfig, CredentialsMethod } from "./types"; -import { Counter, metrics } from "@opentelemetry/api"; +import { TelemetryAttributes } from "../telemetry/attributes"; +import { TelemetryMetrics } from "../telemetry/metrics"; +import { TelemetryCounters } from "../telemetry/counters"; export class Credentials { private accessToken?: string; private accessTokenExpiryDate?: Date; - private tokenCounter?: Counter; public static init(configuration: { credentials: AuthCredentialsConfig }): Credentials { return new Credentials(configuration.credentials); @@ -51,11 +51,6 @@ export class Credentials { } } break; - case CredentialsMethod.ClientCredentials: { - const meter = metrics.getMeter("@openfga/sdk", "0.6.3"); - this.tokenCounter = meter.createCounter("fga-client.credentials.request"); - break; - } case CredentialsMethod.None: default: break; @@ -165,7 +160,25 @@ export class Credentials { this.accessTokenExpiryDate = new Date(Date.now() + response.data.expires_in * 1000); } - this.tokenCounter?.add(1, buildAttributes(response, this.authConfig)); + const telemetryAttributes = new TelemetryAttributes(); + const telemetryMetrics = new TelemetryMetrics(); + + let attributes = {}; + + attributes = telemetryAttributes.fromRequest({ + credentials: clientCredentials, + // resendCount: 0, // TODO: implement resend count tracking, not available in the current context + attributes, + }); + + attributes = telemetryAttributes.fromResponse({ + response, + credentials: clientCredentials, + attributes, + }); + + attributes = telemetryAttributes.prepare(attributes); + telemetryMetrics.counter(TelemetryCounters.credentialsRequest, 1, attributes); return this.accessToken; } catch (err: unknown) { diff --git a/index.ts b/index.ts index c17e345..291e294 100644 --- a/index.ts +++ b/index.ts @@ -18,6 +18,11 @@ export * from "./client"; export * from "./apiModel"; export { Configuration, UserConfigurationParams, GetDefaultRetryParams } from "./configuration"; export { Credentials, CredentialsMethod } from "./credentials"; +export * from "./telemetry/attributes"; +export * from "./telemetry/configuration"; +export * from "./telemetry/counters"; +export * from "./telemetry/histograms"; +export * from "./telemetry/metrics"; export * from "./errors"; diff --git a/package-lock.json b/package-lock.json index a717980..b8ce6b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/semantic-conventions": "^1.26.0", "axios": "^1.7.5", "tiny-async-pool": "^2.1.0" }, @@ -1318,14 +1317,6 @@ "node": ">=8.0.0" } }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", - "engines": { - "node": ">=14" - } - }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -6288,11 +6279,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==" }, - "@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==" - }, "@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", diff --git a/package.json b/package.json index c78f898..c6fa2ba 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/semantic-conventions": "^1.26.0", "axios": "^1.7.5", "tiny-async-pool": "^2.1.0" }, diff --git a/telemetry.ts b/telemetry.ts deleted file mode 100644 index 3c34e08..0000000 --- a/telemetry.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * JavaScript and Node.js SDK for OpenFGA - * - * API version: 1.x - * Website: https://openfga.dev - * Documentation: https://openfga.dev/docs - * Support: https://openfga.dev/community - * License: [Apache-2.0](https://github.com/openfga/js-sdk/blob/main/LICENSE) - * - * NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. - */ - - -import { AxiosResponse } from "axios"; -import { Attributes } from "@opentelemetry/api"; -import { SEMATTRS_HTTP_HOST, SEMATTRS_HTTP_METHOD, SEMATTRS_HTTP_STATUS_CODE } from "@opentelemetry/semantic-conventions"; -import { AuthCredentialsConfig, CredentialsMethod } from "./credentials/types"; - -/** - * Builds an object of attributes that can be used to report alongside an OpenTelemetry metric event. - * - * @param response - The Axios response object, used to add data like HTTP status, host, method, and headers. - * @param credentials - The credentials object, used to add data like the ClientID when using ClientCredentials. - * @param methodAttributes - Extra attributes that the method (i.e. check, listObjects) wishes to have included. Any custom attributes should use the common names. - * @returns {Attributes} - */ - -export const buildAttributes = function buildAttributes(response: AxiosResponse | undefined, credentials: AuthCredentialsConfig, methodAttributes: Record = {}): Attributes { - const attributes: Attributes = { - ...methodAttributes, - }; - - if (response?.status) { - attributes[SEMATTRS_HTTP_STATUS_CODE] = response.status; - } - - if (response?.request) { - attributes[SEMATTRS_HTTP_METHOD] = response.request.method; - attributes[SEMATTRS_HTTP_HOST] = response.request.host; - } - - if (response?.headers) { - const modelId = response.headers["openfga-authorization-model-id"]; - if (modelId !== undefined) { - attributes[attributeNames.responseModelId] = modelId; - } - } - - if (credentials?.method === CredentialsMethod.ClientCredentials) { - attributes[attributeNames.requestClientId] = credentials.config.clientId; - } - - return attributes; -}; -/** - * Common attribute names - */ - -export const attributeNames = { - // Attributes associated with the request made - requestModelId: "fga-client.request.model_id", - requestMethod: "fga-client.request.method", - requestStoreId: "fga-client.request.store_id", - requestClientId: "fga-client.request.client_id", - - // Attributes associated with the response - responseModelId: "fga-client.response.model_id", - - // Attributes associated with specific actions - user: "fga-client.user" -}; diff --git a/telemetry/attributes.ts b/telemetry/attributes.ts new file mode 100644 index 0000000..9f6c1de --- /dev/null +++ b/telemetry/attributes.ts @@ -0,0 +1,103 @@ +import { URL } from 'url'; + +export interface TelemetryAttribute { + name: string; +} + +export class TelemetryAttributes { + static fgaClientRequestClientId: TelemetryAttribute = { name: 'fga-client.request.client_id' }; + static fgaClientRequestMethod: TelemetryAttribute = { name: 'fga-client.request.method' }; + static fgaClientRequestModelId: TelemetryAttribute = { name: 'fga-client.request.model_id' }; + static fgaClientRequestStoreId: TelemetryAttribute = { name: 'fga-client.request.store_id' }; + static fgaClientResponseModelId: TelemetryAttribute = { name: 'fga-client.response.model_id' }; + static fgaClientUser: TelemetryAttribute = { name: 'fga-client.user' }; + static httpClientRequestDuration: TelemetryAttribute = { name: 'http.client.request.duration' }; + static httpHost: TelemetryAttribute = { name: 'http.host' }; + static httpRequestMethod: TelemetryAttribute = { name: 'http.request.method' }; + static httpRequestResendCount: TelemetryAttribute = { name: 'http.request.resend_count' }; + static httpResponseStatusCode: TelemetryAttribute = { name: 'http.response.status_code' }; + static httpServerRequestDuration: TelemetryAttribute = { name: 'http.server.request.duration' }; + static urlScheme: TelemetryAttribute = { name: 'url.scheme' }; + static urlFull: TelemetryAttribute = { name: 'url.full' }; + static userAgentOriginal: TelemetryAttribute = { name: 'user_agent.original' }; + + prepare( + attributes?: Record, + filter?: string[] + ): Record { + const response: Record = {}; + + if (attributes) { + Object.entries(attributes).forEach(([key, value]) => { + if (filter && !filter.includes(key)) return; + response[key] = value; + }); + } + + return Object.fromEntries(Object.entries(response).sort()); + } + + fromRequest({ + userAgent, + fgaMethod, + httpMethod, + url, + resendCount, + start, + credentials, + attributes = {}, + }: { + userAgent?: string; + fgaMethod?: string; + httpMethod?: string; + url?: string; + resendCount?: number; + start?: number; + credentials?: any; + attributes?: Record; + }): Record { + if (fgaMethod) attributes[TelemetryAttributes.fgaClientRequestMethod.name] = fgaMethod; + if (userAgent) attributes[TelemetryAttributes.userAgentOriginal.name] = userAgent; + if (httpMethod) attributes[TelemetryAttributes.httpRequestMethod.name] = httpMethod; + + if (url) { + const parsedUrl = new URL(url); + attributes[TelemetryAttributes.httpHost.name] = parsedUrl.hostname; + attributes[TelemetryAttributes.urlScheme.name] = parsedUrl.protocol; + attributes[TelemetryAttributes.urlFull.name] = url; + } + + if (start) attributes[TelemetryAttributes.httpClientRequestDuration.name] = Date.now() - start; + if (resendCount) attributes[TelemetryAttributes.httpRequestResendCount.name] = resendCount; + if (credentials && credentials.method === 'client_credentials') { + attributes[TelemetryAttributes.fgaClientRequestClientId.name] = credentials.configuration.clientId; + } + + return attributes; + } + + fromResponse({ + response, + credentials, + attributes = {}, + }: { + response: any; + credentials?: any; + attributes?: Record; + }): Record { + if (response?.status) attributes[TelemetryAttributes.httpResponseStatusCode.name] = response.status; + + const responseHeaders = response?.headers || {}; + const responseModelId = responseHeaders['openfga-authorization-model-id']; + const responseQueryDuration = responseHeaders['fga-query-duration-ms']; + + if (responseModelId) attributes[TelemetryAttributes.fgaClientResponseModelId.name] = responseModelId; + if (responseQueryDuration) attributes[TelemetryAttributes.httpServerRequestDuration.name] = responseQueryDuration; + + if (credentials && credentials.method === 'client_credentials') { + attributes[TelemetryAttributes.fgaClientRequestClientId.name] = credentials.configuration.clientId; + } + + return attributes; + } +} diff --git a/telemetry/configuration.ts b/telemetry/configuration.ts new file mode 100644 index 0000000..17a7b3e --- /dev/null +++ b/telemetry/configuration.ts @@ -0,0 +1,53 @@ +import { TelemetryAttributes, TelemetryAttribute } from './attributes'; + +export class TelemetryMetricConfiguration { + constructor( + public enabled: boolean = true, + public attrFgaClientRequestClientId: boolean = true, + public attrFgaClientRequestMethod: boolean = true, + public attrFgaClientRequestModelId: boolean = true, + public attrFgaClientRequestStoreId: boolean = true, + public attrFgaClientResponseModelId: boolean = true, + public attrFgaClientUser: boolean = false, + public attrHttpClientRequestDuration: boolean = false, + public attrHttpHost: boolean = true, + public attrHttpRequestMethod: boolean = true, + public attrHttpRequestResendCount: boolean = true, + public attrHttpResponseStatusCode: boolean = true, + public attrHttpServerRequestDuration: boolean = false, + public attrUrlScheme: boolean = true, + public attrUrlFull: boolean = true, + public attrUserAgentOriginal: boolean = true + ) {} + + attributes(): Record { + const enabled: Record = {}; + + Object.entries(this).forEach(([key, value]) => { + if (key.startsWith('attr') && value) { + let attrKey = key.replace('attr', '') as keyof typeof TelemetryAttributes; + attrKey = attrKey.charAt(0).toLowerCase() + attrKey.slice(1); + + const telemetryAttribute = TelemetryAttributes[attrKey as keyof typeof TelemetryAttributes] as TelemetryAttribute; + + if (telemetryAttribute) { + enabled[telemetryAttribute.name] = true; + } + } + }); + + return enabled; + } +} + +export class TelemetryMetricsConfiguration { + constructor( + public counterCredentialsRequest: TelemetryMetricConfiguration = new TelemetryMetricConfiguration(), + public histogramRequestDuration: TelemetryMetricConfiguration = new TelemetryMetricConfiguration(), + public histogramQueryDuration: TelemetryMetricConfiguration = new TelemetryMetricConfiguration() + ) {} +} + +export class TelemetryConfiguration { + constructor(public metrics: TelemetryMetricsConfiguration = new TelemetryMetricsConfiguration()) {} +} diff --git a/telemetry/counters.ts b/telemetry/counters.ts new file mode 100644 index 0000000..3ed6cd8 --- /dev/null +++ b/telemetry/counters.ts @@ -0,0 +1,13 @@ +export interface TelemetryCounter { + name: string; + unit: string; + description: string; +} + +export class TelemetryCounters { + static credentialsRequest: TelemetryCounter = { + name: 'fga-client.credentials.request', + unit: 'milliseconds', + description: 'The number of times an access token is requested.', + }; +} diff --git a/telemetry/histograms.ts b/telemetry/histograms.ts new file mode 100644 index 0000000..50d4558 --- /dev/null +++ b/telemetry/histograms.ts @@ -0,0 +1,19 @@ +export interface TelemetryHistogram { + name: string; + unit: string; + description: string; +} + +export class TelemetryHistograms { + static requestDuration: TelemetryHistogram = { + name: 'fga-client.request.duration', + unit: 'milliseconds', + description: 'How long it took for a request to be fulfilled.', + }; + + static queryDuration: TelemetryHistogram = { + name: 'fga-client.query.duration', + unit: 'milliseconds', + description: 'How long it took to perform a query request.', + }; +} diff --git a/telemetry/metrics.ts b/telemetry/metrics.ts new file mode 100644 index 0000000..2334eb5 --- /dev/null +++ b/telemetry/metrics.ts @@ -0,0 +1,59 @@ +import { Counter, Histogram, Meter, ValueType } from '@opentelemetry/api'; +import { TelemetryConfiguration, TelemetryMetricsConfiguration } from './configuration'; +import { TelemetryCounters, TelemetryCounter } from './counters'; +import { TelemetryHistograms, TelemetryHistogram } from './histograms'; +import { TelemetryAttributes, TelemetryAttribute } from './attributes'; +import { metrics } from "@opentelemetry/api"; + +export class TelemetryMetrics { + private _meter: Meter | null = null; + private _counters: Record = {}; + private _histograms: Record = {}; + + constructor( + meter?: Meter, + counters?: Record, + histograms?: Record + ) { + this._meter = meter || null; + this._counters = counters || {}; + this._histograms = histograms || {}; + } + + meter(): Meter { + if (!this._meter) { + this._meter = metrics.getMeter("@openfga/sdk", "0.6.3") + } + return this._meter; + } + + counter(counter: TelemetryCounter, value?: number, attributes?: Record): Counter { + if (!this._counters[counter.name]) { + this._counters[counter.name] = this.meter().createCounter(counter.name, { + description: counter.description, + unit: counter.unit, + }); + } + + if (value !== undefined) { + this._counters[counter.name].add(value, attributes); + } + + return this._counters[counter.name]; + } + + histogram(histogram: TelemetryHistogram, value?: number, attributes?: Record): Histogram { + if (!this._histograms[histogram.name]) { + this._histograms[histogram.name] = this.meter().createHistogram(histogram.name, { + description: histogram.description, + unit: histogram.unit, + }); + } + + if (value !== undefined) { + this._histograms[histogram.name].record(value, attributes); + } + + return this._histograms[histogram.name]; + } +} diff --git a/tests/telemetry/attributes.test.ts b/tests/telemetry/attributes.test.ts new file mode 100644 index 0000000..02dcc43 --- /dev/null +++ b/tests/telemetry/attributes.test.ts @@ -0,0 +1,69 @@ +import { TelemetryAttributes } from '../../telemetry/attributes'; + +describe('TelemetryAttributes', () => { + let telemetryAttributes: TelemetryAttributes; + + beforeEach(() => { + telemetryAttributes = new TelemetryAttributes(); + }); + + test('should prepare attributes correctly', () => { + const attributes = { + 'fga-client.request.client_id': 'test-client-id', + 'http.host': 'example.com', + }; + + const filter = ['fga-client.request.client_id']; + const prepared = telemetryAttributes.prepare(attributes, filter); + + expect(prepared).toEqual({ 'fga-client.request.client_id': 'test-client-id' }); + }); + + test('should create attributes from request correctly', () => { + const result = telemetryAttributes.fromRequest({ + userAgent: 'Mozilla/5.0', + fgaMethod: 'GET', + httpMethod: 'POST', + url: 'https://example.com', + resendCount: 2, + start: 1000, + credentials: { method: 'client_credentials', configuration: { clientId: 'client-id' } }, + }); + + expect(result['user_agent.original']).toEqual('Mozilla/5.0'); + expect(result['fga-client.request.method']).toEqual('GET'); + expect(result['http.request.method']).toEqual('POST'); + expect(result['http.host']).toEqual('example.com'); + expect(result['url.scheme']).toEqual('https:'); + }); + + test('should create attributes from response correctly', () => { + const response = { status: 200, headers: { 'openfga-authorization-model-id': 'model-id', 'fga-query-duration-ms': '10' } }; + const result = telemetryAttributes.fromResponse({ response }); + + // Verify line 90 is covered - status is correctly set + expect(result['http.response.status_code']).toEqual(200); + expect(result['fga-client.response.model_id']).toEqual('model-id'); + expect(result['http.server.request.duration']).toEqual('10'); + }); + + test('should handle response without status correctly', () => { + const response = { headers: { 'openfga-authorization-model-id': 'model-id', 'fga-query-duration-ms': '10' } }; + const result = telemetryAttributes.fromResponse({ response }); + + // Verify that no status code is set when response does not have a status + expect(result['http.response.status_code']).toBeUndefined(); + expect(result['fga-client.response.model_id']).toEqual('model-id'); + expect(result['http.server.request.duration']).toEqual('10'); + }); + + test('should create attributes from response with client credentials', () => { + const response = { status: 200, headers: {} }; + const credentials = { method: 'client_credentials', configuration: { clientId: 'client-id' } }; + const result = telemetryAttributes.fromResponse({ response, credentials }); + + // Check that the client ID is set correctly from the credentials + expect(result['http.response.status_code']).toEqual(200); + expect(result['fga-client.request.client_id']).toEqual('client-id'); + }); +}); diff --git a/tests/telemetry/configuration.test.ts b/tests/telemetry/configuration.test.ts new file mode 100644 index 0000000..d368490 --- /dev/null +++ b/tests/telemetry/configuration.test.ts @@ -0,0 +1,68 @@ +import { TelemetryMetricConfiguration, TelemetryConfiguration, TelemetryMetricsConfiguration } from '../../telemetry/configuration'; +import { TelemetryAttributes } from '../../telemetry/attributes'; + +describe('TelemetryMetricConfiguration', () => { + test('should create a default TelemetryMetricConfiguration instance', () => { + const config = new TelemetryMetricConfiguration(); + + expect(config.enabled).toBe(true); + expect(config.attrFgaClientRequestClientId).toBe(true); + expect(config.attrFgaClientRequestMethod).toBe(true); + expect(config.attrFgaClientRequestModelId).toBe(true); + expect(config.attrFgaClientRequestStoreId).toBe(true); + expect(config.attrFgaClientResponseModelId).toBe(true); + expect(config.attrFgaClientUser).toBe(false); + }); + + test('should return correct attributes based on enabled properties', () => { + const config = new TelemetryMetricConfiguration( + true, // enabled + true, // attrFgaClientRequestClientId + false, // attrFgaClientRequestMethod + true, // attrFgaClientRequestModelId + false, // attrFgaClientRequestStoreId + true, // attrFgaClientResponseModelId + false, // attrFgaClientUser + true, // attrHttpClientRequestDuration + false, // attrHttpHost + true, // attrHttpRequestMethod + true, // attrHttpRequestResendCount + false, // attrHttpResponseStatusCode + true, // attrHttpServerRequestDuration + false, // attrUrlScheme + true, // attrUrlFull + false, // attrUserAgentOriginal + ); + + const attributes = config.attributes(); + + expect(attributes).toEqual({ + [TelemetryAttributes.fgaClientRequestClientId.name]: true, + [TelemetryAttributes.fgaClientRequestModelId.name]: true, + [TelemetryAttributes.fgaClientResponseModelId.name]: true, + [TelemetryAttributes.httpClientRequestDuration.name]: true, + [TelemetryAttributes.httpRequestMethod.name]: true, + [TelemetryAttributes.httpRequestResendCount.name]: true, + [TelemetryAttributes.httpServerRequestDuration.name]: true, + [TelemetryAttributes.urlFull.name]: true, + }); + }); +}); + +describe('TelemetryConfiguration', () => { + test('should create a default TelemetryConfiguration instance', () => { + const config = new TelemetryConfiguration(); + + expect(config.metrics).toBeInstanceOf(TelemetryMetricsConfiguration); + expect(config.metrics.counterCredentialsRequest.enabled).toBe(true); + }); + + test('should allow overriding telemetry configuration options', () => { + const customMetrics = new TelemetryMetricsConfiguration(); + customMetrics.counterCredentialsRequest.enabled = false; + + const config = new TelemetryConfiguration(customMetrics); + + expect(config.metrics.counterCredentialsRequest.enabled).toBe(false); + }); +}); diff --git a/tests/telemetry/counters.test.ts b/tests/telemetry/counters.test.ts new file mode 100644 index 0000000..45fad6f --- /dev/null +++ b/tests/telemetry/counters.test.ts @@ -0,0 +1,9 @@ +import { TelemetryCounters } from '../../telemetry/counters'; + +describe('TelemetryCounters', () => { + test('should have correct counter details', () => { + expect(TelemetryCounters.credentialsRequest.name).toBe('fga-client.credentials.request'); + expect(TelemetryCounters.credentialsRequest.unit).toBe('milliseconds'); + expect(TelemetryCounters.credentialsRequest.description).toBe('The number of times an access token is requested.'); + }); +}); diff --git a/tests/telemetry/histograms.test.ts b/tests/telemetry/histograms.test.ts new file mode 100644 index 0000000..f31d915 --- /dev/null +++ b/tests/telemetry/histograms.test.ts @@ -0,0 +1,15 @@ +import { TelemetryHistograms } from '../../telemetry/histograms'; + +describe('TelemetryHistograms', () => { + test('should have correct histogram details for request duration', () => { + expect(TelemetryHistograms.requestDuration.name).toBe('fga-client.request.duration'); + expect(TelemetryHistograms.requestDuration.unit).toBe('milliseconds'); + expect(TelemetryHistograms.requestDuration.description).toBe('How long it took for a request to be fulfilled.'); + }); + + test('should have correct histogram details for query duration', () => { + expect(TelemetryHistograms.queryDuration.name).toBe('fga-client.query.duration'); + expect(TelemetryHistograms.queryDuration.unit).toBe('milliseconds'); + expect(TelemetryHistograms.queryDuration.description).toBe('How long it took to perform a query request.'); + }); +}); diff --git a/tests/telemetry/metrics.test.ts b/tests/telemetry/metrics.test.ts new file mode 100644 index 0000000..d49f37a --- /dev/null +++ b/tests/telemetry/metrics.test.ts @@ -0,0 +1,42 @@ +import { TelemetryMetrics } from '../../telemetry/metrics'; +import { TelemetryCounters } from '../../telemetry/counters'; +import { TelemetryHistograms } from '../../telemetry/histograms'; +import { TelemetryAttributes } from '../../telemetry/attributes'; + +jest.mock('@opentelemetry/api', () => ({ + metrics: { + getMeter: jest.fn().mockReturnValue({ + createCounter: jest.fn().mockReturnValue({ add: jest.fn() }), + createHistogram: jest.fn().mockReturnValue({ record: jest.fn() }), + }), + }, +})); + +describe('TelemetryMetrics', () => { + let telemetryMetrics: TelemetryMetrics; + + beforeEach(() => { + telemetryMetrics = new TelemetryMetrics(); + }); + + test('should create a counter and add a value', () => { + const counter = telemetryMetrics.counter(TelemetryCounters.credentialsRequest, 5); + + expect(counter).toBeDefined(); + expect(counter.add).toHaveBeenCalledWith(5, undefined); + }); + + test('should create a histogram and record a value', () => { + const histogram = telemetryMetrics.histogram(TelemetryHistograms.requestDuration, 200); + + expect(histogram).toBeDefined(); + expect(histogram.record).toHaveBeenCalledWith(200, undefined); + }); + + test('should handle creating metrics with custom attributes', () => { + const attributes = new TelemetryAttributes().prepare({ 'http.host': 'example.com' }); + const counter = telemetryMetrics.counter(TelemetryCounters.credentialsRequest, 3, attributes); + + expect(counter.add).toHaveBeenCalledWith(3, attributes); + }); +});