Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions src/RequestWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import {
buildRequestTelemetryContext,
buildRequestTelemetryResult,
extractResponseHeaders,
extractHttpStatusCode,
extractRequestTelemetryError,
resolveChargebeeApiVersion,
Expand Down Expand Up @@ -346,6 +347,7 @@ export class RequestWrapper {
buildRequestTelemetryResult({
httpStatusCode,
durationMs: Date.now() - requestStartTime,
responseHeaders: result?.headers,
}),
);
} catch (err) {
Expand All @@ -369,6 +371,7 @@ export class RequestWrapper {
httpStatusCode,
durationMs: Date.now() - requestStartTime,
error: telemetryError,
responseHeaders: extractResponseHeaders(err),
}),
);
} catch (telemetryErr) {
Expand Down
84 changes: 81 additions & 3 deletions src/telemetry/TelemetryAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | number>;
Expand Down Expand Up @@ -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();
Expand All @@ -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<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 +162,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 +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<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;
}
244 changes: 244 additions & 0 deletions src/telemetry/chargebeeTelemetryHeaderParser.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function addIfNotBlank(parts: string[], current: string): void {
if (current.length === 0) {
return;
}
const value = current.trim();
if (value !== '') {
parts.push(value);
}
}
Loading
Loading