diff --git a/CHANGELOG.md b/CHANGELOG.md index 5645570..b0247b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ -### v3.27.0 (2026-06-30) +### v3.28.0 (2026-06-30) * * * + +### New Features +* Added an optional `telemetryAdapter` config hook for tracing Chargebee API calls via OpenTelemetry (or any APM). When unconfigured, the SDK skips all telemetry work — no behavior change for existing integrations. +* Each API call emits one CLIENT span (`chargebee.{resource}.{operation}`) with OpenTelemetry HTTP semantic-convention attributes plus `chargebee.*` attributes, and injects W3C trace context (`traceparent`) into outbound requests for distributed tracing. +* Exposed `TelemetryAdapter`, `RequestTelemetryContext`, `RequestTelemetryResult`, `RequestTelemetryError`, `RequestTelemetryHandle` types and the `TelemetryAttributeKeys` constant from the CJS and ESM entry points. +* Added a ready-to-use OpenTelemetry adapter via the `chargebee/telemetry/otel` subpath. `@opentelemetry/api` is an optional peer dependency and is not bundled — install it in your app to use this adapter. + ### Enhancements: - **Zod-backed request validation** — Set `enableValidation: true` in the client configuration to validate outgoing request parameters against each endpoint's generated Zod schema before the API call is sent. Invalid payloads raise `ChargebeeZodValidationError`, which carries the offending `actionName` and the original `ZodError` (including `issues` and `flatten()`) for inspection. - **Runtime dependency** — Added [`zod`](https://www.npmjs.com/package/zod) (v4) as a runtime dependency to support request validation. @@ -41,7 +48,6 @@ - `updated_at` and `created_at` have been added as new values to enum query parameter `sort_by.desc` in [`list_omnichannel_subscriptions`](https://apidocs.chargebee.com/docs/api/omnichannel_subscriptions/list-omnichannel-subscriptions) of [`OmnichannelSubscription`](https://apidocs.chargebee.com/docs/api/omnichannel_subscriptions). - ### v3.25.0 (2026-06-08) * * * ### New Attributes: diff --git a/README.md b/README.md index 4adc586..cb7b7a5 100644 --- a/README.md +++ b/README.md @@ -602,6 +602,98 @@ const chargebee = new Chargebee({ These examples demonstrate how to implement and inject custom clients using `axios` and `ky`, respectively. +### 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. + +`@opentelemetry/api` is an **optional peer dependency** — it is not bundled with `chargebee` and is only loaded when you import the adapter, so a plain `import 'chargebee'` stays dependency-free. + +The SDK builds standardized span attributes (`ctx.startAttributes`, `result.endAttributes`) following the stable [OpenTelemetry HTTP semantic conventions](https://opentelemetry.io/docs/specs/semconv/http/http-spans/) (`url.full`, `http.request.method`, `http.response.status_code`, `server.address`, `error.type`) plus Chargebee-specific `chargebee.*` attributes — use them as-is so spans render correctly in your APM and stay consistent across SDKs. + +Spans are named `chargebee.{resource}.{operation}` (e.g. `chargebee.subscription.create`). + +#### Quick start (built-in adapter) + +```bash +npm install chargebee @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http +``` + +Configure OpenTelemetry once at app startup. Exporting (endpoint, service name, credentials) is driven entirely by your OpenTelemetry runtime and the standard `OTEL_*` environment variables — the adapter just uses the globally registered tracer: + +```typescript +// instrumentation.ts — node --require ./instrumentation.js app.js +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; + +new NodeSDK({ + serviceName: process.env.OTEL_SERVICE_NAME ?? 'billing-service', + traceExporter: new OTLPTraceExporter({ + url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? 'http://localhost:4318/v1/traces', + }), +}).start(); +``` + +Then import the ready-to-use adapter and pass it on client initialization: + +```typescript +import Chargebee from 'chargebee'; +import otelDefaultAdapter from 'chargebee/telemetry/otel'; + +const chargebee = new Chargebee({ + site: '{{site}}', + apiKey: '{{api-key}}', + telemetryAdapter: otelDefaultAdapter, +}); +``` + +That's it — every SDK call now emits a `chargebee..` CLIENT span that attaches to the active OpenTelemetry context (or starts a root span), with W3C trace context propagated to Chargebee. Spans are exported by your own OpenTelemetry setup, so they flow to whatever backend you've configured (Datadog, Splunk, Honeycomb, Jaeger, etc.) — refer to your APM vendor's OpenTelemetry/OTLP documentation for exporter endpoints. + +#### Custom adapter (advanced) + +To customize behavior (different tracer name, extra attributes, a non-OpenTelemetry backend, etc.), implement `TelemetryAdapter` yourself instead of importing the built-in adapter: + +```typescript +import Chargebee, { + type TelemetryAdapter, + type RequestTelemetryContext, + type RequestTelemetryResult, +} from 'chargebee'; +import { context, propagation, trace, SpanKind, SpanStatusCode, type Span } from '@opentelemetry/api'; + +class OtelTelemetryAdapter implements TelemetryAdapter { + private readonly tracer = trace.getTracer('chargebee-node'); + + onRequestStart(ctx: RequestTelemetryContext, requestHeaders: Record): Span { + const span = this.tracer.startSpan(ctx.spanName, { + kind: SpanKind.CLIENT, + attributes: ctx.startAttributes, + }); + propagation.inject(trace.setSpan(context.active(), span), requestHeaders); + return span; + } + + onRequestEnd(span: Span | void, result: RequestTelemetryResult) { + if (!span) return; + for (const [key, value] of Object.entries(result.endAttributes)) { + span.setAttribute(key, value); + } + if (result.error) { + span.recordException(new Error(result.error.message)); + span.setStatus({ code: SpanStatusCode.ERROR, message: result.error.message }); + } else { + span.setStatus({ code: SpanStatusCode.OK }); + } + span.end(); + } +} + +const chargebee = new Chargebee({ + site: '{{site}}', + apiKey: '{{api-key}}', + telemetryAdapter: new OtelTelemetryAdapter(), +}); +``` + ## Feedback If you find any bugs or have any questions / feedback, open an issue in this repository or reach out to us on dx@chargebee.com diff --git a/VERSION b/VERSION index 8c53120..a72fd67 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.27.0 +3.28.0 diff --git a/package-lock.json b/package-lock.json index 9fb40ab..d6ca222 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "chargebee", - "version": "3.27.0", + "version": "3.28.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chargebee", - "version": "3.27.0", + "version": "3.28.0", "dependencies": { "zod": "^4.3.6" }, "devDependencies": { + "@opentelemetry/api": "^1.9.0", "@types/chai": "^4.3.5", "@types/mocha": "^10.0.10", "@types/node": "20.12.0", @@ -23,6 +24,14 @@ }, "engines": { "node": ">=18.*" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } }, "node_modules/@cspotcode/source-map-support": { @@ -66,6 +75,16 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", diff --git a/package.json b/package.json index 42dbb6d..06a4647 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chargebee", - "version": "3.27.0", + "version": "3.28.0", "description": "A library for integrating with Chargebee.", "scripts": { "prepack": "npm install && npm run build", @@ -34,33 +34,49 @@ } ], "exports": { - "types": "./types/index.d.ts", - "browser": { - "import": "./esm/chargebee.esm.worker.js", - "require": "./cjs/chargebee.cjs.worker.js" + ".": { + "types": "./types/index.d.ts", + "browser": { + "import": "./esm/chargebee.esm.worker.js", + "require": "./cjs/chargebee.cjs.worker.js" + }, + "worker": { + "import": "./esm/chargebee.esm.worker.js", + "require": "./cjs/chargebee.cjs.worker.js" + }, + "workerd": { + "import": "./esm/chargebee.esm.worker.js", + "require": "./cjs/chargebee.cjs.worker.js" + }, + "deno": { + "import": "./esm/chargebee.esm.worker.js", + "require": "./cjs/chargebee.cjs.worker.js" + }, + "bun": { + "import": "./esm/chargebee.esm.worker.js", + "require": "./cjs/chargebee.cjs.worker.js" + }, + "default": { + "import": "./esm/chargebee.esm.js", + "require": "./cjs/chargebee.cjs.js" + } }, - "worker": { - "import": "./esm/chargebee.esm.worker.js", - "require": "./cjs/chargebee.cjs.worker.js" - }, - "workerd": { - "import": "./esm/chargebee.esm.worker.js", - "require": "./cjs/chargebee.cjs.worker.js" - }, - "deno": { - "import": "./esm/chargebee.esm.worker.js", - "require": "./cjs/chargebee.cjs.worker.js" - }, - "bun": { - "import": "./esm/chargebee.esm.worker.js", - "require": "./cjs/chargebee.cjs.worker.js" - }, - "default": { - "import": "./esm/chargebee.esm.js", - "require": "./cjs/chargebee.cjs.js" + "./telemetry/otel": { + "types": "./types/telemetry/otel.d.ts", + "import": "./esm/telemetry/otel.js", + "require": "./cjs/telemetry/otel.js" + } + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true } }, "devDependencies": { + "@opentelemetry/api": "^1.9.0", "@types/chai": "^4.3.5", "@types/mocha": "^10.0.10", "@types/node": "20.12.0", diff --git a/src/RequestWrapper.ts b/src/RequestWrapper.ts index bebe765..df3b0ae 100644 --- a/src/RequestWrapper.ts +++ b/src/RequestWrapper.ts @@ -16,6 +16,14 @@ import { RequestHeaders, RetryConfig, } from './types.js'; +import { + buildRequestTelemetryContext, + buildRequestTelemetryResult, + extractHttpStatusCode, + extractRequestTelemetryError, + resolveChargebeeApiVersion, + type TelemetryAdapter, +} from './telemetry/index.js'; import { handleResponse } from './coreCommon.js'; import { Buffer } from 'node:buffer'; import type { ZodObject, ZodRawShape } from 'zod'; @@ -70,9 +78,43 @@ export class RequestWrapper { return null; } + private _buildRequestUrl( + env: EnvType, + urlIdParam: string, + params: JSONValue, + ): URL { + let path: string = getApiURL( + env, + this.apiCall.urlPrefix, + this.apiCall.urlSuffix, + urlIdParam, + ); + + if (this.apiCall.httpMethod === 'GET') { + let requestParams: JSONValue = params; + if (typeof requestParams === 'undefined' || requestParams === null) { + requestParams = {}; + } + const queryParam = this.apiCall.isListReq + ? encodeListParams(serialize(requestParams)) + : encodeParams(serialize(requestParams)); + path += '?' + queryParam; + } + + return new URL( + path, + `${env.protocol}://${getHost(env, this.apiCall.subDomain)}${env.port ? `:${env.port}` : ''}`, + ); + } + public async request(): Promise { let _env: any = {}; extend(true, _env, this.envArg); + // Class-based adapters (e.g. OpenTelemetry) keep methods on the prototype; + // deep extend only copies own enumerable properties, so preserve by reference. + if (this.envArg.telemetryAdapter !== undefined) { + _env.telemetryAdapter = this.envArg.telemetryAdapter; + } const env = _env as EnvType; @@ -129,28 +171,50 @@ export class RequestWrapper { this.httpHeaders['chargebee-idempotency-key'] = uuidv4(); } - const makeRequest = async (attempt: number = 0): Promise => { - let path: string = getApiURL( - env, - this.apiCall.urlPrefix, - this.apiCall.urlSuffix, - urlIdParam, - ); + const telemetryAdapter = env.telemetryAdapter; + const telemetryHeaders: RequestHeaders = {}; + const requestStartTime = Date.now(); + + const requestUrl = this._buildRequestUrl(env, urlIdParam, params); + // No telemetry adapter configured => skip all telemetry work (zero overhead). + let telemetryHandle: unknown; + if (telemetryAdapter !== undefined) { + const telemetryContext = buildRequestTelemetryContext({ + resource: this.apiCall.resource, + operation: this.apiCall.methodName, + httpMethod: this.apiCall.httpMethod, + httpUrl: `${requestUrl.origin}${requestUrl.pathname}`, + serverAddress: requestUrl.hostname, + chargebeeSite: env.site, + chargebeeApiVersion: resolveChargebeeApiVersion(env.apiPath), + sdkVersion: env.clientVersion, + requestHeaders: this.httpHeaders, + }); + try { + telemetryHandle = telemetryAdapter.onRequestStart( + telemetryContext, + telemetryHeaders, + ); + } catch (err) { + const message = + err instanceof Error + ? err.message + : 'Unknown telemetry adapter error'; + log(env, { + level: 'ERROR', + message: `Telemetry adapter onRequestStart failed: ${message}. Continuing without telemetry.`, + }); + telemetryHandle = undefined; + } + } + const makeRequest = async (attempt: number = 0): Promise => { let requestParams: JSONValue = params; if (typeof requestParams === 'undefined' || requestParams === null) { requestParams = {}; } - if (this.apiCall.httpMethod === 'GET') { - const queryParam = this.apiCall.isListReq - ? encodeListParams(serialize(requestParams)) - : encodeParams(serialize(requestParams)); - path += '?' + queryParam; - requestParams = {}; - } - const jsonKeys = this.apiCall.jsonKeys; let data: string | null = null; if (this.apiCall.httpMethod !== 'GET') { @@ -165,7 +229,10 @@ export class RequestWrapper { ); } - const requestHeaders: RequestHeaders = { ...this.httpHeaders }; + const requestHeaders: RequestHeaders = { + ...this.httpHeaders, + ...telemetryHeaders, + }; if (data && data.length) { extend(true, requestHeaders, { 'Content-Length': Buffer.byteLength(data, 'utf8'), @@ -191,11 +258,7 @@ export class RequestWrapper { requestHeaders['X-CB-Retry-Attempt'] = attempt.toString(); } - const url = new URL( - path, - `${env.protocol}://${getHost(env, this.apiCall.subDomain)}${env.port ? `:${env.port}` : ''}`, - ); - const request: Request = new Request(url, { + const request: Request = new Request(requestUrl, { method: this.apiCall.httpMethod, body: data || undefined, headers: this._createHeaders(requestHeaders), @@ -273,7 +336,64 @@ export class RequestWrapper { } }; - const promise = withRetry(0, Date.now()); + const runWithTelemetry = async ( + adapter: TelemetryAdapter, + ): Promise => { + try { + const result = await withRetry(0, requestStartTime); + const httpStatusCode = + typeof result?.httpStatusCode === 'number' + ? result.httpStatusCode + : 200; + try { + adapter.onRequestEnd( + telemetryHandle, + buildRequestTelemetryResult({ + httpStatusCode, + durationMs: Date.now() - requestStartTime, + }), + ); + } catch (err) { + const message = + err instanceof Error + ? err.message + : 'Unknown telemetry adapter error'; + log(env, { + level: 'ERROR', + message: `Telemetry adapter onRequestEnd failed: ${message}.`, + }); + } + return result; + } catch (err) { + const httpStatusCode = extractHttpStatusCode(err) ?? 500; + const telemetryError = extractRequestTelemetryError(err); + try { + adapter.onRequestEnd( + telemetryHandle, + buildRequestTelemetryResult({ + httpStatusCode, + durationMs: Date.now() - requestStartTime, + error: telemetryError, + }), + ); + } catch (telemetryErr) { + const message = + telemetryErr instanceof Error + ? telemetryErr.message + : 'Unknown telemetry adapter error'; + log(env, { + level: 'ERROR', + message: `Telemetry adapter onRequestEnd failed: ${message}.`, + }); + } + throw err; + } + }; + + const promise = + telemetryAdapter !== undefined + ? runWithTelemetry(telemetryAdapter) + : withRetry(0, requestStartTime); return callbackifyPromise(promise); } diff --git a/src/chargebee.cjs.ts b/src/chargebee.cjs.ts index 76cb6f5..7afeda8 100644 --- a/src/chargebee.cjs.ts +++ b/src/chargebee.cjs.ts @@ -9,6 +9,7 @@ import { WebhookPayloadParseError, } from './resources/webhook/handler.js'; import { basicAuthValidator } from './resources/webhook/auth.js'; +import { TelemetryAttributeKeys } from './telemetry/index.js'; import { ChargebeeZodValidationError } from './chargebeeZodValidationError.js'; const httpClient = new FetchHttpClient(); @@ -27,6 +28,7 @@ module.exports.WebhookError = WebhookError; module.exports.WebhookAuthenticationError = WebhookAuthenticationError; module.exports.WebhookPayloadValidationError = WebhookPayloadValidationError; module.exports.WebhookPayloadParseError = WebhookPayloadParseError; +module.exports.TelemetryAttributeKeys = TelemetryAttributeKeys; // Export validation error class module.exports.ChargebeeZodValidationError = ChargebeeZodValidationError; @@ -40,3 +42,12 @@ export type { RequestValidator, } from './resources/webhook/handler.js'; export type { CredentialValidator } from './resources/webhook/auth.js'; + +// Export telemetry types +export type { + TelemetryAdapter, + RequestTelemetryContext, + RequestTelemetryResult, + RequestTelemetryError, + RequestTelemetryHandle, +} from './telemetry/index.js'; diff --git a/src/chargebee.esm.ts b/src/chargebee.esm.ts index 8e3e36b..4f30d5c 100644 --- a/src/chargebee.esm.ts +++ b/src/chargebee.esm.ts @@ -18,6 +18,7 @@ export { WebhookPayloadValidationError, WebhookPayloadParseError, } from './resources/webhook/handler.js'; +export { TelemetryAttributeKeys } from './telemetry/index.js'; // Export validation error class export { ChargebeeZodValidationError } from './chargebeeZodValidationError.js'; @@ -31,3 +32,12 @@ export type { RequestValidator, } from './resources/webhook/handler.js'; export type { CredentialValidator } from './resources/webhook/auth.js'; + +// Export telemetry types +export type { + TelemetryAdapter, + RequestTelemetryContext, + RequestTelemetryResult, + RequestTelemetryError, + RequestTelemetryHandle, +} from './telemetry/index.js'; diff --git a/src/createChargebee.ts b/src/createChargebee.ts index 094a29d..f6b9bd7 100644 --- a/src/createChargebee.ts +++ b/src/createChargebee.ts @@ -20,10 +20,18 @@ import { export const CreateChargebee = (httpClient: HttpClientInterface) => { const Chargebee = function (this: ChargebeeType, conf: Config) { this._env = { ...Environment }; - extend(true, this._env, conf); + const { + telemetryAdapter, + httpClient: configHttpClient, + ...confToMerge + } = conf; + extend(true, this._env, confToMerge); // @ts-ignore this._env.httpClient = - conf.httpClient != null ? conf.httpClient : httpClient; + configHttpClient != null ? configHttpClient : httpClient; + if (telemetryAdapter !== undefined) { + this._env.telemetryAdapter = telemetryAdapter; + } this._buildResources(); this._endpoints = Endpoints; @@ -94,6 +102,7 @@ export const CreateChargebee = (httpClient: HttpClientInterface) => { for (let apiIdx = 0; apiIdx < apiCalls.length; apiIdx++) { const metaArr: EndpointTuple = apiCalls[apiIdx]; const apiCall: ResourceType = { + resource: res, methodName: metaArr[0], httpMethod: metaArr[1], urlPrefix: metaArr[2], diff --git a/src/environment.ts b/src/environment.ts index 1e2e463..a57f3db 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -9,7 +9,7 @@ export const Environment = { hostSuffix: '.chargebee.com', apiPath: '/api/v2', timeout: DEFAULT_TIME_OUT, - clientVersion: 'v3.27.0', + clientVersion: 'v3.28.0', port: DEFAULT_PORT, timemachineWaitInMillis: DEFAULT_TIME_MACHINE_WAIT, exportWaitInMillis: DEFAULT_EXPORT_WAIT, diff --git a/src/telemetry/TelemetryAdapter.ts b/src/telemetry/TelemetryAdapter.ts new file mode 100644 index 0000000..4d30b95 --- /dev/null +++ b/src/telemetry/TelemetryAdapter.ts @@ -0,0 +1,212 @@ +/* + * 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 { + BuildRequestTelemetryContextInput, + CHARGEBEE_SDK_NAME, + CHARGEBEE_TELEMETRY_HEADER_EXCLUDE_PREFIX, + CHARGEBEE_TELEMETRY_HEADER_PREFIX, + HTTP_REQUEST_HEADER_ATTRIBUTE_PREFIX, + RequestTelemetryContext, + RequestTelemetryError, + RequestTelemetryHandle, + RequestTelemetryResult, + TELEMETRY_SPAN_NAME_PREFIX, + TelemetryAttributeKeys, +} from './types.js'; + +export type RequestHeadersForTelemetry = Record; + +/** + * Optional telemetry adapter for observability integrations (e.g. OpenTelemetry). + * When not configured, the SDK skips all telemetry work — zero overhead. + * Implement as a class or plain object; the SDK stores the adapter by reference. + */ +export interface TelemetryAdapter { + onRequestStart( + context: RequestTelemetryContext, + requestHeaders: RequestHeadersForTelemetry, + ): THandle | void; + onRequestEnd(handle: THandle | void, result: RequestTelemetryResult): void; +} + +export class NoOpTelemetryAdapter implements TelemetryAdapter { + onRequestStart(): void { + return; + } + + onRequestEnd(): void { + return; + } +} + +export const NO_OP_TELEMETRY_ADAPTER = new NoOpTelemetryAdapter(); + +export function buildSpanName(resource: string, operation: string): string { + return `${TELEMETRY_SPAN_NAME_PREFIX}.${resource}.${operation}`; +} + +export function resolveChargebeeApiVersion(apiPath: string): 'v1' | 'v2' { + return apiPath === '/api/v1' ? 'v1' : 'v2'; +} + +/** + * Captures Chargebee custom request headers as OTel span attributes. + * + * Headers whose (lowercased) name starts with `chargebee-` are recorded as + * `http.request.header.` with string[] values, per the OpenTelemetry HTTP semantic + * conventions. The `chargebee-request-origin-*` family (origin IP, email, device) is skipped + * because it carries end-user PII. Matching by prefix means new `chargebee-*` headers are + * captured automatically without an SDK upgrade. + */ +export function buildRequestHeaderSpanAttributes( + requestHeaders: Record | undefined, +): Record { + const attributes: Record = {}; + if (!requestHeaders) { + return attributes; + } + + for (const [name, value] of Object.entries(requestHeaders)) { + if (value === undefined || value === null) { + continue; + } + const lowerName = name.toLowerCase(); + if ( + !lowerName.startsWith(CHARGEBEE_TELEMETRY_HEADER_PREFIX) || + lowerName.startsWith(CHARGEBEE_TELEMETRY_HEADER_EXCLUDE_PREFIX) + ) { + continue; + } + attributes[`${HTTP_REQUEST_HEADER_ATTRIBUTE_PREFIX}${lowerName}`] = [ + String(value), + ]; + } + + return attributes; +} + +export function buildRequestStartSpanAttributes( + input: BuildRequestTelemetryContextInput, +): Record { + return { + [TelemetryAttributeKeys.URL_FULL]: input.httpUrl, + [TelemetryAttributeKeys.HTTP_REQUEST_METHOD]: input.httpMethod, + [TelemetryAttributeKeys.SERVER_ADDRESS]: input.serverAddress, + [TelemetryAttributeKeys.CHARGEBEE_SITE]: input.chargebeeSite, + [TelemetryAttributeKeys.CHARGEBEE_API_VERSION]: input.chargebeeApiVersion, + [TelemetryAttributeKeys.CHARGEBEE_RESOURCE]: input.resource, + [TelemetryAttributeKeys.CHARGEBEE_OPERATION]: input.operation, + [TelemetryAttributeKeys.CHARGEBEE_SDK_NAME]: CHARGEBEE_SDK_NAME, + [TelemetryAttributeKeys.CHARGEBEE_SDK_VERSION]: input.sdkVersion, + ...buildRequestHeaderSpanAttributes(input.requestHeaders), + }; +} + +export function buildRequestEndSpanAttributes( + result: Omit, +): Record { + const attributes: Record = { + [TelemetryAttributeKeys.HTTP_RESPONSE_STATUS_CODE]: result.httpStatusCode, + }; + + if (result.error) { + // error.type is the status code on failed requests + attributes[TelemetryAttributeKeys.ERROR_TYPE] = String( + result.httpStatusCode, + ); + + if (result.error.chargebeeErrorCode) { + attributes[TelemetryAttributeKeys.CHARGEBEE_ERROR_CODE] = + result.error.chargebeeErrorCode; + } + if (result.error.chargebeeApiErrorType) { + attributes[TelemetryAttributeKeys.CHARGEBEE_ERROR_TYPE] = + result.error.chargebeeApiErrorType; + } + if (result.error.chargebeeErrorParam) { + attributes[TelemetryAttributeKeys.CHARGEBEE_ERROR_PARAM] = + result.error.chargebeeErrorParam; + } + } + + return attributes; +} + +export function buildRequestTelemetryContext( + input: BuildRequestTelemetryContextInput, +): RequestTelemetryContext { + return { + spanName: buildSpanName(input.resource, input.operation), + resource: input.resource, + operation: input.operation, + httpMethod: input.httpMethod, + httpUrl: input.httpUrl, + serverAddress: input.serverAddress, + chargebeeSite: input.chargebeeSite, + chargebeeApiVersion: input.chargebeeApiVersion, + sdkName: CHARGEBEE_SDK_NAME, + sdkVersion: input.sdkVersion, + startAttributes: buildRequestStartSpanAttributes(input), + }; +} + +export function buildRequestTelemetryResult( + result: Omit, +): RequestTelemetryResult { + return { + ...result, + endAttributes: buildRequestEndSpanAttributes(result), + }; +} + +export function extractRequestTelemetryError( + err: unknown, +): RequestTelemetryError | undefined { + if (err == null || typeof err !== 'object') { + return undefined; + } + + const errorObj = err as Record; + const message = + typeof errorObj.message === 'string' + ? errorObj.message + : 'Chargebee API request failed'; + + const result: RequestTelemetryError = { message }; + + if (typeof errorObj.api_error_code === 'string') { + result.chargebeeErrorCode = errorObj.api_error_code; + } + if (typeof errorObj.type === 'string') { + result.chargebeeApiErrorType = errorObj.type; + } + if (typeof errorObj.param === 'string') { + result.chargebeeErrorParam = errorObj.param; + } + + return result; +} + +export function extractHttpStatusCode(err: unknown): number | undefined { + if (err == null || typeof err !== 'object') { + return undefined; + } + const errorObj = err as Record; + for (const key of [ + 'http_status_code', + 'httpStatusCode', + 'http_code', + 'statusCode', + ]) { + const value = errorObj[key]; + if (typeof value === 'number') { + return value; + } + } + return undefined; +} diff --git a/src/telemetry/index.ts b/src/telemetry/index.ts new file mode 100644 index 0000000..cd2d882 --- /dev/null +++ b/src/telemetry/index.ts @@ -0,0 +1,36 @@ +/* + * 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. + */ + +export { + CHARGEBEE_SDK_NAME, + CHARGEBEE_TELEMETRY_HEADER_EXCLUDE_PREFIX, + CHARGEBEE_TELEMETRY_HEADER_PREFIX, + HTTP_REQUEST_HEADER_ATTRIBUTE_PREFIX, + TELEMETRY_SPAN_NAME_PREFIX, + TelemetryAttributeKeys, + type BuildRequestTelemetryContextInput, + type RequestTelemetryContext, + type RequestTelemetryError, + type RequestTelemetryHandle, + type RequestTelemetryResult, +} from './types.js'; + +export { + NO_OP_TELEMETRY_ADAPTER, + NoOpTelemetryAdapter, + type RequestHeadersForTelemetry, + type TelemetryAdapter, + buildRequestEndSpanAttributes, + buildRequestHeaderSpanAttributes, + buildRequestStartSpanAttributes, + buildRequestTelemetryContext, + buildRequestTelemetryResult, + buildSpanName, + extractHttpStatusCode, + extractRequestTelemetryError, + resolveChargebeeApiVersion, +} from './TelemetryAdapter.js'; diff --git a/src/telemetry/otel.ts b/src/telemetry/otel.ts new file mode 100644 index 0000000..5d19283 --- /dev/null +++ b/src/telemetry/otel.ts @@ -0,0 +1,86 @@ +/* + * 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 { + context, + propagation, + SpanKind, + SpanStatusCode, + trace, + type Span, +} from '@opentelemetry/api'; +import { TelemetryAdapter } from './TelemetryAdapter.js'; +import { + CHARGEBEE_SDK_NAME, + RequestTelemetryContext, + RequestTelemetryError, + RequestTelemetryResult, +} from './types.js'; + +const tracer = trace.getTracer(CHARGEBEE_SDK_NAME); + +function toRecordedException(error: RequestTelemetryError): Error { + const exception = new Error(error.message); + exception.name = error.chargebeeErrorCode ?? 'ChargebeeAPIError'; + return exception; +} + +/** + * Ready-to-use OpenTelemetry adapter for the Chargebee SDK. + * + * Each SDK request becomes a CLIENT span attached to the active OpenTelemetry + * context (or a root span when none is active), and the W3C trace context is + * propagated to Chargebee so the trace continues server-side. + * + * Exporting (endpoint, service name, credentials) is configured by your own + * OpenTelemetry runtime via the standard `OTEL_*` environment variables — this + * adapter only uses the globally registered tracer from `@opentelemetry/api`. + * + * `@opentelemetry/api` is an optional peer dependency; install it to use this + * adapter. A default-exported, ready-to-use instance is provided. + */ +export class OtelTelemetryAdapter implements TelemetryAdapter { + onRequestStart( + ctx: RequestTelemetryContext, + requestHeaders: Record, + ): Span { + // startSpan adopts the active context's span as parent (or starts a root span if none). + const span = tracer.startSpan(ctx.spanName, { + kind: SpanKind.CLIENT, + attributes: ctx.startAttributes, + }); + // Propagate W3C trace context to Chargebee so the trace continues server-side. + propagation.inject(trace.setSpan(context.active(), span), requestHeaders); + return span; + } + + onRequestEnd(span: Span | void, result: RequestTelemetryResult): void { + if (!span) { + return; + } + + for (const [key, value] of Object.entries(result.endAttributes)) { + span.setAttribute(key, value); + } + + if (result.error) { + span.recordException(toRecordedException(result.error)); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: result.error.message, + }); + } else { + span.setStatus({ code: SpanStatusCode.OK }); + } + + span.end(); + } +} + +const otelDefaultAdapter = new OtelTelemetryAdapter(); + +export default otelDefaultAdapter; diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts new file mode 100644 index 0000000..efddc48 --- /dev/null +++ b/src/telemetry/types.ts @@ -0,0 +1,99 @@ +/* + * 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 identifier recorded on telemetry spans. */ +export const CHARGEBEE_SDK_NAME = 'chargebee-node'; + +/** Standard span name prefix: chargebee.{resource}.{operation} */ +export const TELEMETRY_SPAN_NAME_PREFIX = 'chargebee'; + +/** + * OTel HTTP semantic-convention prefix for request-header attributes: + * `http.request.header.` with string[] values. + */ +export const HTTP_REQUEST_HEADER_ATTRIBUTE_PREFIX = 'http.request.header.'; + +/** + * Request headers whose (lowercased) name starts with this prefix are captured as span + * attributes. Using a prefix instead of a fixed list means any future `chargebee-*` header + * is picked up automatically, with no SDK upgrade required. + */ +export const CHARGEBEE_TELEMETRY_HEADER_PREFIX = 'chargebee-'; + +/** + * Headers under this sub-prefix carry end-user PII (origin IP, email, device) and are + * excluded from spans by default. Chargebee namespaces such headers under + * `chargebee-request-origin-*`, so future PII headers stay excluded automatically. + */ +export const CHARGEBEE_TELEMETRY_HEADER_EXCLUDE_PREFIX = + 'chargebee-request-origin-'; + +/** Span attribute keys — shared across Chargebee SDKs. */ +export const TelemetryAttributeKeys = { + URL_FULL: 'url.full', + HTTP_REQUEST_METHOD: 'http.request.method', + HTTP_RESPONSE_STATUS_CODE: 'http.response.status_code', + SERVER_ADDRESS: 'server.address', + ERROR_TYPE: 'error.type', + CHARGEBEE_SITE: 'chargebee.site', + CHARGEBEE_API_VERSION: 'chargebee.api_version', + CHARGEBEE_RESOURCE: 'chargebee.resource', + CHARGEBEE_OPERATION: 'chargebee.operation', + CHARGEBEE_SDK_NAME: 'chargebee.sdk.name', + CHARGEBEE_SDK_VERSION: 'chargebee.sdk.version', + CHARGEBEE_ERROR_CODE: 'chargebee.error.code', + CHARGEBEE_ERROR_TYPE: 'chargebee.error.type', + CHARGEBEE_ERROR_PARAM: 'chargebee.error.param', +} as const; + +export type RequestTelemetryHandle = unknown; + +export type RequestTelemetryContext = { + spanName: string; + resource: string; + operation: string; + httpMethod: string; + httpUrl: string; + serverAddress: string; + chargebeeSite: string; + chargebeeApiVersion: 'v1' | 'v2'; + sdkName: typeof CHARGEBEE_SDK_NAME; + sdkVersion: string; + /** + * Prebuilt span attributes — pass these to your tracer. Captured `chargebee-*` request + * headers appear as `http.request.header.` with string[] values per OTel semconv. + */ + startAttributes: Record; +}; + +export type RequestTelemetryError = { + message: string; + chargebeeErrorCode?: string; + chargebeeApiErrorType?: string; + chargebeeErrorParam?: string; +}; + +export type RequestTelemetryResult = { + httpStatusCode: number; + durationMs: number; + error?: RequestTelemetryError; + /** Prebuilt span attributes — pass these to your tracer. */ + endAttributes: Record; +}; + +export type BuildRequestTelemetryContextInput = { + resource: string; + operation: string; + httpMethod: string; + httpUrl: string; + serverAddress: string; + chargebeeSite: string; + chargebeeApiVersion: 'v1' | 'v2'; + sdkVersion: string; + /** Outgoing request headers; matching `chargebee-*` headers are captured as span attributes. */ + requestHeaders?: Record; +}; diff --git a/src/types.d.ts b/src/types.d.ts index ae6626f..07a3939 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,6 +1,9 @@ +import type { TelemetryAdapter } from './telemetry/index.js'; + interface HttpClientInterface { makeApiRequest: (props: Request, timeout: number) => Promise; } + export type EnvType = { protocol: string; hostSuffix: string; @@ -16,6 +19,7 @@ export type EnvType = { retryConfig?: RetryConfig; enableDebugLogs?: boolean; userAgentSuffix?: string; + telemetryAdapter?: TelemetryAdapter; /** When true, request parameters are validated against Zod schemas before each HTTP call (where a schema exists). */ enableValidation?: boolean; }; @@ -42,6 +46,7 @@ export type Config = { enableDebugLogs?: boolean; userAgentSuffix?: string; httpClient?: HttpClientInterface; + telemetryAdapter?: TelemetryAdapter; /** When true, request parameters are validated against Zod schemas before each HTTP call (where a schema exists). */ enableValidation?: boolean; }; @@ -54,6 +59,7 @@ export type CustomParam = { export type ResponseHeaders = Record; export type RequestHeaders = Record; export type ResourceType = { + resource: string; methodName: string; httpMethod: string; urlPrefix: string; diff --git a/test/requestWrapper.test.ts b/test/requestWrapper.test.ts index d63cdb1..b0f9494 100644 --- a/test/requestWrapper.test.ts +++ b/test/requestWrapper.test.ts @@ -1,6 +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'; let capturedRequests: Request[] = []; let responseFactory: ((attempt: number) => Response) | null = null; @@ -247,9 +248,7 @@ describe('RequestWrapper - request headers', () => { const chargebee = createChargebee(); await chargebee.customer.list(); - expect( - capturedRequests[0].headers.get('X-CB-Retry-Attempt'), - ).to.be.null; + expect(capturedRequests[0].headers.get('X-CB-Retry-Attempt')).to.be.null; }); it('should set X-CB-Retry-Attempt to "1" on the first retry', async () => { @@ -267,14 +266,17 @@ describe('RequestWrapper - request headers', () => { }; const chargebee = createChargebee({ - retryConfig: { enabled: true, maxRetries: 2, delayMs: 0, retryOn: [500] }, + retryConfig: { + enabled: true, + maxRetries: 2, + delayMs: 0, + retryOn: [500], + }, }); await chargebee.customer.list(); expect(capturedRequests.length).to.equal(2); - expect( - capturedRequests[0].headers.get('X-CB-Retry-Attempt'), - ).to.be.null; + expect(capturedRequests[0].headers.get('X-CB-Retry-Attempt')).to.be.null; expect(capturedRequests[1].headers.get('X-CB-Retry-Attempt')).to.equal( '1', ); @@ -295,14 +297,308 @@ describe('RequestWrapper - request headers', () => { }; const chargebee = createChargebee({ - retryConfig: { enabled: true, maxRetries: 3, delayMs: 0, retryOn: [500] }, + retryConfig: { + enabled: true, + maxRetries: 3, + delayMs: 0, + retryOn: [500], + }, }); await chargebee.customer.list(); expect(capturedRequests.length).to.equal(3); expect(capturedRequests[0].headers.get('X-CB-Retry-Attempt')).to.be.null; - expect(capturedRequests[1].headers.get('X-CB-Retry-Attempt')).to.equal('1'); - expect(capturedRequests[2].headers.get('X-CB-Retry-Attempt')).to.equal('2'); + expect(capturedRequests[1].headers.get('X-CB-Retry-Attempt')).to.equal( + '1', + ); + expect(capturedRequests[2].headers.get('X-CB-Retry-Attempt')).to.equal( + '2', + ); + }); + }); +}); + +describe('RequestWrapper - telemetry adapter', () => { + it('should not call telemetry adapter when not configured', async () => { + const chargebee = createChargebee(); + await chargebee.customer.list(); + expect(capturedRequests.length).to.equal(1); + }); + + it('should call telemetry adapter once per API call including retries', async () => { + const telemetryEvents: string[] = []; + let capturedContext: any = null; + let capturedResult: any = null; + + responseFactory = (attempt) => { + if (attempt < 1) { + return new Response( + JSON.stringify({ http_status_code: 500, message: 'server error' }), + { status: 500, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(JSON.stringify({ list: [], next_offset: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const chargebee = createChargebee({ + retryConfig: { enabled: true, maxRetries: 2, delayMs: 0, retryOn: [500] }, + telemetryAdapter: { + onRequestStart: (ctx, headers) => { + telemetryEvents.push('start'); + capturedContext = ctx; + headers['traceparent'] = '00-test-trace'; + return { id: 'span-1' }; + }, + onRequestEnd: (_handle, result) => { + telemetryEvents.push('end'); + capturedResult = result; + }, + }, + }); + + await chargebee.customer.list(); + + expect(telemetryEvents).to.deep.equal(['start', 'end']); + expect(capturedContext.spanName).to.equal('chargebee.customer.list'); + expect(capturedContext.resource).to.equal('customer'); + expect(capturedContext.operation).to.equal('list'); + expect(capturedContext.chargebeeSite).to.equal('test-site'); + expect(capturedContext.startAttributes['url.full']).to.match( + /^https:\/\/test-site\.chargebee\.com/, + ); + expect(capturedContext.startAttributes['http.request.method']).to.equal( + 'GET', + ); + expect(capturedContext.startAttributes['chargebee.resource']).to.equal( + 'customer', + ); + expect(capturedResult.httpStatusCode).to.equal(200); + expect(capturedResult.endAttributes['http.response.status_code']).to.equal( + 200, + ); + expect(capturedResult.endAttributes['error.type']).to.equal(undefined); + expect(capturedRequests.length).to.equal(2); + expect(capturedRequests[0].headers.get('traceparent')).to.equal( + '00-test-trace', + ); + }); + + it('should report error details on failed API response', async () => { + let capturedResult: any = null; + + responseFactory = () => + new Response( + JSON.stringify({ + message: 'Not found', + type: 'invalid_request', + api_error_code: 'resource_not_found', + param: 'subscription_id', + }), + { status: 404, headers: { 'Content-Type': 'application/json' } }, + ); + + const chargebee = createChargebee({ + telemetryAdapter: { + onRequestStart: () => ({}), + onRequestEnd: (_handle, result) => { + capturedResult = result; + }, + }, + }); + + try { + await chargebee.subscription.retrieve('sub_missing'); + } catch (_err) { + // expected + } + + expect(capturedResult.httpStatusCode).to.equal(404); + expect(capturedResult.endAttributes['http.response.status_code']).to.equal( + 404, + ); + expect(capturedResult.endAttributes['error.type']).to.equal('404'); + expect(capturedResult.endAttributes['chargebee.error.code']).to.equal( + 'resource_not_found', + ); + expect(capturedResult.endAttributes['chargebee.error.type']).to.equal( + 'invalid_request', + ); + expect(capturedResult.endAttributes['chargebee.error.param']).to.equal( + 'subscription_id', + ); + expect(capturedResult.error.chargebeeErrorCode).to.equal( + 'resource_not_found', + ); + expect(capturedResult.error.chargebeeApiErrorType).to.equal( + 'invalid_request', + ); + expect(capturedResult.error.chargebeeErrorParam).to.equal( + 'subscription_id', + ); + }); + + it('should not fail API call when onRequestStart throws', async () => { + let onRequestEndCalled = false; + + const chargebee = createChargebee({ + telemetryAdapter: { + onRequestStart: () => { + throw new Error('start hook failed'); + }, + onRequestEnd: () => { + onRequestEndCalled = true; + }, + }, }); + + const result = await chargebee.customer.list(); + expect(result).to.have.property('list'); + expect(capturedRequests.length).to.equal(1); + expect(onRequestEndCalled).to.equal(true); + }); + + it('should invoke class-based telemetry adapters', async () => { + const telemetryEvents: string[] = []; + + class ClassTelemetryAdapter { + onRequestStart() { + telemetryEvents.push('start'); + return { id: 'class-span' }; + } + + onRequestEnd() { + telemetryEvents.push('end'); + } + } + + const chargebee = createChargebee({ + telemetryAdapter: new ClassTelemetryAdapter(), + }); + + await chargebee.customer.list(); + + expect(telemetryEvents).to.deep.equal(['start', 'end']); + }); + + it('should capture chargebee-* request headers as http.request.header.* attributes', async () => { + let capturedContext: any = null; + + const chargebee = createChargebee({ + telemetryAdapter: { + onRequestStart: (ctx) => { + capturedContext = ctx; + return { id: 'span-1' }; + }, + onRequestEnd: () => {}, + }, + }); + + await chargebee.customer.list( + { limit: 1 }, + { + 'chargebee-business-entity-id': 'be_123', + 'chargebee-event-actions': 'all-disabled', + // Mixed-case header name should normalize to lowercase. + 'Chargebee-Idempotency-Key': 'idem-key-1', + // Non-chargebee headers must never be captured. + Authorization: 'Basic super-secret', + 'X-Custom': 'nope', + }, + ); + + const attrs = capturedContext.startAttributes; + expect(attrs['http.request.header.chargebee-business-entity-id']).to.deep.equal( + ['be_123'], + ); + expect(attrs['http.request.header.chargebee-event-actions']).to.deep.equal([ + 'all-disabled', + ]); + expect(attrs['http.request.header.chargebee-idempotency-key']).to.deep.equal( + ['idem-key-1'], + ); + expect(attrs['http.request.header.authorization']).to.equal(undefined); + expect(attrs['http.request.header.x-custom']).to.equal(undefined); + }); + + it('should exclude chargebee-request-origin-* (PII) headers from span attributes', async () => { + let capturedContext: any = null; + + const chargebee = createChargebee({ + telemetryAdapter: { + onRequestStart: (ctx) => { + capturedContext = ctx; + return { id: 'span-1' }; + }, + onRequestEnd: () => {}, + }, + }); + + await chargebee.customer.list( + { limit: 1 }, + { + 'chargebee-business-entity-id': 'be_123', + 'chargebee-request-origin-ip': '202.170.207.70', + 'chargebee-request-origin-user': 'amara@acme.com', + 'chargebee-request-origin-user-encoded': 'dXNlckBhY21lLmNvbQ==', + 'chargebee-request-origin-device': 'iOS', + }, + ); + + const attrs = capturedContext.startAttributes; + // Safe control header is captured... + expect(attrs['http.request.header.chargebee-business-entity-id']).to.deep.equal( + ['be_123'], + ); + // ...but the PII family is excluded by default. + expect(attrs['http.request.header.chargebee-request-origin-ip']).to.equal( + undefined, + ); + expect(attrs['http.request.header.chargebee-request-origin-user']).to.equal( + undefined, + ); + expect( + attrs['http.request.header.chargebee-request-origin-user-encoded'], + ).to.equal(undefined); + expect(attrs['http.request.header.chargebee-request-origin-device']).to.equal( + undefined, + ); + // The PII values must not leak into any attribute. + const serialized = JSON.stringify(attrs); + expect(serialized).to.not.contain('202.170.207.70'); + expect(serialized).to.not.contain('amara@acme.com'); + }); + + it('should not fail API call when onRequestEnd throws', async () => { + const chargebee = createChargebee({ + telemetryAdapter: { + onRequestStart: () => ({ id: 'span-1' }), + onRequestEnd: () => { + throw new Error('end hook failed'); + }, + }, + }); + + const result = await chargebee.customer.list(); + expect(result).to.have.property('list'); + expect(capturedRequests.length).to.equal(1); + }); +}); + +describe('Chargebee telemetry exports', () => { + it('should export TelemetryAttributeKeys at runtime', () => { + expect(TelemetryAttributeKeys.URL_FULL).to.equal('url.full'); + expect(TelemetryAttributeKeys.HTTP_REQUEST_METHOD).to.equal( + 'http.request.method', + ); + expect(TelemetryAttributeKeys.HTTP_RESPONSE_STATUS_CODE).to.equal( + 'http.response.status_code', + ); + expect(TelemetryAttributeKeys.ERROR_TYPE).to.equal('error.type'); + expect(TelemetryAttributeKeys.CHARGEBEE_RESOURCE).to.equal( + 'chargebee.resource', + ); }); }); diff --git a/types/index.d.ts b/types/index.d.ts index f9d67f9..9862003 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -169,6 +169,11 @@ declare module 'chargebee' { */ httpClient?: HttpClientInterface; + /** + * @telemetryAdapter optional telemetry adapter for observability (e.g. OpenTelemetry) + */ + telemetryAdapter?: TelemetryAdapter; + /** * @enableValidation When true, every request's parameters are validated against each endpoint's generated Zod schema before the HTTP request is sent. Violations throw `ChargebeeZodValidationError` with structured Zod issues. Calls with no params argument are validated as `{}`. Required resource ids in the URL path are still checked separately. */ @@ -179,6 +184,70 @@ declare module 'chargebee' { 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'; + readonly HTTP_RESPONSE_STATUS_CODE: 'http.response.status_code'; + readonly SERVER_ADDRESS: 'server.address'; + readonly ERROR_TYPE: 'error.type'; + readonly CHARGEBEE_SITE: 'chargebee.site'; + readonly CHARGEBEE_API_VERSION: 'chargebee.api_version'; + readonly CHARGEBEE_RESOURCE: 'chargebee.resource'; + readonly CHARGEBEE_OPERATION: 'chargebee.operation'; + readonly CHARGEBEE_SDK_NAME: 'chargebee.sdk.name'; + readonly CHARGEBEE_SDK_VERSION: 'chargebee.sdk.version'; + readonly CHARGEBEE_ERROR_CODE: 'chargebee.error.code'; + readonly CHARGEBEE_ERROR_TYPE: 'chargebee.error.type'; + readonly CHARGEBEE_ERROR_PARAM: 'chargebee.error.param'; + }; + + export type RequestTelemetryContext = { + spanName: string; + resource: string; + operation: string; + httpMethod: string; + httpUrl: string; + serverAddress: string; + chargebeeSite: string; + chargebeeApiVersion: 'v1' | 'v2'; + sdkName: string; + sdkVersion: string; + /** + * Prebuilt span attributes — pass these to your tracer. Captured `chargebee-*` request + * headers appear as `http.request.header.` with string[] values per OTel semconv. + */ + startAttributes: Record; + }; + + export type RequestTelemetryError = { + message: string; + chargebeeErrorCode?: string; + chargebeeApiErrorType?: string; + chargebeeErrorParam?: string; + }; + + export type RequestTelemetryResult = { + httpStatusCode: number; + durationMs: number; + error?: RequestTelemetryError; + /** Prebuilt span attributes — pass these to your tracer. */ + endAttributes: Record; + }; + + /** + * Optional telemetry adapter. Implement as a class or plain object — the SDK + * keeps it by reference (never deep-cloned). Wire OpenTelemetry or other tools here. + */ + export interface TelemetryAdapter { + onRequestStart( + context: RequestTelemetryContext, + requestHeaders: Record, + ): THandle | void; + onRequestEnd(handle: THandle | void, result: RequestTelemetryResult): void; + } + export type RetryConfig = { /** * @enabled whether to enable retry logic, default value is false diff --git a/types/telemetry/otel.d.ts b/types/telemetry/otel.d.ts new file mode 100644 index 0000000..ba1fa41 --- /dev/null +++ b/types/telemetry/otel.d.ts @@ -0,0 +1,22 @@ +declare module 'chargebee/telemetry/otel' { + import type { + RequestTelemetryContext, + RequestTelemetryResult, + TelemetryAdapter, + } from 'chargebee'; + /** + * Ready-to-use OpenTelemetry adapter for the Chargebee SDK. Exporting is + * configured by your own OpenTelemetry runtime via the standard `OTEL_*` + * environment variables; this adapter only uses the globally registered + * tracer from `@opentelemetry/api` (an optional peer dependency). + */ + export class OtelTelemetryAdapter implements TelemetryAdapter { + onRequestStart( + ctx: RequestTelemetryContext, + requestHeaders: Record, + ): unknown; + onRequestEnd(handle: unknown, result: RequestTelemetryResult): void; + } + const otelDefaultAdapter: OtelTelemetryAdapter; + export default otelDefaultAdapter; +}