Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,78 @@ 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
```

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';

new NodeSDK({
serviceName: 'billing-service',
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? 'http://localhost:4318/v1/traces',
}),
}).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<Span> {
private readonly tracer = trace.getTracer('chargebee-node');

onRequestStart(ctx: RequestTelemetryContext, requestHeaders: Record<string, string | number>): 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
Expand Down
163 changes: 141 additions & 22 deletions src/RequestWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<any> {
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;

Expand Down Expand Up @@ -83,28 +125,49 @@ export class RequestWrapper {
this.httpHeaders['chargebee-idempotency-key'] = uuidv4();
}

const makeRequest = async (attempt: number = 0): Promise<any> => {
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,
});
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<any> => {
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') {
Expand All @@ -119,7 +182,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'),
Expand All @@ -145,11 +211,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),
Expand Down Expand Up @@ -227,7 +289,64 @@ export class RequestWrapper {
}
};

const promise = withRetry(0, Date.now());
const runWithTelemetry = async (
adapter: TelemetryAdapter,
): Promise<any> => {
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);
}

Expand Down
11 changes: 11 additions & 0 deletions src/chargebee.cjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 {
Expand All @@ -36,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';
10 changes: 10 additions & 0 deletions src/chargebee.esm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export {
WebhookPayloadValidationError,
WebhookPayloadParseError,
} from './resources/webhook/handler.js';
export { TelemetryAttributeKeys } from './telemetry/index.js';

// Export webhook types
export type {
Expand All @@ -28,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';
13 changes: 11 additions & 2 deletions src/createChargebee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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],
Expand Down
Loading
Loading