diff --git a/README.md b/README.md
index a37d4be3..e1b2ca5c 100644
--- a/README.md
+++ b/README.md
@@ -691,6 +691,18 @@ public class Sample {
}
```
+### SDK telemetry
+
+By default, the library sends anonymous usage telemetry to Chargebee. This helps us improve the SDK and API.
+
+You can disable this behavior if you prefer:
+
+```java
+ChargebeeClient client = ChargebeeClient.builder(apiKey, site)
+ .sdkTelemetryEnabled(false)
+ .build();
+```
+
### Telemetry (OpenTelemetry)
Optional. Pass a `telemetryAdapter` when you want Chargebee API calls traced in your observability stack (Datadog, Splunk, Honeycomb, Jaeger, etc.). OpenTelemetry is not bundled with `chargebee-java` — add and configure it in your app, implement `TelemetryAdapter`, and wire it on the client.
diff --git a/src/main/java/com/chargebee/v4/client/ChargebeeClient.java b/src/main/java/com/chargebee/v4/client/ChargebeeClient.java
index 27a09f0a..cf8bccc8 100644
--- a/src/main/java/com/chargebee/v4/client/ChargebeeClient.java
+++ b/src/main/java/com/chargebee/v4/client/ChargebeeClient.java
@@ -9,6 +9,7 @@
import com.chargebee.v4.exceptions.TimeoutException;
import com.chargebee.v4.exceptions.TransportException;
import com.chargebee.v4.internal.RetryConfig;
+import com.chargebee.v4.telemetry.SdkTelemetryState;
import com.chargebee.v4.telemetry.TelemetryAdapter;
import com.chargebee.v4.telemetry.TelemetryExecutor;
import com.chargebee.v4.transport.*;
@@ -43,6 +44,8 @@ public final class ChargebeeClient extends ClientMethodsImpl implements AutoClos
private final RequestInterceptor requestInterceptor;
private final RequestContext clientHeaders;
private final TelemetryAdapter telemetryAdapter;
+ private final boolean sdkTelemetryEnabled;
+ private final SdkTelemetryState sdkTelemetryState = new SdkTelemetryState();
private final ScheduledExecutorService retryScheduler;
// Auto-generated service registry for lazy loading
@@ -61,6 +64,7 @@ private ChargebeeClient(Builder builder) {
this.requestInterceptor = builder.requestInterceptor;
this.clientHeaders = new RequestContext(builder.clientHeaders.getHeaders());
this.telemetryAdapter = builder.telemetryAdapter;
+ this.sdkTelemetryEnabled = builder.sdkTelemetryEnabled;
this.retryScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "chargebee-retry-scheduler");
t.setDaemon(true);
@@ -97,6 +101,10 @@ 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 isSdkTelemetryEnabled() { return sdkTelemetryEnabled; }
+
+ /** Internal SDK telemetry state; not part of the supported public API. */
+ public SdkTelemetryState getSdkTelemetryState() { return sdkTelemetryState; }
public String getSdkVersion() {
return getVersion();
@@ -577,6 +585,7 @@ public static final class Builder {
private String protocol = "https";
private RequestInterceptor requestInterceptor;
private TelemetryAdapter telemetryAdapter;
+ private boolean sdkTelemetryEnabled = true;
private final RequestContext clientHeaders = new RequestContext();
private Builder() {}
@@ -600,6 +609,13 @@ 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; }
+ /**
+ * Enables the anonymous SDK telemetry request header, on by default. It carries SDK name,
+ * version, runtime, and the resource/operation/latency/status of the previous call on this
+ * client. It never carries request or response payloads. Pass {@code false} to opt out;
+ * this is independent of {@link #telemetryAdapter(TelemetryAdapter)}.
+ */
+ public Builder sdkTelemetryEnabled(boolean sdkTelemetryEnabled) { this.sdkTelemetryEnabled = sdkTelemetryEnabled; return this; }
// Header helpers
public Builder header(String name, String value) {
diff --git a/src/main/java/com/chargebee/v4/telemetry/SdkTelemetryEmitter.java b/src/main/java/com/chargebee/v4/telemetry/SdkTelemetryEmitter.java
new file mode 100644
index 00000000..12b9f8a5
--- /dev/null
+++ b/src/main/java/com/chargebee/v4/telemetry/SdkTelemetryEmitter.java
@@ -0,0 +1,210 @@
+/*
+ * 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.
+ */
+
+package com.chargebee.v4.telemetry;
+
+import com.chargebee.v4.client.ChargebeeClient;
+import com.chargebee.v4.exceptions.APIException;
+import com.chargebee.v4.exceptions.HttpException;
+import com.chargebee.v4.transport.DefaultTransport;
+import com.chargebee.v4.transport.Request;
+import com.chargebee.v4.transport.Response;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Function;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Emits the anonymous SDK telemetry request header, independently of any customer telemetry adapter.
+ *
+ *
Uses an N+1 scheme: the header sent with a call describes the previous completed call on the
+ * same client, so the first call of a client never carries the header. Every failure path is
+ * swallowed and logged at {@code WARNING}: telemetry must never fail an API call.
+ */
+final class SdkTelemetryEmitter {
+
+ private static final Logger LOGGER = Logger.getLogger(SdkTelemetryEmitter.class.getName());
+
+ private SdkTelemetryEmitter() {}
+
+ /** Attaches the header describing the previous call, then records this one for the next. */
+ static Response around(
+ ChargebeeClient client, Request request, Function next) {
+ if (!client.isSdkTelemetryEnabled()) {
+ return next.apply(request);
+ }
+
+ long startTimeMs = System.currentTimeMillis();
+ try {
+ Response response = next.apply(attachHeader(client, request));
+ recordSuccess(client, request, response, startTimeMs);
+ return response;
+ } catch (RuntimeException err) {
+ recordFailure(client, request, err, startTimeMs);
+ throw err;
+ }
+ }
+
+ /** Async variant of {@link #around}. */
+ static CompletableFuture aroundAsync(
+ ChargebeeClient client,
+ Request request,
+ Function> next) {
+ if (!client.isSdkTelemetryEnabled()) {
+ return next.apply(request);
+ }
+
+ long startTimeMs = System.currentTimeMillis();
+ return next.apply(attachHeader(client, request))
+ .whenComplete(
+ (response, throwable) -> {
+ if (throwable != null) {
+ Throwable cause = throwable.getCause() != null ? throwable.getCause() : throwable;
+ recordFailure(client, request, cause, startTimeMs);
+ } else {
+ recordSuccess(client, request, response, startTimeMs);
+ }
+ });
+ }
+
+ /** Returns {@code request} with the telemetry header, or {@code request} unchanged. */
+ private static Request attachHeader(ChargebeeClient client, Request request) {
+ try {
+ SdkTelemetrySnapshot previousCall = client.getSdkTelemetryState().lastCall();
+ if (previousCall == null) {
+ return request;
+ }
+ String headerValue = SdkTelemetryHeaderBuilder.build(previousCall);
+ if (headerValue == null) {
+ return request;
+ }
+ return request.withHeader(SdkTelemetryHeader.HEADER_NAME, headerValue);
+ } catch (Exception err) {
+ logSuppressed("attach header", err);
+ return request;
+ }
+ }
+
+ /** Records a successful call for the next N+1 header. */
+ private static void recordSuccess(
+ ChargebeeClient client, Request request, Response response, long startTimeMs) {
+ if (!request.hasTelemetryMetadata()) {
+ return;
+ }
+ try {
+ record(
+ client,
+ buildSnapshot(
+ client,
+ request,
+ startTimeMs,
+ response != null ? response.getStatusCode() : null,
+ null,
+ extractRequestId(response)));
+ } catch (Exception err) {
+ logSuppressed("record success", err);
+ }
+ }
+
+ /** Records a failed call for the next N+1 header. */
+ private static void recordFailure(
+ ChargebeeClient client, Request request, Throwable callError, long startTimeMs) {
+ if (!request.hasTelemetryMetadata()) {
+ return;
+ }
+ try {
+ Integer httpStatus = TelemetrySupport.extractHttpStatusCode(callError);
+ String errorCode = null;
+ Response response = null;
+ if (callError instanceof APIException) {
+ errorCode = ((APIException) callError).getApiErrorCodeRaw();
+ }
+ if (callError instanceof HttpException) {
+ response = ((HttpException) callError).getResponse();
+ if (httpStatus == null && response != null) {
+ httpStatus = response.getStatusCode();
+ }
+ }
+ record(
+ client,
+ buildSnapshot(
+ client, request, startTimeMs, httpStatus, errorCode, extractRequestId(response)));
+ } catch (Exception err) {
+ logSuppressed("record failure", err);
+ }
+ }
+
+ /** Stores {@code snapshot} on the client. */
+ private static void record(ChargebeeClient client, SdkTelemetrySnapshot snapshot) {
+ client.getSdkTelemetryState().record(snapshot);
+ }
+
+ /** Builds an immutable snapshot of the completed call. */
+ private static SdkTelemetrySnapshot buildSnapshot(
+ ChargebeeClient client,
+ Request request,
+ long startTimeMs,
+ Integer httpStatus,
+ String errorCode,
+ String requestId) {
+ return SdkTelemetrySnapshot.builder()
+ .sdkName(TelemetryAttributeKeys.SDK_NAME)
+ .sdkVersion(client.getSdkVersion())
+ .resource(request.getTelemetryResource())
+ .operation(request.getTelemetryOperation())
+ .startTimeEpochSeconds(startTimeMs / 1000L)
+ .timeMs(elapsedMs(startTimeMs))
+ .httpStatus(httpStatus)
+ .errorCode(errorCode)
+ .requestId(requestId)
+ .featureTokens(resolveFeatureTokens(client, request))
+ .build();
+ }
+
+ /** Collects {@code ft-*} tokens for the current client/request configuration. */
+ private static Set resolveFeatureTokens(ChargebeeClient client, Request request) {
+ Set features = new LinkedHashSet<>();
+ if (TelemetryAdapterExecutor.resolveAdapter(client, request) != null) {
+ features.add(SdkTelemetryHeader.FT_TELEMETRY_ADAPTER);
+ }
+ if (!(client.getTransport() instanceof DefaultTransport)) {
+ features.add(SdkTelemetryHeader.FT_CUSTOM_TRANSPORT);
+ }
+ if (isRetryConfigActive(client, request)) {
+ features.add(SdkTelemetryHeader.FT_RETRY_CONFIG);
+ }
+ return features;
+ }
+
+ /** Mirrors how {@code sendWithRetryInternal} decides whether retry configuration is in play. */
+ private static boolean isRetryConfigActive(ChargebeeClient client, Request request) {
+ if (request.getMaxNetworkRetriesOverride() != null) {
+ return true;
+ }
+ return client.getRetry() != null && client.getRetry().isEnabled();
+ }
+
+ /** Reads {@code chargebee-request-id} from the response, if present. */
+ private static String extractRequestId(Response response) {
+ return response != null ? response.getHeader(SdkTelemetryHeader.REQUEST_ID_HEADER) : null;
+ }
+
+ /** Elapsed wall time of the whole call, including any transport-level retries. */
+ private static long elapsedMs(long startTimeMs) {
+ return Math.max(0L, System.currentTimeMillis() - startTimeMs);
+ }
+
+ /** Logs a suppressed telemetry failure without affecting the API call. */
+ private static void logSuppressed(String step, Exception err) {
+ LOGGER.log(
+ Level.WARNING,
+ "SDK telemetry could not " + step + " (" + err.getMessage() + "); API call unaffected.",
+ err);
+ }
+}
diff --git a/src/main/java/com/chargebee/v4/telemetry/SdkTelemetryHeader.java b/src/main/java/com/chargebee/v4/telemetry/SdkTelemetryHeader.java
new file mode 100644
index 00000000..94a89e90
--- /dev/null
+++ b/src/main/java/com/chargebee/v4/telemetry/SdkTelemetryHeader.java
@@ -0,0 +1,33 @@
+/*
+ * 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.
+ */
+
+package com.chargebee.v4.telemetry;
+
+/** Constants for the anonymous SDK telemetry request header. */
+public final class SdkTelemetryHeader {
+
+ /**
+ * Name of the request header carrying SDK telemetry. Exposed so that proxies, interceptors, and
+ * tests can reference it without hardcoding the string.
+ */
+ public static final String HEADER_NAME = "x-chargebee-sdk-telemetry";
+
+ /**
+ * Server drops larger values, so the SDK omits the header rather than sending a truncated one.
+ */
+ static final int MAX_HEADER_BYTES = 4096;
+
+ static final String REQUEST_ID_HEADER = "chargebee-request-id";
+ static final String RUNTIME = "jvm";
+ static final String SDK_SEGMENT = "sdk";
+
+ static final String FT_TELEMETRY_ADAPTER = "ft-telemetry_adapter";
+ static final String FT_CUSTOM_TRANSPORT = "ft-custom_transport";
+ static final String FT_RETRY_CONFIG = "ft-retry_config";
+
+ private SdkTelemetryHeader() {}
+}
diff --git a/src/main/java/com/chargebee/v4/telemetry/SdkTelemetryHeaderBuilder.java b/src/main/java/com/chargebee/v4/telemetry/SdkTelemetryHeaderBuilder.java
new file mode 100644
index 00000000..d07eaaaf
--- /dev/null
+++ b/src/main/java/com/chargebee/v4/telemetry/SdkTelemetryHeaderBuilder.java
@@ -0,0 +1,211 @@
+/*
+ * 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.
+ */
+
+package com.chargebee.v4.telemetry;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Builds RFC 9651 sf-list values for {@link SdkTelemetryHeader#HEADER_NAME}. */
+final class SdkTelemetryHeaderBuilder {
+
+ private SdkTelemetryHeaderBuilder() {}
+
+ /** Returns {@code null} when oversized or a required param contains CR/LF/NUL. */
+ static String build(SdkTelemetrySnapshot snapshot) {
+ if (snapshot == null) {
+ return null;
+ }
+
+ StringBuilder segment = new StringBuilder(SdkTelemetryHeader.SDK_SEGMENT);
+ if (!appendTokenParam(segment, "name", snapshot.getSdkName())) {
+ return null;
+ }
+ if (!appendBareParam(segment, "version", snapshot.getSdkVersion())) {
+ return null;
+ }
+ if (!appendTokenParam(segment, "runtime", SdkTelemetryHeader.RUNTIME)) {
+ return null;
+ }
+ if (!appendTokenParam(segment, "resource", snapshot.getResource())) {
+ return null;
+ }
+ if (!appendTokenParam(segment, "operation", snapshot.getOperation())) {
+ return null;
+ }
+ if (snapshot.getStartTimeEpochSeconds() > 0) {
+ appendDateParam(segment, "start_time", snapshot.getStartTimeEpochSeconds());
+ }
+ appendIntegerParam(segment, "time_ms", snapshot.getTimeMs());
+ if (snapshot.getHttpStatus() != null) {
+ appendIntegerParam(segment, "http_status", snapshot.getHttpStatus());
+ }
+ if (isNotBlank(snapshot.getErrorCode())) {
+ appendStringParam(segment, "error_code", snapshot.getErrorCode());
+ }
+ if (isNotBlank(snapshot.getRequestId())) {
+ appendStringParam(segment, "request_id", snapshot.getRequestId());
+ }
+
+ List items = new ArrayList<>();
+ items.add(segment.toString());
+ for (String featureToken : snapshot.getFeatureTokens()) {
+ if (isValidFeatureToken(featureToken)) {
+ items.add(featureToken.trim());
+ }
+ }
+
+ String headerValue = String.join(", ", items);
+ if (headerValue.getBytes(StandardCharsets.UTF_8).length > SdkTelemetryHeader.MAX_HEADER_BYTES) {
+ return null;
+ }
+ return headerValue;
+ }
+
+ /** Emits an sf-token, falling back to an sf-string. */
+ private static boolean appendTokenParam(StringBuilder segment, String key, String value) {
+ if (!isNotBlank(value)) {
+ return false;
+ }
+ if (containsInvalidSfStringChar(value)) {
+ return false;
+ }
+ String trimmed = value.trim();
+ String serialized = isSfToken(trimmed) ? trimmed : escapeSfString(trimmed);
+ if (serialized == null) {
+ return false;
+ }
+ segment.append(';').append(key).append('=').append(serialized);
+ return true;
+ }
+
+ /** Whether {@code value} is a valid RFC 9651 sf-token. */
+ private static boolean isSfToken(String value) {
+ char first = value.charAt(0);
+ if (!isAsciiLetter(first) && first != '*') {
+ return false;
+ }
+ for (int i = 0; i < value.length(); i++) {
+ char ch = value.charAt(i);
+ boolean allowed =
+ isAsciiLetter(ch)
+ || (ch >= '0' && ch <= '9')
+ || "!#$%&'*+-.^_`|~:/".indexOf(ch) >= 0;
+ if (!allowed) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static boolean isAsciiLetter(char ch) {
+ return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z');
+ }
+
+ /** Emits a bare {@code key=value}, or a quoted sf-string when the value needs escaping. */
+ private static boolean appendBareParam(StringBuilder segment, String key, String value) {
+ if (!isNotBlank(value)) {
+ return false;
+ }
+ if (containsInvalidSfStringChar(value)) {
+ return false;
+ }
+ String trimmed = value.trim();
+ String serialized = isBareSafe(trimmed) ? trimmed : escapeSfString(trimmed);
+ if (serialized == null) {
+ return false;
+ }
+ segment.append(';').append(key).append('=').append(serialized);
+ return true;
+ }
+
+ /** Whether {@code value} can be emitted unquoted without corrupting the sf-list. */
+ private static boolean isBareSafe(String value) {
+ for (int i = 0; i < value.length(); i++) {
+ char ch = value.charAt(i);
+ if (ch == '"'
+ || ch == '\\'
+ || ch == ','
+ || ch == ';'
+ || ch == '='
+ || Character.isWhitespace(ch)) {
+ return false;
+ }
+ }
+ return !value.isEmpty();
+ }
+
+ /** Emits a quoted sf-string parameter, skipping it when the value is invalid. */
+ private static void appendStringParam(StringBuilder segment, String key, String value) {
+ if (!isNotBlank(value)) {
+ return;
+ }
+ if (containsInvalidSfStringChar(value)) {
+ return;
+ }
+ String escaped = escapeSfString(value.trim());
+ if (escaped == null) {
+ return;
+ }
+ segment.append(';').append(key).append('=').append(escaped);
+ }
+
+ /** Emits an integer parameter. */
+ private static void appendIntegerParam(StringBuilder segment, String key, long value) {
+ segment.append(';').append(key).append('=').append(value);
+ }
+
+ /** RFC 9651 sf-date: an {@code @}-prefixed Unix epoch second count. */
+ private static void appendDateParam(StringBuilder segment, String key, long epochSeconds) {
+ segment.append(';').append(key).append("=@").append(epochSeconds);
+ }
+
+ /** Quotes an sf-string; returns {@code null} for CR/LF/NUL. */
+ static String escapeSfString(String value) {
+ if (containsInvalidSfStringChar(value)) {
+ return null;
+ }
+ StringBuilder escaped = new StringBuilder(value.length() + 2);
+ escaped.append('"');
+ for (int i = 0; i < value.length(); i++) {
+ char ch = value.charAt(i);
+ if (ch == '\\' || ch == '"') {
+ escaped.append('\\');
+ }
+ escaped.append(ch);
+ }
+ escaped.append('"');
+ return escaped.toString();
+ }
+
+ /** Whether {@code value} contains CR, LF, or NUL. */
+ private static boolean containsInvalidSfStringChar(String value) {
+ for (int i = 0; i < value.length(); i++) {
+ char ch = value.charAt(i);
+ if (ch == '\0' || ch == '\n' || ch == '\r') {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Whether {@code value} is a valid bare feature-token item. */
+ private static boolean isValidFeatureToken(String value) {
+ if (!isNotBlank(value)) {
+ return false;
+ }
+ if (containsInvalidSfStringChar(value)) {
+ return false;
+ }
+ return isSfToken(value.trim());
+ }
+
+ private static boolean isNotBlank(String value) {
+ return value != null && !value.trim().isEmpty();
+ }
+}
diff --git a/src/main/java/com/chargebee/v4/telemetry/SdkTelemetrySnapshot.java b/src/main/java/com/chargebee/v4/telemetry/SdkTelemetrySnapshot.java
new file mode 100644
index 00000000..44d72098
--- /dev/null
+++ b/src/main/java/com/chargebee/v4/telemetry/SdkTelemetrySnapshot.java
@@ -0,0 +1,185 @@
+/*
+ * 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.
+ */
+
+package com.chargebee.v4.telemetry;
+
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/** Immutable snapshot of a completed SDK API call for N+1 header emission. */
+final class SdkTelemetrySnapshot {
+
+ private final String sdkName;
+ private final String sdkVersion;
+ private final String resource;
+ private final String operation;
+ private final long startTimeEpochSeconds;
+ private final long timeMs;
+ private final Integer httpStatus;
+ private final String errorCode;
+ private final String requestId;
+ private final Set featureTokens;
+
+ private SdkTelemetrySnapshot(Builder builder) {
+ this.sdkName = builder.sdkName;
+ this.sdkVersion = builder.sdkVersion;
+ this.resource = builder.resource;
+ this.operation = builder.operation;
+ this.startTimeEpochSeconds = builder.startTimeEpochSeconds;
+ this.timeMs = builder.timeMs;
+ this.httpStatus = builder.httpStatus;
+ this.errorCode = builder.errorCode;
+ this.requestId = builder.requestId;
+ this.featureTokens =
+ Collections.unmodifiableSet(
+ new LinkedHashSet<>(builder.featureTokens != null ? builder.featureTokens : Set.of()));
+ }
+
+ /** Creates a new builder. */
+ static Builder builder() {
+ return new Builder();
+ }
+
+ /** SDK identifier (e.g. {@code chargebee-java}). */
+ public String getSdkName() {
+ return sdkName;
+ }
+
+ /** SDK version string. */
+ public String getSdkVersion() {
+ return sdkVersion;
+ }
+
+ /** API resource of the completed call. */
+ public String getResource() {
+ return resource;
+ }
+
+ /** API operation of the completed call. */
+ public String getOperation() {
+ return operation;
+ }
+
+ /** Unix epoch seconds at which the reported call started; {@code 0} when unknown. */
+ public long getStartTimeEpochSeconds() {
+ return startTimeEpochSeconds;
+ }
+
+ /** Client-side latency of the completed call in milliseconds. */
+ public long getTimeMs() {
+ return timeMs;
+ }
+
+ /** HTTP status of the completed call, if known. */
+ public Integer getHttpStatus() {
+ return httpStatus;
+ }
+
+ /** Chargebee API error code, if the call failed. */
+ public String getErrorCode() {
+ return errorCode;
+ }
+
+ /** Value of the {@code chargebee-request-id} response header, if present. */
+ public String getRequestId() {
+ return requestId;
+ }
+
+ /** Feature tokens describing SDK configuration for this call. */
+ public Set getFeatureTokens() {
+ return featureTokens;
+ }
+
+ /** Builder for {@link SdkTelemetrySnapshot}. */
+ static final class Builder {
+ private String sdkName;
+ private String sdkVersion;
+ private String resource;
+ private String operation;
+ private long startTimeEpochSeconds;
+ private long timeMs;
+ private Integer httpStatus;
+ private String errorCode;
+ private String requestId;
+ private Set featureTokens = new LinkedHashSet<>();
+
+ /** Sets the SDK name. */
+ public Builder sdkName(String sdkName) {
+ this.sdkName = sdkName;
+ return this;
+ }
+
+ /** Sets the SDK version. */
+ public Builder sdkVersion(String sdkVersion) {
+ this.sdkVersion = sdkVersion;
+ return this;
+ }
+
+ /** Sets the API resource. */
+ public Builder resource(String resource) {
+ this.resource = resource;
+ return this;
+ }
+
+ /** Sets the API operation. */
+ public Builder operation(String operation) {
+ this.operation = operation;
+ return this;
+ }
+
+ /** Sets the call start time in Unix epoch seconds. */
+ public Builder startTimeEpochSeconds(long startTimeEpochSeconds) {
+ this.startTimeEpochSeconds = startTimeEpochSeconds;
+ return this;
+ }
+
+ /** Sets the client-side latency in milliseconds. */
+ public Builder timeMs(long timeMs) {
+ this.timeMs = timeMs;
+ return this;
+ }
+
+ /** Sets the HTTP status code. */
+ public Builder httpStatus(Integer httpStatus) {
+ this.httpStatus = httpStatus;
+ return this;
+ }
+
+ /** Sets the Chargebee API error code. */
+ public Builder errorCode(String errorCode) {
+ this.errorCode = errorCode;
+ return this;
+ }
+
+ /** Sets the Chargebee request id. */
+ public Builder requestId(String requestId) {
+ this.requestId = requestId;
+ return this;
+ }
+
+ /** Replaces the feature-token set. */
+ public Builder featureTokens(Set featureTokens) {
+ this.featureTokens =
+ featureTokens != null ? new LinkedHashSet<>(featureTokens) : new LinkedHashSet<>();
+ return this;
+ }
+
+ /** Adds a single feature token when non-blank. */
+ public Builder addFeatureToken(String featureToken) {
+ if (featureToken != null && !featureToken.isBlank()) {
+ this.featureTokens.add(featureToken);
+ }
+ return this;
+ }
+
+ /** Builds an immutable snapshot. */
+ public SdkTelemetrySnapshot build() {
+ return new SdkTelemetrySnapshot(this);
+ }
+ }
+}
diff --git a/src/main/java/com/chargebee/v4/telemetry/SdkTelemetryState.java b/src/main/java/com/chargebee/v4/telemetry/SdkTelemetryState.java
new file mode 100644
index 00000000..7890e84b
--- /dev/null
+++ b/src/main/java/com/chargebee/v4/telemetry/SdkTelemetryState.java
@@ -0,0 +1,36 @@
+/*
+ * 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.
+ */
+
+package com.chargebee.v4.telemetry;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Per-client holder for the last completed call, used by the N+1 SDK telemetry header.
+ *
+ * Internal SDK type: applications must not depend on it. It is public only so that {@code
+ * ChargebeeClient} can own one instance; all accessors are package-private.
+ */
+public final class SdkTelemetryState {
+
+ private final AtomicReference lastCall = new AtomicReference<>();
+
+ /** Returns the last recorded call, or {@code null} if none. */
+ SdkTelemetrySnapshot lastCall() {
+ return lastCall.get();
+ }
+
+ /** Stores {@code snapshot} as the last completed call. */
+ void record(SdkTelemetrySnapshot snapshot) {
+ lastCall.set(snapshot);
+ }
+
+ /** Clears the last completed call. */
+ void clear() {
+ lastCall.set(null);
+ }
+}
diff --git a/src/main/java/com/chargebee/v4/telemetry/TelemetryAdapterExecutor.java b/src/main/java/com/chargebee/v4/telemetry/TelemetryAdapterExecutor.java
new file mode 100644
index 00000000..bd7e8a1a
--- /dev/null
+++ b/src/main/java/com/chargebee/v4/telemetry/TelemetryAdapterExecutor.java
@@ -0,0 +1,183 @@
+/*
+ * 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.
+ */
+
+package com.chargebee.v4.telemetry;
+
+import com.chargebee.v4.client.ChargebeeClient;
+import com.chargebee.v4.transport.Request;
+import com.chargebee.v4.transport.Response;
+import java.net.URI;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Function;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Drives the customer-supplied {@link TelemetryAdapter} around an API call, so calls show up as
+ * spans in the customer's own observability stack.
+ *
+ * Active only when the client (or request) supplies an adapter and the request carries telemetry
+ * metadata. Adapter failures are logged at {@code WARNING} and never propagate to the caller.
+ */
+final class TelemetryAdapterExecutor {
+
+ private static final Logger LOGGER = Logger.getLogger(TelemetryAdapterExecutor.class.getName());
+
+ private TelemetryAdapterExecutor() {}
+
+ /** Runs {@code next} with the resolved telemetry adapter, if any. */
+ static Response around(
+ ChargebeeClient client, Request request, Function next) {
+ TelemetryAdapter adapter = resolveAdapter(client, request);
+ if (!isActive(adapter, request)) {
+ return next.apply(request);
+ }
+
+ Map adapterHeaders = new HashMap<>();
+ Object handle = startTelemetry(client, adapter, request, adapterHeaders);
+ long startTime = System.currentTimeMillis();
+
+ try {
+ Response response = next.apply(withHeaders(request, adapterHeaders));
+ endTelemetrySuccess(adapter, handle, startTime, response.getStatusCode());
+ return response;
+ } catch (RuntimeException err) {
+ endTelemetryFailure(adapter, handle, startTime, err);
+ throw err;
+ }
+ }
+
+ /** Async variant of {@link #around}. */
+ static CompletableFuture aroundAsync(
+ ChargebeeClient client,
+ Request request,
+ Function> next) {
+ TelemetryAdapter adapter = resolveAdapter(client, request);
+ if (!isActive(adapter, request)) {
+ return next.apply(request);
+ }
+
+ Map adapterHeaders = new HashMap<>();
+ Object handle = startTelemetry(client, adapter, request, adapterHeaders);
+ long startTime = System.currentTimeMillis();
+
+ return next.apply(withHeaders(request, adapterHeaders))
+ .whenComplete(
+ (response, throwable) -> {
+ if (throwable != null) {
+ Throwable cause = throwable.getCause() != null ? throwable.getCause() : throwable;
+ endTelemetryFailure(adapter, handle, startTime, cause);
+ } else {
+ endTelemetrySuccess(adapter, handle, startTime, response.getStatusCode());
+ }
+ });
+ }
+
+ /** Whether an adapter should run for this request. */
+ private static boolean isActive(TelemetryAdapter adapter, Request request) {
+ return adapter != null && request.hasTelemetryMetadata();
+ }
+
+ /** Resolves the request-level adapter override, else the client adapter. */
+ static TelemetryAdapter resolveAdapter(ChargebeeClient client, Request request) {
+ if (request.getTelemetryAdapterOverride() != null) {
+ return request.getTelemetryAdapterOverride();
+ }
+ return client.getTelemetryAdapter();
+ }
+
+ /** Invokes {@link TelemetryAdapter#onRequestStart}; failures are logged and ignored. */
+ private static Object startTelemetry(
+ ChargebeeClient client,
+ TelemetryAdapter adapter,
+ Request request,
+ Map telemetryHeaders) {
+ try {
+ RequestTelemetryContext context = buildContext(client, request);
+ return adapter.onRequestStart(context, telemetryHeaders);
+ } catch (Exception err) {
+ LOGGER.log(
+ Level.WARNING,
+ "Telemetry adapter onRequestStart failed: "
+ + err.getMessage()
+ + ". Continuing without telemetry.",
+ err);
+ return null;
+ }
+ }
+
+ /** Invokes {@link TelemetryAdapter#onRequestEnd} for a successful response. */
+ private static void endTelemetrySuccess(
+ TelemetryAdapter adapter, Object handle, long startTime, int httpStatusCode) {
+ try {
+ adapter.onRequestEnd(
+ handle,
+ TelemetrySupport.buildRequestTelemetryResult(
+ new TelemetrySupport.RequestTelemetryResultInput(
+ httpStatusCode, System.currentTimeMillis() - startTime, null)));
+ } catch (Exception err) {
+ LOGGER.log(Level.WARNING, "Telemetry adapter onRequestEnd failed: " + err.getMessage(), err);
+ }
+ }
+
+ /** Invokes {@link TelemetryAdapter#onRequestEnd} for a failed call. */
+ private static void endTelemetryFailure(
+ TelemetryAdapter adapter, Object handle, long startTime, Throwable err) {
+ Integer status = TelemetrySupport.extractHttpStatusCode(err);
+ int httpStatusCode = status != null ? status : 500;
+ try {
+ adapter.onRequestEnd(
+ handle,
+ TelemetrySupport.buildRequestTelemetryResult(
+ new TelemetrySupport.RequestTelemetryResultInput(
+ httpStatusCode,
+ System.currentTimeMillis() - startTime,
+ TelemetrySupport.extractRequestTelemetryError(err))));
+ } catch (Exception telemetryErr) {
+ LOGGER.log(
+ Level.WARNING,
+ "Telemetry adapter onRequestEnd failed: " + telemetryErr.getMessage(),
+ telemetryErr);
+ }
+ }
+
+ /** Builds the start context passed to the adapter. */
+ static RequestTelemetryContext buildContext(ChargebeeClient client, Request request) {
+ URI uri = URI.create(request.getUrl());
+ String httpUrl = uri.getScheme() + "://" + uri.getHost() + uri.getPath();
+ String apiPath = extractApiPath(client.getBaseUrl());
+ return TelemetrySupport.buildRequestTelemetryContext(
+ new TelemetrySupport.BuildRequestTelemetryContextInput(
+ request.getTelemetryResource(),
+ request.getTelemetryOperation(),
+ request.getMethod(),
+ httpUrl,
+ uri.getHost(),
+ client.getSiteName(),
+ TelemetrySupport.resolveChargebeeApiVersion(apiPath),
+ client.getSdkVersion(),
+ request.getHeaders()));
+ }
+
+ /** Extracts the API path prefix from the client base URL. */
+ private static String extractApiPath(String baseUrl) {
+ URI uri = URI.create(baseUrl);
+ String path = uri.getPath();
+ return path != null && !path.isEmpty() ? path : "/api/v2";
+ }
+
+ /** Returns a copy of {@code request} with {@code headers} applied. */
+ static Request withHeaders(Request request, Map headers) {
+ Request updated = request;
+ for (Map.Entry header : headers.entrySet()) {
+ updated = updated.withHeader(header.getKey(), header.getValue());
+ }
+ return updated;
+ }
+}
diff --git a/src/main/java/com/chargebee/v4/telemetry/TelemetryExecutor.java b/src/main/java/com/chargebee/v4/telemetry/TelemetryExecutor.java
index 3567ee60..d6e2ff54 100644
--- a/src/main/java/com/chargebee/v4/telemetry/TelemetryExecutor.java
+++ b/src/main/java/com/chargebee/v4/telemetry/TelemetryExecutor.java
@@ -1,4 +1,7 @@
/*
+ * 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.
*/
@@ -7,157 +10,42 @@
import com.chargebee.v4.client.ChargebeeClient;
import com.chargebee.v4.transport.Request;
import com.chargebee.v4.transport.Response;
-import java.net.URI;
-import java.util.HashMap;
-import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-/** Executes Chargebee API calls with optional telemetry adapter hooks. */
+/**
+ * Wraps an API call in the SDK's telemetry layers.
+ *
+ * Two unrelated concerns are composed here, and each one owns its own class:
+ *
+ *
+ * - {@link SdkTelemetryEmitter} — the anonymous {@code x-chargebee-sdk-telemetry} request
+ * header Chargebee uses to track SDK adoption. Enabled by default, opt out with {@code
+ * ChargebeeClient.Builder#sdkTelemetryEnabled(boolean)}.
+ *
- {@link TelemetryAdapterExecutor} — the customer's own {@link TelemetryAdapter}, which turns
+ * calls into spans in their observability stack. Off unless an adapter is supplied.
+ *
+ *
+ * Neither layer gates the other, and each is a no-op that passes the request through untouched
+ * when it is not enabled.
+ */
public final class TelemetryExecutor {
- private static final Logger LOGGER = Logger.getLogger(TelemetryExecutor.class.getName());
-
private TelemetryExecutor() {}
+ /** Runs {@code action} with SDK telemetry and the customer adapter layers applied. */
public static Response execute(
ChargebeeClient client, Request request, Function action) {
- TelemetryAdapter adapter = resolveAdapter(client, request);
- if (adapter == null || !request.hasTelemetryMetadata()) {
- return action.apply(request);
- }
-
- long startTime = System.currentTimeMillis();
- Map telemetryHeaders = new HashMap<>();
- Object handle = startTelemetry(client, adapter, request, telemetryHeaders);
- Request requestWithHeaders = withHeaders(request, telemetryHeaders);
-
- try {
- Response response = action.apply(requestWithHeaders);
- endTelemetrySuccess(adapter, handle, startTime, response.getStatusCode());
- return response;
- } catch (RuntimeException e) {
- endTelemetryFailure(adapter, handle, startTime, e);
- throw e;
- }
+ return SdkTelemetryEmitter.around(
+ client, request, outgoing -> TelemetryAdapterExecutor.around(client, outgoing, action));
}
+ /** Async variant of {@link #execute}. */
public static CompletableFuture executeAsync(
ChargebeeClient client,
Request request,
Function> action) {
- TelemetryAdapter adapter = resolveAdapter(client, request);
- if (adapter == null || !request.hasTelemetryMetadata()) {
- return action.apply(request);
- }
-
- long startTime = System.currentTimeMillis();
- Map telemetryHeaders = new HashMap<>();
- Object handle = startTelemetry(client, adapter, request, telemetryHeaders);
- Request requestWithHeaders = withHeaders(request, telemetryHeaders);
-
- return action
- .apply(requestWithHeaders)
- .whenComplete(
- (response, throwable) -> {
- if (throwable != null) {
- Throwable cause = throwable.getCause() != null ? throwable.getCause() : throwable;
- endTelemetryFailure(adapter, handle, startTime, cause);
- } else {
- endTelemetrySuccess(adapter, handle, startTime, response.getStatusCode());
- }
- });
- }
-
- public static TelemetryAdapter resolveAdapter(ChargebeeClient client, Request request) {
- if (request.getTelemetryAdapterOverride() != null) {
- return request.getTelemetryAdapterOverride();
- }
- return client.getTelemetryAdapter();
- }
-
- private static Object startTelemetry(
- ChargebeeClient client,
- TelemetryAdapter adapter,
- Request request,
- Map telemetryHeaders) {
- try {
- RequestTelemetryContext context = buildContext(client, request);
- return adapter.onRequestStart(context, telemetryHeaders);
- } catch (Exception err) {
- LOGGER.log(
- Level.WARNING,
- "Telemetry adapter onRequestStart failed: "
- + err.getMessage()
- + ". Continuing without telemetry.",
- err);
- return null;
- }
- }
-
- private static void endTelemetrySuccess(
- TelemetryAdapter adapter, Object handle, long startTime, int httpStatusCode) {
- try {
- adapter.onRequestEnd(
- handle,
- TelemetrySupport.buildRequestTelemetryResult(
- new TelemetrySupport.RequestTelemetryResultInput(
- httpStatusCode, System.currentTimeMillis() - startTime, null)));
- } catch (Exception err) {
- LOGGER.log(Level.WARNING, "Telemetry adapter onRequestEnd failed: " + err.getMessage(), err);
- }
- }
-
- private static void endTelemetryFailure(
- TelemetryAdapter adapter, Object handle, long startTime, Throwable err) {
- Integer status = TelemetrySupport.extractHttpStatusCode(err);
- int httpStatusCode = status != null ? status : 500;
- try {
- adapter.onRequestEnd(
- handle,
- TelemetrySupport.buildRequestTelemetryResult(
- new TelemetrySupport.RequestTelemetryResultInput(
- httpStatusCode,
- System.currentTimeMillis() - startTime,
- TelemetrySupport.extractRequestTelemetryError(err))));
- } catch (Exception telemetryErr) {
- LOGGER.log(
- Level.WARNING,
- "Telemetry adapter onRequestEnd failed: " + telemetryErr.getMessage(),
- telemetryErr);
- }
- }
-
- static RequestTelemetryContext buildContext(ChargebeeClient client, Request request) {
- URI uri = URI.create(request.getUrl());
- String httpUrl = uri.getScheme() + "://" + uri.getHost() + uri.getPath();
- String apiPath = extractApiPath(client.getBaseUrl());
- return TelemetrySupport.buildRequestTelemetryContext(
- new TelemetrySupport.BuildRequestTelemetryContextInput(
- request.getTelemetryResource(),
- request.getTelemetryOperation(),
- request.getMethod(),
- httpUrl,
- uri.getHost(),
- client.getSiteName(),
- TelemetrySupport.resolveChargebeeApiVersion(apiPath),
- client.getSdkVersion(),
- request.getHeaders()));
- }
-
- private static String extractApiPath(String baseUrl) {
- URI uri = URI.create(baseUrl);
- String path = uri.getPath();
- return path != null && !path.isEmpty() ? path : "/api/v2";
- }
-
- static Request withHeaders(Request request, Map headers) {
- Request updated = request;
- for (Map.Entry header : headers.entrySet()) {
- updated = updated.withHeader(header.getKey(), header.getValue());
- }
- return updated;
+ return SdkTelemetryEmitter.aroundAsync(
+ client, request, outgoing -> TelemetryAdapterExecutor.aroundAsync(client, outgoing, action));
}
}
diff --git a/src/main/java/com/chargebee/v4/telemetry/TelemetrySupport.java b/src/main/java/com/chargebee/v4/telemetry/TelemetrySupport.java
index dd713054..50fd8afa 100644
--- a/src/main/java/com/chargebee/v4/telemetry/TelemetrySupport.java
+++ b/src/main/java/com/chargebee/v4/telemetry/TelemetrySupport.java
@@ -137,14 +137,17 @@ public RequestTelemetryError getError() {
}
}
+ /** Builds the span name {@code chargebee.{resource}.{operation}}. */
public static String buildSpanName(String resource, String operation) {
return TelemetryAttributeKeys.TELEMETRY_SPAN_NAME_PREFIX + "." + resource + "." + operation;
}
+ /** Maps an API path prefix to {@code v1} or {@code v2}. */
public static String resolveChargebeeApiVersion(String apiPath) {
return "/api/v1".equals(apiPath) ? "v1" : "v2";
}
+ /** Builds start-span attributes from the request context. */
public static Map buildRequestStartSpanAttributes(
BuildRequestTelemetryContextInput input) {
Map attributes = new HashMap<>();
@@ -161,6 +164,7 @@ public static Map buildRequestStartSpanAttributes(
return attributes;
}
+ /** Captures {@code chargebee-*} request headers as span attributes, excluding PII origin headers. */
public static Map buildRequestHeaderSpanAttributes(
Map requestHeaders) {
Map attributes = new HashMap<>();
@@ -176,8 +180,7 @@ public static Map buildRequestHeaderSpanAttributes(
}
String lowerName = name.toLowerCase(Locale.ROOT);
if (!lowerName.startsWith(TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_HEADER_PREFIX)
- || lowerName.startsWith(
- TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_HEADER_EXCLUDE_PREFIX)) {
+ || lowerName.startsWith(TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_HEADER_EXCLUDE_PREFIX)) {
continue;
}
attributes.put(
@@ -187,6 +190,7 @@ public static Map buildRequestHeaderSpanAttributes(
return attributes;
}
+ /** Builds end-span attributes from the request result. */
public static Map buildRequestEndSpanAttributes(
RequestTelemetryResultInput result) {
Map attributes = new HashMap<>();
@@ -212,6 +216,7 @@ public static Map buildRequestEndSpanAttributes(
return attributes;
}
+ /** Builds the context passed to {@link TelemetryAdapter#onRequestStart}. */
public static RequestTelemetryContext buildRequestTelemetryContext(
BuildRequestTelemetryContextInput input) {
return new RequestTelemetryContext(
@@ -228,6 +233,7 @@ public static RequestTelemetryContext buildRequestTelemetryContext(
buildRequestStartSpanAttributes(input));
}
+ /** Builds the result passed to {@link TelemetryAdapter#onRequestEnd}. */
public static RequestTelemetryResult buildRequestTelemetryResult(
RequestTelemetryResultInput result) {
return new RequestTelemetryResult(
@@ -237,12 +243,14 @@ public static RequestTelemetryResult buildRequestTelemetryResult(
buildRequestEndSpanAttributes(result));
}
+ /** Extracts Chargebee error details from {@code err}, if present. */
public static RequestTelemetryError extractRequestTelemetryError(Throwable err) {
if (err == null) {
return null;
}
- String message = err.getMessage() != null ? err.getMessage() : "Chargebee API request failed";
+ String message =
+ err.getMessage() != null ? err.getMessage() : "Chargebee API request failed";
if (err instanceof APIException) {
APIException apiException = (APIException) err;
@@ -256,6 +264,7 @@ public static RequestTelemetryError extractRequestTelemetryError(Throwable err)
return new RequestTelemetryError(message, null, null, null);
}
+ /** Extracts the HTTP status code from an {@link HttpException}, if present. */
public static Integer extractHttpStatusCode(Throwable err) {
if (err instanceof HttpException) {
return ((HttpException) err).getStatusCode();
diff --git a/src/test/java/com/chargebee/v4/telemetry/SdkTelemetryEmitterTest.java b/src/test/java/com/chargebee/v4/telemetry/SdkTelemetryEmitterTest.java
new file mode 100644
index 00000000..bcb4eb6c
--- /dev/null
+++ b/src/test/java/com/chargebee/v4/telemetry/SdkTelemetryEmitterTest.java
@@ -0,0 +1,265 @@
+package com.chargebee.v4.telemetry;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.chargebee.v4.client.ChargebeeClient;
+import com.chargebee.v4.exceptions.APIException;
+import com.chargebee.v4.exceptions.codes.NotFoundApiErrorCode;
+import com.chargebee.v4.internal.RetryConfig;
+import com.chargebee.v4.transport.Request;
+import com.chargebee.v4.transport.Response;
+import com.chargebee.v4.transport.Transport;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+@DisplayName("SDK telemetry header emission")
+class SdkTelemetryEmitterTest {
+
+ private static Request listCustomersRequest() {
+ return Request.builder()
+ .method("GET")
+ .url("https://acme.chargebee.com/api/v2/customers")
+ .telemetryResource("customer")
+ .telemetryOperation("list")
+ .build();
+ }
+
+ private static Request retrieveCustomerRequest() {
+ return Request.builder()
+ .method("GET")
+ .url("https://acme.chargebee.com/api/v2/customers/cust_1")
+ .telemetryResource("customer")
+ .telemetryOperation("retrieve")
+ .build();
+ }
+
+ private static final class RecordingTransport implements Transport {
+ private final List requests = new ArrayList<>();
+ private final Map> responseHeaders;
+
+ RecordingTransport() {
+ this(new HashMap<>());
+ }
+
+ RecordingTransport(Map> responseHeaders) {
+ this.responseHeaders = responseHeaders;
+ }
+
+ @Override
+ public Response send(Request request) {
+ requests.add(request);
+ return new Response(200, responseHeaders, "{\"list\":[]}".getBytes());
+ }
+
+ @Override
+ public CompletableFuture sendAsync(Request request) {
+ return CompletableFuture.completedFuture(send(request));
+ }
+ }
+
+ @Test
+ @DisplayName("Should omit header on first call and attach N+1 header on second call")
+ void shouldEmitNPlusOneHeader() {
+ Map> responseHeaders = new HashMap<>();
+ responseHeaders.put("chargebee-request-id", List.of("req_abc123"));
+ RecordingTransport transport = new RecordingTransport(responseHeaders);
+
+ ChargebeeClient client =
+ ChargebeeClient.builder("key_test", "acme")
+ .transport(transport)
+ .retry(RetryConfig.builder().enabled(false).build())
+ .build();
+
+ client.sendWithRetry(listCustomersRequest());
+ client.sendWithRetry(retrieveCustomerRequest());
+
+ assertEquals(2, transport.requests.size());
+ assertNull(transport.requests.get(0).getHeaders().get(SdkTelemetryHeader.HEADER_NAME));
+
+ String header = transport.requests.get(1).getHeaders().get(SdkTelemetryHeader.HEADER_NAME);
+ assertNotNull(header);
+ assertTrue(header.contains("resource=customer;operation=list"));
+ assertTrue(header.contains("start_time=@"));
+ assertTrue(header.contains("http_status=200"));
+ assertTrue(header.contains("request_id=\"req_abc123\""));
+ }
+
+ @Test
+ @DisplayName("Should not attach or record when sdk telemetry is disabled")
+ void shouldRespectOptOut() {
+ RecordingTransport transport = new RecordingTransport();
+ ChargebeeClient client =
+ ChargebeeClient.builder("key_test", "acme")
+ .transport(transport)
+ .retry(RetryConfig.builder().enabled(false).build())
+ .sdkTelemetryEnabled(false)
+ .build();
+
+ client.sendWithRetry(listCustomersRequest());
+ client.sendWithRetry(retrieveCustomerRequest());
+
+ assertEquals(2, transport.requests.size());
+ assertNull(transport.requests.get(0).getHeaders().get(SdkTelemetryHeader.HEADER_NAME));
+ assertNull(transport.requests.get(1).getHeaders().get(SdkTelemetryHeader.HEADER_NAME));
+ }
+
+ @Test
+ @DisplayName("Should record failure details for the next header")
+ void shouldRecordFailureOnNextHeader() {
+ RecordingTransport successTransport =
+ new RecordingTransport(Map.of("chargebee-request-id", List.of("req_fail")));
+
+ ChargebeeClient client =
+ ChargebeeClient.builder("key_test", "acme")
+ .transport(
+ new Transport() {
+ private int attempt;
+
+ @Override
+ public Response send(Request request) {
+ attempt++;
+ if (attempt == 1) {
+ throw new APIException(
+ 404,
+ "invalid_request",
+ NotFoundApiErrorCode.RESOURCE_NOT_FOUND,
+ "Not found",
+ "{}",
+ request,
+ new Response(404, Map.of("chargebee-request-id", List.of("req_fail")), "{}".getBytes()));
+ }
+ successTransport.requests.add(request);
+ return successTransport.send(request);
+ }
+
+ @Override
+ public CompletableFuture sendAsync(Request request) {
+ return CompletableFuture.completedFuture(send(request));
+ }
+ })
+ .retry(RetryConfig.builder().enabled(false).build())
+ .build();
+
+ assertThrows(APIException.class, () -> client.sendWithRetry(retrieveCustomerRequest()));
+ client.sendWithRetry(listCustomersRequest());
+
+ String header = successTransport.requests.get(0).getHeaders().get(SdkTelemetryHeader.HEADER_NAME);
+ assertNotNull(header);
+ assertTrue(header.contains("operation=retrieve"));
+ assertTrue(header.contains("http_status=404"));
+ assertTrue(header.contains("error_code=\"resource_not_found\""));
+ }
+
+ @Test
+ @DisplayName("Should emit feature tokens independently of OTel adapter")
+ void shouldEmitFeatureTokensWithoutOtelAdapter() {
+ RecordingTransport transport = new RecordingTransport();
+ ChargebeeClient client =
+ ChargebeeClient.builder("key_test", "acme")
+ .transport(transport)
+ .retry(RetryConfig.builder().enabled(true).maxRetries(1).build())
+ .build();
+
+ client.sendWithRetry(listCustomersRequest());
+ client.sendWithRetry(retrieveCustomerRequest());
+
+ String header = transport.requests.get(1).getHeaders().get(SdkTelemetryHeader.HEADER_NAME);
+ assertNotNull(header);
+ assertTrue(header.contains(SdkTelemetryHeader.FT_RETRY_CONFIG));
+ assertTrue(header.contains(SdkTelemetryHeader.FT_CUSTOM_TRANSPORT));
+ assertFalse(header.contains(SdkTelemetryHeader.FT_TELEMETRY_ADAPTER));
+ }
+
+ @Test
+ @DisplayName("Should emit ft-telemetry_adapter when OTel adapter is configured")
+ void shouldEmitTelemetryAdapterFeatureToken() {
+ RecordingTransport transport = new RecordingTransport();
+ TelemetryAdapter adapter =
+ new TelemetryAdapter() {
+ @Override
+ public Object onRequestStart(
+ RequestTelemetryContext context, Map requestHeaders) {
+ return "span";
+ }
+
+ @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();
+
+ client.sendWithRetry(listCustomersRequest());
+ client.sendWithRetry(retrieveCustomerRequest());
+
+ String header = transport.requests.get(1).getHeaders().get(SdkTelemetryHeader.HEADER_NAME);
+ assertNotNull(header);
+ assertTrue(header.contains(SdkTelemetryHeader.FT_TELEMETRY_ADAPTER));
+ }
+
+ @Test
+ @DisplayName("Should support async N+1 emission")
+ void shouldEmitAsyncNPlusOneHeader() throws Exception {
+ RecordingTransport transport = new RecordingTransport();
+ ChargebeeClient client =
+ ChargebeeClient.builder("key_test", "acme")
+ .transport(transport)
+ .retry(RetryConfig.builder().enabled(false).build())
+ .build();
+
+ client.sendWithRetryAsync(listCustomersRequest()).get();
+ client.sendWithRetryAsync(retrieveCustomerRequest()).get();
+
+ assertNull(transport.requests.get(0).getHeaders().get(SdkTelemetryHeader.HEADER_NAME));
+ assertNotNull(transport.requests.get(1).getHeaders().get(SdkTelemetryHeader.HEADER_NAME));
+ }
+
+ @Test
+ @DisplayName("Should keep telemetry state per client instance, not per configuration")
+ void shouldNotShareStateBetweenIdenticallyConfiguredClients() {
+ RecordingTransport transport = new RecordingTransport();
+ RetryConfig retry = RetryConfig.builder().enabled(false).build();
+
+ ChargebeeClient first =
+ ChargebeeClient.builder("key_test", "acme").transport(transport).retry(retry).build();
+ ChargebeeClient second =
+ ChargebeeClient.builder("key_test", "acme").transport(transport).retry(retry).build();
+
+ first.sendWithRetry(listCustomersRequest());
+ second.sendWithRetry(retrieveCustomerRequest());
+
+ assertEquals(2, transport.requests.size());
+ assertNull(transport.requests.get(1).getHeaders().get(SdkTelemetryHeader.HEADER_NAME));
+ }
+
+ @Test
+ @DisplayName("Should not update snapshot when telemetry metadata is missing")
+ void shouldSkipSnapshotWithoutTelemetryMetadata() {
+ RecordingTransport transport = new RecordingTransport();
+ ChargebeeClient client =
+ ChargebeeClient.builder("key_test", "acme")
+ .transport(transport)
+ .retry(RetryConfig.builder().enabled(false).build())
+ .build();
+
+ Request withoutMetadata =
+ Request.builder()
+ .method("GET")
+ .url("https://acme.chargebee.com/api/v2/customers")
+ .build();
+
+ client.sendWithRetry(withoutMetadata);
+ client.sendWithRetry(listCustomersRequest());
+
+ assertNull(transport.requests.get(1).getHeaders().get(SdkTelemetryHeader.HEADER_NAME));
+ }
+}
diff --git a/src/test/java/com/chargebee/v4/telemetry/SdkTelemetryHeaderBuilderTest.java b/src/test/java/com/chargebee/v4/telemetry/SdkTelemetryHeaderBuilderTest.java
new file mode 100644
index 00000000..37be402b
--- /dev/null
+++ b/src/test/java/com/chargebee/v4/telemetry/SdkTelemetryHeaderBuilderTest.java
@@ -0,0 +1,220 @@
+package com.chargebee.v4.telemetry;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.LinkedHashSet;
+import java.util.Set;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+@DisplayName("SDK telemetry header builder")
+class SdkTelemetryHeaderBuilderTest {
+
+ @Test
+ @DisplayName("Should serialize a complete snapshot")
+ void shouldSerializeCompleteSnapshot() {
+ Set features = new LinkedHashSet<>();
+ features.add(SdkTelemetryHeader.FT_TELEMETRY_ADAPTER);
+ features.add(SdkTelemetryHeader.FT_CUSTOM_TRANSPORT);
+
+ SdkTelemetrySnapshot snapshot =
+ SdkTelemetrySnapshot.builder()
+ .sdkName("chargebee-java")
+ .sdkVersion("4.14.0")
+ .resource("customer")
+ .operation("list")
+ .startTimeEpochSeconds(1781280400L)
+ .timeMs(380)
+ .httpStatus(200)
+ .requestId("req_abc123")
+ .featureTokens(features)
+ .build();
+
+ String header = SdkTelemetryHeaderBuilder.build(snapshot);
+
+ assertNotNull(header);
+ assertTrue(header.startsWith("sdk;name=chargebee-java;version=4.14.0;runtime=jvm;"));
+ assertTrue(
+ header.contains(
+ "resource=customer;operation=list;start_time=@1781280400;time_ms=380;http_status=200"));
+ assertTrue(header.contains("request_id=\"req_abc123\""));
+ assertTrue(header.contains(SdkTelemetryHeader.FT_TELEMETRY_ADAPTER));
+ assertTrue(header.contains(SdkTelemetryHeader.FT_CUSTOM_TRANSPORT));
+ }
+
+ @Test
+ @DisplayName("Should include error_code on failure snapshots")
+ void shouldIncludeErrorCode() {
+ SdkTelemetrySnapshot snapshot =
+ SdkTelemetrySnapshot.builder()
+ .sdkName("chargebee-java")
+ .sdkVersion("4.14.0")
+ .resource("customer")
+ .operation("update")
+ .timeMs(210)
+ .httpStatus(400)
+ .errorCode("param_wrong_value")
+ .requestId("req_def456")
+ .build();
+
+ String header = SdkTelemetryHeaderBuilder.build(snapshot);
+
+ assertNotNull(header);
+ assertTrue(header.contains("http_status=400"));
+ assertTrue(header.contains("error_code=\"param_wrong_value\""));
+ }
+
+ @Test
+ @DisplayName("Should escape sf-string values")
+ void shouldEscapeSfStrings() {
+ assertEquals("\"hello\"", SdkTelemetryHeaderBuilder.escapeSfString("hello"));
+ assertEquals("\"say \\\"hi\\\"\"", SdkTelemetryHeaderBuilder.escapeSfString("say \"hi\""));
+ assertEquals("\"path\\\\to\"", SdkTelemetryHeaderBuilder.escapeSfString("path\\to"));
+ }
+
+ @Test
+ @DisplayName("Should reject sf-string values containing CR, LF, or NUL")
+ void shouldRejectInvalidSfStringChars() {
+ assertNull(SdkTelemetryHeaderBuilder.escapeSfString("bad\rvalue"));
+ assertNull(SdkTelemetryHeaderBuilder.escapeSfString("bad\nvalue"));
+ assertNull(SdkTelemetryHeaderBuilder.escapeSfString("bad\0value"));
+ }
+
+ @Test
+ @DisplayName("Should omit invalid error_code while keeping the rest of the header")
+ void shouldOmitInvalidErrorCode() {
+ SdkTelemetrySnapshot snapshot =
+ SdkTelemetrySnapshot.builder()
+ .sdkName("chargebee-java")
+ .sdkVersion("4.14.0")
+ .resource("customer")
+ .operation("retrieve")
+ .timeMs(50)
+ .httpStatus(404)
+ .errorCode("resource_not_found\rinjected")
+ .build();
+
+ String header = SdkTelemetryHeaderBuilder.build(snapshot);
+
+ assertNotNull(header);
+ assertTrue(header.contains("http_status=404"));
+ assertFalse(header.contains("error_code="));
+ }
+
+ @Test
+ @DisplayName("Should omit the entire header when a required field contains invalid characters")
+ void shouldOmitHeaderWhenRequiredFieldInvalid() {
+ SdkTelemetrySnapshot snapshot =
+ SdkTelemetrySnapshot.builder()
+ .sdkName("chargebee-java")
+ .sdkVersion("4.14.0\0")
+ .resource("customer")
+ .operation("list")
+ .timeMs(5)
+ .build();
+
+ assertNull(SdkTelemetryHeaderBuilder.build(snapshot));
+ }
+
+ @Test
+ @DisplayName("Should skip invalid feature tokens while keeping valid ones")
+ void shouldSkipInvalidFeatureTokens() {
+ Set features = new LinkedHashSet<>();
+ features.add(SdkTelemetryHeader.FT_RETRY_CONFIG);
+ features.add("ft-bad\rinjected");
+ features.add(SdkTelemetryHeader.FT_TELEMETRY_ADAPTER);
+ features.add("ft-bad\0");
+ features.add("ft-bad\n");
+
+ SdkTelemetrySnapshot snapshot =
+ SdkTelemetrySnapshot.builder()
+ .sdkName("chargebee-java")
+ .sdkVersion("4.14.0")
+ .resource("customer")
+ .operation("list")
+ .timeMs(5)
+ .featureTokens(features)
+ .build();
+
+ String header = SdkTelemetryHeaderBuilder.build(snapshot);
+
+ assertNotNull(header);
+ assertTrue(header.contains(SdkTelemetryHeader.FT_RETRY_CONFIG));
+ assertTrue(header.contains(SdkTelemetryHeader.FT_TELEMETRY_ADAPTER));
+ assertFalse(header.contains("ft-bad"));
+ assertFalse(header.contains("\r"));
+ assertFalse(header.contains("\n"));
+ }
+
+ @Test
+ @DisplayName("Should omit start_time when the call start is unknown")
+ void shouldOmitUnknownStartTime() {
+ SdkTelemetrySnapshot snapshot =
+ SdkTelemetrySnapshot.builder()
+ .sdkName("chargebee-java")
+ .sdkVersion("4.14.0")
+ .resource("customer")
+ .operation("list")
+ .timeMs(12)
+ .build();
+
+ String header = SdkTelemetryHeaderBuilder.build(snapshot);
+
+ assertNotNull(header);
+ assertFalse(header.contains("start_time"));
+ }
+
+ @Test
+ @DisplayName("Should quote token params that are not valid sf-tokens")
+ void shouldQuoteInvalidTokenValues() {
+ SdkTelemetrySnapshot snapshot =
+ SdkTelemetrySnapshot.builder()
+ .sdkName("chargebee-java")
+ .sdkVersion("4.14.0")
+ .resource("customer report")
+ .operation("list")
+ .timeMs(5)
+ .build();
+
+ String header = SdkTelemetryHeaderBuilder.build(snapshot);
+
+ assertNotNull(header);
+ assertTrue(header.contains("resource=\"customer report\""));
+ assertTrue(header.contains("operation=list"));
+ }
+
+ @Test
+ @DisplayName("Should quote version when it contains structural characters")
+ void shouldQuoteUnsafeVersionValues() {
+ SdkTelemetrySnapshot snapshot =
+ SdkTelemetrySnapshot.builder()
+ .sdkName("chargebee-java")
+ .sdkVersion("4.14.0-rc\"1")
+ .resource("customer")
+ .operation("list")
+ .timeMs(5)
+ .build();
+
+ String header = SdkTelemetryHeaderBuilder.build(snapshot);
+
+ assertNotNull(header);
+ assertTrue(header.contains("version=\"4.14.0-rc\\\"1\""));
+ }
+
+ @Test
+ @DisplayName("Should return null when header exceeds max UTF-8 bytes")
+ void shouldReturnNullWhenOversized() {
+ String longVersion = "v".repeat(SdkTelemetryHeader.MAX_HEADER_BYTES);
+ SdkTelemetrySnapshot snapshot =
+ SdkTelemetrySnapshot.builder()
+ .sdkName("chargebee-java")
+ .sdkVersion(longVersion)
+ .resource("customer")
+ .operation("list")
+ .timeMs(1)
+ .httpStatus(200)
+ .build();
+
+ assertNull(SdkTelemetryHeaderBuilder.build(snapshot));
+ }
+}
diff --git a/src/test/java/com/chargebee/v4/telemetry/TelemetryExecutorTest.java b/src/test/java/com/chargebee/v4/telemetry/TelemetryExecutorTest.java
index 20914b0d..725df2ca 100644
--- a/src/test/java/com/chargebee/v4/telemetry/TelemetryExecutorTest.java
+++ b/src/test/java/com/chargebee/v4/telemetry/TelemetryExecutorTest.java
@@ -295,7 +295,7 @@ public void onRequestEnd(Object handle, RequestTelemetryResult result) {
@Test
@DisplayName("Should log adapter failures at WARNING (not SEVERE) and still return the response")
void shouldLogAdapterFailureAtWarning() {
- Logger logger = Logger.getLogger(TelemetryExecutor.class.getName());
+ Logger logger = Logger.getLogger(TelemetryAdapterExecutor.class.getName());
List records = new ArrayList<>();
Handler captor =
new Handler() {