diff --git a/Makefile b/Makefile index 586024e..95e7156 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ test: install npm test format: - npm run prettier + npm run format build: install npm run build diff --git a/README.md b/README.md index cb7b7a5..b0991eb 100644 --- a/README.md +++ b/README.md @@ -602,6 +602,20 @@ const chargebee = new Chargebee({ These examples demonstrate how to implement and inject custom clients using `axios` and `ky`, respectively. +### SDK telemetry + +By default, the library sends anonymous usage telemetry to Chargebee. This helps us improve the SDK and API. + +You can disable this behavior if you prefer: + +```javascript +const chargebee = new Chargebee({ + site: 'your-site', + apiKey: 'your-api-key', + sdkTelemetryEnabled: false, +}); +``` + ### Telemetry (OpenTelemetry) Optional. Pass a `telemetryAdapter` when you want Chargebee API calls traced in your observability stack (Datadog, Splunk, Honeycomb, Jaeger, etc.). The SDK ships a ready-to-use OpenTelemetry adapter, so for most setups you only need to add `@opentelemetry/api` and wire the adapter on the client. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..e060997 --- /dev/null +++ b/biome.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.7/schema.json", + "files": { + "includes": ["src/**/*.ts", "types/**/*.d.ts"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 80, + "lineEnding": "lf" + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always", + "trailingCommas": "all" + } + }, + "linter": { + "enabled": false + }, + "assist": { + "enabled": false + } +} diff --git a/package-lock.json b/package-lock.json index 20597dc..b08e225 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,13 +11,13 @@ "zod": "^4.3.6" }, "devDependencies": { + "@biomejs/biome": "^2.5.7", "@opentelemetry/api": "^1.9.0", "@types/chai": "^4.3.5", "@types/mocha": "^10.0.10", "@types/node": "20.12.0", "chai": "^4.3.7", "mocha": "^10.2.0", - "prettier": "^3.3.3", "ts-node": "^10.9.1", "typescript": "^5.5.4", "undici-types": "^7.16.0" @@ -34,6 +34,169 @@ } } }, + "node_modules/@biomejs/biome": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.7.tgz", + "integrity": "sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.7", + "@biomejs/cli-darwin-x64": "2.5.7", + "@biomejs/cli-linux-arm64": "2.5.7", + "@biomejs/cli-linux-arm64-musl": "2.5.7", + "@biomejs/cli-linux-x64": "2.5.7", + "@biomejs/cli-linux-x64-musl": "2.5.7", + "@biomejs/cli-win32-arm64": "2.5.7", + "@biomejs/cli-win32-x64": "2.5.7" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.7.tgz", + "integrity": "sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.7.tgz", + "integrity": "sha512-Cd3Ga61amT/Yl/0x8elP5hhGYaFy4bw6WuysTgf7oo8TA5tJ5A1k+DkVoJ2BHbTVil51gTX9VPzArnrlLJ3Kyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.7.tgz", + "integrity": "sha512-rR2QE0yF2GYSuYuKIa7pKvODGJqnOH+2eDREAM8wV+mWKSkMQKdAp4zXEZfTaxY8PMoNONnpgSWcBCyLDPDOKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.7.tgz", + "integrity": "sha512-xPI5yB6XlpDbNkS+bm1t42olw5c4l3UrlOmLg7KtLJvjvkNF/1V4tnUgfkylGIeb3u/T+BzMGYqgQhzjAoJzuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.7.tgz", + "integrity": "sha512-FQgqJhscrqJUFptGaRSUJWlXAExwWcDwLuK49dvKfkQ1bB5SEEyFssnsxQY83Xm6jR0EbbX3+8+D5bfvYqUG2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.7.tgz", + "integrity": "sha512-rE5VZi+qtmPgQH+l7jVxYoZ18b/TiHEhulhMpjmCZH1PltSbjRcxNWywC3HZ9tYottG7ORkeTtoscBilKSBm0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.7.tgz", + "integrity": "sha512-Oq4x0CCwP4jirrcTywXs5kOGZ4v5vuEP+gWrbtjApOA2CL9F3F9GlIdQIci8AKSCa/zURanMRpX/4wQ7Am6hHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.7.tgz", + "integrity": "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -955,21 +1118,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/prettier": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", - "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", - "dev": true, - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", diff --git a/package.json b/package.json index 153d29a..96da087 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "npm run build-esm && npm run build-cjs", "build-esm": "rm -rf esm && mkdir -p esm && tsc -p tsconfig.esm.json && echo '{\"type\":\"module\"}' > esm/package.json", "build-cjs": "rm -rf cjs && mkdir -p cjs && tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > cjs/package.json", - "prettier": "prettier --write \"src/**/*.ts\" \"types/**/*.d.ts\"" + "format": "biome format --write", + "format:check": "biome format" }, "types": "./types/index.d.ts", "keywords": [ @@ -76,22 +77,17 @@ } }, "devDependencies": { + "@biomejs/biome": "^2.5.7", "@opentelemetry/api": "^1.9.0", "@types/chai": "^4.3.5", "@types/mocha": "^10.0.10", "@types/node": "20.12.0", "chai": "^4.3.7", "mocha": "^10.2.0", - "prettier": "^3.3.3", "ts-node": "^10.9.1", "typescript": "^5.5.4", "undici-types": "^7.16.0" }, - "prettier": { - "semi": true, - "singleQuote": true, - "parser": "typescript" - }, "dependencies": { "zod": "^4.3.6" } diff --git a/src/RequestWrapper.ts b/src/RequestWrapper.ts index 988d109..73a951e 100644 --- a/src/RequestWrapper.ts +++ b/src/RequestWrapper.ts @@ -22,6 +22,7 @@ import { extractHttpStatusCode, extractRequestTelemetryError, resolveChargebeeApiVersion, + attachSdkTelemetryHeader, type TelemetryAdapter, } from './telemetry/index.js'; import { handleResponse } from './coreCommon.js'; @@ -115,6 +116,15 @@ export class RequestWrapper { if (this.envArg.telemetryAdapter !== undefined) { _env.telemetryAdapter = this.envArg.telemetryAdapter; } + if (this.envArg.sdkTelemetryState !== undefined) { + _env.sdkTelemetryState = this.envArg.sdkTelemetryState; + } + if (this.envArg.sdkTelemetryEnabled !== undefined) { + _env.sdkTelemetryEnabled = this.envArg.sdkTelemetryEnabled; + } + if (this.envArg.httpClientIsCustom !== undefined) { + _env.httpClientIsCustom = this.envArg.httpClientIsCustom; + } const env = _env as EnvType; @@ -233,6 +243,7 @@ export class RequestWrapper { ...this.httpHeaders, ...telemetryHeaders, }; + attachSdkTelemetryHeader(env, requestHeaders); const contentType = this.apiCall.isJsonRequest ? 'application/json;charset=UTF-8' @@ -385,10 +396,12 @@ export class RequestWrapper { } }; - const promise = + const executeCall = () => telemetryAdapter !== undefined ? runWithTelemetry(telemetryAdapter) : withRetry(0, requestStartTime); + + const promise = executeCall(); return callbackifyPromise(promise); } diff --git a/src/chargebee.cjs.ts b/src/chargebee.cjs.ts index 79684c0..49345de 100644 --- a/src/chargebee.cjs.ts +++ b/src/chargebee.cjs.ts @@ -10,7 +10,10 @@ import { } from './resources/webhook/handler.js'; import { basicAuthValidator } from './resources/webhook/auth.js'; import { ChargebeeZodValidationError } from './chargebeeZodValidationError.js'; -import { TelemetryAttributeKeys } from './telemetry/index.js'; +import { + TelemetryAttributeKeys, + SDK_TELEMETRY_HEADER_NAME, +} from './telemetry/index.js'; const httpClient = new FetchHttpClient(); const Chargebee = CreateChargebee(httpClient); @@ -29,6 +32,7 @@ module.exports.WebhookAuthenticationError = WebhookAuthenticationError; module.exports.WebhookPayloadValidationError = WebhookPayloadValidationError; module.exports.WebhookPayloadParseError = WebhookPayloadParseError; module.exports.TelemetryAttributeKeys = TelemetryAttributeKeys; +module.exports.SDK_TELEMETRY_HEADER_NAME = SDK_TELEMETRY_HEADER_NAME; // Export validation error class module.exports.ChargebeeZodValidationError = ChargebeeZodValidationError; diff --git a/src/chargebee.esm.ts b/src/chargebee.esm.ts index 4f30d5c..7e9f165 100644 --- a/src/chargebee.esm.ts +++ b/src/chargebee.esm.ts @@ -18,7 +18,10 @@ export { WebhookPayloadValidationError, WebhookPayloadParseError, } from './resources/webhook/handler.js'; -export { TelemetryAttributeKeys } from './telemetry/index.js'; +export { + TelemetryAttributeKeys, + SDK_TELEMETRY_HEADER_NAME, +} from './telemetry/index.js'; // Export validation error class export { ChargebeeZodValidationError } from './chargebeeZodValidationError.js'; diff --git a/src/createChargebee.ts b/src/createChargebee.ts index f6b9bd7..09cde5b 100644 --- a/src/createChargebee.ts +++ b/src/createChargebee.ts @@ -16,6 +16,7 @@ import { type WebhookHandlerOptions, createDefaultHandler, } from './resources/webhook/handler.js'; +import { SdkTelemetryState } from './telemetry/index.js'; export const CreateChargebee = (httpClient: HttpClientInterface) => { const Chargebee = function (this: ChargebeeType, conf: Config) { @@ -23,12 +24,16 @@ export const CreateChargebee = (httpClient: HttpClientInterface) => { const { telemetryAdapter, httpClient: configHttpClient, + sdkTelemetryEnabled, ...confToMerge } = conf; extend(true, this._env, confToMerge); // @ts-ignore this._env.httpClient = configHttpClient != null ? configHttpClient : httpClient; + this._env.sdkTelemetryState = new SdkTelemetryState(); + this._env.sdkTelemetryEnabled = sdkTelemetryEnabled !== false; + this._env.httpClientIsCustom = configHttpClient != null; if (telemetryAdapter !== undefined) { this._env.telemetryAdapter = telemetryAdapter; } diff --git a/src/telemetry/index.ts b/src/telemetry/index.ts index cd2d882..b07605e 100644 --- a/src/telemetry/index.ts +++ b/src/telemetry/index.ts @@ -34,3 +34,17 @@ export { extractRequestTelemetryError, resolveChargebeeApiVersion, } from './TelemetryAdapter.js'; + +export { + SDK_TELEMETRY_FEATURES_KEY, + SDK_TELEMETRY_HEADER_NAME, + SDK_TELEMETRY_MAX_HEADER_BYTES, +} from './sdkTelemetryHeader.js'; + +export { SdkTelemetryFeature } from './sdkTelemetryFeature.js'; +export { SdkTelemetryState } from './sdkTelemetryState.js'; +export { buildSdkTelemetryHeader } from './sdkTelemetryHeaderBuilder.js'; +export { + attachSdkTelemetryHeader, + type SdkTelemetryEnv, +} from './sdkTelemetryEmitter.js'; diff --git a/src/telemetry/sdkTelemetryEmitter.ts b/src/telemetry/sdkTelemetryEmitter.ts new file mode 100644 index 0000000..c15777a --- /dev/null +++ b/src/telemetry/sdkTelemetryEmitter.ts @@ -0,0 +1,84 @@ +/* + * This file is auto-generated by Chargebee. + * For more information on how to make changes to this file, please see the README. + * Reach out to dx@chargebee.com for any questions. + * Copyright 2026 Chargebee Inc. + */ + +import type { TelemetryAdapter } from './TelemetryAdapter.js'; +import { buildSdkTelemetryHeader } from './sdkTelemetryHeaderBuilder.js'; +import { SdkTelemetryFeature } from './sdkTelemetryFeature.js'; +import { SDK_TELEMETRY_HEADER_NAME } from './sdkTelemetryHeader.js'; +import type { SdkTelemetryState } from './sdkTelemetryState.js'; + +/** Client env fields needed to emit SDK telemetry. */ +export type SdkTelemetryEnv = { + sdkTelemetryEnabled?: boolean; + sdkTelemetryState?: SdkTelemetryState; + telemetryAdapter?: TelemetryAdapter; + httpClientIsCustom?: boolean; + retryConfig?: { enabled?: boolean }; +}; + +/** Mutable request-header map used when attaching the telemetry header. */ +export type RequestHeadersForSdkTelemetry = Record; + +/** + * Emits the anonymous SDK telemetry request header, independently of any customer telemetry + * adapter. + * + * On the first API call of a client instance, attaches {@code f;…} with enabled feature codes when + * any are present; omits the header when none are enabled. Later calls on the same client never + * attach again. SDK identity is correlated via User-Agent. Every failure path is swallowed and + * logged at WARNING: telemetry must never fail an API call. + */ +export function attachSdkTelemetryHeader( + env: SdkTelemetryEnv, + headers: RequestHeadersForSdkTelemetry, +): void { + if (env.sdkTelemetryEnabled === false) { + return; + } + + try { + if (!env.sdkTelemetryState?.tryMarkEmitted()) { + return; + } + const headerValue = buildSdkTelemetryHeader(resolveFeatures(env)); + if (!headerValue) { + return; + } + headers[SDK_TELEMETRY_HEADER_NAME] = headerValue; + } catch (err) { + logSuppressed('attach header', err); + } +} + +/** Collects enabled feature codes for the current client configuration. */ +function resolveFeatures(env: SdkTelemetryEnv): SdkTelemetryFeature[] { + const features: SdkTelemetryFeature[] = []; + if (env.telemetryAdapter !== undefined) { + features.push(SdkTelemetryFeature.TELEMETRY_ADAPTER); + } + if (env.httpClientIsCustom) { + features.push(SdkTelemetryFeature.CUSTOM_TRANSPORT); + } + if (isRetryConfigActive(env)) { + features.push(SdkTelemetryFeature.RETRY_CONFIG); + } + return features; +} + +/** Whether retries are enabled on the client. */ +function isRetryConfigActive(env: SdkTelemetryEnv): boolean { + return env.retryConfig?.enabled === true; +} + +/** Logs a suppressed telemetry failure without affecting the API call. */ +function logSuppressed(step: string, err: unknown): void { + const message = err instanceof Error ? err.message : String(err); + console.warn( + `SDK telemetry could not ${step} (${message}); API call unaffected.`, + err, + ); +} diff --git a/src/telemetry/sdkTelemetryFeature.ts b/src/telemetry/sdkTelemetryFeature.ts new file mode 100644 index 0000000..d64a03f --- /dev/null +++ b/src/telemetry/sdkTelemetryFeature.ts @@ -0,0 +1,21 @@ +/* + * This file is auto-generated by Chargebee. + * For more information on how to make changes to this file, please see the README. + * Reach out to dx@chargebee.com for any questions. + * Copyright 2026 Chargebee Inc. + */ + +/** + * SDK configuration features reported under the {@code f} segment of + * {@link SDK_TELEMETRY_HEADER_NAME}. Wire codes are maintained in sdk-generator. + */ +export enum SdkTelemetryFeature { + /** Customer TelemetryAdapter configured. */ + TELEMETRY_ADAPTER = 'ta', + + /** Custom HTTP client configured. */ + CUSTOM_TRANSPORT = 'ct', + + /** Retries enabled on the client. */ + RETRY_CONFIG = 'rc', +} diff --git a/src/telemetry/sdkTelemetryHeader.ts b/src/telemetry/sdkTelemetryHeader.ts new file mode 100644 index 0000000..dfada96 --- /dev/null +++ b/src/telemetry/sdkTelemetryHeader.ts @@ -0,0 +1,15 @@ +/* + * This file is auto-generated by Chargebee. + * For more information on how to make changes to this file, please see the README. + * Reach out to dx@chargebee.com for any questions. + * Copyright 2026 Chargebee Inc. + */ + +/** Constants for the anonymous SDK telemetry request header. */ +export const SDK_TELEMETRY_HEADER_NAME = 'x-chargebee-sdk-telemetry'; + +/** Defensive size cap; feature-only values are tiny. */ +export const SDK_TELEMETRY_MAX_HEADER_BYTES = 4096; + +/** RFC 9651 sf-list item name for the features segment. */ +export const SDK_TELEMETRY_FEATURES_KEY = 'f'; diff --git a/src/telemetry/sdkTelemetryHeaderBuilder.ts b/src/telemetry/sdkTelemetryHeaderBuilder.ts new file mode 100644 index 0000000..16fe4d9 --- /dev/null +++ b/src/telemetry/sdkTelemetryHeaderBuilder.ts @@ -0,0 +1,42 @@ +/* + * This file is auto-generated by Chargebee. + * For more information on how to make changes to this file, please see the README. + * Reach out to dx@chargebee.com for any questions. + * Copyright 2026 Chargebee Inc. + */ + +import { SdkTelemetryFeature } from './sdkTelemetryFeature.js'; +import { + SDK_TELEMETRY_FEATURES_KEY, + SDK_TELEMETRY_MAX_HEADER_BYTES, +} from './sdkTelemetryHeader.js'; + +/** + * Builds RFC 9651 values for {@link SDK_TELEMETRY_HEADER_NAME}: a features segment keyed by + * {@link SDK_TELEMETRY_FEATURES_KEY} with enabled feature codes as boolean params + * (for example {@code f;ta;rc}). Correlate SDK identity via User-Agent. + */ +export function buildSdkTelemetryHeader( + features: ReadonlyArray | undefined, +): string | undefined { + if (!features || features.length === 0) { + return undefined; + } + + let value = SDK_TELEMETRY_FEATURES_KEY; + for (const feature of features) { + if (feature == null) { + continue; + } + value += `;${feature}`; + } + + if (value.length === SDK_TELEMETRY_FEATURES_KEY.length) { + return undefined; + } + + if (new TextEncoder().encode(value).length > SDK_TELEMETRY_MAX_HEADER_BYTES) { + return undefined; + } + return value; +} diff --git a/src/telemetry/sdkTelemetryState.ts b/src/telemetry/sdkTelemetryState.ts new file mode 100644 index 0000000..09de285 --- /dev/null +++ b/src/telemetry/sdkTelemetryState.ts @@ -0,0 +1,34 @@ +/* + * This file is auto-generated by Chargebee. + * For more information on how to make changes to this file, please see the README. + * Reach out to dx@chargebee.com for any questions. + * Copyright 2026 Chargebee Inc. + */ + +/** + * Per-client gate so the SDK telemetry header is considered at most once per client instance. + */ +export class SdkTelemetryState { + private emitted = false; + + /** + * Claims the single emission slot for this client. Returns true only for the first caller. + */ + tryMarkEmitted(): boolean { + if (this.emitted) { + return false; + } + this.emitted = true; + return true; + } + + /** Whether this client has already considered emitting the telemetry header. */ + hasEmitted(): boolean { + return this.emitted; + } + + /** Clears the emission gate (tests only). */ + clear(): void { + this.emitted = false; + } +} diff --git a/src/types.d.ts b/src/types.d.ts index 07a3939..a91f789 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,4 +1,4 @@ -import type { TelemetryAdapter } from './telemetry/index.js'; +import type { TelemetryAdapter, SdkTelemetryState } from './telemetry/index.js'; interface HttpClientInterface { makeApiRequest: (props: Request, timeout: number) => Promise; @@ -20,6 +20,9 @@ export type EnvType = { enableDebugLogs?: boolean; userAgentSuffix?: string; telemetryAdapter?: TelemetryAdapter; + sdkTelemetryEnabled?: boolean; + sdkTelemetryState?: SdkTelemetryState; + httpClientIsCustom?: boolean; /** When true, request parameters are validated against Zod schemas before each HTTP call (where a schema exists). */ enableValidation?: boolean; }; @@ -47,6 +50,8 @@ export type Config = { userAgentSuffix?: string; httpClient?: HttpClientInterface; telemetryAdapter?: TelemetryAdapter; + /** When false, the SDK does not send the anonymous x-chargebee-sdk-telemetry header. Default true. */ + sdkTelemetryEnabled?: boolean; /** When true, request parameters are validated against Zod schemas before each HTTP call (where a schema exists). */ enableValidation?: boolean; }; diff --git a/test/requestWrapper.test.ts b/test/requestWrapper.test.ts index 023e465..fd8eab9 100644 --- a/test/requestWrapper.test.ts +++ b/test/requestWrapper.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import { CreateChargebee } from '../src/createChargebee.js'; import { Environment } from '../src/environment.js'; -import { TelemetryAttributeKeys } from '../src/chargebee.esm.js'; +import { TelemetryAttributeKeys, SDK_TELEMETRY_HEADER_NAME } from '../src/chargebee.esm.js'; let capturedRequests: Request[] = []; let responseFactory: ((attempt: number) => Response) | null = null; @@ -591,6 +591,83 @@ describe('RequestWrapper - telemetry adapter', () => { }); }); +describe('RequestWrapper - SDK telemetry header', () => { + it('should omit header when no features are enabled', async () => { + const chargebee = createChargebee({ + retryConfig: { enabled: false }, + }); + await chargebee.customer.list({ limit: 1 }); + await chargebee.customer.list({ limit: 1 }); + + expect(capturedRequests.length).to.equal(2); + expect( + capturedRequests[0].headers.get(SDK_TELEMETRY_HEADER_NAME), + ).to.equal(null); + expect( + capturedRequests[1].headers.get(SDK_TELEMETRY_HEADER_NAME), + ).to.equal(null); + }); + + it('should not attach header when sdk telemetry is disabled', async () => { + const chargebee = createChargebee({ + sdkTelemetryEnabled: false, + httpClient: mockHttpClient, + retryConfig: { enabled: true, maxRetries: 1, delayMs: 0, retryOn: [500] }, + }); + await chargebee.customer.list({ limit: 1 }); + await chargebee.customer.list({ limit: 1 }); + + expect(capturedRequests.length).to.equal(2); + expect( + capturedRequests[0].headers.get(SDK_TELEMETRY_HEADER_NAME), + ).to.equal(null); + expect( + capturedRequests[1].headers.get(SDK_TELEMETRY_HEADER_NAME), + ).to.equal(null); + expect((chargebee as any)._env.sdkTelemetryState.hasEmitted()).to.equal( + false, + ); + }); + + it('should emit keyed feature codes once on the first call only', async () => { + const chargebee = createChargebee({ + httpClient: mockHttpClient, + retryConfig: { enabled: true, maxRetries: 1, delayMs: 0, retryOn: [500] }, + telemetryAdapter: { + onRequestStart: () => ({}), + onRequestEnd: () => {}, + }, + }); + + await chargebee.customer.list({ limit: 1 }); + await chargebee.customer.list({ limit: 1 }); + + expect(capturedRequests[0].headers.get(SDK_TELEMETRY_HEADER_NAME)).to.equal( + 'f;ta;ct;rc', + ); + expect( + capturedRequests[1].headers.get(SDK_TELEMETRY_HEADER_NAME), + ).to.equal(null); + }); + + it('should emit ct and rc without telemetry adapter', async () => { + const chargebee = createChargebee({ + httpClient: mockHttpClient, + retryConfig: { enabled: true, maxRetries: 1, delayMs: 0, retryOn: [500] }, + }); + + await chargebee.customer.list({ limit: 1 }); + await chargebee.customer.list({ limit: 1 }); + + expect(capturedRequests[0].headers.get(SDK_TELEMETRY_HEADER_NAME)).to.equal( + 'f;ct;rc', + ); + expect( + capturedRequests[1].headers.get(SDK_TELEMETRY_HEADER_NAME), + ).to.equal(null); + }); +}); + describe('Chargebee telemetry exports', () => { it('should export TelemetryAttributeKeys at runtime', () => { expect(TelemetryAttributeKeys.URL_FULL).to.equal('url.full'); diff --git a/test/sdkTelemetryHeaderBuilder.test.ts b/test/sdkTelemetryHeaderBuilder.test.ts new file mode 100644 index 0000000..0d78774 --- /dev/null +++ b/test/sdkTelemetryHeaderBuilder.test.ts @@ -0,0 +1,35 @@ +import { expect } from 'chai'; +import { buildSdkTelemetryHeader } from '../src/telemetry/sdkTelemetryHeaderBuilder.js'; +import { SdkTelemetryFeature } from '../src/telemetry/sdkTelemetryFeature.js'; + +describe('sdkTelemetryHeaderBuilder', () => { + it('should serialize features under the f key', () => { + const header = buildSdkTelemetryHeader([ + SdkTelemetryFeature.TELEMETRY_ADAPTER, + SdkTelemetryFeature.CUSTOM_TRANSPORT, + ]); + + expect(header).to.equal('f;ta;ct'); + }); + + it('should return undefined when no features are enabled', () => { + expect(buildSdkTelemetryHeader([])).to.equal(undefined); + expect(buildSdkTelemetryHeader(undefined)).to.equal(undefined); + }); + + it('should serialize all feature codes in enum order', () => { + const header = buildSdkTelemetryHeader([ + SdkTelemetryFeature.TELEMETRY_ADAPTER, + SdkTelemetryFeature.CUSTOM_TRANSPORT, + SdkTelemetryFeature.RETRY_CONFIG, + ]); + + expect(header).to.equal('f;ta;ct;rc'); + }); + + it('should serialize a single feature', () => { + expect(buildSdkTelemetryHeader([SdkTelemetryFeature.RETRY_CONFIG])).to.equal( + 'f;rc', + ); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index f4023de..6d532d6 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -185,14 +185,17 @@ declare module 'chargebee' { * @telemetryAdapter optional telemetry adapter for observability (e.g. OpenTelemetry) */ telemetryAdapter?: TelemetryAdapter; + + /** + * @sdkTelemetryEnabled when false, the SDK does not send the anonymous x-chargebee-sdk-telemetry header. Default true. + */ + sdkTelemetryEnabled?: boolean; }; export interface HttpClientInterface { makeApiRequest: (request: Request, timeout: number) => Promise; } - export type RequestTelemetryHandle = unknown; - export const TelemetryAttributeKeys: { readonly URL_FULL: 'url.full'; readonly HTTP_REQUEST_METHOD: 'http.request.method'; @@ -210,6 +213,10 @@ declare module 'chargebee' { readonly CHARGEBEE_ERROR_PARAM: 'chargebee.error.param'; }; + export const SDK_TELEMETRY_HEADER_NAME: 'x-chargebee-sdk-telemetry'; + + export type RequestTelemetryHandle = unknown; + export type RequestTelemetryContext = { spanName: string; resource: string;