From e2221ed00489fcadf21db9d58323622c1dc1603b Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Fri, 7 Aug 2026 14:04:11 +0530 Subject: [PATCH 1/5] telemetry header handler --- src/RequestWrapper.ts | 3 + src/telemetry/TelemetryAdapter.ts | 84 +++++- .../chargebeeTelemetryHeaderParser.ts | 244 ++++++++++++++++++ src/telemetry/index.ts | 12 + src/telemetry/types.ts | 24 +- test/chargebeeTelemetryHeaderParser.test.ts | 93 +++++++ test/telemetryAdapter.test.ts | 133 ++++++++++ 7 files changed, 589 insertions(+), 4 deletions(-) create mode 100644 src/telemetry/chargebeeTelemetryHeaderParser.ts create mode 100644 test/chargebeeTelemetryHeaderParser.test.ts create mode 100644 test/telemetryAdapter.test.ts diff --git a/src/RequestWrapper.ts b/src/RequestWrapper.ts index 988d109..851e67b 100644 --- a/src/RequestWrapper.ts +++ b/src/RequestWrapper.ts @@ -19,6 +19,7 @@ import { import { buildRequestTelemetryContext, buildRequestTelemetryResult, + extractResponseHeaders, extractHttpStatusCode, extractRequestTelemetryError, resolveChargebeeApiVersion, @@ -346,6 +347,7 @@ export class RequestWrapper { buildRequestTelemetryResult({ httpStatusCode, durationMs: Date.now() - requestStartTime, + responseHeaders: result?.headers, }), ); } catch (err) { @@ -369,6 +371,7 @@ export class RequestWrapper { httpStatusCode, durationMs: Date.now() - requestStartTime, error: telemetryError, + responseHeaders: extractResponseHeaders(err), }), ); } catch (telemetryErr) { diff --git a/src/telemetry/TelemetryAdapter.ts b/src/telemetry/TelemetryAdapter.ts index 12505ac..c00ed22 100644 --- a/src/telemetry/TelemetryAdapter.ts +++ b/src/telemetry/TelemetryAdapter.ts @@ -5,18 +5,23 @@ * Copyright 2026 Chargebee Inc. */ +import { parseChargebeeTelemetryHeaderToSpanAttributes } from './chargebeeTelemetryHeaderParser.js'; import { BuildRequestTelemetryContextInput, CHARGEBEE_SDK_NAME, CHARGEBEE_TELEMETRY_HEADER_EXCLUDE_PREFIX, CHARGEBEE_TELEMETRY_HEADER_PREFIX, 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; @@ -72,7 +77,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(); @@ -90,6 +95,54 @@ export function buildRequestHeaderSpanAttributes( return attributes; } +/** Case-insensitive response header lookup; skips entries whose name is null/undefined. */ +export function getResponseHeaderValueIgnoreCase( + headers: Record | 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 { + const attributes: Record = {}; + 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 { @@ -109,9 +162,10 @@ export function buildRequestStartSpanAttributes( export function buildRequestEndSpanAttributes( result: Omit, -): Record { - const attributes: Record = { +): Record { + const attributes: Record = { [TelemetryAttributeKeys.HTTP_RESPONSE_STATUS_CODE]: result.httpStatusCode, + ...buildResponseHeaderSpanAttributes(result.responseHeaders), }; if (result.error) { @@ -213,3 +267,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; + const response = errorObj.response; + if (response != null && typeof response === 'object') { + const headers = (response as Record).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; +} diff --git a/src/telemetry/chargebeeTelemetryHeaderParser.ts b/src/telemetry/chargebeeTelemetryHeaderParser.ts new file mode 100644 index 0000000..806c271 --- /dev/null +++ b/src/telemetry/chargebeeTelemetryHeaderParser.ts @@ -0,0 +1,244 @@ +/* + * 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. + */ + +const CHARGEBEE_TELEMETRY_PREFIX = 'chargebee.telemetry.'; +const CHARGEBEE_TELEMETRY_CB_SEGMENT = 'cb'; +const CHARGEBEE_TELEMETRY_CB_PREFIX = `${CHARGEBEE_TELEMETRY_PREFIX}cb.`; +const CHARGEBEE_TELEMETRY_TP_PREFIX = 'tp-'; +const CHARGEBEE_TELEMETRY_TP_ATTRIBUTE_PREFIX = `${CHARGEBEE_TELEMETRY_PREFIX}tp.`; +const CHARGEBEE_TELEMETRY_FT_PREFIX = 'ft-'; +const CHARGEBEE_TELEMETRY_FEATURES = `${CHARGEBEE_TELEMETRY_PREFIX}features`; + +const SF_DATE_PREFIX = '@'; +const SF_BOOLEAN_TRUE = '?1'; +const SF_BOOLEAN_FALSE = '?0'; +const INTEGER_PATTERN = /^-?\d+$/; +const DECIMAL_PATTERN = /^-?\d+\.\d+$/; + +export type ChargebeeTelemetrySpanAttributes = Record< + string, + string | number | boolean | string[] +>; + +/** Parses a raw {@code X-Chargebee-Telemetry} header into typed span attributes. */ +export function parseChargebeeTelemetryHeaderToSpanAttributes( + headerValue: string | null | undefined, +): ChargebeeTelemetrySpanAttributes { + if (headerValue == null || headerValue.trim() === '') { + return {}; + } + + try { + const attributes: ChargebeeTelemetrySpanAttributes = {}; + const features: string[] = []; + + for (const item of splitListItems(headerValue)) { + parseListItem(item, attributes, features); + } + + if (features.length > 0) { + attributes[CHARGEBEE_TELEMETRY_FEATURES] = features; + } + + return Object.keys(attributes).length === 0 ? {} : attributes; + } catch { + return {}; + } +} + +function parseListItem( + item: string, + attributes: ChargebeeTelemetrySpanAttributes, + features: string[], +): void { + const trimmed = item.trim(); + if (trimmed === '') { + return; + } + + const separator = indexOfParameterSeparator(trimmed); + const token = + separator < 0 ? trimmed : trimmed.substring(0, separator).trim(); + if (token === '') { + throw new Error('missing sf-item token'); + } + + if (token.startsWith(CHARGEBEE_TELEMETRY_FT_PREFIX)) { + features.push(token.substring(CHARGEBEE_TELEMETRY_FT_PREFIX.length)); + return; + } + + const attributePrefix = segmentAttributePrefix(token); + if (separator >= 0) { + parseParameters(trimmed.substring(separator + 1), attributePrefix, attributes); + } +} + +function segmentAttributePrefix(token: string): string { + if (token === CHARGEBEE_TELEMETRY_CB_SEGMENT) { + return CHARGEBEE_TELEMETRY_CB_PREFIX; + } + if (token.startsWith(CHARGEBEE_TELEMETRY_TP_PREFIX)) { + return ( + CHARGEBEE_TELEMETRY_TP_ATTRIBUTE_PREFIX + + token.substring(CHARGEBEE_TELEMETRY_TP_PREFIX.length) + + '.' + ); + } + return `${CHARGEBEE_TELEMETRY_PREFIX}${token}.`; +} + +function parseParameters( + parametersSection: string, + attributePrefix: string, + attributes: ChargebeeTelemetrySpanAttributes, +): void { + for (const parameter of splitParameters(parametersSection)) { + parseParameter(parameter, attributePrefix, attributes); + } +} + +function parseParameter( + parameter: string, + attributePrefix: string, + attributes: ChargebeeTelemetrySpanAttributes, +): void { + const trimmed = parameter.trim(); + if (trimmed === '') { + return; + } + + const equalsIndex = indexOfEquals(trimmed); + if (equalsIndex <= 0) { + throw new Error(`invalid parameter: ${trimmed}`); + } + + const key = trimmed.substring(0, equalsIndex).trim(); + const rawValue = trimmed.substring(equalsIndex + 1).trim(); + if (key === '') { + throw new Error('missing parameter key'); + } + + attributes[attributePrefix + key] = parseScalarValue(rawValue); +} + +export function parseScalarValue( + rawValue: string, +): string | number | boolean { + if (rawValue == null || rawValue === '') { + throw new Error('missing scalar value'); + } + + if (rawValue.startsWith(SF_DATE_PREFIX)) { + return Number.parseInt(rawValue.substring(SF_DATE_PREFIX.length), 10); + } + if (rawValue === SF_BOOLEAN_TRUE) { + return true; + } + if (rawValue === SF_BOOLEAN_FALSE) { + return false; + } + if (rawValue.startsWith(':') && rawValue.endsWith(':') && rawValue.length >= 2) { + return rawValue.substring(1, rawValue.length - 1); + } + if (rawValue.startsWith('"')) { + return parseStringValue(rawValue); + } + if (INTEGER_PATTERN.test(rawValue)) { + return Number.parseInt(rawValue, 10); + } + if (DECIMAL_PATTERN.test(rawValue)) { + return Number.parseFloat(rawValue); + } + return rawValue; +} + +function parseStringValue(rawValue: string): string { + if (rawValue.length < 2 || rawValue.charAt(rawValue.length - 1) !== '"') { + throw new Error('invalid sf-string value'); + } + + let decoded = ''; + for (let i = 1; i < rawValue.length - 1; i++) { + const current = rawValue.charAt(i); + if (current === '\\') { + if (i + 1 >= rawValue.length - 1) { + throw new Error('invalid sf-string escape'); + } + decoded += rawValue.charAt(++i); + } else { + decoded += current; + } + } + return decoded; +} + +function splitListItems(input: string): string[] { + return splitOnDelimiter(input, ','); +} + +function splitParameters(input: string): string[] { + return splitOnDelimiter(input, ';'); +} + +function splitOnDelimiter(input: string, delimiter: string): string[] { + const parts: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < input.length; i++) { + const currentChar = input.charAt(i); + if (currentChar === '"') { + inQuotes = !inQuotes; + current += currentChar; + } else if (currentChar === delimiter && !inQuotes) { + addIfNotBlank(parts, current); + current = ''; + } else { + current += currentChar; + } + } + + addIfNotBlank(parts, current); + return parts; +} + +function indexOfParameterSeparator(item: string): number { + let inQuotes = false; + for (let i = 0; i < item.length; i++) { + const current = item.charAt(i); + if (current === '"') { + inQuotes = !inQuotes; + } else if (current === ';' && !inQuotes) { + return i; + } + } + return -1; +} + +function indexOfEquals(parameter: string): number { + let inQuotes = false; + for (let i = 0; i < parameter.length; i++) { + const current = parameter.charAt(i); + if (current === '"') { + inQuotes = !inQuotes; + } else if (current === '=' && !inQuotes) { + return i; + } + } + return -1; +} + +function addIfNotBlank(parts: string[], current: string): void { + if (current.length === 0) { + return; + } + const value = current.trim(); + if (value !== '') { + parts.push(value); + } +} diff --git a/src/telemetry/index.ts b/src/telemetry/index.ts index cd2d882..9d5d3e1 100644 --- a/src/telemetry/index.ts +++ b/src/telemetry/index.ts @@ -10,15 +10,24 @@ export { CHARGEBEE_TELEMETRY_HEADER_EXCLUDE_PREFIX, CHARGEBEE_TELEMETRY_HEADER_PREFIX, HTTP_REQUEST_HEADER_ATTRIBUTE_PREFIX, + HTTP_RESPONSE_HEADER_ATTRIBUTE_PREFIX, TELEMETRY_SPAN_NAME_PREFIX, TelemetryAttributeKeys, + X_CHARGEBEE_TELEMETRY_HEADER, type BuildRequestTelemetryContextInput, type RequestTelemetryContext, + type RequestTelemetryEndAttributeValue, type RequestTelemetryError, type RequestTelemetryHandle, type RequestTelemetryResult, + type ResponseHeadersForTelemetry, } from './types.js'; +export { + parseChargebeeTelemetryHeaderToSpanAttributes, + type ChargebeeTelemetrySpanAttributes, +} from './chargebeeTelemetryHeaderParser.js'; + export { NO_OP_TELEMETRY_ADAPTER, NoOpTelemetryAdapter, @@ -29,8 +38,11 @@ export { buildRequestStartSpanAttributes, buildRequestTelemetryContext, buildRequestTelemetryResult, + buildResponseHeaderSpanAttributes, buildSpanName, extractHttpStatusCode, extractRequestTelemetryError, + extractResponseHeaders, + getResponseHeaderValueIgnoreCase, resolveChargebeeApiVersion, } from './TelemetryAdapter.js'; diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts index efddc48..e776a5f 100644 --- a/src/telemetry/types.ts +++ b/src/telemetry/types.ts @@ -17,6 +17,15 @@ export const TELEMETRY_SPAN_NAME_PREFIX = 'chargebee'; */ export const HTTP_REQUEST_HEADER_ATTRIBUTE_PREFIX = 'http.request.header.'; +/** + * OTel HTTP semantic-convention prefix for response-header attributes: + * `http.response.header.`. + */ +export const HTTP_RESPONSE_HEADER_ATTRIBUTE_PREFIX = 'http.response.header.'; + +/** Lowercased name of the Chargebee response telemetry header. */ +export const X_CHARGEBEE_TELEMETRY_HEADER = 'x-chargebee-telemetry'; + /** * 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 @@ -77,12 +86,25 @@ export type RequestTelemetryError = { chargebeeErrorParam?: string; }; +export type ResponseHeadersForTelemetry = Record< + string, + string | string[] | number | undefined +>; + +export type RequestTelemetryEndAttributeValue = + | string + | number + | boolean + | string[]; + export type RequestTelemetryResult = { httpStatusCode: number; durationMs: number; error?: RequestTelemetryError; + /** Raw HTTP response headers from the Chargebee API response. */ + responseHeaders?: ResponseHeadersForTelemetry; /** Prebuilt span attributes — pass these to your tracer. */ - endAttributes: Record; + endAttributes: Record; }; export type BuildRequestTelemetryContextInput = { diff --git a/test/chargebeeTelemetryHeaderParser.test.ts b/test/chargebeeTelemetryHeaderParser.test.ts new file mode 100644 index 0000000..271b961 --- /dev/null +++ b/test/chargebeeTelemetryHeaderParser.test.ts @@ -0,0 +1,93 @@ +import { expect } from 'chai'; +import { + parseChargebeeTelemetryHeaderToSpanAttributes, + parseScalarValue, +} from '../src/telemetry/chargebeeTelemetryHeaderParser.js'; + +describe('ChargebeeTelemetryHeaderParser', () => { + it('should parse example 1 from response-header-format.md', () => { + const header = + 'cb;start_time=@1781280400;res_wait_time_ms=90;tp_time_ms=1350;time_ms=3800, tp-avalara;time_ms=730, tp-stripe;pm=card;time_ms=620, ft-consolidated_invoicing, ft-account_hierarchy'; + + const attributes = parseChargebeeTelemetryHeaderToSpanAttributes(header); + + expect(attributes['chargebee.telemetry.cb.start_time']).to.equal(1781280400); + expect(attributes['chargebee.telemetry.cb.time_ms']).to.equal(3800); + expect(attributes['chargebee.telemetry.cb.res_wait_time_ms']).to.equal(90); + expect(attributes['chargebee.telemetry.cb.tp_time_ms']).to.equal(1350); + expect(attributes['chargebee.telemetry.tp.avalara.time_ms']).to.equal(730); + expect(attributes['chargebee.telemetry.tp.stripe.time_ms']).to.equal(620); + expect(attributes['chargebee.telemetry.tp.stripe.pm']).to.equal('card'); + expect(attributes['chargebee.telemetry.features']).to.deep.equal([ + 'consolidated_invoicing', + 'account_hierarchy', + ]); + }); + + it('should parse example 2 from response-header-format.md', () => { + const header = + 'cb;start_time=@1781280900;res_wait_time_ms=120;tp_time_ms=1950;time_ms=5200, tp-stripe;pm=card;time_ms=1200, tp-avalara;time_ms=750, ft-account_hierarchy, ft-calendar_billing, ft-consolidated_invoicing'; + + const attributes = parseChargebeeTelemetryHeaderToSpanAttributes(header); + + expect(attributes['chargebee.telemetry.cb.start_time']).to.equal(1781280900); + expect(attributes['chargebee.telemetry.cb.time_ms']).to.equal(5200); + expect(attributes['chargebee.telemetry.cb.res_wait_time_ms']).to.equal(120); + expect(attributes['chargebee.telemetry.cb.tp_time_ms']).to.equal(1950); + expect(attributes['chargebee.telemetry.tp.stripe.time_ms']).to.equal(1200); + expect(attributes['chargebee.telemetry.tp.avalara.time_ms']).to.equal(750); + expect(attributes['chargebee.telemetry.features']).to.deep.equal([ + 'account_hierarchy', + 'calendar_billing', + 'consolidated_invoicing', + ]); + }); + + it('should parse Postman 4b subscription_for_items header', () => { + const header = + 'cb;start_time=@1785415309;res_wait_time_ms=3;time_ms=1911;tp_time_ms=191, tp-avalara;time_ms=189, tp-chargebee;time_ms=2;pm=card, ft-account_hierarchy'; + + const attributes = parseChargebeeTelemetryHeaderToSpanAttributes(header); + + expect(attributes['chargebee.telemetry.cb.start_time']).to.equal(1785415309); + expect(attributes['chargebee.telemetry.cb.res_wait_time_ms']).to.equal(3); + expect(attributes['chargebee.telemetry.cb.time_ms']).to.equal(1911); + expect(attributes['chargebee.telemetry.cb.tp_time_ms']).to.equal(191); + expect(attributes['chargebee.telemetry.tp.avalara.time_ms']).to.equal(189); + expect(attributes['chargebee.telemetry.tp.chargebee.time_ms']).to.equal(2); + expect(attributes['chargebee.telemetry.tp.chargebee.pm']).to.equal('card'); + expect(attributes['chargebee.telemetry.features']).to.deep.equal([ + 'account_hierarchy', + ]); + }); + + it('should map all RFC 9651 scalar types', () => { + const header = + 'cb;start_time=@1781280400;time_ms=3800;ratio=99.9;desc="hello world";enabled=?1;disabled=?0;payload=:aGVsbG8=:;pm=card'; + + const attributes = parseChargebeeTelemetryHeaderToSpanAttributes(header); + + expect(attributes['chargebee.telemetry.cb.start_time']).to.equal(1781280400); + expect(attributes['chargebee.telemetry.cb.time_ms']).to.equal(3800); + expect(attributes['chargebee.telemetry.cb.ratio']).to.equal(99.9); + expect(attributes['chargebee.telemetry.cb.desc']).to.equal('hello world'); + expect(attributes['chargebee.telemetry.cb.enabled']).to.equal(true); + expect(attributes['chargebee.telemetry.cb.disabled']).to.equal(false); + expect(attributes['chargebee.telemetry.cb.payload']).to.equal('aGVsbG8='); + expect(attributes['chargebee.telemetry.cb.pm']).to.equal('card'); + }); + + it('should parse sf-string escapes', () => { + expect(parseScalarValue('"hello \\"world\\""')).to.equal('hello "world"'); + }); + + it('should return empty map for blank or malformed headers', () => { + expect(parseChargebeeTelemetryHeaderToSpanAttributes(null)).to.deep.equal( + {}, + ); + expect(parseChargebeeTelemetryHeaderToSpanAttributes('')).to.deep.equal({}); + expect(parseChargebeeTelemetryHeaderToSpanAttributes(';;;')).to.deep.equal( + {}, + ); + }); +}); diff --git a/test/telemetryAdapter.test.ts b/test/telemetryAdapter.test.ts new file mode 100644 index 0000000..19b3b44 --- /dev/null +++ b/test/telemetryAdapter.test.ts @@ -0,0 +1,133 @@ +import { expect } from 'chai'; +import { + buildRequestEndSpanAttributes, + buildRequestHeaderSpanAttributes, + buildRequestTelemetryResult, + buildResponseHeaderSpanAttributes, + getResponseHeaderValueIgnoreCase, +} from '../src/telemetry/TelemetryAdapter.js'; +import { TelemetryAttributeKeys } from '../src/telemetry/types.js'; + +describe('TelemetryAdapter header helpers', () => { + it('should ignore null header names when building request header span attributes', () => { + const headers: Record = Object.create(null); + (headers as Record)[null as unknown as string] = + 'ignored'; + headers['chargebee-business-entity-id'] = 'entity-1'; + + const attributes = buildRequestHeaderSpanAttributes(headers); + + expect(attributes).to.deep.equal({ + 'http.request.header.chargebee-business-entity-id': ['entity-1'], + }); + }); + + it('should ignore null header names when looking up response headers', () => { + const headers: Record = + Object.create(null); + (headers as Record)[null as unknown as string] = + 'ignored'; + headers['X-Chargebee-Telemetry'] = 'cb;time_ms=1'; + + expect( + getResponseHeaderValueIgnoreCase(headers, 'x-chargebee-telemetry'), + ).to.equal('cb;time_ms=1'); + }); +}); + +describe('TelemetryAdapter response telemetry', () => { + const telemetryHeader = + 'cb;start_time=@1781280400;time_ms=3800, tp-stripe;pm=card;time_ms=620, ft-account_hierarchy'; + + it('should promote X-Chargebee-Telemetry response header to end span attributes', () => { + const headers = { + 'X-Chargebee-Telemetry': telemetryHeader, + }; + + const attributes = buildResponseHeaderSpanAttributes(headers); + + expect(attributes['http.response.header.x-chargebee-telemetry']).to.equal( + telemetryHeader, + ); + expect(attributes['chargebee.telemetry.cb.start_time']).to.equal( + 1781280400, + ); + expect(attributes['chargebee.telemetry.cb.time_ms']).to.equal(3800); + expect(attributes['chargebee.telemetry.tp.stripe.time_ms']).to.equal(620); + expect(attributes['chargebee.telemetry.tp.stripe.pm']).to.equal('card'); + expect(attributes['chargebee.telemetry.features']).to.deep.equal([ + 'account_hierarchy', + ]); + expect(attributes).to.not.have.property('http.response.header.content-type'); + }); + + it('should ignore null header names when building response header span attributes', () => { + const headers: Record = + Object.create(null); + (headers as Record)[null as unknown as string] = + 'ignored'; + headers['X-Chargebee-Telemetry'] = 'cb;time_ms=1'; + + const attributes = buildResponseHeaderSpanAttributes(headers); + + expect(attributes['http.response.header.x-chargebee-telemetry']).to.equal( + 'cb;time_ms=1', + ); + expect(attributes['chargebee.telemetry.cb.time_ms']).to.equal(1); + }); + + it('should omit telemetry attributes when response header is absent', () => { + const attributes = buildResponseHeaderSpanAttributes({}); + + expect(attributes).to.not.have.property( + 'http.response.header.x-chargebee-telemetry', + ); + expect(attributes).to.not.have.property('chargebee.telemetry.cb.time_ms'); + }); + + it('should emit raw header only when parsing fails', () => { + const headers = { + 'X-Chargebee-Telemetry': 'cb;=missing_key', + }; + + const attributes = buildResponseHeaderSpanAttributes(headers); + + expect(attributes['http.response.header.x-chargebee-telemetry']).to.equal( + 'cb;=missing_key', + ); + expect(attributes).to.not.have.property('chargebee.telemetry.cb.time_ms'); + }); + + it('should merge response telemetry into buildRequestEndSpanAttributes', () => { + const attributes = buildRequestEndSpanAttributes({ + httpStatusCode: 200, + durationMs: 10, + responseHeaders: { + 'X-Chargebee-Telemetry': telemetryHeader, + }, + }); + + expect(attributes[TelemetryAttributeKeys.HTTP_RESPONSE_STATUS_CODE]).to.equal( + 200, + ); + expect(attributes['http.response.header.x-chargebee-telemetry']).to.equal( + telemetryHeader, + ); + expect(attributes['chargebee.telemetry.cb.time_ms']).to.equal(3800); + }); + + it('should build request telemetry result with parsed response attributes', () => { + const result = buildRequestTelemetryResult({ + httpStatusCode: 200, + durationMs: 42, + responseHeaders: { + 'X-Chargebee-Telemetry': 'cb;time_ms=99', + }, + }); + + expect(result.endAttributes['chargebee.telemetry.cb.time_ms']).to.equal(99); + expect(result.endAttributes[TelemetryAttributeKeys.HTTP_RESPONSE_STATUS_CODE]).to.equal( + 200, + ); + }); +}); From e0d84117ef3132b2fdbe5b0fda3a23a3a916b6d4 Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Fri, 7 Aug 2026 14:36:41 +0530 Subject: [PATCH 2/5] Default otel adapter getter method for runtime --- src/telemetry/otel.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/telemetry/otel.ts b/src/telemetry/otel.ts index 5d19283..f93364f 100644 --- a/src/telemetry/otel.ts +++ b/src/telemetry/otel.ts @@ -21,7 +21,9 @@ import { RequestTelemetryResult, } from './types.js'; -const tracer = trace.getTracer(CHARGEBEE_SDK_NAME); +function getTracer() { + return trace.getTracer(CHARGEBEE_SDK_NAME); +} function toRecordedException(error: RequestTelemetryError): Error { const exception = new Error(error.message); @@ -49,7 +51,7 @@ export class OtelTelemetryAdapter implements TelemetryAdapter { 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, { + const span = getTracer().startSpan(ctx.spanName, { kind: SpanKind.CLIENT, attributes: ctx.startAttributes, }); From de9f19783577ed93126d92189df3a71b080fcd7a Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Fri, 7 Aug 2026 15:19:25 +0530 Subject: [PATCH 3/5] add preferChargebeeTelemetry opt-in flag --- README.md | 7 ++ src/RequestWrapper.ts | 10 ++- src/chargebee.cjs.ts | 8 ++- src/chargebee.esm.ts | 4 ++ src/telemetry/TelemetryAdapter.ts | 19 ++++++ .../chargebeeTelemetryHeaderParser.ts | 13 ++++ src/telemetry/index.ts | 3 + src/telemetry/types.ts | 9 +++ src/types.d.ts | 10 +++ test/chargebeeTelemetryHeaderParser.test.ts | 12 ++++ test/requestWrapper.test.ts | 68 +++++++++++++++++++ test/telemetryAdapter.test.ts | 36 +++++++++- types/index.d.ts | 6 ++ 13 files changed, 202 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cb7b7a5..a28d5bc 100644 --- a/README.md +++ b/README.md @@ -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 @@ -643,6 +649,7 @@ const chargebee = new Chargebee({ site: '{{site}}', apiKey: '{{api-key}}', telemetryAdapter: otelDefaultAdapter, + preferChargebeeTelemetry: true, }); ``` diff --git a/src/RequestWrapper.ts b/src/RequestWrapper.ts index 851e67b..8077b1a 100644 --- a/src/RequestWrapper.ts +++ b/src/RequestWrapper.ts @@ -17,6 +17,7 @@ import { RetryConfig, } from './types.js'; import { + applyResponseTelemetryPreferHeader, buildRequestTelemetryContext, buildRequestTelemetryResult, extractResponseHeaders, @@ -162,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'] && @@ -172,7 +181,6 @@ export class RequestWrapper { this.httpHeaders['chargebee-idempotency-key'] = uuidv4(); } - const telemetryAdapter = env.telemetryAdapter; const telemetryHeaders: RequestHeaders = {}; const requestStartTime = Date.now(); diff --git a/src/chargebee.cjs.ts b/src/chargebee.cjs.ts index 79684c0..c107943 100644 --- a/src/chargebee.cjs.ts +++ b/src/chargebee.cjs.ts @@ -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); @@ -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; diff --git a/src/chargebee.esm.ts b/src/chargebee.esm.ts index 4f30d5c..4c5b430 100644 --- a/src/chargebee.esm.ts +++ b/src/chargebee.esm.ts @@ -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'; diff --git a/src/telemetry/TelemetryAdapter.ts b/src/telemetry/TelemetryAdapter.ts index c00ed22..33f690f 100644 --- a/src/telemetry/TelemetryAdapter.ts +++ b/src/telemetry/TelemetryAdapter.ts @@ -11,6 +11,8 @@ import { 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, @@ -59,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, +): 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. * diff --git a/src/telemetry/chargebeeTelemetryHeaderParser.ts b/src/telemetry/chargebeeTelemetryHeaderParser.ts index 806c271..743e3f9 100644 --- a/src/telemetry/chargebeeTelemetryHeaderParser.ts +++ b/src/telemetry/chargebeeTelemetryHeaderParser.ts @@ -192,6 +192,11 @@ function splitOnDelimiter(input: string, delimiter: string): string[] { for (let i = 0; i < input.length; i++) { const currentChar = input.charAt(i); + if (inQuotes && currentChar === '\\' && i + 1 < input.length) { + current += currentChar + input.charAt(i + 1); + i++; + continue; + } if (currentChar === '"') { inQuotes = !inQuotes; current += currentChar; @@ -211,6 +216,10 @@ function indexOfParameterSeparator(item: string): number { let inQuotes = false; for (let i = 0; i < item.length; i++) { const current = item.charAt(i); + if (inQuotes && current === '\\' && i + 1 < item.length) { + i++; + continue; + } if (current === '"') { inQuotes = !inQuotes; } else if (current === ';' && !inQuotes) { @@ -224,6 +233,10 @@ function indexOfEquals(parameter: string): number { let inQuotes = false; for (let i = 0; i < parameter.length; i++) { const current = parameter.charAt(i); + if (inQuotes && current === '\\' && i + 1 < parameter.length) { + i++; + continue; + } if (current === '"') { inQuotes = !inQuotes; } else if (current === '=' && !inQuotes) { diff --git a/src/telemetry/index.ts b/src/telemetry/index.ts index 9d5d3e1..282e4e1 100644 --- a/src/telemetry/index.ts +++ b/src/telemetry/index.ts @@ -9,6 +9,8 @@ export { 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, TELEMETRY_SPAN_NAME_PREFIX, @@ -40,6 +42,7 @@ export { buildRequestTelemetryResult, buildResponseHeaderSpanAttributes, buildSpanName, + applyResponseTelemetryPreferHeader, extractHttpStatusCode, extractRequestTelemetryError, extractResponseHeaders, diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts index e776a5f..72e1fbe 100644 --- a/src/telemetry/types.ts +++ b/src/telemetry/types.ts @@ -26,6 +26,15 @@ export const HTTP_RESPONSE_HEADER_ATTRIBUTE_PREFIX = 'http.response.header.'; /** Lowercased name of the Chargebee response telemetry header. */ export const X_CHARGEBEE_TELEMETRY_HEADER = 'x-chargebee-telemetry'; +/** HTTP {@code Prefer} header name used to opt in to response telemetry. */ +export const CHARGEBEE_TELEMETRY_PREFER_HEADER = 'Prefer'; + +/** + * {@code Prefer} header value that requests {@code X-Chargebee-Telemetry} on the response. + * Chargebee only returns response telemetry when this header is sent. + */ +export const CHARGEBEE_TELEMETRY_PREFER_VALUE = 'chargebee-telemetry=include'; + /** * 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 diff --git a/src/types.d.ts b/src/types.d.ts index 07a3939..3180a9d 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -22,6 +22,11 @@ export type EnvType = { telemetryAdapter?: TelemetryAdapter; /** When true, request parameters are validated against Zod schemas before each HTTP call (where a schema exists). */ enableValidation?: boolean; + /** + * When true, send `Prefer: chargebee-telemetry=include` on requests so Chargebee + * returns `X-Chargebee-Telemetry`. Requires `telemetryAdapter` to be configured. + */ + preferChargebeeTelemetry?: boolean; }; export type RetryConfig = { @@ -49,6 +54,11 @@ export type Config = { telemetryAdapter?: TelemetryAdapter; /** When true, request parameters are validated against Zod schemas before each HTTP call (where a schema exists). */ enableValidation?: boolean; + /** + * When true, send `Prefer: chargebee-telemetry=include` on requests so Chargebee + * returns `X-Chargebee-Telemetry`. Requires `telemetryAdapter` to be configured. + */ + preferChargebeeTelemetry?: boolean; }; export type Callback = (error: unknown, result: any | null) => void; diff --git a/test/chargebeeTelemetryHeaderParser.test.ts b/test/chargebeeTelemetryHeaderParser.test.ts index 271b961..f80ef29 100644 --- a/test/chargebeeTelemetryHeaderParser.test.ts +++ b/test/chargebeeTelemetryHeaderParser.test.ts @@ -81,6 +81,18 @@ describe('ChargebeeTelemetryHeaderParser', () => { expect(parseScalarValue('"hello \\"world\\""')).to.equal('hello "world"'); }); + it('should split list items after an sf-string with escaped quotes', () => { + const header = + 'cb;desc="say \\"hi\\"", ft-account_hierarchy'; + + const attributes = parseChargebeeTelemetryHeaderToSpanAttributes(header); + + expect(attributes['chargebee.telemetry.cb.desc']).to.equal('say "hi"'); + expect(attributes['chargebee.telemetry.features']).to.deep.equal([ + 'account_hierarchy', + ]); + }); + it('should return empty map for blank or malformed headers', () => { expect(parseChargebeeTelemetryHeaderToSpanAttributes(null)).to.deep.equal( {}, diff --git a/test/requestWrapper.test.ts b/test/requestWrapper.test.ts index 023e465..d1fe5aa 100644 --- a/test/requestWrapper.test.ts +++ b/test/requestWrapper.test.ts @@ -589,6 +589,74 @@ describe('RequestWrapper - telemetry adapter', () => { expect(result).to.have.property('list'); expect(capturedRequests.length).to.equal(1); }); + + it('should send Prefer: chargebee-telemetry=include when preferChargebeeTelemetry is true', async () => { + const chargebee = createChargebee({ + preferChargebeeTelemetry: true, + telemetryAdapter: { + onRequestStart: () => ({ id: 'span-1' }), + onRequestEnd: () => {}, + }, + }); + + await chargebee.customer.list(); + + expect(capturedRequests[0].headers.get('Prefer')).to.equal( + 'chargebee-telemetry=include', + ); + }); + + it('should not override an existing Prefer request header', async () => { + const chargebee = createChargebee({ + preferChargebeeTelemetry: true, + telemetryAdapter: { + onRequestStart: () => ({ id: 'span-1' }), + onRequestEnd: () => {}, + }, + }); + + await chargebee.customer.list( + { limit: 1 }, + { Prefer: 'respond-async' }, + ); + + expect(capturedRequests[0].headers.get('Prefer')).to.equal('respond-async'); + }); + + it('should not send Prefer when preferChargebeeTelemetry is not enabled', async () => { + const chargebee = createChargebee({ + telemetryAdapter: { + onRequestStart: () => ({ id: 'span-1' }), + onRequestEnd: () => {}, + }, + }); + + await chargebee.customer.list(); + + expect(capturedRequests[0].headers.get('Prefer')).to.be.null; + }); + + it('should not send Prefer when preferChargebeeTelemetry is false', async () => { + const chargebee = createChargebee({ + preferChargebeeTelemetry: false, + telemetryAdapter: { + onRequestStart: () => ({ id: 'span-1' }), + onRequestEnd: () => {}, + }, + }); + + await chargebee.customer.list(); + + expect(capturedRequests[0].headers.get('Prefer')).to.be.null; + }); + + it('should not send Prefer when telemetryAdapter is not configured', async () => { + const chargebee = createChargebee(); + + await chargebee.customer.list(); + + expect(capturedRequests[0].headers.get('Prefer')).to.be.null; + }); }); describe('Chargebee telemetry exports', () => { diff --git a/test/telemetryAdapter.test.ts b/test/telemetryAdapter.test.ts index 19b3b44..3b8d8f6 100644 --- a/test/telemetryAdapter.test.ts +++ b/test/telemetryAdapter.test.ts @@ -1,12 +1,46 @@ import { expect } from 'chai'; import { + applyResponseTelemetryPreferHeader, buildRequestEndSpanAttributes, buildRequestHeaderSpanAttributes, buildRequestTelemetryResult, buildResponseHeaderSpanAttributes, getResponseHeaderValueIgnoreCase, } from '../src/telemetry/TelemetryAdapter.js'; -import { TelemetryAttributeKeys } from '../src/telemetry/types.js'; +import { + CHARGEBEE_TELEMETRY_PREFER_HEADER, + CHARGEBEE_TELEMETRY_PREFER_VALUE, + TelemetryAttributeKeys, +} from '../src/telemetry/types.js'; + +describe('TelemetryAdapter response telemetry Prefer header', () => { + it('should add Prefer when missing', () => { + const headers: Record = {}; + + applyResponseTelemetryPreferHeader(headers); + + expect(headers[CHARGEBEE_TELEMETRY_PREFER_HEADER]).to.equal( + CHARGEBEE_TELEMETRY_PREFER_VALUE, + ); + }); + + it('should not override an existing Prefer header', () => { + const headers = { Prefer: 'respond-async' }; + + applyResponseTelemetryPreferHeader(headers); + + expect(headers.Prefer).to.equal('respond-async'); + }); + + it('should treat Prefer as case-insensitive', () => { + const headers: Record = { prefer: 'custom' }; + + applyResponseTelemetryPreferHeader(headers); + + expect(headers.prefer).to.equal('custom'); + expect(headers.Prefer).to.equal(undefined); + }); +}); describe('TelemetryAdapter header helpers', () => { it('should ignore null header names when building request header span attributes', () => { diff --git a/types/index.d.ts b/types/index.d.ts index f4023de..fda7048 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -185,6 +185,12 @@ declare module 'chargebee' { * @telemetryAdapter optional telemetry adapter for observability (e.g. OpenTelemetry) */ telemetryAdapter?: TelemetryAdapter; + + /** + * When true, send `Prefer: chargebee-telemetry=include` on requests so Chargebee + * returns `X-Chargebee-Telemetry`. Requires `telemetryAdapter` to be configured. + */ + preferChargebeeTelemetry?: boolean; }; export interface HttpClientInterface { From 9edf8d5c9df7f6e7861b3ae1611775122db98b21 Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Fri, 7 Aug 2026 15:54:54 +0530 Subject: [PATCH 4/5] update type and tests --- test/publicTypes.test.ts | 131 +++++++++++++++++++++++++++++++++++++++ types/index.d.ts | 15 +++-- 2 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 test/publicTypes.test.ts diff --git a/test/publicTypes.test.ts b/test/publicTypes.test.ts new file mode 100644 index 0000000..714df8c --- /dev/null +++ b/test/publicTypes.test.ts @@ -0,0 +1,131 @@ +/// +import { expect } from 'chai'; +import * as entryPoint from '../src/chargebee.esm.js'; +import { + CHARGEBEE_TELEMETRY_PREFER_HEADER, + CHARGEBEE_TELEMETRY_PREFER_VALUE, + TelemetryAttributeKeys, +} from '../src/chargebee.esm.js'; +import type { + RequestTelemetryContext as SrcRequestTelemetryContext, + RequestTelemetryResult as SrcRequestTelemetryResult, +} from '../src/telemetry/types.js'; + +// `types/index.d.ts` is hand-maintained and not covered by the src build, so the checks +// below compare it against the real entry point. They are compile-time assertions: a +// mismatch fails `npm test` before any assertion runs. +type Declared = typeof import('chargebee'); + +type ExpectExtends = Sub; + +// `keyof` on an ambient module namespace collapses to `string`, so the export surface +// cannot be diffed generically. Each value export is named here instead, which fails to +// compile when a declaration is missing, and the runtime test below keeps the list +// exhaustive by asserting the entry point exposes exactly these names. +const declaredValueExports = [ + 'CHARGEBEE_TELEMETRY_PREFER_HEADER', + 'CHARGEBEE_TELEMETRY_PREFER_VALUE', + 'ChargebeeZodValidationError', + 'TelemetryAttributeKeys', + 'WebhookAuthenticationError', + 'WebhookContentType', + 'WebhookError', + 'WebhookEventType', + 'WebhookPayloadParseError', + 'WebhookPayloadValidationError', + 'basicAuthValidator', + 'default', +] as const; + +type _EveryRuntimeExportIsDeclared = [ + Declared['CHARGEBEE_TELEMETRY_PREFER_HEADER'], + Declared['CHARGEBEE_TELEMETRY_PREFER_VALUE'], + Declared['ChargebeeZodValidationError'], + Declared['TelemetryAttributeKeys'], + Declared['WebhookAuthenticationError'], + Declared['WebhookContentType'], + Declared['WebhookError'], + Declared['WebhookEventType'], + Declared['WebhookPayloadParseError'], + Declared['WebhookPayloadValidationError'], + Declared['basicAuthValidator'], + Declared['default'], +]; + +// Telemetry payloads the SDK hands to adapters must be fully representable by the +// declared types, including every attribute value type the parser can emit. +type _ContextIsRepresentable = ExpectExtends< + import('chargebee').RequestTelemetryContext, + SrcRequestTelemetryContext +>; +type _ResultIsRepresentable = ExpectExtends< + import('chargebee').RequestTelemetryResult, + SrcRequestTelemetryResult +>; + +// Response headers stay readable from the declared result type. +type _ResponseHeadersAreReadable = ExpectExtends< + import('chargebee').RequestTelemetryResult['responseHeaders'], + SrcRequestTelemetryResult['responseHeaders'] +>; + +// Constants resolved from the declarations, so these fail if either goes missing or +// drifts from its literal value. +type DeclaredPreferHeader = Declared['CHARGEBEE_TELEMETRY_PREFER_HEADER']; +type DeclaredPreferValue = Declared['CHARGEBEE_TELEMETRY_PREFER_VALUE']; + +describe('Chargebee public type declarations', () => { + it('should declare every value exported from the package entry point', () => { + expect(Object.keys(entryPoint).sort()).to.deep.equal( + [...declaredValueExports].sort(), + ); + }); + + it('should declare telemetry Prefer constants with literal types', () => { + const header: DeclaredPreferHeader = CHARGEBEE_TELEMETRY_PREFER_HEADER; + const value: DeclaredPreferValue = CHARGEBEE_TELEMETRY_PREFER_VALUE; + + expect(header).to.equal('Prefer'); + expect(value).to.equal('chargebee-telemetry=include'); + }); + + it('should declare TelemetryAttributeKeys with the same keys and values as runtime', () => { + const declared: Declared['TelemetryAttributeKeys'] = TelemetryAttributeKeys; + + expect(declared).to.deep.equal(TelemetryAttributeKeys); + expect(Object.keys(declared).sort()).to.deep.equal( + Object.keys(TelemetryAttributeKeys).sort(), + ); + }); + + it('should accept telemetry options on the declared Config type', () => { + const config: import('chargebee').Config = { + apiKey: 'test-api-key', + site: 'test-site', + preferChargebeeTelemetry: true, + telemetryAdapter: { + onRequestStart: () => 'span-1', + onRequestEnd: () => {}, + }, + }; + + expect(config.preferChargebeeTelemetry).to.equal(true); + }); + + it('should let adapters read every telemetry attribute value type', () => { + const result: import('chargebee').RequestTelemetryResult = { + httpStatusCode: 200, + durationMs: 12, + endAttributes: { + 'chargebee.telemetry.cb.time_ms': 3800, + 'chargebee.telemetry.tp.stripe.pm': 'card', + 'chargebee.telemetry.cb.enabled': true, + 'chargebee.telemetry.features': ['account_hierarchy'], + }, + }; + + expect(result.endAttributes['chargebee.telemetry.features']).to.deep.equal([ + 'account_hierarchy', + ]); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index fda7048..3d969ea 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -186,10 +186,7 @@ declare module 'chargebee' { */ telemetryAdapter?: TelemetryAdapter; - /** - * When true, send `Prefer: chargebee-telemetry=include` on requests so Chargebee - * returns `X-Chargebee-Telemetry`. Requires `telemetryAdapter` to be configured. - */ + /** @preferChargebeeTelemetry when true, sends `Prefer: chargebee-telemetry=include` so Chargebee returns `X-Chargebee-Telemetry`. Requires `telemetryAdapter`. */ preferChargebeeTelemetry?: boolean; }; @@ -199,6 +196,12 @@ declare module 'chargebee' { export type RequestTelemetryHandle = unknown; + /** HTTP `Prefer` header name used to opt in to response telemetry. */ + export const CHARGEBEE_TELEMETRY_PREFER_HEADER: 'Prefer'; + + /** `Prefer` value that requests `X-Chargebee-Telemetry` on the response. */ + export const CHARGEBEE_TELEMETRY_PREFER_VALUE: 'chargebee-telemetry=include'; + export const TelemetryAttributeKeys: { readonly URL_FULL: 'url.full'; readonly HTTP_REQUEST_METHOD: 'http.request.method'; @@ -242,8 +245,10 @@ declare module 'chargebee' { httpStatusCode: number; durationMs: number; error?: RequestTelemetryError; + /** Raw HTTP response headers from the Chargebee API response. */ + responseHeaders?: Record; /** Prebuilt span attributes — pass these to your tracer. */ - endAttributes: Record; + endAttributes: Record; }; /** From dd49bc48dd49e2128ba6856aad67455881860914 Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Fri, 7 Aug 2026 16:06:26 +0530 Subject: [PATCH 5/5] Prettier --- src/chargebee.cjs.ts | 6 ++++-- src/telemetry/chargebeeTelemetryHeaderParser.ts | 16 +++++++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/chargebee.cjs.ts b/src/chargebee.cjs.ts index c107943..f859da4 100644 --- a/src/chargebee.cjs.ts +++ b/src/chargebee.cjs.ts @@ -33,8 +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; +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; diff --git a/src/telemetry/chargebeeTelemetryHeaderParser.ts b/src/telemetry/chargebeeTelemetryHeaderParser.ts index 743e3f9..00e5c8e 100644 --- a/src/telemetry/chargebeeTelemetryHeaderParser.ts +++ b/src/telemetry/chargebeeTelemetryHeaderParser.ts @@ -74,7 +74,11 @@ function parseListItem( const attributePrefix = segmentAttributePrefix(token); if (separator >= 0) { - parseParameters(trimmed.substring(separator + 1), attributePrefix, attributes); + parseParameters( + trimmed.substring(separator + 1), + attributePrefix, + attributes, + ); } } @@ -126,9 +130,7 @@ function parseParameter( attributes[attributePrefix + key] = parseScalarValue(rawValue); } -export function parseScalarValue( - rawValue: string, -): string | number | boolean { +export function parseScalarValue(rawValue: string): string | number | boolean { if (rawValue == null || rawValue === '') { throw new Error('missing scalar value'); } @@ -142,7 +144,11 @@ export function parseScalarValue( if (rawValue === SF_BOOLEAN_FALSE) { return false; } - if (rawValue.startsWith(':') && rawValue.endsWith(':') && rawValue.length >= 2) { + if ( + rawValue.startsWith(':') && + rawValue.endsWith(':') && + rawValue.length >= 2 + ) { return rawValue.substring(1, rawValue.length - 1); } if (rawValue.startsWith('"')) {