Skip to content

Commit 60e8f90

Browse files
authored
Align CompactConsoleLogRecordExporter with OTLP backend schema (#1358)
## Summary - Align `CompactConsoleLogRecordExporter` JSON output with CloudWatch OTLP backend format (field names, types, structure) - Fix `customizeLogsExporter` to support `OTEL_LOGS_EXPORTER=otlp,console` — previously only `console` (exact match) was recognized - Fix SigV4 config validation to accept comma-separated exporter lists - Fix unsafe cast that crashed the OTel agent when both OTLP and console exporters were enabled simultaneously - Add `exportPath:"console"` field to enable independent validation of console vs OTLP export paths ## Bug Fixes 1. **SigV4 never enabled with `otlp,console`**: `isSigv4ValidConfig()` checked `exporter.equals("otlp")` which fails for `"otlp,console"`. Changed to `.contains("otlp")`. 2. **OTel agent crash with dual exporters**: `customizeLogsExporter()` blindly cast every exporter to `OtlpHttpLogRecordExporter`. When called with `SystemOutLogRecordExporter` , it threw `ClassCastException` → `IllegalStateException`, crashing the entire agent. Added `instanceof` check before cast. 3. **Console exporter not wired in with `otlp,console`**: Previously checked `logsExporterConfig.equals("console")`. Now checks the exporter's class name (`SystemOutLogRecordExporter`) to correctly identify and replace it regardless of the config string. ## Test plan - [x] Unit tests updated and passing (all `awsagentprovider` tests pass) - [x] E2E validated locally: built layer from source, deployed Lambda via Terraform, verified console JSON in CloudWatch, verified OTLP logs in `otlp-logs` stream, ran validator — PASSED - [x] Depends on test framework PR being merged first for CI validation By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
1 parent 6e1f521 commit 60e8f90

7 files changed

Lines changed: 120 additions & 122 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ If your change does not need a CHANGELOG entry, add the "skip changelog" label t
2424
([#1352](https://github.com/aws-observability/aws-otel-java-instrumentation/pull/1352))
2525
- Bump Netty to 4.1.132.Final to fix CVE-2026-33870 and CVE-2026-33871
2626
([#1348](https://github.com/aws-observability/aws-otel-java-instrumentation/pull/1348))
27+
- fix(lambda-layer): Standardize CompactConsoleLogRecordExporter output with CloudWatch OTLP backend schema.
28+
([#1358](https://github.com/aws-observability/aws-otel-java-instrumentation/pull/1358))
2729

2830
## v2.26.1 - 2026-03-27
2931

awsagentprovider/src/main/java/software/amazon/opentelemetry/javaagent/providers/AwsApplicationSignalsConfigUtils.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,10 @@ private static boolean isSigv4ValidConfig(
167167
if (isValidOtlpEndpoint) {
168168
logger.log(Level.INFO, String.format("Detected using AWS OTLP Endpoint: %s.", endpoint));
169169

170-
if (exporter != null && !exporter.equals("otlp")) {
170+
if (exporter != null && !exporter.contains("otlp")) {
171171
logger.warning(
172172
String.format(
173-
"Improper configuration: Please configure your environment variables and export/set %s=otlp",
173+
"Improper configuration: Please configure your environment variables and export/set %s to include otlp",
174174
exporterType));
175175
return false;
176176
}

awsagentprovider/src/main/java/software/amazon/opentelemetry/javaagent/providers/AwsApplicationSignalsCustomizerProvider.java

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ public final class AwsApplicationSignalsCustomizerProvider
146146
private static final String OTEL_BSP_MAX_EXPORT_BATCH_SIZE_CONFIG =
147147
"otel.bsp.max.export.batch.size";
148148

149+
static final String SYSTEM_OUT_LOG_RECORD_EXPORTER_NAME = "SystemOutLogRecordExporter";
149150
static final String OTEL_METRICS_EXPORTER = "otel.metrics.exporter";
150151
static final String OTEL_LOGS_EXPORTER = "otel.logs.exporter";
151152
static final String OTEL_TRACES_EXPORTER = "otel.traces.exporter";
@@ -525,36 +526,23 @@ private boolean isOtlpSpanExporter(SpanExporter spanExporter) {
525526

526527
LogRecordExporter customizeLogsExporter(
527528
LogRecordExporter logsExporter, ConfigProperties configProps) {
528-
if (AwsApplicationSignalsConfigUtils.isSigV4EnabledLogs(configProps)) {
529-
// can cast here since we've checked that the configuration for OTEL_LOGS_EXPORTER is otlp and
530-
// OTEL_EXPORTER_OTLP_LOGS_PROTOCOL is http/protobuf
531-
// so the given logsExporter will be an instance of OtlpHttpLogRecorderExporter
532-
533-
// get compression method from environment
529+
// Wrap OTLP exporter with SigV4 signing for AWS CloudWatch OTLP endpoint
530+
if (AwsApplicationSignalsConfigUtils.isSigV4EnabledLogs(configProps)
531+
&& logsExporter instanceof OtlpHttpLogRecordExporter) {
534532
String compression =
535533
configProps.getString(
536534
OTEL_EXPORTER_OTLP_LOGS_COMPRESSION_CONFIG,
537535
configProps.getString(OTEL_EXPORTER_OTLP_COMPRESSION_CONFIG, "none"));
538536

539-
try {
540-
return OtlpAwsLogRecordExporterBuilder.create(
541-
(OtlpHttpLogRecordExporter) logsExporter,
542-
configProps.getString(OTEL_EXPORTER_OTLP_LOGS_ENDPOINT))
543-
.setCompression(compression)
544-
.build();
545-
} catch (Exception e) {
546-
// This technically should never happen as the validator checks for the correct env
547-
// variables
548-
throw new IllegalStateException(
549-
"Given LogsExporter is not an instance of OtlpHttpLogRecordExporter, please check that you have the correct environment variables: ",
550-
e);
551-
}
537+
return OtlpAwsLogRecordExporterBuilder.create(
538+
(OtlpHttpLogRecordExporter) logsExporter,
539+
configProps.getString(OTEL_EXPORTER_OTLP_LOGS_ENDPOINT))
540+
.setCompression(compression)
541+
.build();
552542
}
553-
String logsExporterConfig = configProps.getString(OTEL_LOGS_EXPORTER);
554543

555544
if (isLambdaEnvironment(configProps)
556-
&& logsExporterConfig != null
557-
&& logsExporterConfig.equals("console")) {
545+
&& logsExporter.getClass().getSimpleName().equals(SYSTEM_OUT_LOG_RECORD_EXPORTER_NAME)) {
558546
return new CompactConsoleLogRecordExporter();
559547
}
560548

awsagentprovider/src/main/java/software/amazon/opentelemetry/javaagent/providers/exporter/aws/logs/CompactConsoleLogRecordExporter.java

Lines changed: 39 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
* Modifications Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2323
*/
2424

25+
import com.fasterxml.jackson.annotation.JsonInclude;
2526
import com.fasterxml.jackson.annotation.JsonProperty;
2627
import com.fasterxml.jackson.databind.ObjectMapper;
2728
import com.fasterxml.jackson.databind.SerializationFeature;
@@ -32,13 +33,9 @@
3233
import io.opentelemetry.sdk.logs.export.LogRecordExporter;
3334
import io.opentelemetry.sdk.resources.Resource;
3435
import java.io.PrintStream;
35-
import java.time.Instant;
36-
import java.time.ZoneOffset;
37-
import java.time.format.DateTimeFormatter;
3836
import java.util.Collection;
3937
import java.util.HashMap;
4038
import java.util.Map;
41-
import java.util.concurrent.TimeUnit;
4239
import java.util.concurrent.atomic.AtomicBoolean;
4340

4441
/**
@@ -48,7 +45,6 @@
4845
*/
4946
@SuppressWarnings("SystemOut")
5047
public class CompactConsoleLogRecordExporter implements LogRecordExporter {
51-
private static final DateTimeFormatter ISO_FORMAT = DateTimeFormatter.ISO_INSTANT;
5248
private static final ObjectMapper MAPPER =
5349
new ObjectMapper().disable(SerializationFeature.INDENT_OUTPUT);
5450
private final AtomicBoolean isShutdown = new AtomicBoolean();
@@ -126,6 +122,9 @@ private static final class LogRecordDataTemplate {
126122
@JsonProperty("resource")
127123
private final ResourceTemplate resourceTemplate;
128124

125+
@JsonProperty("scope")
126+
private final ScopeTemplate scope;
127+
129128
@JsonProperty("body")
130129
private final String body;
131130

@@ -136,61 +135,62 @@ private static final class LogRecordDataTemplate {
136135
private final String severityText;
137136

138137
@JsonProperty("attributes")
139-
private final Map<String, String> attributes;
138+
private final Map<String, Object> attributes;
140139

141140
@JsonProperty("droppedAttributes")
142141
private final int droppedAttributes;
143142

144-
@JsonProperty("timestamp")
145-
private final String timestamp;
143+
@JsonProperty("timeUnixNano")
144+
private final long timeUnixNano;
146145

147-
@JsonProperty("observedTimestamp")
148-
private final String observedTimestamp;
146+
@JsonProperty("observedTimeUnixNano")
147+
private final long observedTimeUnixNano;
149148

150149
@JsonProperty("traceId")
151150
private final String traceId;
152151

153152
@JsonProperty("spanId")
154153
private final String spanId;
155154

156-
@JsonProperty("traceFlags")
157-
private final int traceFlags;
155+
@JsonProperty("flags")
156+
private final int flags;
158157

159-
@JsonProperty("instrumentationScope")
160-
private final InstrumentationScopeTemplate instrumentationScope;
158+
@JsonProperty("exportPath")
159+
@JsonInclude(JsonInclude.Include.NON_NULL)
160+
private final String exportPath;
161161

162162
private LogRecordDataTemplate(
163163
String body,
164164
int severityNumber,
165165
String severityText,
166-
Map<String, String> attributes,
166+
Map<String, Object> attributes,
167167
int droppedAttributes,
168-
String timestamp,
169-
String observedTimestamp,
168+
long timeUnixNano,
169+
long observedTimeUnixNano,
170170
String traceId,
171171
String spanId,
172172
int traceFlags,
173173
ResourceTemplate resourceTemplate,
174-
InstrumentationScopeTemplate instrumentationScope) {
174+
ScopeTemplate scope) {
175175
this.resourceTemplate = resourceTemplate;
176+
this.scope = scope;
176177
this.body = body;
177178
this.severityNumber = severityNumber;
178179
this.severityText = severityText;
179180
this.attributes = attributes;
180181
this.droppedAttributes = droppedAttributes;
181-
this.timestamp = timestamp;
182-
this.observedTimestamp = observedTimestamp;
182+
this.timeUnixNano = timeUnixNano;
183+
this.observedTimeUnixNano = observedTimeUnixNano;
183184
this.traceId = traceId;
184185
this.spanId = spanId;
185-
this.traceFlags = traceFlags;
186-
this.instrumentationScope = instrumentationScope;
186+
this.flags = traceFlags;
187+
this.exportPath =
188+
"true".equals(System.getenv("ADOT_TEST_EXPORT_PATH_ENABLED")) ? "console" : null;
187189
}
188190

189191
private static LogRecordDataTemplate parse(LogRecordData log) {
190-
// https://github.com/open-telemetry/opentelemetry-java/blob/48684d6d33048030b133b4f6479d45afddcdc313/exporters/otlp/common/src/main/java/io/opentelemetry/exporter/internal/otlp/logs/LogMarshaler.java#L59
191-
Map<String, String> attributes = new HashMap<>();
192-
log.getAttributes()
193-
.forEach((key, value) -> attributes.put(key.getKey(), String.valueOf(value)));
192+
Map<String, Object> attributes = new HashMap<>();
193+
log.getAttributes().forEach((key, value) -> attributes.put(key.getKey(), value));
194194

195195
int attributeSize =
196196
IncubatingUtil.isExtendedLogRecordData(log)
@@ -203,54 +203,45 @@ private static LogRecordDataTemplate parse(LogRecordData log) {
203203
log.getSeverity().name(),
204204
attributes,
205205
log.getTotalAttributeCount() - attributeSize,
206-
formatTimestamp(log.getTimestampEpochNanos()),
207-
formatTimestamp(log.getObservedTimestampEpochNanos()),
206+
log.getTimestampEpochNanos(),
207+
log.getObservedTimestampEpochNanos(),
208208
log.getSpanContext().isValid() ? log.getSpanContext().getTraceId() : "",
209209
log.getSpanContext().isValid() ? log.getSpanContext().getSpanId() : "",
210210
log.getSpanContext().getTraceFlags().asByte(),
211211
log.getResource() != null
212212
? ResourceTemplate.parse(log.getResource())
213213
: new ResourceTemplate(new HashMap<>(), ""),
214214
log.getInstrumentationScopeInfo() != null
215-
? InstrumentationScopeTemplate.parse(log.getInstrumentationScopeInfo())
216-
: new InstrumentationScopeTemplate("", "", ""));
217-
}
218-
219-
private static String formatTimestamp(long nanos) {
220-
return nanos != 0
221-
? ISO_FORMAT.format(
222-
Instant.ofEpochMilli(TimeUnit.NANOSECONDS.toMillis(nanos)).atZone(ZoneOffset.UTC))
223-
: null;
215+
? ScopeTemplate.parse(log.getInstrumentationScopeInfo())
216+
: new ScopeTemplate("", "", ""));
224217
}
225218
}
226219

227220
@SuppressWarnings("unused")
228221
private static final class ResourceTemplate {
229222
@JsonProperty("attributes")
230-
private final Map<String, String> attributes;
223+
private final Map<String, Object> attributes;
231224

232225
@JsonProperty("schemaUrl")
233226
private final String schemaUrl;
234227

235-
private ResourceTemplate(Map<String, String> attributes, String schemaUrl) {
228+
private ResourceTemplate(Map<String, Object> attributes, String schemaUrl) {
236229
this.attributes = attributes;
237230
this.schemaUrl = schemaUrl != null ? schemaUrl : "";
238231
}
239232

240233
private static ResourceTemplate parse(Resource resource) {
241-
Map<String, String> attributes = new HashMap<>();
234+
Map<String, Object> attributes = new HashMap<>();
242235
if (resource == null) {
243236
return new ResourceTemplate(attributes, "");
244237
}
245-
resource
246-
.getAttributes()
247-
.forEach((key, value) -> attributes.put(key.getKey(), String.valueOf(value)));
238+
resource.getAttributes().forEach((key, value) -> attributes.put(key.getKey(), value));
248239
return new ResourceTemplate(attributes, resource.getSchemaUrl());
249240
}
250241
}
251242

252243
@SuppressWarnings("unused")
253-
private static final class InstrumentationScopeTemplate {
244+
private static final class ScopeTemplate {
254245
@JsonProperty("name")
255246
private final String name;
256247

@@ -260,18 +251,17 @@ private static final class InstrumentationScopeTemplate {
260251
@JsonProperty("schemaUrl")
261252
private final String schemaUrl;
262253

263-
private InstrumentationScopeTemplate(String name, String version, String schemaUrl) {
254+
private ScopeTemplate(String name, String version, String schemaUrl) {
264255
this.name = name != null ? name : "";
265256
this.version = version != null ? version : "";
266257
this.schemaUrl = schemaUrl != null ? schemaUrl : "";
267258
}
268259

269-
private static InstrumentationScopeTemplate parse(InstrumentationScopeInfo scope) {
260+
private static ScopeTemplate parse(InstrumentationScopeInfo scope) {
270261
if (scope == null) {
271-
return new InstrumentationScopeTemplate("", "", "");
262+
return new ScopeTemplate("", "", "");
272263
}
273-
return new InstrumentationScopeTemplate(
274-
scope.getName(), scope.getVersion(), scope.getSchemaUrl());
264+
return new ScopeTemplate(scope.getName(), scope.getVersion(), scope.getSchemaUrl());
275265
}
276266
}
277267
}

awsagentprovider/src/test/java/software/amazon/opentelemetry/javaagent/providers/AwsApplicationSignalsCustomizerProviderTest.java

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter;
3434
import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties;
3535
import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties;
36+
import io.opentelemetry.sdk.common.CompletableResultCode;
37+
import io.opentelemetry.sdk.logs.data.LogRecordData;
3638
import io.opentelemetry.sdk.logs.export.LogRecordExporter;
3739
import io.opentelemetry.sdk.metrics.export.MetricExporter;
3840
import io.opentelemetry.sdk.trace.export.SpanExporter;
@@ -119,11 +121,28 @@ void testLambdaShouldEnableCompactLogsExporterIfConfigIsCorrect() {
119121
DefaultConfigProperties configProps = DefaultConfigProperties.createFromMap(lambdaConfig);
120122
this.provider.customizeProperties(configProps);
121123

122-
customizeExporterTest(
123-
lambdaConfig,
124-
defaultHttpLogsExporter,
125-
this.provider::customizeLogsExporter,
126-
CompactConsoleLogRecordExporter.class);
124+
// The customizer checks class simple name equals "SystemOutLogRecordExporter"
125+
LogRecordExporter result =
126+
this.provider.customizeLogsExporter(new SystemOutLogRecordExporter(), configProps);
127+
assertInstanceOf(CompactConsoleLogRecordExporter.class, result);
128+
}
129+
130+
/** Stub that mimics the real SystemOutLogRecordExporter's class name. */
131+
private static class SystemOutLogRecordExporter implements LogRecordExporter {
132+
@Override
133+
public CompletableResultCode export(java.util.Collection<LogRecordData> logs) {
134+
return CompletableResultCode.ofSuccess();
135+
}
136+
137+
@Override
138+
public CompletableResultCode flush() {
139+
return CompletableResultCode.ofSuccess();
140+
}
141+
142+
@Override
143+
public CompletableResultCode shutdown() {
144+
return CompletableResultCode.ofSuccess();
145+
}
127146
}
128147

129148
@ParameterizedTest
@@ -195,25 +214,23 @@ void testShouldThrowIllegalStateExceptionIfIncorrectSpanExporter() {
195214
OtlpHttpSpanExporter.class));
196215
}
197216

198-
// This technically should never happen as the validator checks for the correct env variables
217+
// When SigV4 is enabled but the exporter is not OtlpHttpLogRecordExporter,
218+
// the customizer should pass through the exporter unchanged (no exception).
199219
@Test
200-
void testShouldThrowIllegalStateExceptionIfIncorrectLogsExporter() {
201-
assertThrows(
202-
IllegalStateException.class,
203-
() ->
204-
customizeExporterTest(
205-
Map.of(
206-
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT,
207-
"https://logs.us-east-1.amazonaws.com/v1/logs",
208-
OTEL_EXPORTER_OTLP_LOGS_HEADERS,
209-
"x-aws-log-group=test1,x-aws-log-stream=test2",
210-
OTEL_EXPORTER_OTLP_LOGS_PROTOCOL,
211-
"http/protobuf",
212-
OTEL_LOGS_EXPORTER,
213-
"otlp"),
214-
OtlpGrpcLogRecordExporter.getDefault(),
215-
this.provider::customizeLogsExporter,
216-
OtlpHttpLogRecordExporter.class));
220+
void testShouldPassThroughNonHttpLogsExporterWhenSigV4Enabled() {
221+
customizeExporterTest(
222+
Map.of(
223+
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT,
224+
"https://logs.us-east-1.amazonaws.com/v1/logs",
225+
OTEL_EXPORTER_OTLP_LOGS_HEADERS,
226+
"x-aws-log-group=test1,x-aws-log-stream=test2",
227+
OTEL_EXPORTER_OTLP_LOGS_PROTOCOL,
228+
"http/protobuf",
229+
OTEL_LOGS_EXPORTER,
230+
"otlp"),
231+
OtlpGrpcLogRecordExporter.getDefault(),
232+
this.provider::customizeLogsExporter,
233+
OtlpGrpcLogRecordExporter.class);
217234
}
218235

219236
@Test

0 commit comments

Comments
 (0)