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: + * + *

+ * + *

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