Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,62 @@ The SDK builds standardized span attributes (`ctx.getStartAttributes()`, `result

Spans are named `chargebee.{resource}.{operation}` (e.g. `chargebee.subscription.create`).

#### Server-side timing telemetry (Beta)

> **Beta.** `X-Chargebee-Telemetry` response parsing and `preferChargebeeTelemetry` are in beta. Header availability, wire format, and SDK behavior may change.

Chargebee returns `X-Chargebee-Telemetry` only when the client opts in with `Prefer: chargebee-telemetry=include`. Call `.preferChargebeeTelemetry(true)` on the client builder to have the SDK add that header on each request when a `telemetryAdapter` is configured (parsed into `chargebee.telemetry.*` span attributes). You can also set the `Prefer` header yourself on individual requests.

On select APIs, Chargebee may include an `X-Chargebee-Telemetry` response header with a server-side timing breakdown — treat it as optional enrichment, not a required contract.

When the header is present and a `telemetryAdapter` is configured, the SDK adds span attributes at request end in two layers:

1. **Raw** — the full header string under `http.response.header.x-chargebee-telemetry` (audit, debug, or custom parsing)
2. **Parsed** — typed flat attributes under `chargebee.telemetry.*` (ready for APM dashboards without writing an parser)

The header value is an [RFC 9651](https://www.rfc-editor.org/rfc/rfc9651) `sf-list`: comma-separated segments, each optionally followed by semicolon-separated `key=value` parameters.

```
X-Chargebee-Telemetry: cb;start_time=@1781280400;time_ms=3800, tp-stripe;pm=card;time_ms=620, ft-account_hierarchy
```

| Segment | Meaning | Parsed span attributes |
|---|---|---|
| `cb;…` | Chargebee processing time | `chargebee.telemetry.cb.{param}` — e.g. `time_ms`, `start_time`, `res_wait_time_ms`, `tp_time_ms` |
| `tp-{provider};…` | Third-party call time (Stripe, Avalara, …) | `chargebee.telemetry.tp.{provider}.{param}` — e.g. `chargebee.telemetry.tp.stripe.time_ms` |
| `ft-{feature}` | Feature flag active on this request (bare token, no params) | Collected into `chargebee.telemetry.features` (`string[]`) |

Parameter values are typed by RFC 9651 wire format and mapped to OTel-friendly types:

| Wire format | Example | OTel type |
|---|---|---|
| sf-date (`@epoch`) | `start_time=@1781280400` | `long` (Unix seconds) |
| sf-integer | `time_ms=3800` | `long` |
| sf-decimal | `ratio=99.9` | `double` |
| sf-token (bare word) | `pm=card` | `string` |
| sf-string (quoted) | `desc="hello world"` | `string` |
| sf-boolean | `enabled=?1` / `?0` | `boolean` |
| sf-binary | `payload=:aGVsbG8=:` | `string` (base64 payload) |

For the example header above, `result.getEndAttributes()` at span end includes:

```
http.response.header.x-chargebee-telemetry → "cb;start_time=@1781280400;time_ms=3800, tp-stripe;pm=card;time_ms=620, ft-account_hierarchy"
chargebee.telemetry.cb.start_time → 1781280400
chargebee.telemetry.cb.time_ms → 3800
chargebee.telemetry.tp.stripe.time_ms → 620
chargebee.telemetry.tp.stripe.pm → "card"
chargebee.telemetry.features → ["account_hierarchy"]
```

**Behavior:**

- Header **absent** → no telemetry header attributes are added; the span is unaffected.
- Header **present but unparseable** → only the raw `http.response.header.x-chargebee-telemetry` attribute is emitted; the API call is never failed or delayed by parsing.
- Parsed timing fields such as `chargebee.telemetry.cb.time_ms` are numeric (`long`) so backends like Datadog, New Relic, and Honeycomb can filter, average, and chart percentiles out of the box.

Use the included `OtelTelemetryAdapter` example below as-is to forward both raw and parsed attributes to your exporter.

#### OpenTelemetry example

```kotlin
Expand Down Expand Up @@ -755,6 +811,7 @@ import io.opentelemetry.api.trace.SpanKind;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Context;
import java.util.List;
import java.util.Map;

class OtelTelemetryAdapter implements TelemetryAdapter {
Expand Down Expand Up @@ -803,6 +860,14 @@ class OtelTelemetryAdapter implements TelemetryAdapter {
span.setAttribute(k, (Long) v);
} else if (v instanceof Integer) {
span.setAttribute(k, ((Integer) v).longValue());
} else if (v instanceof Double) {
span.setAttribute(k, (Double) v);
} else if (v instanceof Boolean) {
span.setAttribute(k, (Boolean) v);
} else if (v instanceof List) {
@SuppressWarnings("unchecked")
List<String> values = (List<String>) v;
span.setAttribute(AttributeKey.stringArrayKey(k), values);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
if (result.getError() != null) {
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/com/chargebee/v4/client/ChargebeeClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public final class ChargebeeClient extends ClientMethodsImpl implements AutoClos
private final RequestInterceptor requestInterceptor;
private final RequestContext clientHeaders;
private final TelemetryAdapter telemetryAdapter;
private final boolean preferChargebeeTelemetry;
private final ScheduledExecutorService retryScheduler;

// Auto-generated service registry for lazy loading
Expand All @@ -61,6 +62,7 @@ private ChargebeeClient(Builder builder) {
this.requestInterceptor = builder.requestInterceptor;
this.clientHeaders = new RequestContext(builder.clientHeaders.getHeaders());
this.telemetryAdapter = builder.telemetryAdapter;
this.preferChargebeeTelemetry = builder.preferChargebeeTelemetry;
this.retryScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "chargebee-retry-scheduler");
t.setDaemon(true);
Expand Down Expand Up @@ -97,6 +99,7 @@ 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 isPreferChargebeeTelemetry() { return preferChargebeeTelemetry; }

public String getSdkVersion() {
return getVersion();
Expand Down Expand Up @@ -577,6 +580,7 @@ public static final class Builder {
private String protocol = "https";
private RequestInterceptor requestInterceptor;
private TelemetryAdapter telemetryAdapter;
private boolean preferChargebeeTelemetry = false;
private final RequestContext clientHeaders = new RequestContext();

private Builder() {}
Expand All @@ -600,6 +604,7 @@ 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; }
public Builder preferChargebeeTelemetry(boolean preferChargebeeTelemetry) { this.preferChargebeeTelemetry = preferChargebeeTelemetry; return this; }

// Header helpers
public Builder header(String name, String value) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
/*
* Copyright 2026 Chargebee Inc.
*/

package com.chargebee.v4.telemetry;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;

/** Parses the {@code X-Chargebee-Telemetry} response header into OpenTelemetry span attributes. */
public final class ChargebeeTelemetryHeaderParser {

static final String SF_DATE_PREFIX = "@";
static final String SF_BOOLEAN_TRUE = "?1";
static final String SF_BOOLEAN_FALSE = "?0";

private static final Pattern INTEGER_PATTERN = Pattern.compile("-?\\d+");
private static final Pattern DECIMAL_PATTERN = Pattern.compile("-?\\d+\\.\\d+");

private ChargebeeTelemetryHeaderParser() {
// utility class
}

/**
* Parses a raw {@code X-Chargebee-Telemetry} header value into typed span attributes.
*
* @param headerValue raw header string
* @return parsed attributes, or empty map when input is null/blank or structurally invalid
*/
public static Map<String, Object> parseToSpanAttributes(String headerValue) {
if (headerValue == null || headerValue.trim().isEmpty()) {
return Collections.emptyMap();
}

try {
Map<String, Object> attributes = new HashMap<>();
List<String> features = new ArrayList<>();

for (String item : splitListItems(headerValue)) {
parseListItem(item, attributes, features);
}

if (!features.isEmpty()) {
attributes.put(TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_FEATURES, features);
}

return attributes.isEmpty() ? Collections.emptyMap() : attributes;
} catch (RuntimeException ex) {
return Collections.emptyMap();
}
}

private static void parseListItem(
String item, Map<String, Object> attributes, List<String> features) {
String trimmed = item.trim();
if (trimmed.isEmpty()) {
return;
}

int separator = indexOfParameterSeparator(trimmed);
String token = separator < 0 ? trimmed : trimmed.substring(0, separator).trim();
if (token.isEmpty()) {
throw new IllegalArgumentException("missing sf-item token");
}

if (token.startsWith(TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_FT_PREFIX)) {
features.add(token.substring(TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_FT_PREFIX.length()));
return;
}

String attributePrefix = segmentAttributePrefix(token);
if (separator >= 0) {
parseParameters(trimmed.substring(separator + 1), attributePrefix, attributes);
}
}

private static String segmentAttributePrefix(String token) {
if (TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_CB_SEGMENT.equals(token)) {
return TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_CB_PREFIX;
}
if (token.startsWith(TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_TP_PREFIX)) {
return TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_TP_ATTRIBUTE_PREFIX
+ token.substring(TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_TP_PREFIX.length())
+ ".";
}
return TelemetryAttributeKeys.CHARGEBEE_TELEMETRY_PREFIX + token + ".";
}

private static void parseParameters(
String parametersSection, String attributePrefix, Map<String, Object> attributes) {
for (String parameter : splitParameters(parametersSection)) {
parseParameter(parameter, attributePrefix, attributes);
}
}

private static void parseParameter(
String parameter, String attributePrefix, Map<String, Object> attributes) {
String trimmed = parameter.trim();
if (trimmed.isEmpty()) {
return;
}

int equalsIndex = indexOfEquals(trimmed);
if (equalsIndex <= 0) {
throw new IllegalArgumentException("invalid parameter: " + trimmed);
}

String key = trimmed.substring(0, equalsIndex).trim();
String rawValue = trimmed.substring(equalsIndex + 1).trim();
if (key.isEmpty()) {
throw new IllegalArgumentException("missing parameter key");
}

attributes.put(attributePrefix + key, parseScalarValue(rawValue));
}

static Object parseScalarValue(String rawValue) {
if (rawValue == null || rawValue.isEmpty()) {
throw new IllegalArgumentException("missing scalar value");
}

if (rawValue.startsWith(SF_DATE_PREFIX)) {
return Long.parseLong(rawValue.substring(SF_DATE_PREFIX.length()));
}
if (SF_BOOLEAN_TRUE.equals(rawValue)) {
return Boolean.TRUE;
}
if (SF_BOOLEAN_FALSE.equals(rawValue)) {
return Boolean.FALSE;
}
if (rawValue.startsWith(":") && rawValue.endsWith(":") && rawValue.length() >= 2) {
return rawValue.substring(1, rawValue.length() - 1);
}
if (rawValue.startsWith("\"")) {
return parseStringValue(rawValue);
}
if (INTEGER_PATTERN.matcher(rawValue).matches()) {
return Long.parseLong(rawValue);
}
if (DECIMAL_PATTERN.matcher(rawValue).matches()) {
return Double.parseDouble(rawValue);
}
return rawValue;
}

private static String parseStringValue(String rawValue) {
if (rawValue.length() < 2 || rawValue.charAt(rawValue.length() - 1) != '"') {
throw new IllegalArgumentException("invalid sf-string value");
}

StringBuilder decoded = new StringBuilder();
for (int i = 1; i < rawValue.length() - 1; i++) {
char current = rawValue.charAt(i);
if (current == '\\') {
if (i + 1 >= rawValue.length() - 1) {
throw new IllegalArgumentException("invalid sf-string escape");
}
decoded.append(rawValue.charAt(++i));
} else {
decoded.append(current);
}
}
return decoded.toString();
}

private static List<String> splitListItems(String input) {
return splitOnDelimiter(input, ',');
}

private static List<String> splitParameters(String input) {
return splitOnDelimiter(input, ';');
}

private static List<String> splitOnDelimiter(String input, char delimiter) {
List<String> parts = new ArrayList<>();
StringBuilder current = new StringBuilder();
boolean inQuotes = false;

for (int i = 0; i < input.length(); i++) {
char currentChar = input.charAt(i);
if (currentChar == '"') {
if (!isEscapedQuote(input, i)) {
inQuotes = !inQuotes;
}
current.append(currentChar);
} else if (currentChar == delimiter && !inQuotes) {
addIfNotBlank(parts, current);
current = new StringBuilder();
} else {
current.append(currentChar);
}
}

addIfNotBlank(parts, current);
return parts;
}

private static int indexOfParameterSeparator(String item) {
boolean inQuotes = false;
for (int i = 0; i < item.length(); i++) {
char current = item.charAt(i);
if (current == '"') {
if (!isEscapedQuote(item, i)) {
inQuotes = !inQuotes;
}
} else if (current == ';' && !inQuotes) {
return i;
}
}
return -1;
}

private static int indexOfEquals(String parameter) {
boolean inQuotes = false;
for (int i = 0; i < parameter.length(); i++) {
char current = parameter.charAt(i);
if (current == '"') {
if (!isEscapedQuote(parameter, i)) {
inQuotes = !inQuotes;
}
} else if (current == '=' && !inQuotes) {
return i;
}
}
return -1;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Returns true when {@code input[index]} is a quote escaped by a preceding backslash. */
private static boolean isEscapedQuote(String input, int index) {
int backslashes = 0;
for (int i = index - 1; i >= 0 && input.charAt(i) == '\\'; i--) {
backslashes++;
}
return backslashes % 2 == 1;
}

private static void addIfNotBlank(List<String> parts, StringBuilder current) {
if (current.length() == 0) {
return;
}
String value = current.toString().trim();
if (!value.isEmpty()) {
parts.add(value);
}
}
}
Loading
Loading