Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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
8 changes: 7 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,8 @@ 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