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

+ * + *

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