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() {