Skip to content
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,20 @@ const chargebee = new Chargebee({

These examples demonstrate how to implement and inject custom clients using `axios` and `ky`, respectively.

### SDK telemetry

By default, the library sends anonymous usage telemetry to Chargebee. This helps us improve the SDK and API.

You can disable this behavior if you prefer:

```javascript
const chargebee = new Chargebee({
site: 'your-site',
apiKey: 'your-api-key',
sdkTelemetryEnabled: false,
});
```

### Telemetry (OpenTelemetry)

Optional. Pass a `telemetryAdapter` when you want Chargebee API calls traced in your observability stack (Datadog, Splunk, Honeycomb, Jaeger, etc.). The SDK ships a ready-to-use OpenTelemetry adapter, so for most setups you only need to add `@opentelemetry/api` and wire the adapter on the client.
Expand Down
38 changes: 37 additions & 1 deletion src/RequestWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import {
extractHttpStatusCode,
extractRequestTelemetryError,
resolveChargebeeApiVersion,
attachSdkTelemetryHeader,
recordSdkTelemetryFailure,
recordSdkTelemetrySuccess,
type TelemetryAdapter,
} from './telemetry/index.js';
import { handleResponse } from './coreCommon.js';
Expand Down Expand Up @@ -115,6 +118,15 @@ export class RequestWrapper {
if (this.envArg.telemetryAdapter !== undefined) {
_env.telemetryAdapter = this.envArg.telemetryAdapter;
}
if (this.envArg.sdkTelemetryState !== undefined) {
_env.sdkTelemetryState = this.envArg.sdkTelemetryState;
}
if (this.envArg.sdkTelemetryEnabled !== undefined) {
_env.sdkTelemetryEnabled = this.envArg.sdkTelemetryEnabled;
}
if (this.envArg.httpClientIsCustom !== undefined) {
_env.httpClientIsCustom = this.envArg.httpClientIsCustom;
}

const env = _env as EnvType;

Expand Down Expand Up @@ -233,6 +245,7 @@ export class RequestWrapper {
...this.httpHeaders,
...telemetryHeaders,
};
attachSdkTelemetryHeader(env, requestHeaders);

const contentType = this.apiCall.isJsonRequest
? 'application/json;charset=UTF-8'
Expand Down Expand Up @@ -385,10 +398,33 @@ export class RequestWrapper {
}
};

const promise =
const callMetadata = {
resource: this.apiCall.resource,
operation: this.apiCall.methodName,
};

const executeCall = () =>
telemetryAdapter !== undefined
? runWithTelemetry(telemetryAdapter)
: withRetry(0, requestStartTime);

const promise = executeCall()
.then((result) => {
recordSdkTelemetrySuccess(
env,
callMetadata,
requestStartTime,
typeof result?.httpStatusCode === 'number'
? result.httpStatusCode
: 200,
result?.headers,
);
return result;
})
.catch((err) => {
recordSdkTelemetryFailure(env, callMetadata, requestStartTime, err);
throw err;
});
return callbackifyPromise(promise);
}

Expand Down
3 changes: 2 additions & 1 deletion src/chargebee.cjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from './resources/webhook/handler.js';
import { basicAuthValidator } from './resources/webhook/auth.js';
import { ChargebeeZodValidationError } from './chargebeeZodValidationError.js';
import { TelemetryAttributeKeys } from './telemetry/index.js';
import { TelemetryAttributeKeys, SDK_TELEMETRY_HEADER_NAME } from './telemetry/index.js';

const httpClient = new FetchHttpClient();
const Chargebee = CreateChargebee(httpClient);
Expand All @@ -29,6 +29,7 @@ module.exports.WebhookAuthenticationError = WebhookAuthenticationError;
module.exports.WebhookPayloadValidationError = WebhookPayloadValidationError;
module.exports.WebhookPayloadParseError = WebhookPayloadParseError;
module.exports.TelemetryAttributeKeys = TelemetryAttributeKeys;
module.exports.SDK_TELEMETRY_HEADER_NAME = SDK_TELEMETRY_HEADER_NAME;

// Export validation error class
module.exports.ChargebeeZodValidationError = ChargebeeZodValidationError;
Expand Down
2 changes: 1 addition & 1 deletion src/chargebee.esm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export {
WebhookPayloadValidationError,
WebhookPayloadParseError,
} from './resources/webhook/handler.js';
export { TelemetryAttributeKeys } from './telemetry/index.js';
export { TelemetryAttributeKeys, SDK_TELEMETRY_HEADER_NAME } from './telemetry/index.js';

// Export validation error class
export { ChargebeeZodValidationError } from './chargebeeZodValidationError.js';
Expand Down
5 changes: 5 additions & 0 deletions src/createChargebee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,24 @@ import {
type WebhookHandlerOptions,
createDefaultHandler,
} from './resources/webhook/handler.js';
import { SdkTelemetryState } from './telemetry/index.js';

export const CreateChargebee = (httpClient: HttpClientInterface) => {
const Chargebee = function (this: ChargebeeType, conf: Config) {
this._env = { ...Environment };
const {
telemetryAdapter,
httpClient: configHttpClient,
sdkTelemetryEnabled,
...confToMerge
} = conf;
extend(true, this._env, confToMerge);
// @ts-ignore
this._env.httpClient =
configHttpClient != null ? configHttpClient : httpClient;
this._env.sdkTelemetryState = new SdkTelemetryState();
this._env.sdkTelemetryEnabled = sdkTelemetryEnabled !== false;
this._env.httpClientIsCustom = configHttpClient != null;
if (telemetryAdapter !== undefined) {
this._env.telemetryAdapter = telemetryAdapter;
}
Expand Down
24 changes: 24 additions & 0 deletions src/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,27 @@ export {
extractRequestTelemetryError,
resolveChargebeeApiVersion,
} from './TelemetryAdapter.js';

export {
SDK_TELEMETRY_FT_CUSTOM_TRANSPORT,
SDK_TELEMETRY_FT_RETRY_CONFIG,
SDK_TELEMETRY_FT_TELEMETRY_ADAPTER,
SDK_TELEMETRY_HEADER_NAME,
SDK_TELEMETRY_MAX_HEADER_BYTES,
SDK_TELEMETRY_REQUEST_ID_HEADER,
SDK_TELEMETRY_RUNTIME,
} from './sdkTelemetryHeader.js';

export type { SdkTelemetrySnapshot } from './sdkTelemetrySnapshot.js';
export { SdkTelemetryState } from './sdkTelemetryState.js';
export {
buildSdkTelemetryHeader,
escapeSfString,
} from './sdkTelemetryHeaderBuilder.js';
export {
attachSdkTelemetryHeader,
recordSdkTelemetryFailure,
recordSdkTelemetrySuccess,
type SdkTelemetryCallMetadata,
type SdkTelemetryEnv,
} from './sdkTelemetryEmitter.js';
221 changes: 221 additions & 0 deletions src/telemetry/sdkTelemetryEmitter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
/*
* 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 { CHARGEBEE_SDK_NAME } from './types.js';
import {
extractHttpStatusCode,
type TelemetryAdapter,
} from './TelemetryAdapter.js';
import { buildSdkTelemetryHeader } from './sdkTelemetryHeaderBuilder.js';
import {
SDK_TELEMETRY_FT_CUSTOM_TRANSPORT,
SDK_TELEMETRY_FT_RETRY_CONFIG,
SDK_TELEMETRY_FT_TELEMETRY_ADAPTER,
SDK_TELEMETRY_HEADER_NAME,
SDK_TELEMETRY_REQUEST_ID_HEADER,
} from './sdkTelemetryHeader.js';
import type { SdkTelemetrySnapshot } from './sdkTelemetrySnapshot.js';
import type { SdkTelemetryState } from './sdkTelemetryState.js';

export type SdkTelemetryEnv = {
sdkTelemetryEnabled?: boolean;
sdkTelemetryState?: SdkTelemetryState;
clientVersion: string;
telemetryAdapter?: TelemetryAdapter;
httpClientIsCustom?: boolean;
retryConfig?: { enabled?: boolean };
};

export type SdkTelemetryCallMetadata = {
resource: string;
operation: string;
};

export type RequestHeadersForSdkTelemetry = Record<string, string | number>;

/**
* Emits the anonymous SDK telemetry request header, independently of any customer telemetry
* adapter.
*
* Uses an N+1 scheme: the header sent with a call describes the previous completed call on the
* same client, so the first call of a client never carries the header. Every failure path is
* swallowed and logged at WARNING: telemetry must never fail an API call.
*/
export function attachSdkTelemetryHeader(
env: SdkTelemetryEnv,
headers: RequestHeadersForSdkTelemetry,
): void {
if (env.sdkTelemetryEnabled === false) {
return;
}

try {
const previousCall = env.sdkTelemetryState?.lastCall();
if (!previousCall) {
return;
}
const headerValue = buildSdkTelemetryHeader(previousCall);
if (!headerValue) {
return;
}
headers[SDK_TELEMETRY_HEADER_NAME] = headerValue;
} catch (err) {
logSuppressed('attach header', err);
}
}

export function recordSdkTelemetrySuccess(
env: SdkTelemetryEnv,
call: SdkTelemetryCallMetadata,
startTimeMs: number,
httpStatus: number | undefined,
responseHeaders: Record<string, string | string[] | number> | undefined,
): void {
if (!hasTelemetryMetadata(call)) {
return;
}

try {
record(
env,
buildSnapshot(
env,
call,
startTimeMs,
httpStatus,
undefined,
extractRequestId(responseHeaders),
),
);
} catch (err) {
logSuppressed('record success', err);
}
}

export function recordSdkTelemetryFailure(
env: SdkTelemetryEnv,
call: SdkTelemetryCallMetadata,
startTimeMs: number,
callError: unknown,
): void {
if (!hasTelemetryMetadata(call)) {
return;
}

try {
const httpStatus = extractHttpStatusCode(callError);
const errorObj =
callError != null && typeof callError === 'object'
? (callError as Record<string, unknown>)
: undefined;
const errorCode =
typeof errorObj?.api_error_code === 'string'
? errorObj.api_error_code
: undefined;
const responseHeaders =
errorObj?.headers != null && typeof errorObj.headers === 'object'
? (errorObj.headers as Record<string, string | string[] | number>)
: undefined;

record(
env,
buildSnapshot(
env,
call,
startTimeMs,
httpStatus,
errorCode,
extractRequestId(responseHeaders),
),
);
} catch (err) {
logSuppressed('record failure', err);
}
}

function record(env: SdkTelemetryEnv, snapshot: SdkTelemetrySnapshot): void {
env.sdkTelemetryState?.record(snapshot);
}

function buildSnapshot(
env: SdkTelemetryEnv,
call: SdkTelemetryCallMetadata,
startTimeMs: number,
httpStatus: number | undefined,
errorCode: string | undefined,
requestId: string | undefined,
): SdkTelemetrySnapshot {
return {
sdkName: CHARGEBEE_SDK_NAME,
sdkVersion: env.clientVersion,
resource: call.resource,
operation: call.operation,
startTimeEpochSeconds: Math.floor(startTimeMs / 1000),
timeMs: elapsedMs(startTimeMs),
httpStatus,
errorCode,
requestId,
featureTokens: resolveFeatureTokens(env),
};
}

function resolveFeatureTokens(env: SdkTelemetryEnv): string[] {
const features: string[] = [];
if (env.telemetryAdapter !== undefined) {
features.push(SDK_TELEMETRY_FT_TELEMETRY_ADAPTER);
}
if (env.httpClientIsCustom) {
features.push(SDK_TELEMETRY_FT_CUSTOM_TRANSPORT);
}
if (isRetryConfigActive(env)) {
features.push(SDK_TELEMETRY_FT_RETRY_CONFIG);
}
return features;
}

function isRetryConfigActive(env: SdkTelemetryEnv): boolean {
return env.retryConfig?.enabled === true;
}

function extractRequestId(
headers: Record<string, string | string[] | number> | undefined,
): string | undefined {
if (!headers) {
return undefined;
}
const value = headers[SDK_TELEMETRY_REQUEST_ID_HEADER];
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value) && value.length > 0) {
return String(value[0]);
}
if (typeof value === 'number') {
return String(value);
}
return undefined;
}

function hasTelemetryMetadata(call: SdkTelemetryCallMetadata): boolean {
return isNotBlank(call.resource) && isNotBlank(call.operation);
}

function elapsedMs(startTimeMs: number): number {
return Math.max(0, Date.now() - startTimeMs);
}

function isNotBlank(value: string | undefined): boolean {
return value != null && value.trim().length > 0;
}

function logSuppressed(step: string, err: unknown): void {
const message = err instanceof Error ? err.message : String(err);
console.warn(
`SDK telemetry could not ${step} (${message}); API call unaffected.`,
err,
);
}
Loading
Loading