Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,58 @@ The SDK builds standardized span attributes (`ctx.getStartAttributes()`, `result

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

#### X-Chargebee-Telemetry response header

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 +807,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 +856,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(k, values);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
if (result.getError() != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
/*
* 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 == '"') {
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 == '"') {
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 == '"') {
inQuotes = !inQuotes;
} else if (current == '=' && !inQuotes) {
return i;
}
}
return -1;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,20 @@ public final class TelemetryAttributeKeys {
public static final String TELEMETRY_SPAN_NAME_PREFIX = "chargebee";

public static final String HTTP_REQUEST_HEADER_ATTRIBUTE_PREFIX = "http.request.header.";
public static final String HTTP_RESPONSE_HEADER_ATTRIBUTE_PREFIX = "http.response.header.";
public static final String CHARGEBEE_TELEMETRY_HEADER_PREFIX = "chargebee-";
public static final String CHARGEBEE_TELEMETRY_HEADER_EXCLUDE_PREFIX = "chargebee-request-origin-";
public static final String X_CHARGEBEE_TELEMETRY_HEADER = "x-chargebee-telemetry";

public static final String CHARGEBEE_TELEMETRY_PREFIX = "chargebee.telemetry.";

public static final String CHARGEBEE_TELEMETRY_CB_SEGMENT = "cb";
public static final String CHARGEBEE_TELEMETRY_CB_PREFIX = CHARGEBEE_TELEMETRY_PREFIX + "cb.";
public static final String CHARGEBEE_TELEMETRY_TP_PREFIX = "tp-";
public static final String CHARGEBEE_TELEMETRY_TP_ATTRIBUTE_PREFIX =
CHARGEBEE_TELEMETRY_PREFIX + "tp.";
public static final String CHARGEBEE_TELEMETRY_FT_PREFIX = "ft-";
public static final String CHARGEBEE_TELEMETRY_FEATURES = CHARGEBEE_TELEMETRY_PREFIX + "features";

public static final String URL_FULL = "url.full";
public static final String HTTP_REQUEST_METHOD = "http.request.method";
Expand Down
14 changes: 9 additions & 5 deletions src/main/java/com/chargebee/v4/telemetry/TelemetryExecutor.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public static Response execute(

try {
Response response = action.apply(requestWithHeaders);
endTelemetrySuccess(adapter, handle, startTime, response.getStatusCode());
endTelemetrySuccess(adapter, handle, startTime, response);
return response;
} catch (RuntimeException e) {
endTelemetryFailure(adapter, handle, startTime, e);
Expand Down Expand Up @@ -66,7 +66,7 @@ public static CompletableFuture<Response> executeAsync(
Throwable cause = throwable.getCause() != null ? throwable.getCause() : throwable;
endTelemetryFailure(adapter, handle, startTime, cause);
} else {
endTelemetrySuccess(adapter, handle, startTime, response.getStatusCode());
endTelemetrySuccess(adapter, handle, startTime, response);
}
});
}
Expand Down Expand Up @@ -98,13 +98,16 @@ private static Object startTelemetry(
}

private static void endTelemetrySuccess(
TelemetryAdapter adapter, Object handle, long startTime, int httpStatusCode) {
TelemetryAdapter adapter, Object handle, long startTime, Response response) {
try {
adapter.onRequestEnd(
handle,
TelemetrySupport.buildRequestTelemetryResult(
new TelemetrySupport.RequestTelemetryResultInput(
httpStatusCode, System.currentTimeMillis() - startTime, null)));
response.getStatusCode(),
System.currentTimeMillis() - startTime,
null,
response.getHeaders())));
} catch (Exception err) {
LOGGER.log(Level.WARNING, "Telemetry adapter onRequestEnd failed: " + err.getMessage(), err);
}
Expand All @@ -121,7 +124,8 @@ private static void endTelemetryFailure(
new TelemetrySupport.RequestTelemetryResultInput(
httpStatusCode,
System.currentTimeMillis() - startTime,
TelemetrySupport.extractRequestTelemetryError(err))));
TelemetrySupport.extractRequestTelemetryError(err),
TelemetrySupport.extractResponseHeaders(err))));
} catch (Exception telemetryErr) {
LOGGER.log(
Level.WARNING,
Expand Down
Loading
Loading