Skip to content

Commit 0d0d648

Browse files
Decode plus sign in resource attributes (#8059)
1 parent e270c59 commit 0d0d648

File tree

2 files changed

+88
-15
lines changed

2 files changed

+88
-15
lines changed

sdk-extensions/autoconfigure/src/main/java/io/opentelemetry/sdk/autoconfigure/ResourceConfiguration.java

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,11 @@
1111
import io.opentelemetry.common.ComponentLoader;
1212
import io.opentelemetry.sdk.autoconfigure.internal.SpiHelper;
1313
import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties;
14-
import io.opentelemetry.sdk.autoconfigure.spi.ConfigurationException;
1514
import io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider;
1615
import io.opentelemetry.sdk.autoconfigure.spi.internal.ConditionalResourceProvider;
1716
import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties;
1817
import io.opentelemetry.sdk.resources.Resource;
1918
import io.opentelemetry.sdk.resources.ResourceBuilder;
20-
import java.io.UnsupportedEncodingException;
21-
import java.net.URLDecoder;
2219
import java.nio.charset.StandardCharsets;
2320
import java.util.Collections;
2421
import java.util.HashSet;
@@ -68,18 +65,13 @@ public static Resource createEnvironmentResource() {
6865
@SuppressWarnings("JdkObsolete") // Recommended alternative was introduced in java 10
6966
public static Resource createEnvironmentResource(ConfigProperties config) {
7067
AttributesBuilder resourceAttributes = Attributes.builder();
71-
try {
72-
for (Map.Entry<String, String> entry : config.getMap(ATTRIBUTE_PROPERTY).entrySet()) {
73-
resourceAttributes.put(
74-
entry.getKey(),
75-
// Attributes specified via otel.resource.attributes follow the W3C Baggage spec and
76-
// characters outside the baggage-octet range are percent encoded
77-
// https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/resource/sdk.md#specifying-resource-information-via-an-environment-variable
78-
URLDecoder.decode(entry.getValue(), StandardCharsets.UTF_8.name()));
79-
}
80-
} catch (UnsupportedEncodingException e) {
81-
// Should not happen since always using standard charset
82-
throw new ConfigurationException("Unable to decode resource attributes.", e);
68+
for (Map.Entry<String, String> entry : config.getMap(ATTRIBUTE_PROPERTY).entrySet()) {
69+
resourceAttributes.put(
70+
entry.getKey(),
71+
// Attributes specified via otel.resource.attributes follow the W3C Baggage spec and
72+
// characters outside the baggage-octet range are percent encoded
73+
// https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/resource/sdk.md#specifying-resource-information-via-an-environment-variable
74+
decodeResourceAttributes(entry.getValue()));
8375
}
8476
String serviceName = config.getString(SERVICE_NAME_PROPERTY);
8577
if (serviceName != null) {
@@ -133,5 +125,52 @@ static Resource filterAttributes(Resource resource, ConfigProperties configPrope
133125
return builder.build();
134126
}
135127

128+
/**
129+
* Decodes percent-encoded characters in resource attribute values per W3C Baggage spec.
130+
*
131+
* <p>Unlike {@link java.net.URLDecoder}, this method:
132+
*
133+
* <ul>
134+
* <li>Preserves '+' as a literal plus sign (URLDecoder decodes '+' as space)
135+
* <li>Preserves invalid percent sequences as literals (e.g., "%2G", "%", "%2")
136+
* <li>Supports multi-byte UTF-8 sequences (e.g., "%C3%A9" decodes to "é")
137+
* </ul>
138+
*
139+
* @param value the percent-encoded string
140+
* @return the decoded string
141+
*/
142+
private static String decodeResourceAttributes(String value) {
143+
// no percent signs means nothing to decode
144+
if (value.indexOf('%') < 0) {
145+
return value;
146+
}
147+
148+
int n = value.length();
149+
// Use byte array to properly handle multi-byte UTF-8 sequences
150+
byte[] bytes = new byte[n];
151+
int pos = 0;
152+
153+
for (int i = 0; i < n; i++) {
154+
char c = value.charAt(i);
155+
// Check for percent-encoded sequence i.e. '%' followed by two hex digits
156+
if (c == '%' && i + 2 < n) {
157+
int d1 = Character.digit(value.charAt(i + 1), 16);
158+
int d2 = Character.digit(value.charAt(i + 2), 16);
159+
// Valid hex digits return 0-15, invalid returns -1
160+
if (d1 != -1 && d2 != -1) {
161+
// Combine two hex digits into a single byte (e.g., "2F" becomes 0x2F)
162+
bytes[pos++] = (byte) ((d1 << 4) + d2);
163+
// Skip the two hex digits (loop will also do i++)
164+
i += 2;
165+
continue;
166+
}
167+
}
168+
// Keep '+' as '+' (unlike URLDecoder) and preserve invalid percent sequences which will be
169+
// treated as literals
170+
bytes[pos++] = (byte) c;
171+
}
172+
return new String(bytes, 0, pos, StandardCharsets.UTF_8);
173+
}
174+
136175
private ResourceConfiguration() {}
137176
}

sdk-extensions/autoconfigure/src/test/java/io/opentelemetry/sdk/autoconfigure/ResourceConfigurationTest.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,12 @@
1919
import io.opentelemetry.sdk.resources.Resource;
2020
import java.util.HashMap;
2121
import java.util.Map;
22+
import java.util.stream.Stream;
2223
import org.junit.jupiter.api.Test;
2324
import org.junit.jupiter.api.extension.ExtendWith;
25+
import org.junit.jupiter.params.ParameterizedTest;
26+
import org.junit.jupiter.params.provider.Arguments;
27+
import org.junit.jupiter.params.provider.MethodSource;
2428
import org.mockito.junit.jupiter.MockitoExtension;
2529

2630
@ExtendWith(MockitoExtension.class)
@@ -50,6 +54,36 @@ void customConfigResourceWithDisabledKeys() {
5054
.build());
5155
}
5256

57+
@ParameterizedTest
58+
@MethodSource("decodeResourceAttributesArgs")
59+
void decodeResourceAttributes(String input, String expectedKey, String expectedValue) {
60+
Map<String, String> props = new HashMap<>();
61+
props.put("otel.resource.attributes", input);
62+
63+
assertThat(
64+
ResourceConfiguration.createEnvironmentResource(
65+
DefaultConfigProperties.createFromMap(props)))
66+
.isEqualTo(Resource.create(Attributes.of(stringKey(expectedKey), expectedValue)));
67+
}
68+
69+
private static Stream<Arguments> decodeResourceAttributesArgs() {
70+
return Stream.of(
71+
// Plus sign preserved
72+
Arguments.of("food=cheese+cake", "food", "cheese+cake"),
73+
// Percent-encoded space in resource attribute value decoded to space
74+
Arguments.of("key=hello%20world", "key", "hello world"),
75+
// Invalid percent encoding preserved
76+
Arguments.of("key=abc%2Gdef", "key", "abc%2Gdef"),
77+
// Incomplete percent encoding preserved
78+
Arguments.of("key=abc%2", "key", "abc%2"),
79+
// Percent at end preserved
80+
Arguments.of("key=abc%", "key", "abc%"),
81+
// Multiple percent encodings
82+
Arguments.of("key=a%20b%2Bc%3Dd", "key", "a b+c=d"),
83+
// No percent encoding
84+
Arguments.of("key=plain-value", "key", "plain-value"));
85+
}
86+
5387
@Test
5488
void createEnvironmentResource_Empty() {
5589
Attributes attributes = ResourceConfiguration.createEnvironmentResource().getAttributes();

0 commit comments

Comments
 (0)