From 0c1c79e50fbb83e7a7457194bedb6a7fe40b3872 Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Fri, 12 Jun 2026 12:37:29 +0530 Subject: [PATCH 1/7] Add optional OpenTelemetry telemetry adapter for API calls --- README.md | 74 ++++++++++ src/RequestWrapper.ts | 151 +++++++++++++++++--- src/chargebee.cjs.ts | 2 + src/chargebee.esm.ts | 1 + src/createChargebee.ts | 13 +- src/telemetry/TelemetryAdapter.ts | 177 +++++++++++++++++++++++ src/telemetry/index.ts | 32 +++++ src/telemetry/types.ts | 73 ++++++++++ src/types.d.ts | 6 + test/requestWrapper.test.ts | 228 ++++++++++++++++++++++++++++-- types/index.d.ts | 66 +++++++++ 11 files changed, 790 insertions(+), 33 deletions(-) create mode 100644 src/telemetry/TelemetryAdapter.ts create mode 100644 src/telemetry/index.ts create mode 100644 src/telemetry/types.ts diff --git a/README.md b/README.md index 8c17df0..b6320c7 100644 --- a/README.md +++ b/README.md @@ -563,6 +563,80 @@ 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.). OpenTelemetry is not bundled with `chargebee` — install and configure it in your app, implement `TelemetryAdapter`, and wire it on the client. + +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`). + +#### OpenTelemetry example + +```bash +npm install chargebee @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http @opentelemetry/auto-instrumentations-node +``` + +Configure OpenTelemetry at app startup, then pass your adapter: + +```typescript +// instrumentation.ts — node --require ./instrumentation.js app.js +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; + +new NodeSDK({ + serviceName: 'billing-service', + traceExporter: new OTLPTraceExporter({ + url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? 'http://localhost:4318/v1/traces', + }), + instrumentations: [getNodeAutoInstrumentations()], +}).start(); +``` + +```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.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(), +}); +``` + +Spans are exported by your own OpenTelemetry setup, so they flow to whatever backend you've configured (Datadog, Splunk, Honeycomb, Jaeger, etc.). The Chargebee config above stays the same regardless of backend — refer to your APM vendor's OpenTelemetry/OTLP documentation for exporter endpoints. + ## 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/src/RequestWrapper.ts b/src/RequestWrapper.ts index b3e004e..6f87647 100644 --- a/src/RequestWrapper.ts +++ b/src/RequestWrapper.ts @@ -16,6 +16,14 @@ import { RequestHeaders, RetryConfig, } from './types.js'; +import { + buildRequestTelemetryContext, + buildRequestTelemetryResult, + extractHttpStatusCode, + extractRequestTelemetryError, + NO_OP_TELEMETRY_ADAPTER, + resolveChargebeeApiVersion, +} from './telemetry/index.js'; import { handleResponse } from './coreCommon.js'; import { Buffer } from 'node:buffer'; @@ -51,9 +59,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; @@ -83,28 +125,44 @@ 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 ?? NO_OP_TELEMETRY_ADAPTER; + const telemetryHeaders: RequestHeaders = {}; + const requestStartTime = Date.now(); + + const requestUrl = this._buildRequestUrl(env, urlIdParam, params); + 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, + }); + let telemetryHandle: unknown; + 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') { @@ -119,7 +177,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'), @@ -145,11 +206,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), @@ -227,7 +284,59 @@ export class RequestWrapper { } }; - const promise = withRetry(0, Date.now()); + const runWithTelemetry = async (): Promise => { + try { + const result = await withRetry(0, requestStartTime); + const httpStatusCode = + typeof result?.httpStatusCode === 'number' + ? result.httpStatusCode + : 200; + try { + telemetryAdapter.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 { + telemetryAdapter.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 = runWithTelemetry(); return callbackifyPromise(promise); } diff --git a/src/chargebee.cjs.ts b/src/chargebee.cjs.ts index b2aa811..1df480e 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'; const httpClient = new FetchHttpClient(); const Chargebee = CreateChargebee(httpClient); @@ -26,6 +27,7 @@ module.exports.WebhookError = WebhookError; module.exports.WebhookAuthenticationError = WebhookAuthenticationError; module.exports.WebhookPayloadValidationError = WebhookPayloadValidationError; module.exports.WebhookPayloadParseError = WebhookPayloadParseError; +module.exports.TelemetryAttributeKeys = TelemetryAttributeKeys; // Export webhook types export type { diff --git a/src/chargebee.esm.ts b/src/chargebee.esm.ts index 5fa6393..0dc028b 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 webhook types export type { diff --git a/src/createChargebee.ts b/src/createChargebee.ts index 3ec3082..87c149c 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/telemetry/TelemetryAdapter.ts b/src/telemetry/TelemetryAdapter.ts new file mode 100644 index 0000000..44264eb --- /dev/null +++ b/src/telemetry/TelemetryAdapter.ts @@ -0,0 +1,177 @@ +/* + * 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, + 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). + * Default is a no-op — zero overhead when not configured. + * 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'; +} + +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, + }; +} + +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') { + if (err instanceof Error) { + return { message: err.message }; + } + return undefined; + } + + const errorObj = err as Record; + const message = + typeof errorObj.message === 'string' + ? errorObj.message + : err instanceof Error + ? err.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..96a9a34 --- /dev/null +++ b/src/telemetry/index.ts @@ -0,0 +1,32 @@ +/* + * 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, + 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, + buildRequestStartSpanAttributes, + buildRequestTelemetryContext, + buildRequestTelemetryResult, + buildSpanName, + extractHttpStatusCode, + extractRequestTelemetryError, + resolveChargebeeApiVersion, +} from './TelemetryAdapter.js'; diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts new file mode 100644 index 0000000..e29d45b --- /dev/null +++ b/src/telemetry/types.ts @@ -0,0 +1,73 @@ +/* + * 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'; + +/** 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. */ + 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; +}; diff --git a/src/types.d.ts b/src/types.d.ts index 99ac074..c2b6e01 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; }; export type RetryConfig = { @@ -40,6 +44,7 @@ export type Config = { enableDebugLogs?: boolean; userAgentSuffix?: string; httpClient?: HttpClientInterface; + telemetryAdapter?: TelemetryAdapter; }; export type Callback = (error: unknown, result: any | null) => void; @@ -50,6 +55,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..2044447 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,220 @@ 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 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 b5eb5eb..f6f955e 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -164,12 +164,78 @@ declare module 'chargebee' { * @httpClient optional http client implementation, default http client will be used if not provided */ httpClient?: HttpClientInterface; + + /** + * @telemetryAdapter optional telemetry adapter for observability (e.g. OpenTelemetry) + */ + telemetryAdapter?: TelemetryAdapter; }; 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'; + 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. */ + 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 From 6ad0866863856b0a29cf0050304229f960cb7d0f Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Fri, 12 Jun 2026 16:35:29 +0530 Subject: [PATCH 2/7] fix(telemetry): export adapter types from entry points and skip work when unconfigured --- src/RequestWrapper.ts | 66 ++++++++++++++++++------------- src/chargebee.cjs.ts | 9 +++++ src/chargebee.esm.ts | 9 +++++ src/telemetry/TelemetryAdapter.ts | 2 +- 4 files changed, 57 insertions(+), 29 deletions(-) diff --git a/src/RequestWrapper.ts b/src/RequestWrapper.ts index 6f87647..67fd651 100644 --- a/src/RequestWrapper.ts +++ b/src/RequestWrapper.ts @@ -21,8 +21,8 @@ import { buildRequestTelemetryResult, extractHttpStatusCode, extractRequestTelemetryError, - NO_OP_TELEMETRY_ADAPTER, resolveChargebeeApiVersion, + type TelemetryAdapter, } from './telemetry/index.js'; import { handleResponse } from './coreCommon.js'; import { Buffer } from 'node:buffer'; @@ -125,35 +125,40 @@ export class RequestWrapper { this.httpHeaders['chargebee-idempotency-key'] = uuidv4(); } - const telemetryAdapter = env.telemetryAdapter ?? NO_OP_TELEMETRY_ADAPTER; + const telemetryAdapter = env.telemetryAdapter; const telemetryHeaders: RequestHeaders = {}; const requestStartTime = Date.now(); const requestUrl = this._buildRequestUrl(env, urlIdParam, params); - 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, - }); + // No telemetry adapter configured => skip all telemetry work (zero overhead). let telemetryHandle: unknown; - 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.`, + 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, }); - telemetryHandle = undefined; + 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 => { @@ -284,7 +289,9 @@ export class RequestWrapper { } }; - const runWithTelemetry = async (): Promise => { + const runWithTelemetry = async ( + adapter: TelemetryAdapter, + ): Promise => { try { const result = await withRetry(0, requestStartTime); const httpStatusCode = @@ -292,7 +299,7 @@ export class RequestWrapper { ? result.httpStatusCode : 200; try { - telemetryAdapter.onRequestEnd( + adapter.onRequestEnd( telemetryHandle, buildRequestTelemetryResult({ httpStatusCode, @@ -314,7 +321,7 @@ export class RequestWrapper { const httpStatusCode = extractHttpStatusCode(err) ?? 500; const telemetryError = extractRequestTelemetryError(err); try { - telemetryAdapter.onRequestEnd( + adapter.onRequestEnd( telemetryHandle, buildRequestTelemetryResult({ httpStatusCode, @@ -336,7 +343,10 @@ export class RequestWrapper { } }; - const promise = runWithTelemetry(); + 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 1df480e..860a7e4 100644 --- a/src/chargebee.cjs.ts +++ b/src/chargebee.cjs.ts @@ -38,3 +38,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 0dc028b..3438f3c 100644 --- a/src/chargebee.esm.ts +++ b/src/chargebee.esm.ts @@ -29,3 +29,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/telemetry/TelemetryAdapter.ts b/src/telemetry/TelemetryAdapter.ts index 44264eb..a9029a1 100644 --- a/src/telemetry/TelemetryAdapter.ts +++ b/src/telemetry/TelemetryAdapter.ts @@ -20,7 +20,7 @@ export type RequestHeadersForTelemetry = Record; /** * Optional telemetry adapter for observability integrations (e.g. OpenTelemetry). - * Default is a no-op — zero overhead when not configured. + * 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 { From ff1cb135581b4733d7cb6f82f78b8875a0694b72 Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Fri, 12 Jun 2026 20:12:21 +0530 Subject: [PATCH 3/7] Update README.md --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index b6320c7..b160582 100644 --- a/README.md +++ b/README.md @@ -574,7 +574,7 @@ Spans are named `chargebee.{resource}.{operation}` (e.g. `chargebee.subscription #### OpenTelemetry example ```bash -npm install chargebee @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http @opentelemetry/auto-instrumentations-node +npm install chargebee @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http ``` Configure OpenTelemetry at app startup, then pass your adapter: @@ -583,14 +583,12 @@ Configure OpenTelemetry at app startup, then pass your adapter: // instrumentation.ts — node --require ./instrumentation.js app.js import { NodeSDK } from '@opentelemetry/sdk-node'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; -import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; new NodeSDK({ serviceName: 'billing-service', traceExporter: new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? 'http://localhost:4318/v1/traces', }), - instrumentations: [getNodeAutoInstrumentations()], }).start(); ``` From a3f356b6851c844f179ee9ad3e675656c086384f Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Wed, 17 Jun 2026 12:23:17 +0530 Subject: [PATCH 4/7] update Readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b160582..f5d082f 100644 --- a/README.md +++ b/README.md @@ -618,6 +618,7 @@ class OtelTelemetryAdapter implements TelemetryAdapter { 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 }); From c4f1a733acaa6fa0589bc8d1ae18ec257ecf5d35 Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Fri, 26 Jun 2026 13:05:31 +0530 Subject: [PATCH 5/7] feat(telemetry): capture chargebee-* request headers as span attributes --- src/RequestWrapper.ts | 1 + src/telemetry/TelemetryAdapter.ts | 42 ++++++++++++++- src/telemetry/index.ts | 4 ++ src/telemetry/types.ts | 30 ++++++++++- test/requestWrapper.test.ts | 88 +++++++++++++++++++++++++++++++ types/index.d.ts | 7 ++- 6 files changed, 167 insertions(+), 5 deletions(-) diff --git a/src/RequestWrapper.ts b/src/RequestWrapper.ts index 67fd651..7ca3362 100644 --- a/src/RequestWrapper.ts +++ b/src/RequestWrapper.ts @@ -142,6 +142,7 @@ export class RequestWrapper { chargebeeSite: env.site, chargebeeApiVersion: resolveChargebeeApiVersion(env.apiPath), sdkVersion: env.clientVersion, + requestHeaders: this.httpHeaders, }); try { telemetryHandle = telemetryAdapter.onRequestStart( diff --git a/src/telemetry/TelemetryAdapter.ts b/src/telemetry/TelemetryAdapter.ts index a9029a1..2d0f576 100644 --- a/src/telemetry/TelemetryAdapter.ts +++ b/src/telemetry/TelemetryAdapter.ts @@ -8,6 +8,9 @@ import { BuildRequestTelemetryContextInput, CHARGEBEE_SDK_NAME, + CHARGEBEE_TELEMETRY_HEADER_EXCLUDE_PREFIX, + CHARGEBEE_TELEMETRY_HEADER_PREFIX, + HTTP_REQUEST_HEADER_ATTRIBUTE_PREFIX, RequestTelemetryContext, RequestTelemetryError, RequestTelemetryHandle, @@ -51,9 +54,45 @@ 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 { +): Record { return { [TelemetryAttributeKeys.URL_FULL]: input.httpUrl, [TelemetryAttributeKeys.HTTP_REQUEST_METHOD]: input.httpMethod, @@ -64,6 +103,7 @@ export function buildRequestStartSpanAttributes( [TelemetryAttributeKeys.CHARGEBEE_OPERATION]: input.operation, [TelemetryAttributeKeys.CHARGEBEE_SDK_NAME]: CHARGEBEE_SDK_NAME, [TelemetryAttributeKeys.CHARGEBEE_SDK_VERSION]: input.sdkVersion, + ...buildRequestHeaderSpanAttributes(input.requestHeaders), }; } diff --git a/src/telemetry/index.ts b/src/telemetry/index.ts index 96a9a34..cd2d882 100644 --- a/src/telemetry/index.ts +++ b/src/telemetry/index.ts @@ -7,6 +7,9 @@ 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, @@ -22,6 +25,7 @@ export { type RequestHeadersForTelemetry, type TelemetryAdapter, buildRequestEndSpanAttributes, + buildRequestHeaderSpanAttributes, buildRequestStartSpanAttributes, buildRequestTelemetryContext, buildRequestTelemetryResult, diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts index e29d45b..efddc48 100644 --- a/src/telemetry/types.ts +++ b/src/telemetry/types.ts @@ -11,6 +11,27 @@ 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', @@ -42,8 +63,11 @@ export type RequestTelemetryContext = { chargebeeApiVersion: 'v1' | 'v2'; sdkName: typeof CHARGEBEE_SDK_NAME; sdkVersion: string; - /** Prebuilt span attributes — pass these to your tracer. */ - startAttributes: Record; + /** + * 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 = { @@ -70,4 +94,6 @@ export type BuildRequestTelemetryContextInput = { chargebeeSite: string; chargebeeApiVersion: 'v1' | 'v2'; sdkVersion: string; + /** Outgoing request headers; matching `chargebee-*` headers are captured as span attributes. */ + requestHeaders?: Record; }; diff --git a/test/requestWrapper.test.ts b/test/requestWrapper.test.ts index 2044447..b0f9494 100644 --- a/test/requestWrapper.test.ts +++ b/test/requestWrapper.test.ts @@ -483,6 +483,94 @@ describe('RequestWrapper - telemetry adapter', () => { 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: { diff --git a/types/index.d.ts b/types/index.d.ts index f6f955e..5cf9a61 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -205,8 +205,11 @@ declare module 'chargebee' { chargebeeApiVersion: 'v1' | 'v2'; sdkName: string; sdkVersion: string; - /** Prebuilt span attributes — pass these to your tracer. */ - startAttributes: Record; + /** + * 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 = { From b4b63bce12f39a0fda97e61b955059fd04bd1941 Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Tue, 30 Jun 2026 11:55:39 +0530 Subject: [PATCH 6/7] Add ready-to-use OpenTelemetry adapter generation for Node SDK --- README.md | 31 +++++++++++--- package-lock.json | 19 +++++++++ package.json | 62 +++++++++++++++++----------- src/telemetry/otel.ts | 86 +++++++++++++++++++++++++++++++++++++++ types/telemetry/otel.d.ts | 22 ++++++++++ 5 files changed, 191 insertions(+), 29 deletions(-) create mode 100644 src/telemetry/otel.ts create mode 100644 types/telemetry/otel.d.ts diff --git a/README.md b/README.md index f5d082f..acf6873 100644 --- a/README.md +++ b/README.md @@ -565,19 +565,21 @@ These examples demonstrate how to implement and inject custom clients using `axi ### Telemetry (OpenTelemetry) -Optional. Pass a `telemetryAdapter` when you want Chargebee API calls traced in your observability stack (Datadog, Splunk, Honeycomb, Jaeger, etc.). OpenTelemetry is not bundled with `chargebee` — install and configure it in your app, implement `TelemetryAdapter`, and wire it on the client. +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`). -#### OpenTelemetry example +#### Quick start (built-in adapter) ```bash npm install chargebee @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http ``` -Configure OpenTelemetry at app startup, then pass your adapter: +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 @@ -585,13 +587,32 @@ import { NodeSDK } from '@opentelemetry/sdk-node'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; new NodeSDK({ - serviceName: 'billing-service', + 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, @@ -634,8 +655,6 @@ const chargebee = new Chargebee({ }); ``` -Spans are exported by your own OpenTelemetry setup, so they flow to whatever backend you've configured (Datadog, Splunk, Honeycomb, Jaeger, etc.). The Chargebee config above stays the same regardless of backend — refer to your APM vendor's OpenTelemetry/OTLP documentation for exporter endpoints. - ## 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/package-lock.json b/package-lock.json index 3acbe7d..acdb6df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "chargebee", "version": "3.25.0", "devDependencies": { + "@opentelemetry/api": "^1.9.0", "@types/chai": "^4.3.5", "@types/mocha": "^10.0.10", "@types/node": "20.12.0", @@ -20,6 +21,14 @@ }, "engines": { "node": ">=18.*" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } }, "node_modules/@cspotcode/source-map-support": { @@ -63,6 +72,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 8c1b042..3c17522 100644 --- a/package.json +++ b/package.json @@ -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/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/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; +} From 41009d48ea9fd0fb98c2697bc7978a896eb2f570 Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Tue, 30 Jun 2026 12:10:30 +0530 Subject: [PATCH 7/7] chore(release): v3.26.0 telemetry adapter --- CHANGELOG.md | 10 ++++++++++ VERSION | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src/environment.ts | 2 +- src/telemetry/TelemetryAdapter.ts | 7 +------ 6 files changed, 16 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac90924..9cf9c9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +### v3.26.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. + + ### v3.25.0 (2026-06-08) * * * ### New Attributes: diff --git a/VERSION b/VERSION index 0914443..419ede3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.25.0 +3.26.0 diff --git a/package-lock.json b/package-lock.json index acdb6df..464313d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chargebee", - "version": "3.25.0", + "version": "3.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chargebee", - "version": "3.25.0", + "version": "3.26.0", "devDependencies": { "@opentelemetry/api": "^1.9.0", "@types/chai": "^4.3.5", diff --git a/package.json b/package.json index 3c17522..d3a800e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chargebee", - "version": "3.25.0", + "version": "3.26.0", "description": "A library for integrating with Chargebee.", "scripts": { "prepack": "npm install && npm run build", diff --git a/src/environment.ts b/src/environment.ts index 28c7efc..b7ce522 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.25.0', + clientVersion: 'v3.26.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 index 2d0f576..4d30b95 100644 --- a/src/telemetry/TelemetryAdapter.ts +++ b/src/telemetry/TelemetryAdapter.ts @@ -168,9 +168,6 @@ export function extractRequestTelemetryError( err: unknown, ): RequestTelemetryError | undefined { if (err == null || typeof err !== 'object') { - if (err instanceof Error) { - return { message: err.message }; - } return undefined; } @@ -178,9 +175,7 @@ export function extractRequestTelemetryError( const message = typeof errorObj.message === 'string' ? errorObj.message - : err instanceof Error - ? err.message - : 'Chargebee API request failed'; + : 'Chargebee API request failed'; const result: RequestTelemetryError = { message };