From 2742928d9ee9cd9bb649019482524f737068300a Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Wed, 15 Jul 2026 14:38:29 +0530 Subject: [PATCH 1/7] Chargebee telemetry handling --- README.md | 61 +++++ .../ChargebeeTelemetryHeaderParser.java | 235 ++++++++++++++++++ .../v4/telemetry/TelemetryAttributeKeys.java | 12 + .../v4/telemetry/TelemetryExecutor.java | 14 +- .../v4/telemetry/TelemetrySupport.java | 62 +++++ .../ChargebeeTelemetryHeaderParserTest.java | 83 +++++++ .../v4/telemetry/TelemetryExecutorTest.java | 61 +++++ .../v4/telemetry/TelemetrySupportTest.java | 45 ++++ 8 files changed, 568 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/chargebee/v4/telemetry/ChargebeeTelemetryHeaderParser.java create mode 100644 src/test/java/com/chargebee/v4/telemetry/ChargebeeTelemetryHeaderParserTest.java diff --git a/README.md b/README.md index a37d4be3..b752e0e5 100644 --- a/README.md +++ b/README.md @@ -699,6 +699,58 @@ The SDK builds standardized span attributes (`ctx.getStartAttributes()`, `result Spans are named `chargebee.{resource}.{operation}` (e.g. `chargebee.subscription.create`). +#### X-Chargebee-Telemetry response header + +On select APIs, Chargebee may include an `X-Chargebee-Telemetry` response header with a server-side timing breakdown — treat it as optional enrichment, not a required contract. + +When the header is present and a `telemetryAdapter` is configured, the SDK adds span attributes at request end in two layers: + +1. **Raw** — the full header string under `http.response.header.x-chargebee-telemetry` (audit, debug, or custom parsing) +2. **Parsed** — typed flat attributes under `chargebee.telemetry.*` (ready for APM dashboards without writing an parser) + +The header value is an [RFC 9651](https://www.rfc-editor.org/rfc/rfc9651) `sf-list`: comma-separated segments, each optionally followed by semicolon-separated `key=value` parameters. + +``` +X-Chargebee-Telemetry: cb;start_time=@1781280400;time_ms=3800, tp-stripe;pm=card;time_ms=620, ft-account_hierarchy +``` + +| Segment | Meaning | Parsed span attributes | +|---|---|---| +| `cb;…` | Chargebee processing time | `chargebee.telemetry.cb.{param}` — e.g. `time_ms`, `start_time`, `res_wait_time_ms`, `tp_time_ms` | +| `tp-{provider};…` | Third-party call time (Stripe, Avalara, …) | `chargebee.telemetry.tp.{provider}.{param}` — e.g. `chargebee.telemetry.tp.stripe.time_ms` | +| `ft-{feature}` | Feature flag active on this request (bare token, no params) | Collected into `chargebee.telemetry.features` (`string[]`) | + +Parameter values are typed by RFC 9651 wire format and mapped to OTel-friendly types: + +| Wire format | Example | OTel type | +|---|---|---| +| sf-date (`@epoch`) | `start_time=@1781280400` | `long` (Unix seconds) | +| sf-integer | `time_ms=3800` | `long` | +| sf-decimal | `ratio=99.9` | `double` | +| sf-token (bare word) | `pm=card` | `string` | +| sf-string (quoted) | `desc="hello world"` | `string` | +| sf-boolean | `enabled=?1` / `?0` | `boolean` | +| sf-binary | `payload=:aGVsbG8=:` | `string` (base64 payload) | + +For the example header above, `result.getEndAttributes()` at span end includes: + +``` +http.response.header.x-chargebee-telemetry → "cb;start_time=@1781280400;time_ms=3800, tp-stripe;pm=card;time_ms=620, ft-account_hierarchy" +chargebee.telemetry.cb.start_time → 1781280400 +chargebee.telemetry.cb.time_ms → 3800 +chargebee.telemetry.tp.stripe.time_ms → 620 +chargebee.telemetry.tp.stripe.pm → "card" +chargebee.telemetry.features → ["account_hierarchy"] +``` + +**Behavior:** + +- Header **absent** → no telemetry header attributes are added; the span is unaffected. +- Header **present but unparseable** → only the raw `http.response.header.x-chargebee-telemetry` attribute is emitted; the API call is never failed or delayed by parsing. +- Parsed timing fields such as `chargebee.telemetry.cb.time_ms` are numeric (`long`) so backends like Datadog, New Relic, and Honeycomb can filter, average, and chart percentiles out of the box. + +Use the included `OtelTelemetryAdapter` example below as-is to forward both raw and parsed attributes to your exporter. + #### OpenTelemetry example ```kotlin @@ -755,6 +807,7 @@ import io.opentelemetry.api.trace.SpanKind; import io.opentelemetry.api.trace.StatusCode; import io.opentelemetry.api.trace.Tracer; import io.opentelemetry.context.Context; +import java.util.List; import java.util.Map; class OtelTelemetryAdapter implements TelemetryAdapter { @@ -803,6 +856,14 @@ class OtelTelemetryAdapter implements TelemetryAdapter { span.setAttribute(k, (Long) v); } else if (v instanceof Integer) { span.setAttribute(k, ((Integer) v).longValue()); + } else if (v instanceof Double) { + span.setAttribute(k, (Double) v); + } else if (v instanceof Boolean) { + span.setAttribute(k, (Boolean) v); + } else if (v instanceof List) { + @SuppressWarnings("unchecked") + List values = (List) v; + span.setAttribute(k, values); } }); if (result.getError() != null) { diff --git a/src/main/java/com/chargebee/v4/telemetry/ChargebeeTelemetryHeaderParser.java b/src/main/java/com/chargebee/v4/telemetry/ChargebeeTelemetryHeaderParser.java new file mode 100644 index 00000000..4e17976a --- /dev/null +++ b/src/main/java/com/chargebee/v4/telemetry/ChargebeeTelemetryHeaderParser.java @@ -0,0 +1,235 @@ +/* + * Copyright 2026 Chargebee Inc. + */ + +package com.chargebee.v4.telemetry; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** Parses the {@code X-Chargebee-Telemetry} response header into OpenTelemetry span attributes. */ +public final class ChargebeeTelemetryHeaderParser { + + static final String SF_DATE_PREFIX = "@"; + static final String SF_BOOLEAN_TRUE = "?1"; + static final String SF_BOOLEAN_FALSE = "?0"; + + private static final Pattern INTEGER_PATTERN = Pattern.compile("-?\\d+"); + private static final Pattern DECIMAL_PATTERN = Pattern.compile("-?\\d+\\.\\d+"); + + private ChargebeeTelemetryHeaderParser() { + // utility class + } + + /** + * Parses a raw {@code X-Chargebee-Telemetry} header value into typed span attributes. + * + * @param headerValue raw header string + * @return parsed attributes, or empty map when input is null/blank or structurally invalid + */ + public static Map parseToSpanAttributes(String headerValue) { + if (headerValue == null || headerValue.trim().isEmpty()) { + return Collections.emptyMap(); + } + + try { + Map attributes = new HashMap<>(); + List features = new ArrayList<>(); + + for (String item : splitListItems(headerValue)) { + parseListItem(item, attributes, features); + } + + if (!features.isEmpty()) { + attributes.put(TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_FEATURES, features); + } + + return attributes.isEmpty() ? Collections.emptyMap() : attributes; + } catch (RuntimeException ex) { + return Collections.emptyMap(); + } + } + + private static void parseListItem( + String item, Map attributes, List features) { + String trimmed = item.trim(); + if (trimmed.isEmpty()) { + return; + } + + int separator = indexOfParameterSeparator(trimmed); + String token = separator < 0 ? trimmed : trimmed.substring(0, separator).trim(); + if (token.isEmpty()) { + throw new IllegalArgumentException("missing sf-item token"); + } + + if (token.startsWith(TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_FT_PREFIX)) { + features.add(token.substring(TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_FT_PREFIX.length())); + return; + } + + String attributePrefix = segmentAttributePrefix(token); + if (separator >= 0) { + parseParameters(trimmed.substring(separator + 1), attributePrefix, attributes); + } + } + + private static String segmentAttributePrefix(String token) { + if (TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_CB_SEGMENT.equals(token)) { + return TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_CB_PREFIX; + } + if (token.startsWith(TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_TP_PREFIX)) { + return TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_TP_ATTRIBUTE_PREFIX + + token.substring(TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_TP_PREFIX.length()) + + "."; + } + return TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_PREFIX + token + "."; + } + + private static void parseParameters( + String parametersSection, String attributePrefix, Map attributes) { + for (String parameter : splitParameters(parametersSection)) { + parseParameter(parameter, attributePrefix, attributes); + } + } + + private static void parseParameter( + String parameter, String attributePrefix, Map attributes) { + String trimmed = parameter.trim(); + if (trimmed.isEmpty()) { + return; + } + + int equalsIndex = indexOfEquals(trimmed); + if (equalsIndex <= 0) { + throw new IllegalArgumentException("invalid parameter: " + trimmed); + } + + String key = trimmed.substring(0, equalsIndex).trim(); + String rawValue = trimmed.substring(equalsIndex + 1).trim(); + if (key.isEmpty()) { + throw new IllegalArgumentException("missing parameter key"); + } + + attributes.put(attributePrefix + key, parseScalarValue(rawValue)); + } + + static Object parseScalarValue(String rawValue) { + if (rawValue == null || rawValue.isEmpty()) { + throw new IllegalArgumentException("missing scalar value"); + } + + if (rawValue.startsWith(SF_DATE_PREFIX)) { + return Long.parseLong(rawValue.substring(SF_DATE_PREFIX.length())); + } + if (SF_BOOLEAN_TRUE.equals(rawValue)) { + return Boolean.TRUE; + } + if (SF_BOOLEAN_FALSE.equals(rawValue)) { + return Boolean.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.matcher(rawValue).matches()) { + return Long.parseLong(rawValue); + } + if (DECIMAL_PATTERN.matcher(rawValue).matches()) { + return Double.parseDouble(rawValue); + } + return rawValue; + } + + private static String parseStringValue(String rawValue) { + if (rawValue.length() < 2 || rawValue.charAt(rawValue.length() - 1) != '"') { + throw new IllegalArgumentException("invalid sf-string value"); + } + + StringBuilder decoded = new StringBuilder(); + for (int i = 1; i < rawValue.length() - 1; i++) { + char current = rawValue.charAt(i); + if (current == '\\') { + if (i + 1 >= rawValue.length() - 1) { + throw new IllegalArgumentException("invalid sf-string escape"); + } + decoded.append(rawValue.charAt(++i)); + } else { + decoded.append(current); + } + } + return decoded.toString(); + } + + private static List splitListItems(String input) { + return splitOnDelimiter(input, ','); + } + + private static List splitParameters(String input) { + return splitOnDelimiter(input, ';'); + } + + private static List splitOnDelimiter(String input, char delimiter) { + List parts = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean inQuotes = false; + + for (int i = 0; i < input.length(); i++) { + char currentChar = input.charAt(i); + if (currentChar == '"') { + inQuotes = !inQuotes; + current.append(currentChar); + } else if (currentChar == delimiter && !inQuotes) { + addIfNotBlank(parts, current); + current = new StringBuilder(); + } else { + current.append(currentChar); + } + } + + addIfNotBlank(parts, current); + return parts; + } + + private static int indexOfParameterSeparator(String item) { + boolean inQuotes = false; + for (int i = 0; i < item.length(); i++) { + char current = item.charAt(i); + if (current == '"') { + inQuotes = !inQuotes; + } else if (current == ';' && !inQuotes) { + return i; + } + } + return -1; + } + + private static int indexOfEquals(String parameter) { + boolean inQuotes = false; + for (int i = 0; i < parameter.length(); i++) { + char current = parameter.charAt(i); + if (current == '"') { + inQuotes = !inQuotes; + } else if (current == '=' && !inQuotes) { + return i; + } + } + return -1; + } + + private static void addIfNotBlank(List parts, StringBuilder current) { + if (current.length() == 0) { + return; + } + String value = current.toString().trim(); + if (!value.isEmpty()) { + parts.add(value); + } + } +} diff --git a/src/main/java/com/chargebee/v4/telemetry/TelemetryAttributeKeys.java b/src/main/java/com/chargebee/v4/telemetry/TelemetryAttributeKeys.java index 4a4a15f7..36400db5 100644 --- a/src/main/java/com/chargebee/v4/telemetry/TelemetryAttributeKeys.java +++ b/src/main/java/com/chargebee/v4/telemetry/TelemetryAttributeKeys.java @@ -23,8 +23,20 @@ public final class TelemetryAttributeKeys { public static final String TELEMETRY_SPAN_NAME_PREFIX = "chargebee"; public static final String HTTP_REQUEST_HEADER_ATTRIBUTE_PREFIX = "http.request.header."; + public static final String HTTP_RESPONSE_HEADER_ATTRIBUTE_PREFIX = "http.response.header."; public static final String CHARGEBEE_TELEMETRY_HEADER_PREFIX = "chargebee-"; public static final String CHARGEBEE_TELEMETRY_HEADER_EXCLUDE_PREFIX = "chargebee-request-origin-"; + public static final String X_CHARGEBEE_TELEMETRY_HEADER = "x-chargebee-telemetry"; + + public static final String CHARGEBEE_TELEMETRY_PREFIX = "chargebee.telemetry."; + + public static final String CHARGEBEE_TELEMETRY_CB_SEGMENT = "cb"; + public static final String CHARGEBEE_TELEMETRY_CB_PREFIX = CHARGEBEE_TELEMETRY_PREFIX + "cb."; + public static final String CHARGEBEE_TELEMETRY_TP_PREFIX = "tp-"; + public static final String CHARGEBEE_TELEMETRY_TP_ATTRIBUTE_PREFIX = + CHARGEBEE_TELEMETRY_PREFIX + "tp."; + public static final String CHARGEBEE_TELEMETRY_FT_PREFIX = "ft-"; + public static final String CHARGEBEE_TELEMETRY_FEATURES = CHARGEBEE_TELEMETRY_PREFIX + "features"; public static final String URL_FULL = "url.full"; public static final String HTTP_REQUEST_METHOD = "http.request.method"; diff --git a/src/main/java/com/chargebee/v4/telemetry/TelemetryExecutor.java b/src/main/java/com/chargebee/v4/telemetry/TelemetryExecutor.java index 3567ee60..c15b38f7 100644 --- a/src/main/java/com/chargebee/v4/telemetry/TelemetryExecutor.java +++ b/src/main/java/com/chargebee/v4/telemetry/TelemetryExecutor.java @@ -36,7 +36,7 @@ public static Response execute( try { Response response = action.apply(requestWithHeaders); - endTelemetrySuccess(adapter, handle, startTime, response.getStatusCode()); + endTelemetrySuccess(adapter, handle, startTime, response); return response; } catch (RuntimeException e) { endTelemetryFailure(adapter, handle, startTime, e); @@ -66,7 +66,7 @@ public static CompletableFuture executeAsync( Throwable cause = throwable.getCause() != null ? throwable.getCause() : throwable; endTelemetryFailure(adapter, handle, startTime, cause); } else { - endTelemetrySuccess(adapter, handle, startTime, response.getStatusCode()); + endTelemetrySuccess(adapter, handle, startTime, response); } }); } @@ -98,13 +98,16 @@ private static Object startTelemetry( } private static void endTelemetrySuccess( - TelemetryAdapter adapter, Object handle, long startTime, int httpStatusCode) { + TelemetryAdapter adapter, Object handle, long startTime, Response response) { try { adapter.onRequestEnd( handle, TelemetrySupport.buildRequestTelemetryResult( new TelemetrySupport.RequestTelemetryResultInput( - httpStatusCode, System.currentTimeMillis() - startTime, null))); + response.getStatusCode(), + System.currentTimeMillis() - startTime, + null, + response.getHeaders()))); } catch (Exception err) { LOGGER.log(Level.WARNING, "Telemetry adapter onRequestEnd failed: " + err.getMessage(), err); } @@ -121,7 +124,8 @@ private static void endTelemetryFailure( new TelemetrySupport.RequestTelemetryResultInput( httpStatusCode, System.currentTimeMillis() - startTime, - TelemetrySupport.extractRequestTelemetryError(err)))); + TelemetrySupport.extractRequestTelemetryError(err), + TelemetrySupport.extractResponseHeaders(err)))); } catch (Exception telemetryErr) { LOGGER.log( Level.WARNING, diff --git a/src/main/java/com/chargebee/v4/telemetry/TelemetrySupport.java b/src/main/java/com/chargebee/v4/telemetry/TelemetrySupport.java index dd713054..0d7dfae5 100644 --- a/src/main/java/com/chargebee/v4/telemetry/TelemetrySupport.java +++ b/src/main/java/com/chargebee/v4/telemetry/TelemetrySupport.java @@ -9,7 +9,9 @@ import com.chargebee.v4.exceptions.APIException; import com.chargebee.v4.exceptions.HttpException; +import com.chargebee.v4.transport.Response; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; @@ -116,12 +118,22 @@ public static final class RequestTelemetryResultInput { private final int httpStatusCode; private final long durationMs; private final RequestTelemetryError error; + private final Map> responseHeaders; public RequestTelemetryResultInput( int httpStatusCode, long durationMs, RequestTelemetryError error) { + this(httpStatusCode, durationMs, error, null); + } + + public RequestTelemetryResultInput( + int httpStatusCode, + long durationMs, + RequestTelemetryError error, + Map> responseHeaders) { this.httpStatusCode = httpStatusCode; this.durationMs = durationMs; this.error = error; + this.responseHeaders = responseHeaders; } public int getHttpStatusCode() { @@ -135,6 +147,10 @@ public long getDurationMs() { public RequestTelemetryError getError() { return error; } + + public Map> getResponseHeaders() { + return responseHeaders; + } } public static String buildSpanName(String resource, String operation) { @@ -187,10 +203,33 @@ public static Map buildRequestHeaderSpanAttributes( return attributes; } + /** Captures the {@code X-Chargebee-Telemetry} response header as OpenTelemetry span attributes. */ + public static Map buildResponseHeaderSpanAttributes( + Map> responseHeaders) { + Map attributes = new HashMap<>(); + if (responseHeaders == null) { + return attributes; + } + + String value = + getHeaderValueIgnoreCase( + responseHeaders, TelemetryAttributeKeys.X_CHARGEBEE_TELEMETRY_HEADER); + if (value != null) { + attributes.put( + TelemetryAttributeKeys.HTTP_RESPONSE_HEADER_ATTRIBUTE_PREFIX + + TelemetryAttributeKeys.X_CHARGEBEE_TELEMETRY_HEADER, + value); + attributes.putAll(ChargebeeTelemetryHeaderParser.parseToSpanAttributes(value)); + } + + return attributes; + } + public static Map buildRequestEndSpanAttributes( RequestTelemetryResultInput result) { Map attributes = new HashMap<>(); attributes.put(TelemetryAttributeKeys.HTTP_RESPONSE_STATUS_CODE, result.getHttpStatusCode()); + attributes.putAll(buildResponseHeaderSpanAttributes(result.getResponseHeaders())); RequestTelemetryError error = result.getError(); if (error != null) { @@ -262,4 +301,27 @@ public static Integer extractHttpStatusCode(Throwable err) { } return null; } + + public static Map> extractResponseHeaders(Throwable err) { + if (err instanceof HttpException) { + Response response = ((HttpException) err).getResponse(); + if (response != null) { + return response.getHeaders(); + } + } + return null; + } + + private static String getHeaderValueIgnoreCase( + Map> headers, String headerName) { + for (Map.Entry> entry : headers.entrySet()) { + if (entry.getKey() != null + && entry.getKey().equalsIgnoreCase(headerName) + && entry.getValue() != null + && !entry.getValue().isEmpty()) { + return String.join(", ", entry.getValue()); + } + } + return null; + } } diff --git a/src/test/java/com/chargebee/v4/telemetry/ChargebeeTelemetryHeaderParserTest.java b/src/test/java/com/chargebee/v4/telemetry/ChargebeeTelemetryHeaderParserTest.java new file mode 100644 index 00000000..e6cd97f7 --- /dev/null +++ b/src/test/java/com/chargebee/v4/telemetry/ChargebeeTelemetryHeaderParserTest.java @@ -0,0 +1,83 @@ +package com.chargebee.v4.telemetry; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("ChargebeeTelemetryHeaderParser") +class ChargebeeTelemetryHeaderParserTest { + + @Test + @DisplayName("Should parse example 1 from response-header-format.md") + void shouldParseExampleOne() { + String 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"; + + Map attributes = ChargebeeTelemetryHeaderParser.parseToSpanAttributes(header); + + assertEquals(1781280400L, attributes.get("chargebee.telemetry.cb.start_time")); + assertEquals(3800L, attributes.get("chargebee.telemetry.cb.time_ms")); + assertEquals(90L, attributes.get("chargebee.telemetry.cb.res_wait_time_ms")); + assertEquals(1350L, attributes.get("chargebee.telemetry.cb.tp_time_ms")); + assertEquals(730L, attributes.get("chargebee.telemetry.tp.avalara.time_ms")); + assertEquals(620L, attributes.get("chargebee.telemetry.tp.stripe.time_ms")); + assertEquals("card", attributes.get("chargebee.telemetry.tp.stripe.pm")); + assertEquals( + List.of("consolidated_invoicing", "account_hierarchy"), + attributes.get("chargebee.telemetry.features")); + } + + @Test + @DisplayName("Should parse example 2 from response-header-format.md") + void shouldParseExampleTwo() { + String 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"; + + Map attributes = ChargebeeTelemetryHeaderParser.parseToSpanAttributes(header); + + assertEquals(1781280900L, attributes.get("chargebee.telemetry.cb.start_time")); + assertEquals(5200L, attributes.get("chargebee.telemetry.cb.time_ms")); + assertEquals(120L, attributes.get("chargebee.telemetry.cb.res_wait_time_ms")); + assertEquals(1950L, attributes.get("chargebee.telemetry.cb.tp_time_ms")); + assertEquals(1200L, attributes.get("chargebee.telemetry.tp.stripe.time_ms")); + assertEquals(750L, attributes.get("chargebee.telemetry.tp.avalara.time_ms")); + assertEquals( + List.of("account_hierarchy", "calendar_billing", "consolidated_invoicing"), + attributes.get("chargebee.telemetry.features")); + } + + @Test + @DisplayName("Should map all RFC 9651 scalar types") + void shouldMapAllScalarTypes() { + String header = + "cb;start_time=@1781280400;time_ms=3800;ratio=99.9;desc=\"hello world\";enabled=?1;disabled=?0;payload=:aGVsbG8=:;pm=card"; + + Map attributes = ChargebeeTelemetryHeaderParser.parseToSpanAttributes(header); + + assertEquals(1781280400L, attributes.get("chargebee.telemetry.cb.start_time")); + assertEquals(3800L, attributes.get("chargebee.telemetry.cb.time_ms")); + assertEquals(99.9d, attributes.get("chargebee.telemetry.cb.ratio")); + assertEquals("hello world", attributes.get("chargebee.telemetry.cb.desc")); + assertEquals(Boolean.TRUE, attributes.get("chargebee.telemetry.cb.enabled")); + assertEquals(Boolean.FALSE, attributes.get("chargebee.telemetry.cb.disabled")); + assertEquals("aGVsbG8=", attributes.get("chargebee.telemetry.cb.payload")); + assertEquals("card", attributes.get("chargebee.telemetry.cb.pm")); + } + + @Test + @DisplayName("Should parse sf-string escapes") + void shouldParseSfStringEscapes() { + assertEquals("hello \"world\"", ChargebeeTelemetryHeaderParser.parseScalarValue("\"hello \\\"world\\\"\"")); + } + + @Test + @DisplayName("Should return empty map for blank or malformed headers") + void shouldReturnEmptyForInvalidInput() { + assertTrue(ChargebeeTelemetryHeaderParser.parseToSpanAttributes(null).isEmpty()); + assertTrue(ChargebeeTelemetryHeaderParser.parseToSpanAttributes("").isEmpty()); + assertTrue(ChargebeeTelemetryHeaderParser.parseToSpanAttributes(";;;").isEmpty()); + } +} diff --git a/src/test/java/com/chargebee/v4/telemetry/TelemetryExecutorTest.java b/src/test/java/com/chargebee/v4/telemetry/TelemetryExecutorTest.java index 20914b0d..b3482242 100644 --- a/src/test/java/com/chargebee/v4/telemetry/TelemetryExecutorTest.java +++ b/src/test/java/com/chargebee/v4/telemetry/TelemetryExecutorTest.java @@ -40,6 +40,22 @@ public CompletableFuture sendAsync(Request request) { } } + private static final class TelemetryResponseTransport implements Transport { + @Override + public Response send(Request request) { + Map> headers = new HashMap<>(); + headers.put( + "X-Chargebee-Telemetry", + List.of("cb;start_time=@1781280400;time_ms=3800, tp-stripe;pm=card;time_ms=620")); + return new Response(200, headers, "{}".getBytes()); + } + + @Override + public CompletableFuture sendAsync(Request request) { + return CompletableFuture.completedFuture(send(request)); + } + } + @Test @DisplayName("Should skip telemetry when no adapter is configured") void shouldSkipWhenNoAdapter() { @@ -137,6 +153,51 @@ public CompletableFuture sendAsync(Request request) { assertEquals("00-test-trace", allRequests.get(1).getHeaders().get("traceparent")); } + @Test + @DisplayName("Should capture X-Chargebee-Telemetry response header on success") + void shouldCaptureChargebeeTelemetryResponseHeader() { + RequestTelemetryResult[] capturedResult = new RequestTelemetryResult[1]; + + TelemetryAdapter adapter = + new TelemetryAdapter() { + @Override + public Object onRequestStart( + RequestTelemetryContext context, Map requestHeaders) { + return "span-1"; + } + + @Override + public void onRequestEnd(Object handle, RequestTelemetryResult result) { + capturedResult[0] = result; + } + }; + + ChargebeeClient client = + ChargebeeClient.builder("key_test", "acme") + .transport(new TelemetryResponseTransport()) + .retry(RetryConfig.builder().enabled(false).build()) + .telemetryAdapter(adapter) + .build(); + + Request request = + Request.builder() + .method("POST") + .url("https://acme.chargebee.com/api/v2/invoices/consolidate") + .telemetryResource("invoice") + .telemetryOperation("consolidate") + .build(); + + client.sendWithRetry(request); + + Map endAttributes = capturedResult[0].getEndAttributes(); + assertEquals( + "cb;start_time=@1781280400;time_ms=3800, tp-stripe;pm=card;time_ms=620", + endAttributes.get("http.response.header.x-chargebee-telemetry")); + assertEquals(3800L, endAttributes.get("chargebee.telemetry.cb.time_ms")); + assertEquals(620L, endAttributes.get("chargebee.telemetry.tp.stripe.time_ms")); + assertEquals("card", endAttributes.get("chargebee.telemetry.tp.stripe.pm")); + } + @Test @DisplayName("Should capture chargebee-* request headers and exclude the PII origin family") void shouldCaptureChargebeeRequestHeaders() { diff --git a/src/test/java/com/chargebee/v4/telemetry/TelemetrySupportTest.java b/src/test/java/com/chargebee/v4/telemetry/TelemetrySupportTest.java index ec983cd2..64b5aef0 100644 --- a/src/test/java/com/chargebee/v4/telemetry/TelemetrySupportTest.java +++ b/src/test/java/com/chargebee/v4/telemetry/TelemetrySupportTest.java @@ -7,6 +7,7 @@ import com.chargebee.v4.transport.Request; import com.chargebee.v4.transport.Response; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,6 +15,50 @@ @DisplayName("TelemetrySupport") class TelemetrySupportTest { + @Test + @DisplayName("Should promote X-Chargebee-Telemetry response header to end span attributes") + void shouldBuildResponseHeaderSpanAttributes() { + Map> headers = new HashMap<>(); + headers.put( + "X-Chargebee-Telemetry", + List.of( + "cb;start_time=@1781280400;time_ms=3800, tp-stripe;pm=card;time_ms=620, ft-account_hierarchy")); + + Map attributes = TelemetrySupport.buildResponseHeaderSpanAttributes(headers); + + assertEquals( + "cb;start_time=@1781280400;time_ms=3800, tp-stripe;pm=card;time_ms=620, ft-account_hierarchy", + attributes.get("http.response.header.x-chargebee-telemetry")); + assertEquals(1781280400L, attributes.get("chargebee.telemetry.cb.start_time")); + assertEquals(3800L, attributes.get("chargebee.telemetry.cb.time_ms")); + assertEquals(620L, attributes.get("chargebee.telemetry.tp.stripe.time_ms")); + assertEquals("card", attributes.get("chargebee.telemetry.tp.stripe.pm")); + assertEquals(List.of("account_hierarchy"), attributes.get("chargebee.telemetry.features")); + assertFalse(attributes.containsKey("http.response.header.content-type")); + } + + @Test + @DisplayName("Should omit telemetry attributes when response header is absent") + void shouldOmitTelemetryAttributesWhenHeaderAbsent() { + Map attributes = + TelemetrySupport.buildResponseHeaderSpanAttributes(new HashMap<>()); + + assertFalse(attributes.containsKey("http.response.header.x-chargebee-telemetry")); + assertFalse(attributes.containsKey("chargebee.telemetry.cb.time_ms")); + } + + @Test + @DisplayName("Should emit raw header only when parsing fails") + void shouldEmitRawOnlyWhenParsingFails() { + Map> headers = new HashMap<>(); + headers.put("X-Chargebee-Telemetry", List.of("cb;=missing_key")); + + Map attributes = TelemetrySupport.buildResponseHeaderSpanAttributes(headers); + + assertEquals("cb;=missing_key", attributes.get("http.response.header.x-chargebee-telemetry")); + assertFalse(attributes.containsKey("chargebee.telemetry.cb.time_ms")); + } + @Test @DisplayName("Should promote chargebee-* headers and exclude the PII origin family") void shouldBuildRequestHeaderSpanAttributes() { From 81a8a4e758f71afe8f50ef731580fb694fa2a05a Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Fri, 31 Jul 2026 11:02:46 +0530 Subject: [PATCH 2/7] Update BaseResponse.java --- src/main/java/com/chargebee/v4/models/BaseResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/chargebee/v4/models/BaseResponse.java b/src/main/java/com/chargebee/v4/models/BaseResponse.java index 9d70190e..886161a1 100644 --- a/src/main/java/com/chargebee/v4/models/BaseResponse.java +++ b/src/main/java/com/chargebee/v4/models/BaseResponse.java @@ -35,7 +35,7 @@ public Map> headers() { public List header(String name) { if (httpResponse == null) return null; return httpResponse.getHeaders().entrySet().stream() - .filter(e -> e.getKey().equalsIgnoreCase(name)) + .filter(e -> e.getKey() != null && e.getKey().equalsIgnoreCase(name)) .map(Map.Entry::getValue) .findFirst() .orElse(null); From 272eb27023bc16321e449fe6c4cc56ddc6c72794 Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Wed, 5 Aug 2026 15:56:06 +0530 Subject: [PATCH 3/7] add testcase --- .../chargebee/v4/models/BaseResponseTest.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/test/java/com/chargebee/v4/models/BaseResponseTest.java diff --git a/src/test/java/com/chargebee/v4/models/BaseResponseTest.java b/src/test/java/com/chargebee/v4/models/BaseResponseTest.java new file mode 100644 index 00000000..9f2f9493 --- /dev/null +++ b/src/test/java/com/chargebee/v4/models/BaseResponseTest.java @@ -0,0 +1,25 @@ +package com.chargebee.v4.models; + +import static org.junit.jupiter.api.Assertions.*; + +import com.chargebee.v4.transport.Response; +import java.util.HashMap; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("BaseResponse") +class BaseResponseTest { + + @Test + @DisplayName("Should ignore null header keys when looking up by name") + void shouldIgnoreNullHeaderKeys() { + HashMap> headers = new HashMap<>(); + headers.put(null, List.of("ignored")); + headers.put("X-Chargebee-Telemetry", List.of("cb;time_ms=1")); + + BaseResponse response = new BaseResponse(new Response(200, headers, "{}".getBytes())) {}; + + assertEquals(List.of("cb;time_ms=1"), response.header("X-Chargebee-Telemetry")); + } +} From e100e46f0852f7505f57227ada635c3bef606cdf Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Fri, 7 Aug 2026 15:20:05 +0530 Subject: [PATCH 4/7] add preferChargebeeTelemetry flag --- README.md | 6 +- .../chargebee/v4/client/ChargebeeClient.java | 5 + .../ChargebeeTelemetryHeaderParser.java | 21 ++- .../v4/telemetry/TelemetryExecutor.java | 18 +- .../ChargebeeTelemetryHeaderParserTest.java | 11 ++ .../v4/telemetry/TelemetryExecutorTest.java | 177 ++++++++++++++++++ .../v4/telemetry/TelemetrySupportTest.java | 40 ++++ 7 files changed, 270 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b752e0e5..f57772e1 100644 --- a/README.md +++ b/README.md @@ -699,7 +699,11 @@ The SDK builds standardized span attributes (`ctx.getStartAttributes()`, `result Spans are named `chargebee.{resource}.{operation}` (e.g. `chargebee.subscription.create`). -#### X-Chargebee-Telemetry response header +#### 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`. Call `.preferChargebeeTelemetry(true)` on the client builder 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. On select APIs, Chargebee may include an `X-Chargebee-Telemetry` response header with a server-side timing breakdown — treat it as optional enrichment, not a required contract. diff --git a/src/main/java/com/chargebee/v4/client/ChargebeeClient.java b/src/main/java/com/chargebee/v4/client/ChargebeeClient.java index 27a09f0a..a3c9ea69 100644 --- a/src/main/java/com/chargebee/v4/client/ChargebeeClient.java +++ b/src/main/java/com/chargebee/v4/client/ChargebeeClient.java @@ -43,6 +43,7 @@ public final class ChargebeeClient extends ClientMethodsImpl implements AutoClos private final RequestInterceptor requestInterceptor; private final RequestContext clientHeaders; private final TelemetryAdapter telemetryAdapter; + private final boolean preferChargebeeTelemetry; private final ScheduledExecutorService retryScheduler; // Auto-generated service registry for lazy loading @@ -61,6 +62,7 @@ private ChargebeeClient(Builder builder) { this.requestInterceptor = builder.requestInterceptor; this.clientHeaders = new RequestContext(builder.clientHeaders.getHeaders()); this.telemetryAdapter = builder.telemetryAdapter; + this.preferChargebeeTelemetry = builder.preferChargebeeTelemetry; this.retryScheduler = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "chargebee-retry-scheduler"); t.setDaemon(true); @@ -97,6 +99,7 @@ public static Builder builder(String apiKey, String siteName) { public RequestInterceptor getRequestInterceptor() { return requestInterceptor; } public RequestContext getClientHeaders() { return clientHeaders; } public TelemetryAdapter getTelemetryAdapter() { return telemetryAdapter; } + public boolean isPreferChargebeeTelemetry() { return preferChargebeeTelemetry; } public String getSdkVersion() { return getVersion(); @@ -577,6 +580,7 @@ public static final class Builder { private String protocol = "https"; private RequestInterceptor requestInterceptor; private TelemetryAdapter telemetryAdapter; + private boolean preferChargebeeTelemetry = false; private final RequestContext clientHeaders = new RequestContext(); private Builder() {} @@ -600,6 +604,7 @@ public Builder timeout(int connectTimeoutMs, int readTimeoutMs) { public Builder protocol(String protocol) { this.protocol = protocol; return this; } public Builder requestInterceptor(RequestInterceptor requestInterceptor) { this.requestInterceptor = requestInterceptor; return this; } public Builder telemetryAdapter(TelemetryAdapter telemetryAdapter) { this.telemetryAdapter = telemetryAdapter; return this; } + public Builder preferChargebeeTelemetry(boolean preferChargebeeTelemetry) { this.preferChargebeeTelemetry = preferChargebeeTelemetry; return this; } // Header helpers public Builder header(String name, String value) { diff --git a/src/main/java/com/chargebee/v4/telemetry/ChargebeeTelemetryHeaderParser.java b/src/main/java/com/chargebee/v4/telemetry/ChargebeeTelemetryHeaderParser.java index 4e17976a..e086e544 100644 --- a/src/main/java/com/chargebee/v4/telemetry/ChargebeeTelemetryHeaderParser.java +++ b/src/main/java/com/chargebee/v4/telemetry/ChargebeeTelemetryHeaderParser.java @@ -183,7 +183,9 @@ private static List splitOnDelimiter(String input, char delimiter) { for (int i = 0; i < input.length(); i++) { char currentChar = input.charAt(i); if (currentChar == '"') { - inQuotes = !inQuotes; + if (!isEscapedQuote(input, i)) { + inQuotes = !inQuotes; + } current.append(currentChar); } else if (currentChar == delimiter && !inQuotes) { addIfNotBlank(parts, current); @@ -202,7 +204,9 @@ private static int indexOfParameterSeparator(String item) { for (int i = 0; i < item.length(); i++) { char current = item.charAt(i); if (current == '"') { - inQuotes = !inQuotes; + if (!isEscapedQuote(item, i)) { + inQuotes = !inQuotes; + } } else if (current == ';' && !inQuotes) { return i; } @@ -215,7 +219,9 @@ private static int indexOfEquals(String parameter) { for (int i = 0; i < parameter.length(); i++) { char current = parameter.charAt(i); if (current == '"') { - inQuotes = !inQuotes; + if (!isEscapedQuote(parameter, i)) { + inQuotes = !inQuotes; + } } else if (current == '=' && !inQuotes) { return i; } @@ -223,6 +229,15 @@ private static int indexOfEquals(String parameter) { return -1; } + /** Returns true when {@code input[index]} is a quote escaped by a preceding backslash. */ + private static boolean isEscapedQuote(String input, int index) { + int backslashes = 0; + for (int i = index - 1; i >= 0 && input.charAt(i) == '\\'; i--) { + backslashes++; + } + return backslashes % 2 == 1; + } + private static void addIfNotBlank(List parts, StringBuilder current) { if (current.length() == 0) { return; diff --git a/src/main/java/com/chargebee/v4/telemetry/TelemetryExecutor.java b/src/main/java/com/chargebee/v4/telemetry/TelemetryExecutor.java index c15b38f7..3860cf19 100644 --- a/src/main/java/com/chargebee/v4/telemetry/TelemetryExecutor.java +++ b/src/main/java/com/chargebee/v4/telemetry/TelemetryExecutor.java @@ -29,10 +29,11 @@ public static Response execute( return action.apply(request); } + Request requestForTelemetry = maybeApplyResponseTelemetryPreferHeader(client, request); long startTime = System.currentTimeMillis(); Map telemetryHeaders = new HashMap<>(); - Object handle = startTelemetry(client, adapter, request, telemetryHeaders); - Request requestWithHeaders = withHeaders(request, telemetryHeaders); + Object handle = startTelemetry(client, adapter, requestForTelemetry, telemetryHeaders); + Request requestWithHeaders = withHeaders(requestForTelemetry, telemetryHeaders); try { Response response = action.apply(requestWithHeaders); @@ -53,10 +54,11 @@ public static CompletableFuture executeAsync( return action.apply(request); } + Request requestForTelemetry = maybeApplyResponseTelemetryPreferHeader(client, request); long startTime = System.currentTimeMillis(); Map telemetryHeaders = new HashMap<>(); - Object handle = startTelemetry(client, adapter, request, telemetryHeaders); - Request requestWithHeaders = withHeaders(request, telemetryHeaders); + Object handle = startTelemetry(client, adapter, requestForTelemetry, telemetryHeaders); + Request requestWithHeaders = withHeaders(requestForTelemetry, telemetryHeaders); return action .apply(requestWithHeaders) @@ -164,4 +166,12 @@ static Request withHeaders(Request request, Map headers) { } return updated; } + + private static Request maybeApplyResponseTelemetryPreferHeader( + ChargebeeClient client, Request request) { + if (!client.isPreferChargebeeTelemetry()) { + return request; + } + return TelemetrySupport.applyResponseTelemetryPreferHeader(request); + } } diff --git a/src/test/java/com/chargebee/v4/telemetry/ChargebeeTelemetryHeaderParserTest.java b/src/test/java/com/chargebee/v4/telemetry/ChargebeeTelemetryHeaderParserTest.java index e6cd97f7..abacf76f 100644 --- a/src/test/java/com/chargebee/v4/telemetry/ChargebeeTelemetryHeaderParserTest.java +++ b/src/test/java/com/chargebee/v4/telemetry/ChargebeeTelemetryHeaderParserTest.java @@ -73,6 +73,17 @@ void shouldParseSfStringEscapes() { assertEquals("hello \"world\"", ChargebeeTelemetryHeaderParser.parseScalarValue("\"hello \\\"world\\\"\"")); } + @Test + @DisplayName("Should keep delimiters inside escaped sf-strings") + void shouldKeepDelimitersInsideEscapedSfStrings() { + String header = "cb;desc=\"a\\\"b;c\";time_ms=1"; + + Map attributes = ChargebeeTelemetryHeaderParser.parseToSpanAttributes(header); + + assertEquals("a\"b;c", attributes.get("chargebee.telemetry.cb.desc")); + assertEquals(1L, attributes.get("chargebee.telemetry.cb.time_ms")); + } + @Test @DisplayName("Should return empty map for blank or malformed headers") void shouldReturnEmptyForInvalidInput() { diff --git a/src/test/java/com/chargebee/v4/telemetry/TelemetryExecutorTest.java b/src/test/java/com/chargebee/v4/telemetry/TelemetryExecutorTest.java index b3482242..477582dd 100644 --- a/src/test/java/com/chargebee/v4/telemetry/TelemetryExecutorTest.java +++ b/src/test/java/com/chargebee/v4/telemetry/TelemetryExecutorTest.java @@ -177,6 +177,7 @@ public void onRequestEnd(Object handle, RequestTelemetryResult result) { .transport(new TelemetryResponseTransport()) .retry(RetryConfig.builder().enabled(false).build()) .telemetryAdapter(adapter) + .preferChargebeeTelemetry(true) .build(); Request request = @@ -221,6 +222,7 @@ public void onRequestEnd(Object handle, RequestTelemetryResult result) {} .transport(new RecordingTransport()) .retry(RetryConfig.builder().enabled(false).build()) .telemetryAdapter(adapter) + .preferChargebeeTelemetry(true) .build(); Request request = @@ -310,6 +312,7 @@ public CompletableFuture sendAsync(Request transportRequest) { }) .retry(RetryConfig.builder().enabled(false).build()) .telemetryAdapter(adapter) + .preferChargebeeTelemetry(true) .build(); assertThrows(APIException.class, () -> client.sendWithRetry(request)); @@ -423,4 +426,178 @@ public void onRequestEnd(Object handle, RequestTelemetryResult result) {} logger.setUseParentHandlers(previousUseParent); } } + + @Test + @DisplayName("Should send Prefer chargebee-telemetry=include when preferChargebeeTelemetry is true") + void shouldSendPreferHeaderWhenOptedIn() { + RecordingTransport transport = new RecordingTransport(); + TelemetryAdapter adapter = + new TelemetryAdapter() { + @Override + public Object onRequestStart( + RequestTelemetryContext context, Map requestHeaders) { + return "span-1"; + } + + @Override + public void onRequestEnd(Object handle, RequestTelemetryResult result) {} + }; + + ChargebeeClient client = + ChargebeeClient.builder("key_test", "acme") + .transport(transport) + .retry(RetryConfig.builder().enabled(false).build()) + .telemetryAdapter(adapter) + .preferChargebeeTelemetry(true) + .build(); + + Request request = + Request.builder() + .method("GET") + .url("https://acme.chargebee.com/api/v2/customers") + .telemetryResource("customer") + .telemetryOperation("list") + .build(); + + client.sendWithRetry(request); + + assertEquals(1, transport.requests.size()); + assertEquals( + TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_PREFER_VALUE, + transport.requests.get(0).getHeaders().get(TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_PREFER_HEADER)); + } + + @Test + @DisplayName("Should not override an existing Prefer request header") + void shouldNotOverrideExistingPreferHeader() { + RecordingTransport transport = new RecordingTransport(); + TelemetryAdapter adapter = + new TelemetryAdapter() { + @Override + public Object onRequestStart( + RequestTelemetryContext context, Map requestHeaders) { + return "span-1"; + } + + @Override + public void onRequestEnd(Object handle, RequestTelemetryResult result) {} + }; + + ChargebeeClient client = + ChargebeeClient.builder("key_test", "acme") + .transport(transport) + .retry(RetryConfig.builder().enabled(false).build()) + .telemetryAdapter(adapter) + .preferChargebeeTelemetry(true) + .build(); + + Request request = + Request.builder() + .method("GET") + .url("https://acme.chargebee.com/api/v2/customers") + .header("Prefer", "respond-async") + .telemetryResource("customer") + .telemetryOperation("list") + .build(); + + client.sendWithRetry(request); + + assertEquals("respond-async", transport.requests.get(0).getHeaders().get("Prefer")); + } + + @Test + @DisplayName("Should not send Prefer by default when telemetry adapter is configured") + void shouldNotSendPreferByDefault() { + RecordingTransport transport = new RecordingTransport(); + TelemetryAdapter adapter = + new TelemetryAdapter() { + @Override + public Object onRequestStart( + RequestTelemetryContext context, Map requestHeaders) { + return "span-1"; + } + + @Override + public void onRequestEnd(Object handle, RequestTelemetryResult result) {} + }; + + ChargebeeClient client = + ChargebeeClient.builder("key_test", "acme") + .transport(transport) + .retry(RetryConfig.builder().enabled(false).build()) + .telemetryAdapter(adapter) + .build(); + + Request request = + Request.builder() + .method("GET") + .url("https://acme.chargebee.com/api/v2/customers") + .telemetryResource("customer") + .telemetryOperation("list") + .build(); + + client.sendWithRetry(request); + + assertFalse(transport.requests.get(0).getHeaders().containsKey("Prefer")); + } + + @Test + @DisplayName("Should not send Prefer when preferChargebeeTelemetry is false") + void shouldNotSendPreferWhenDisabled() { + RecordingTransport transport = new RecordingTransport(); + TelemetryAdapter adapter = + new TelemetryAdapter() { + @Override + public Object onRequestStart( + RequestTelemetryContext context, Map requestHeaders) { + return "span-1"; + } + + @Override + public void onRequestEnd(Object handle, RequestTelemetryResult result) {} + }; + + ChargebeeClient client = + ChargebeeClient.builder("key_test", "acme") + .transport(transport) + .retry(RetryConfig.builder().enabled(false).build()) + .telemetryAdapter(adapter) + .preferChargebeeTelemetry(false) + .build(); + + Request request = + Request.builder() + .method("GET") + .url("https://acme.chargebee.com/api/v2/customers") + .telemetryResource("customer") + .telemetryOperation("list") + .build(); + + client.sendWithRetry(request); + + assertFalse(transport.requests.get(0).getHeaders().containsKey("Prefer")); + } + + @Test + @DisplayName("Should not send Prefer when telemetry adapter is not configured") + void shouldNotSendPreferWhenNoAdapter() { + RecordingTransport transport = new RecordingTransport(); + ChargebeeClient client = + ChargebeeClient.builder("key_test", "acme") + .transport(transport) + .retry(RetryConfig.builder().enabled(false).build()) + .build(); + + Request request = + Request.builder() + .method("GET") + .url("https://acme.chargebee.com/api/v2/customers") + .telemetryResource("customer") + .telemetryOperation("list") + .build(); + + client.sendWithRetry(request); + + assertFalse(transport.requests.get(0).getHeaders().containsKey("Prefer")); + } } diff --git a/src/test/java/com/chargebee/v4/telemetry/TelemetrySupportTest.java b/src/test/java/com/chargebee/v4/telemetry/TelemetrySupportTest.java index 64b5aef0..70fa9de2 100644 --- a/src/test/java/com/chargebee/v4/telemetry/TelemetrySupportTest.java +++ b/src/test/java/com/chargebee/v4/telemetry/TelemetrySupportTest.java @@ -106,4 +106,44 @@ void shouldSetErrorTypeFromChargebeeApiErrorType() { assertEquals("resource_not_found", attributes.get(TelemetryAttributeKeys.CHARGEBEE_ERROR_CODE)); assertFalse(attributes.containsValue("404")); } + + @Test + @DisplayName("Should add Prefer chargebee-telemetry=include when not already set") + void shouldApplyResponseTelemetryPreferHeader() { + Map headers = new HashMap<>(); + TelemetrySupport.applyResponseTelemetryPreferHeader(headers); + + assertEquals( + TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_PREFER_VALUE, + headers.get(TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_PREFER_HEADER)); + } + + @Test + @DisplayName("Should not override an existing Prefer request header") + void shouldNotOverrideExistingPreferHeader() { + Map headers = new HashMap<>(); + headers.put("prefer", "respond-async"); + + TelemetrySupport.applyResponseTelemetryPreferHeader(headers); + + assertEquals("respond-async", headers.get("prefer")); + assertFalse(headers.containsKey(TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_PREFER_HEADER)); + } + + @Test + @DisplayName("Should add Prefer to immutable request copies") + void shouldApplyResponseTelemetryPreferHeaderToRequest() { + Request request = + Request.builder() + .method("GET") + .url("https://acme.chargebee.com/api/v2/customers") + .build(); + + Request updated = TelemetrySupport.applyResponseTelemetryPreferHeader(request); + + assertEquals( + TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_PREFER_VALUE, + updated.getHeaders().get(TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_PREFER_HEADER)); + assertFalse(request.getHeaders().containsKey(TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_PREFER_HEADER)); + } } From ae99e67fafe7fe65f89aa84c2146684abc9166ac Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Fri, 7 Aug 2026 15:34:00 +0530 Subject: [PATCH 5/7] add telemetry perference --- .../com/chargebee/v4/models/BaseResponse.java | 2 +- .../v4/telemetry/TelemetryAttributeKeys.java | 6 ++- .../v4/telemetry/TelemetrySupport.java | 54 +++++++++++++++++-- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/chargebee/v4/models/BaseResponse.java b/src/main/java/com/chargebee/v4/models/BaseResponse.java index 886161a1..9d70190e 100644 --- a/src/main/java/com/chargebee/v4/models/BaseResponse.java +++ b/src/main/java/com/chargebee/v4/models/BaseResponse.java @@ -35,7 +35,7 @@ public Map> headers() { public List header(String name) { if (httpResponse == null) return null; return httpResponse.getHeaders().entrySet().stream() - .filter(e -> e.getKey() != null && e.getKey().equalsIgnoreCase(name)) + .filter(e -> e.getKey().equalsIgnoreCase(name)) .map(Map.Entry::getValue) .findFirst() .orElse(null); diff --git a/src/main/java/com/chargebee/v4/telemetry/TelemetryAttributeKeys.java b/src/main/java/com/chargebee/v4/telemetry/TelemetryAttributeKeys.java index 3c27b4c8..cd44ca80 100644 --- a/src/main/java/com/chargebee/v4/telemetry/TelemetryAttributeKeys.java +++ b/src/main/java/com/chargebee/v4/telemetry/TelemetryAttributeKeys.java @@ -25,7 +25,11 @@ public final class TelemetryAttributeKeys { public static final String HTTP_REQUEST_HEADER_ATTRIBUTE_PREFIX = "http.request.header."; public static final String HTTP_RESPONSE_HEADER_ATTRIBUTE_PREFIX = "http.response.header."; public static final String CHARGEBEE_TELEMETRY_HEADER_PREFIX = "chargebee-"; + public static final String CHARGEBEE_TELEMETRY_HEADER_EXCLUDE_PREFIX = + "chargebee-request-origin-"; public static final String X_CHARGEBEE_TELEMETRY_HEADER = "x-chargebee-telemetry"; + public static final String CHARGEBEE_TELEMETRY_PREFER_HEADER = "Prefer"; + public static final String CHARGEBEE_TELEMETRY_PREFER_VALUE = "chargebee-telemetry=include"; public static final String CHARGEBEE_TELEMETRY_PREFIX = "chargebee.telemetry."; @@ -36,8 +40,6 @@ public final class TelemetryAttributeKeys { CHARGEBEE_TELEMETRY_PREFIX + "tp."; public static final String CHARGEBEE_TELEMETRY_FT_PREFIX = "ft-"; public static final String CHARGEBEE_TELEMETRY_FEATURES = CHARGEBEE_TELEMETRY_PREFIX + "features"; - public static final String CHARGEBEE_TELEMETRY_HEADER_EXCLUDE_PREFIX = - "chargebee-request-origin-"; public static final String URL_FULL = "url.full"; public static final String HTTP_REQUEST_METHOD = "http.request.method"; diff --git a/src/main/java/com/chargebee/v4/telemetry/TelemetrySupport.java b/src/main/java/com/chargebee/v4/telemetry/TelemetrySupport.java index 0d7dfae5..cdb10567 100644 --- a/src/main/java/com/chargebee/v4/telemetry/TelemetrySupport.java +++ b/src/main/java/com/chargebee/v4/telemetry/TelemetrySupport.java @@ -9,6 +9,7 @@ import com.chargebee.v4.exceptions.APIException; import com.chargebee.v4.exceptions.HttpException; +import com.chargebee.v4.transport.Request; import com.chargebee.v4.transport.Response; import java.util.HashMap; import java.util.List; @@ -203,7 +204,9 @@ public static Map buildRequestHeaderSpanAttributes( return attributes; } - /** Captures the {@code X-Chargebee-Telemetry} response header as OpenTelemetry span attributes. */ + /** + * Captures the {@code X-Chargebee-Telemetry} response header as OpenTelemetry span attributes. + */ public static Map buildResponseHeaderSpanAttributes( Map> responseHeaders) { Map attributes = new HashMap<>(); @@ -312,11 +315,56 @@ public static Map> extractResponseHeaders(Throwable err) { return null; } + /** + * Adds {@code Prefer: chargebee-telemetry=include} when not already set. + * + *

Chargebee returns {@code X-Chargebee-Telemetry} only when this header is present. + */ + public static void applyResponseTelemetryPreferHeader(Map requestHeaders) { + if (requestHeaders == null) { + return; + } + for (String name : requestHeaders.keySet()) { + if (name.equalsIgnoreCase(TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_PREFER_HEADER)) { + return; + } + } + requestHeaders.put( + TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_PREFER_HEADER, + TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_PREFER_VALUE); + } + + /** + * Returns a copy of {@code request} with {@code Prefer: chargebee-telemetry=include} when not + * already set. + */ + public static Request applyResponseTelemetryPreferHeader(Request request) { + if (hasRequestHeaderIgnoreCase( + request.getHeaders(), TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_PREFER_HEADER)) { + return request; + } + return request.withHeader( + TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_PREFER_HEADER, + TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_PREFER_VALUE); + } + + private static boolean hasRequestHeaderIgnoreCase( + Map headers, String headerName) { + if (headers == null) { + return false; + } + for (String name : headers.keySet()) { + if (name.equalsIgnoreCase(headerName)) { + return true; + } + } + return false; + } + private static String getHeaderValueIgnoreCase( Map> headers, String headerName) { for (Map.Entry> entry : headers.entrySet()) { - if (entry.getKey() != null - && entry.getKey().equalsIgnoreCase(headerName) + if (entry.getKey().equalsIgnoreCase(headerName) && entry.getValue() != null && !entry.getValue().isEmpty()) { return String.join(", ", entry.getValue()); From 5fdb8c83041c1934ae9349b3da892fb672604560 Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Fri, 7 Aug 2026 15:37:28 +0530 Subject: [PATCH 6/7] Update BaseResponseTest.java --- .../java/com/chargebee/v4/models/BaseResponseTest.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/chargebee/v4/models/BaseResponseTest.java b/src/test/java/com/chargebee/v4/models/BaseResponseTest.java index 9f2f9493..4a804abf 100644 --- a/src/test/java/com/chargebee/v4/models/BaseResponseTest.java +++ b/src/test/java/com/chargebee/v4/models/BaseResponseTest.java @@ -12,14 +12,13 @@ class BaseResponseTest { @Test - @DisplayName("Should ignore null header keys when looking up by name") - void shouldIgnoreNullHeaderKeys() { + @DisplayName("Should look up headers case-insensitively") + void shouldLookUpHeadersCaseInsensitively() { HashMap> headers = new HashMap<>(); - headers.put(null, List.of("ignored")); headers.put("X-Chargebee-Telemetry", List.of("cb;time_ms=1")); BaseResponse response = new BaseResponse(new Response(200, headers, "{}".getBytes())) {}; - assertEquals(List.of("cb;time_ms=1"), response.header("X-Chargebee-Telemetry")); + assertEquals(List.of("cb;time_ms=1"), response.header("x-chargebee-telemetry")); } } From 969c9d396569b4e8545b687f2bdbbf3dd3ae04c0 Mon Sep 17 00:00:00 2001 From: cb-karthikp Date: Fri, 7 Aug 2026 15:48:26 +0530 Subject: [PATCH 7/7] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f57772e1..8816c0f7 100644 --- a/README.md +++ b/README.md @@ -867,7 +867,7 @@ class OtelTelemetryAdapter implements TelemetryAdapter { } else if (v instanceof List) { @SuppressWarnings("unchecked") List values = (List) v; - span.setAttribute(k, values); + span.setAttribute(AttributeKey.stringArrayKey(k), values); } }); if (result.getError() != null) {