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
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 on lines +863 to 867

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

OpenTelemetry Java Span setAttribute overloads AttributeKey string array List<String> compile

💡 Result:

In OpenTelemetry Java, the Span interface uses a generic setAttribute method when working with AttributeKey instances [1][2]. To set a string array (or any list-based attribute), you must use an AttributeKey defined for a List type, such as one created via AttributeKey.stringArrayKey(String) [3][4]. The Span.setAttribute method signature is defined as: Span setAttribute(AttributeKey key, T value); When using this with a string array, the value must be a List, not a raw array [3][5]. Example compilation snippet: import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.trace.Span; import java.util.List; // Define the key for a List of Strings AttributeKey<List> MY_ARRAY_KEY = AttributeKey.stringArrayKey("my.string.array"); // Use the key with the span Span span = Span.current; span.setAttribute(MY_ARRAY_KEY, List.of("value1", "value2")); While there are convenience methods on AttributesBuilder that support varargs (e.g., put(String key, String... values)) [6], the primary Span.setAttribute method using AttributeKey requires the value to match the generic type T exactly—in this case, a List [3][1]. If you have a raw array, you must convert it to a List (e.g., via Arrays.asList or List.of) to satisfy the compiler [7].

Citations:


🏁 Script executed:

python3 - <<'PY'
import sys, textwrap, json, os, subprocess, pathlib
print("Need web search results.")
PY

Repository: chargebee/chargebee-java

Length of output: 187


Use AttributeKey.stringArrayKey for list attributes

Span.setAttribute only accepts AttributeKey<T> for non-scalar values, so span.setAttribute(k, values) with a String key and List<String> value has no matching overload. Use a string-array key here:

Fix
-                span.setAttribute(k, values);
+                span.setAttribute(AttributeKey.stringArrayKey(k), values);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else if (v instanceof List) {
@SuppressWarnings("unchecked")
List<String> values = (List<String>) v;
span.setAttribute(k, values);
}
} else if (v instanceof List) {
`@SuppressWarnings`("unchecked")
List<String> values = (List<String>) v;
span.setAttribute(AttributeKey.stringArrayKey(k), values);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 863 - 867, Update the List branch in the README
example to create/use an AttributeKey via AttributeKey.stringArrayKey for the
attribute name before calling span.setAttribute, while preserving the existing
String list conversion and suppression behavior.

});
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 on lines +178 to +224

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file='src/main/java/com/chargebee/v4/telemetry/ChargebeeTelemetryHeaderParser.java'
wc -l "$file"
echo '--- outline ---'
ast-grep outline "$file" --view expanded || true
echo '--- relevant lines ---'
sed -n '1,260p' "$file" | cat -n

Repository: chargebee/chargebee-java

Length of output: 10501


Skip escaped quotes while scanning for delimiters. splitOnDelimiter, indexOfParameterSeparator, and indexOfEquals toggle quote state on every "; a value like desc="a\"b;c" can be split at the ;, and parseToSpanAttributes will drop the whole header on the resulting parse error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/chargebee/v4/telemetry/ChargebeeTelemetryHeaderParser.java`
around lines 178 - 224, Update splitOnDelimiter, indexOfParameterSeparator, and
indexOfEquals to recognize escaped quotes and avoid toggling inQuotes when a
quote is preceded by an escape character. Ensure delimiters and equals signs
inside quoted values such as desc="a\"b;c" remain part of the value, while
unescaped quotes continue to change quote state.


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