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..ae2b78ba
--- /dev/null
+++ b/src/main/java/com/chargebee/v4/telemetry/SdkTelemetryEmitter.java
@@ -0,0 +1,103 @@
+/*
+ * 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.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.
+ *
+ *
On the first API call of a client instance, attach {@code f;…} with enabled feature codes when
+ * any are present; omit the header when none are enabled. Later calls on the same client never
+ * attach again. SDK identity is correlated via {@code User-Agent}. 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 one-shot features header when applicable, then invokes {@code next}. */
+ static Response around(
+ ChargebeeClient client, Request request, Function next) {
+ if (!client.isSdkTelemetryEnabled()) {
+ return next.apply(request);
+ }
+ return next.apply(attachHeader(client, request));
+ }
+
+ /** Async variant of {@link #around}. */
+ static CompletableFuture aroundAsync(
+ ChargebeeClient client,
+ Request request,
+ Function> next) {
+ if (!client.isSdkTelemetryEnabled()) {
+ return next.apply(request);
+ }
+ return next.apply(attachHeader(client, request));
+ }
+
+ /** Returns {@code request} with the telemetry header, or {@code request} unchanged. */
+ private static Request attachHeader(ChargebeeClient client, Request request) {
+ try {
+ if (!client.getSdkTelemetryState().tryMarkEmitted()) {
+ return request;
+ }
+ String headerValue = SdkTelemetryHeaderBuilder.build(resolveFeatures(client, request));
+ if (headerValue == null) {
+ return request;
+ }
+ return request.withHeader(SdkTelemetryHeader.HEADER_NAME, headerValue);
+ } catch (Exception err) {
+ logSuppressed("attach header", err);
+ return request;
+ }
+ }
+
+ /** Collects enabled feature codes for the current client/request configuration. */
+ private static Set resolveFeatures(ChargebeeClient client, Request request) {
+ Set features = new LinkedHashSet<>();
+ if (TelemetryAdapterExecutor.resolveAdapter(client, request) != null) {
+ features.add(SdkTelemetryFeature.TELEMETRY_ADAPTER);
+ }
+ if (!(client.getTransport() instanceof DefaultTransport)) {
+ features.add(SdkTelemetryFeature.CUSTOM_TRANSPORT);
+ }
+ if (isRetryConfigActive(client, request)) {
+ features.add(SdkTelemetryFeature.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();
+ }
+
+ /** 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/SdkTelemetryFeature.java b/src/main/java/com/chargebee/v4/telemetry/SdkTelemetryFeature.java
new file mode 100644
index 00000000..05538b7a
--- /dev/null
+++ b/src/main/java/com/chargebee/v4/telemetry/SdkTelemetryFeature.java
@@ -0,0 +1,34 @@
+/*
+ * 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;
+
+/**
+ * SDK configuration features reported under the {@code f} segment of {@link
+ * SdkTelemetryHeader#HEADER_NAME}. Wire codes are maintained in sdk-generator.
+ */
+enum SdkTelemetryFeature {
+ /** Customer {@link TelemetryAdapter} configured. */
+ TELEMETRY_ADAPTER("ta"),
+
+ /** Non-default transport configured. */
+ CUSTOM_TRANSPORT("ct"),
+
+ /** Retries enabled on the client or request. */
+ RETRY_CONFIG("rc");
+
+ private final String code;
+
+ SdkTelemetryFeature(String code) {
+ this.code = code;
+ }
+
+ /** Two-letter wire code appended as a boolean sf-param on {@code f}. */
+ String code() {
+ return code;
+ }
+}
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..90f50506
--- /dev/null
+++ b/src/main/java/com/chargebee/v4/telemetry/SdkTelemetryHeader.java
@@ -0,0 +1,28 @@
+/*
+ * 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;
+
+ /** RFC 9651 sf-list item name for the features segment. */
+ static final String FEATURES_KEY = "f";
+
+ 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..bb226c69
--- /dev/null
+++ b/src/main/java/com/chargebee/v4/telemetry/SdkTelemetryHeaderBuilder.java
@@ -0,0 +1,49 @@
+/*
+ * 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.Collection;
+
+/**
+ * Builds RFC 9651 values for {@link SdkTelemetryHeader#HEADER_NAME}: a features segment keyed by
+ * {@link SdkTelemetryHeader#FEATURES_KEY} with enabled feature codes as boolean params (for example
+ * {@code f;ta;rc}). Correlate SDK identity via {@code User-Agent}.
+ */
+final class SdkTelemetryHeaderBuilder {
+
+ private SdkTelemetryHeaderBuilder() {}
+
+ /**
+ * Returns {@code null} when {@code features} is empty/null or the value exceeds {@link
+ * SdkTelemetryHeader#MAX_HEADER_BYTES}.
+ */
+ static String build(Collection features) {
+ if (features == null || features.isEmpty()) {
+ return null;
+ }
+
+ StringBuilder value = new StringBuilder(SdkTelemetryHeader.FEATURES_KEY);
+ for (SdkTelemetryFeature feature : features) {
+ if (feature == null) {
+ continue;
+ }
+ value.append(';').append(feature.code());
+ }
+
+ if (value.length() == SdkTelemetryHeader.FEATURES_KEY.length()) {
+ return null;
+ }
+
+ String headerValue = value.toString();
+ if (headerValue.getBytes(StandardCharsets.UTF_8).length > SdkTelemetryHeader.MAX_HEADER_BYTES) {
+ return null;
+ }
+ return headerValue;
+ }
+}
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..87cf0466
--- /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.AtomicBoolean;
+
+/**
+ * Per-client gate so the SDK telemetry header is considered at most once per client instance.
+ */
+public final class SdkTelemetryState {
+
+ private final AtomicBoolean emitted = new AtomicBoolean(false);
+
+ /**
+ * Claims the single emission slot for this client. Returns {@code true} only for the first
+ * caller.
+ */
+ boolean tryMarkEmitted() {
+ return emitted.compareAndSet(false, true);
+ }
+
+ /** Whether this client has already considered emitting the telemetry header. */
+ boolean hasEmitted() {
+ return emitted.get();
+ }
+
+ /** Clears the emission gate (tests only). */
+ void clear() {
+ emitted.set(false);
+ }
+}
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..4d7d8ef8 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,44 @@
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..3b6c2cb8 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,9 @@ 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<>();
@@ -187,6 +193,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 +219,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 +236,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,6 +246,7 @@ 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;
@@ -256,6 +266,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..b37f4469
--- /dev/null
+++ b/src/test/java/com/chargebee/v4/telemetry/SdkTelemetryEmitterTest.java
@@ -0,0 +1,212 @@
+package com.chargebee.v4.telemetry;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.chargebee.v4.client.ChargebeeClient;
+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));
+ }
+ }
+
+ /**
+ * Extends {@link com.chargebee.v4.transport.DefaultTransport} so {@code ct} is not reported, while
+ * still capturing outbound requests.
+ */
+ private static final class RecordingDefaultTransport
+ extends com.chargebee.v4.transport.DefaultTransport {
+ private final List requests = new ArrayList<>();
+
+ RecordingDefaultTransport() {
+ super(com.chargebee.v4.transport.TransportConfig.builder().apiKey("key_test").build());
+ }
+
+ @Override
+ public Response send(Request request) {
+ requests.add(request);
+ return new Response(200, Map.of(), "{\"list\":[]}".getBytes());
+ }
+
+ @Override
+ public CompletableFuture sendAsync(Request request) {
+ return CompletableFuture.completedFuture(send(request));
+ }
+ }
+
+ @Test
+ @DisplayName("Should omit header when no features are enabled")
+ void shouldOmitHeaderWhenNoFeatures() {
+ RecordingDefaultTransport transport = new RecordingDefaultTransport();
+ 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));
+ assertNull(transport.requests.get(1).getHeaders().get(SdkTelemetryHeader.HEADER_NAME));
+ }
+
+ @Test
+ @DisplayName("Should not attach when sdk telemetry is disabled")
+ void shouldRespectOptOut() {
+ RecordingTransport transport = new RecordingTransport();
+ ChargebeeClient client =
+ ChargebeeClient.builder("key_test", "acme")
+ .transport(transport)
+ .retry(RetryConfig.builder().enabled(true).maxRetries(1).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 emit keyed feature codes once on the first call only")
+ void shouldEmitFeaturesOncePerClient() {
+ 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 first = transport.requests.get(0).getHeaders().get(SdkTelemetryHeader.HEADER_NAME);
+ assertNotNull(first);
+ assertEquals("f;ct;rc", first);
+ assertNull(transport.requests.get(1).getHeaders().get(SdkTelemetryHeader.HEADER_NAME));
+ }
+
+ @Test
+ @DisplayName("Should emit ta 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(0).getHeaders().get(SdkTelemetryHeader.HEADER_NAME);
+ assertEquals("f;ta;ct", header);
+ assertNull(transport.requests.get(1).getHeaders().get(SdkTelemetryHeader.HEADER_NAME));
+ }
+
+ @Test
+ @DisplayName("Should support async once-per-client emission")
+ void shouldEmitAsyncOncePerClient() throws Exception {
+ RecordingTransport transport = new RecordingTransport();
+ ChargebeeClient client =
+ ChargebeeClient.builder("key_test", "acme")
+ .transport(transport)
+ .retry(RetryConfig.builder().enabled(true).maxRetries(1).build())
+ .build();
+
+ client.sendWithRetryAsync(listCustomersRequest()).get();
+ client.sendWithRetryAsync(retrieveCustomerRequest()).get();
+
+ assertEquals(
+ "f;ct;rc", transport.requests.get(0).getHeaders().get(SdkTelemetryHeader.HEADER_NAME));
+ assertNull(transport.requests.get(1).getHeaders().get(SdkTelemetryHeader.HEADER_NAME));
+ }
+
+ @Test
+ @DisplayName("Should keep telemetry state per client instance")
+ void shouldNotShareStateBetweenIdenticallyConfiguredClients() {
+ RecordingTransport transport = new RecordingTransport();
+ RetryConfig retry = RetryConfig.builder().enabled(true).maxRetries(1).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());
+ assertEquals(
+ "f;ct;rc", transport.requests.get(0).getHeaders().get(SdkTelemetryHeader.HEADER_NAME));
+ assertEquals(
+ "f;ct;rc", 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..3ebc0952
--- /dev/null
+++ b/src/test/java/com/chargebee/v4/telemetry/SdkTelemetryHeaderBuilderTest.java
@@ -0,0 +1,54 @@
+package com.chargebee.v4.telemetry;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.EnumSet;
+import java.util.List;
+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 features under the f key")
+ void shouldSerializeFeaturesUnderKey() {
+ String header =
+ SdkTelemetryHeaderBuilder.build(
+ List.of(
+ SdkTelemetryFeature.TELEMETRY_ADAPTER, SdkTelemetryFeature.CUSTOM_TRANSPORT));
+
+ assertEquals("f;ta;ct", header);
+ }
+
+ @Test
+ @DisplayName("Should return null when no features are enabled")
+ void shouldReturnNullWhenEmpty() {
+ assertNull(SdkTelemetryHeaderBuilder.build(Set.of()));
+ assertNull(SdkTelemetryHeaderBuilder.build(null));
+ }
+
+ @Test
+ @DisplayName("Should skip null feature entries")
+ void shouldSkipNullEntries() {
+ String header =
+ SdkTelemetryHeaderBuilder.build(
+ java.util.Arrays.asList(SdkTelemetryFeature.RETRY_CONFIG, null));
+
+ assertEquals("f;rc", header);
+ }
+
+ @Test
+ @DisplayName("Should preserve enum declaration order via LinkedHashSet-style input")
+ void shouldPreserveOrder() {
+ String header =
+ SdkTelemetryHeaderBuilder.build(
+ EnumSet.of(
+ SdkTelemetryFeature.TELEMETRY_ADAPTER,
+ SdkTelemetryFeature.CUSTOM_TRANSPORT,
+ SdkTelemetryFeature.RETRY_CONFIG));
+
+ assertEquals("f;ta;ct;rc", header);
+ }
+}
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() {