Skip to content

Commit 977b524

Browse files
committed
Avoid breaking change by making the aws config block required.
- Updated README - Fixed init bug - Performed e2e and perf tests Signed-off-by: huy pham <huyp@amazon.com>
1 parent 2728591 commit 977b524

9 files changed

Lines changed: 180 additions & 31 deletions

File tree

data-prepper-plugins/otlp-sink/README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,25 @@ to any OTLP Protobuf-compatible endpoint.
1717

1818
## Sample Pipeline Configuration
1919

20+
### Minimal Configuration (No STS)
21+
22+
Use this when Data Prepper has permission to write to AWS X-Ray directly.
23+
24+
```yaml
25+
otlp_pipeline:
26+
source:
27+
otel_trace_source:
28+
29+
sink:
30+
- otlp:
31+
endpoint: "https://xray.us-west-2.amazonaws.com/v1/traces"
32+
aws: { }
33+
```
34+
35+
### Full Configuration with STS
36+
37+
Use this when assuming a cross-account role is required.
38+
2039
```yaml
2140
otlp_pipeline:
2241
workers: 2
@@ -56,7 +75,7 @@ otlp_pipeline:
5675
| `threshold.max_events` | `int` | No | `512` (recommended) | Maximum number of spans per batch. Use `0` to disable count-based flushing. Must be ≥ 0. |
5776
| `threshold.max_batch_size` | `String` | No | `1mb` (recommended) | Maximum total payload bytes per batch. Supports human-readable suffixes (`kb`, `mb`). |
5877
| `threshold.flush_timeout` | `String` | No | `200ms` (recommended) | Maximum time to wait before flushing a non-empty batch. Minimum: 1ms (e.g., `200ms`, `1s`) |
59-
| **aws** | `Object` | No | — | AWS authentication settings. See below. |
78+
| **aws** | `Object` | Yes | — | AWS authentication settings. Use `{}` if no STS role is needed. See below. |
6079
| `aws.sts_role_arn` | `String` | No | — | IAM Role ARN that Data Prepper (or OSI) assumes to send spans to X-Ray on behalf of a customer account. |
6180
| `aws.sts_external_id` | `String` | No | — | External ID to use when assuming the role. Required only if the target IAM role enforces sts:ExternalId. |
6281

data-prepper-plugins/otlp-sink/src/main/java/org/opensearch/dataprepper/plugins/sink/otlp/OtlpSink.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
pluginConfigurationType = OtlpSinkConfig.class
3333
)
3434
public class OtlpSink extends AbstractSink<Record<Span>> {
35+
private volatile boolean initialized = false;
3536

3637
private final OtlpSinkBuffer buffer;
3738
private final OtlpSinkMetrics sinkMetrics;
@@ -48,6 +49,8 @@ public class OtlpSink extends AbstractSink<Record<Span>> {
4849
public OtlpSink(@Nonnull final AwsCredentialsSupplier awsCredentialsSupplier, @Nonnull final OtlpSinkConfig config, @Nonnull final PluginMetrics pluginMetrics, @Nonnull final PluginSetting pluginSetting) {
4950
super(pluginSetting);
5051

52+
config.validate();
53+
5154
this.sinkMetrics = new OtlpSinkMetrics(pluginMetrics, pluginSetting);
5255
this.buffer = new OtlpSinkBuffer(awsCredentialsSupplier, config, sinkMetrics);
5356
}
@@ -58,6 +61,7 @@ public OtlpSink(@Nonnull final AwsCredentialsSupplier awsCredentialsSupplier, @N
5861
@Override
5962
public void doInitialize() {
6063
buffer.start();
64+
initialized = true;
6165
}
6266

6367
/**
@@ -79,7 +83,7 @@ public void doOutput(@Nonnull final Collection<Record<Span>> records) {
7983
*/
8084
@Override
8185
public boolean isReady() {
82-
return buffer.isRunning();
86+
return initialized && buffer.isRunning();
8387
}
8488

8589
/**

data-prepper-plugins/otlp-sink/src/main/java/org/opensearch/dataprepper/plugins/sink/otlp/configuration/OtlpSinkConfig.java

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public long getFlushTimeoutMillis() {
6868
*/
6969
@JsonProperty("aws")
7070
@Valid
71-
private AwsConfig awsAuthenticationConfig;
71+
private AwsConfig awsConfig;
7272

7373
/**
7474
* Get AWS region from the provided endpoint.
@@ -97,18 +97,30 @@ public Region getAwsRegion() {
9797
}
9898

9999
public String getStsRoleArn() {
100-
if (awsAuthenticationConfig == null) {
100+
if (awsConfig == null || awsConfig.getAwsStsRoleArn() == null) {
101101
return null;
102102
}
103103

104-
return awsAuthenticationConfig.getAwsStsRoleArn();
104+
return awsConfig.getAwsStsRoleArn();
105105
}
106106

107107
public String getStsExternalId() {
108-
if (awsAuthenticationConfig == null) {
108+
if (awsConfig == null || awsConfig.getAwsStsExternalId() == null) {
109109
return null;
110110
}
111111

112-
return awsAuthenticationConfig.getAwsStsExternalId();
112+
return awsConfig.getAwsStsExternalId();
113+
}
114+
115+
/**
116+
* Validate the AWS configuration.
117+
* This method ensures breaking change in future release where non-AWS OTLP endpoints are supported.
118+
*
119+
* @throws IllegalArgumentException if the AWS configuration is invalid
120+
*/
121+
public void validate() {
122+
if (awsConfig == null) {
123+
throw new IllegalArgumentException("aws configuration is required");
124+
}
113125
}
114126
}

data-prepper-plugins/otlp-sink/src/main/java/org/opensearch/dataprepper/plugins/sink/otlp/http/OtlpHttpSender.java

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
import com.linecorp.armeria.common.HttpMethod;
1515
import com.linecorp.armeria.common.HttpRequest;
1616
import com.linecorp.armeria.common.HttpResponse;
17-
import com.linecorp.armeria.common.MediaType;
1817
import com.linecorp.armeria.common.RequestHeaders;
1918
import com.linecorp.armeria.common.RequestHeadersBuilder;
2019
import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest;
@@ -42,7 +41,6 @@
4241
public class OtlpHttpSender {
4342
private static final Logger LOG = LoggerFactory.getLogger(OtlpHttpSender.class);
4443
private static final Set<Integer> RETRYABLE_STATUS_CODES = Set.of(429, 502, 503, 504);
45-
private static final MediaType PROTOBUF = MediaType.parse("application/x-protobuf");
4644

4745
private final SigV4Signer signer;
4846
private final WebClient webClient;
@@ -85,7 +83,6 @@ public OtlpHttpSender(@Nonnull final AwsCredentialsSupplier awsCredentialsSuppli
8583
* adding complexity with minimal benefit for most OTLP endpoints.
8684
* - Our exponential backoff already handles typical retry intervals gracefully.
8785
*/
88-
8986
private static WebClient buildWebClient(final OtlpSinkConfig config) {
9087
final RetryRuleWithContent<HttpResponse> retryRule = RetryRuleWithContent.<HttpResponse>builder()
9188
.onStatus((ctx, status) -> RETRYABLE_STATUS_CODES.contains(status.code()))
@@ -118,11 +115,14 @@ public void send(@Nonnull final List<Pair<ResourceSpans, EventHandle>> batch) {
118115
return;
119116
}
120117

121-
final Pair<byte[], byte[]> payloadAndCompressedPayload = getPayloadAndCompressedPayload(batch);
122-
final int spans = batch.size();
118+
// Defensive copy to avoid ConcurrentModificationException
119+
final List<Pair<ResourceSpans, EventHandle>> immutableBatch = List.copyOf(batch);
120+
121+
final Pair<byte[], byte[]> payloadAndCompressedPayload = getPayloadAndCompressedPayload(immutableBatch);
122+
final int spans = immutableBatch.size();
123123
if (payloadAndCompressedPayload.right().length == 0) {
124124
sinkMetrics.incrementFailedSpansCount(spans);
125-
releaseAllEventHandle(batch, false);
125+
releaseAllEventHandle(immutableBatch, false);
126126
return;
127127
}
128128

@@ -139,12 +139,12 @@ public void send(@Nonnull final List<Pair<ResourceSpans, EventHandle>> batch) {
139139

140140
final int statusCode = response.status().code();
141141
final byte[] responseBytes = response.content().array();
142-
handleResponse(statusCode, responseBytes, batch);
142+
handleResponse(statusCode, responseBytes, immutableBatch);
143143
})
144144
.exceptionally(e -> {
145145
LOG.error("Failed to send {} spans.", spans, e);
146146
sinkMetrics.incrementRejectedSpansCount(spans);
147-
releaseAllEventHandle(batch, false);
147+
releaseAllEventHandle(immutableBatch, false);
148148
return null;
149149
});
150150
}
@@ -161,12 +161,14 @@ private Pair<byte[], byte[]> getPayloadAndCompressedPayload(final List<Pair<Reso
161161

162162
private HttpRequest buildHttpRequest(final byte[] compressedPayload) {
163163
final SdkHttpFullRequest signedRequest = signer.signRequest(compressedPayload);
164+
164165
final RequestHeadersBuilder headersBuilder = RequestHeaders.builder()
165166
.method(HttpMethod.POST)
166-
.path(signedRequest.getUri().getPath())
167-
.contentType(PROTOBUF)
168-
.add("Content-Encoding", "gzip");
167+
.scheme(signedRequest.getUri().getScheme())
168+
.path(signedRequest.getUri().getRawPath())
169+
.authority(signedRequest.getUri().getAuthority());
169170

171+
// ONLY use the signed headers
170172
signedRequest.headers().forEach((k, vList) -> vList.forEach(v -> headersBuilder.add(k, v)));
171173
return HttpRequest.of(headersBuilder.build(), HttpData.wrap(compressedPayload));
172174
}

data-prepper-plugins/otlp-sink/src/main/java/org/opensearch/dataprepper/plugins/sink/otlp/http/SigV4Signer.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ SdkHttpFullRequest signRequest(@Nonnull final byte[] payload) {
6363
.method(SdkHttpMethod.POST)
6464
.uri(endpointUri)
6565
.putHeader("Content-Type", "application/x-protobuf")
66+
.putHeader("Content-Encoding", "gzip")
6667
.contentStreamProvider(() -> SdkBytes.fromByteArray(payload).asInputStream())
6768
.build();
6869

data-prepper-plugins/otlp-sink/src/test/java/org/opensearch/dataprepper/plugins/sink/otlp/OtlpSinkTest.java

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
*/
55
package org.opensearch.dataprepper.plugins.sink.otlp;
66

7+
import org.junit.jupiter.api.Assertions;
78
import org.junit.jupiter.api.BeforeEach;
89
import org.junit.jupiter.api.Test;
10+
import org.junit.jupiter.api.function.Executable;
911
import org.opensearch.dataprepper.aws.api.AwsCredentialsSupplier;
1012
import org.opensearch.dataprepper.metrics.PluginMetrics;
1113
import org.opensearch.dataprepper.model.configuration.PluginSetting;
@@ -21,6 +23,7 @@
2123
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
2224
import static org.junit.jupiter.api.Assertions.assertFalse;
2325
import static org.junit.jupiter.api.Assertions.assertTrue;
26+
import static org.mockito.Mockito.doThrow;
2427
import static org.mockito.Mockito.mock;
2528
import static org.mockito.Mockito.verify;
2629
import static org.mockito.Mockito.verifyNoMoreInteractions;
@@ -40,6 +43,7 @@ void setUp() throws Exception {
4043
mockAwsCredSupplier = mock(AwsCredentialsSupplier.class);
4144
mockConfig = mock(OtlpSinkConfig.class);
4245
when(mockConfig.getAwsRegion()).thenReturn(Region.of("us-west-2"));
46+
when(mockConfig.getEndpoint()).thenReturn("https://localhost/v1/traces");
4347

4448
mockMetrics = mock(PluginMetrics.class);
4549

@@ -66,6 +70,23 @@ void testInitialize_startsBuffer() {
6670
verify(mockBuffer).start();
6771
}
6872

73+
@Test
74+
void testConstructor_throwsWhenAwsConfigIsMissing() {
75+
doThrow(new IllegalArgumentException("aws configuration is required"))
76+
.when(mockConfig).validate();
77+
78+
// Act & Assert
79+
final Executable constructorCall = () ->
80+
new OtlpSink(mockAwsCredSupplier, mockConfig, mockMetrics, mockSetting);
81+
82+
final IllegalArgumentException thrown = Assertions.assertThrows(
83+
IllegalArgumentException.class,
84+
constructorCall
85+
);
86+
87+
Assertions.assertEquals("aws configuration is required", thrown.getMessage());
88+
}
89+
6990
@Test
7091
void testOutput_addsEveryRecordToBuffer() {
7192
// Arrange
@@ -82,12 +103,17 @@ void testOutput_addsEveryRecordToBuffer() {
82103
}
83104

84105
@Test
85-
void testIsReady_delegatesToBuffer() {
86-
// true case
106+
void testIsReady_returnsTrueOnlyAfterInitialization() {
87107
when(mockBuffer.isRunning()).thenReturn(true);
108+
109+
// Not initialized yet
110+
assertFalse(target.isReady());
111+
112+
// Initialize, which sets 'initialized = true' and starts the buffer
113+
target.initialize();
88114
assertTrue(target.isReady());
89115

90-
// false case
116+
// Now simulate buffer being not running
91117
when(mockBuffer.isRunning()).thenReturn(false);
92118
assertFalse(target.isReady());
93119
}

data-prepper-plugins/otlp-sink/src/test/java/org/opensearch/dataprepper/plugins/sink/otlp/buffer/OtlpSinkBufferTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class OtlpSinkBufferTest {
5353
@BeforeEach
5454
void setUp() {
5555
config = mock(OtlpSinkConfig.class);
56+
when(config.getEndpoint()).thenReturn("https://localhost/v1/traces");
5657
when(config.getMaxEvents()).thenReturn(2);
5758
when(config.getMaxRetries()).thenReturn(2);
5859
when(config.getMaxBatchSize()).thenReturn(1_000_000L);
@@ -456,7 +457,7 @@ void testMaxEventsZeroDoesNotTriggerFlush() throws Exception {
456457
}
457458

458459
@Test
459-
void testDaemonThreadConfiguration() throws Exception {
460+
void testDaemonThreadConfiguration() {
460461
// This test verifies that the thread is created as non-daemon
461462
// We can't directly test this, but we can verify the thread factory is called
462463
buffer.start();

0 commit comments

Comments
 (0)