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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,12 @@ The SDK builds standardized span attributes (`ctx.startAttributes`, `result.endA

Spans are named `chargebee.{resource}.{operation}` (e.g. `chargebee.subscription.create`).

#### Server-side timing telemetry (Beta)

> **Beta.** `X-Chargebee-Telemetry` response parsing and `preferChargebeeTelemetry` are in beta. Header availability, wire format, and SDK behavior may change.

Chargebee returns `X-Chargebee-Telemetry` only when the client opts in with `Prefer: chargebee-telemetry=include`. Set `preferChargebeeTelemetry: true` on the client to have the SDK add that header on each request when a `telemetryAdapter` is configured (parsed into `chargebee.telemetry.*` span attributes). You can also set the `Prefer` header yourself on individual requests.

#### Quick start (built-in adapter)

```bash
Expand Down Expand Up @@ -643,6 +649,7 @@ const chargebee = new Chargebee({
site: '{{site}}',
apiKey: '{{api-key}}',
telemetryAdapter: otelDefaultAdapter,
preferChargebeeTelemetry: true,
});
```

Expand Down
13 changes: 12 additions & 1 deletion src/RequestWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ import {
RetryConfig,
} from './types.js';
import {
applyResponseTelemetryPreferHeader,
buildRequestTelemetryContext,
buildRequestTelemetryResult,
extractResponseHeaders,
extractHttpStatusCode,
extractRequestTelemetryError,
resolveChargebeeApiVersion,
Expand Down Expand Up @@ -161,6 +163,14 @@ export class RequestWrapper {

Object.assign(this.httpHeaders, headers);

const telemetryAdapter = env.telemetryAdapter;
if (
telemetryAdapter !== undefined &&
env.preferChargebeeTelemetry === true
) {
applyResponseTelemetryPreferHeader(this.httpHeaders);
}

if (
this.apiCall.httpMethod === 'POST' &&
!this.httpHeaders['chargebee-idempotency-key'] &&
Expand All @@ -171,7 +181,6 @@ export class RequestWrapper {
this.httpHeaders['chargebee-idempotency-key'] = uuidv4();
}

const telemetryAdapter = env.telemetryAdapter;
const telemetryHeaders: RequestHeaders = {};
const requestStartTime = Date.now();

Expand Down Expand Up @@ -346,6 +355,7 @@ export class RequestWrapper {
buildRequestTelemetryResult({
httpStatusCode,
durationMs: Date.now() - requestStartTime,
responseHeaders: result?.headers,
}),
);
} catch (err) {
Expand All @@ -369,6 +379,7 @@ export class RequestWrapper {
httpStatusCode,
durationMs: Date.now() - requestStartTime,
error: telemetryError,
responseHeaders: extractResponseHeaders(err),
}),
);
} catch (telemetryErr) {
Expand Down
10 changes: 9 additions & 1 deletion src/chargebee.cjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ 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 {
CHARGEBEE_TELEMETRY_PREFER_HEADER,
CHARGEBEE_TELEMETRY_PREFER_VALUE,
TelemetryAttributeKeys,
} from './telemetry/index.js';

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

// Export validation error class
module.exports.ChargebeeZodValidationError = ChargebeeZodValidationError;
Expand Down
4 changes: 4 additions & 0 deletions src/chargebee.esm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export {
WebhookPayloadParseError,
} from './resources/webhook/handler.js';
export { TelemetryAttributeKeys } from './telemetry/index.js';
export {
CHARGEBEE_TELEMETRY_PREFER_HEADER,
CHARGEBEE_TELEMETRY_PREFER_VALUE,
} from './telemetry/index.js';

// Export validation error class
export { ChargebeeZodValidationError } from './chargebeeZodValidationError.js';
Expand Down
103 changes: 100 additions & 3 deletions src/telemetry/TelemetryAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,25 @@
* Copyright 2026 Chargebee Inc.
*/

import { parseChargebeeTelemetryHeaderToSpanAttributes } from './chargebeeTelemetryHeaderParser.js';
import {
BuildRequestTelemetryContextInput,
CHARGEBEE_SDK_NAME,
CHARGEBEE_TELEMETRY_HEADER_EXCLUDE_PREFIX,
CHARGEBEE_TELEMETRY_HEADER_PREFIX,
CHARGEBEE_TELEMETRY_PREFER_HEADER,
CHARGEBEE_TELEMETRY_PREFER_VALUE,
HTTP_REQUEST_HEADER_ATTRIBUTE_PREFIX,
HTTP_RESPONSE_HEADER_ATTRIBUTE_PREFIX,
RequestTelemetryContext,
RequestTelemetryEndAttributeValue,
RequestTelemetryError,
RequestTelemetryHandle,
RequestTelemetryResult,
ResponseHeadersForTelemetry,
TELEMETRY_SPAN_NAME_PREFIX,
TelemetryAttributeKeys,
X_CHARGEBEE_TELEMETRY_HEADER,
} from './types.js';

export type RequestHeadersForTelemetry = Record<string, string | number>;
Expand Down Expand Up @@ -54,6 +61,23 @@ export function resolveChargebeeApiVersion(apiPath: string): 'v1' | 'v2' {
return apiPath === '/api/v1' ? 'v1' : 'v2';
}

/**
* Adds {@code Prefer: chargebee-telemetry=include} when not already set.
* Chargebee returns {@code X-Chargebee-Telemetry} only when this header is present.
*/
export function applyResponseTelemetryPreferHeader(
requestHeaders: Record<string, string | number>,
): void {
const preferHeader = CHARGEBEE_TELEMETRY_PREFER_HEADER.toLowerCase();
for (const name of Object.keys(requestHeaders)) {
if (name != null && name.toLowerCase() === preferHeader) {
return;
}
}
requestHeaders[CHARGEBEE_TELEMETRY_PREFER_HEADER] =
CHARGEBEE_TELEMETRY_PREFER_VALUE;
}

/**
* Captures Chargebee custom request headers as OTel span attributes.
*
Expand All @@ -72,7 +96,7 @@ export function buildRequestHeaderSpanAttributes(
}

for (const [name, value] of Object.entries(requestHeaders)) {
if (value === undefined || value === null) {
if (name == null || value === undefined || value === null) {
continue;
}
const lowerName = name.toLowerCase();
Expand All @@ -90,6 +114,54 @@ export function buildRequestHeaderSpanAttributes(
return attributes;
}

/** Case-insensitive response header lookup; skips entries whose name is null/undefined. */
export function getResponseHeaderValueIgnoreCase(
headers: Record<string, string | string[] | number | undefined> | undefined,
headerName: string,
): string | undefined {
if (!headers) {
return undefined;
}
const target = headerName.toLowerCase();
for (const [name, value] of Object.entries(headers)) {
if (name == null || value === undefined || value === null) {
continue;
}
if (name.toLowerCase() === target) {
return Array.isArray(value) ? value.join(', ') : String(value);
}
}
return undefined;
}

/**
* Captures the {@code X-Chargebee-Telemetry} response header as OpenTelemetry span attributes.
*/
export function buildResponseHeaderSpanAttributes(
responseHeaders: ResponseHeadersForTelemetry | undefined,
): Record<string, RequestTelemetryEndAttributeValue> {
const attributes: Record<string, RequestTelemetryEndAttributeValue> = {};
if (!responseHeaders) {
return attributes;
}

const value = getResponseHeaderValueIgnoreCase(
responseHeaders,
X_CHARGEBEE_TELEMETRY_HEADER,
);
if (value != null) {
attributes[
`${HTTP_RESPONSE_HEADER_ATTRIBUTE_PREFIX}${X_CHARGEBEE_TELEMETRY_HEADER}`
] = value;
Object.assign(
attributes,
parseChargebeeTelemetryHeaderToSpanAttributes(value),
);
}

return attributes;
}

export function buildRequestStartSpanAttributes(
input: BuildRequestTelemetryContextInput,
): Record<string, string | string[]> {
Expand All @@ -109,9 +181,10 @@ export function buildRequestStartSpanAttributes(

export function buildRequestEndSpanAttributes(
result: Omit<RequestTelemetryResult, 'endAttributes'>,
): Record<string, string | number> {
const attributes: Record<string, string | number> = {
): Record<string, RequestTelemetryEndAttributeValue> {
const attributes: Record<string, RequestTelemetryEndAttributeValue> = {
[TelemetryAttributeKeys.HTTP_RESPONSE_STATUS_CODE]: result.httpStatusCode,
...buildResponseHeaderSpanAttributes(result.responseHeaders),
};

if (result.error) {
Expand Down Expand Up @@ -213,3 +286,27 @@ export function extractHttpStatusCode(err: unknown): number | undefined {
}
return undefined;
}

export function extractResponseHeaders(
err: unknown,
): ResponseHeadersForTelemetry | undefined {
if (err == null || typeof err !== 'object') {
return undefined;
}

const errorObj = err as Record<string, unknown>;
const response = errorObj.response;
if (response != null && typeof response === 'object') {
const headers = (response as Record<string, unknown>).headers;
if (headers != null && typeof headers === 'object') {
return headers as ResponseHeadersForTelemetry;
}
}

const headers = errorObj.headers;
if (headers != null && typeof headers === 'object') {
return headers as ResponseHeadersForTelemetry;
}

return undefined;
}
Loading
Loading