Skip to content

Commit 6c2c18c

Browse files
authored
fix(serviceevents): gate incident correlation on SAMPLED + per-collector fault isolation (#1416)
## Description Refines the ServiceEvents span processor and incident-snapshot pipeline for the Java distro. Builds on the initial ServiceEvents feature (#1386). Aligns incident-route derivation with the Python/JS distros. Highlights: - **Incident trace correlation gated on SAMPLED** — an incident snapshot carries `trace_id`/`span_id` only when the request's trace was actually sampled; also removes dead lite-mode drainer code. - **Per-collector fault isolation** — `recordEndpointRequest` and `recordPotentialIncident` are wrapped in separate try/catch blocks in `processRequestSpan`, so a crash in one collector no longer skips the other. - **Incident route derived from the ingress operation** — aligns route derivation with the Python/JS distros. ## Testing - `./gradlew build` — BUILD SUCCESSFUL (compile + unit tests + spotless + contract-test assembly). - Validated end-to-end against live CloudWatch (ServiceEvents contract tests + validators). ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [x] Refactor / code hardening ## Checklist - [x] Unit tests added/updated and passing - [x] `CHANGELOG.md` updated
1 parent 868630b commit 6c2c18c

12 files changed

Lines changed: 162 additions & 517 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ If your change does not need a CHANGELOG entry, add the "skip changelog" label t
1313

1414
## Unreleased
1515

16+
- fix(serviceevents): gate incident trace correlation on the SAMPLED flag
1617
- fix: remove EOL AWS SDK v1 dependency for ARN parsing
1718
([#1401](https://github.com/aws-observability/aws-otel-java-instrumentation/pull/1401))
1819

appsignals-tests/contract-tests/src/test/java/software/amazon/opentelemetry/appsignals/test/serviceevents/base/ServiceEventsContractTestBase.java

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -519,19 +519,24 @@ protected JsonNode waitForEndpointRecord(String method, String route) throws Exc
519519
/**
520520
* Assert that an IncidentSnapshot has valid telemetry correlation fields.
521521
*
522-
* <p>Validates that request_id is present (always generated) and optionally checks trace/span
523-
* IDs.
522+
* <p>Trace correlation is sampling-conditional: the SpanProcessor only attaches trace_id/span_id
523+
* when the request's trace was sampled (otherwise the link would point at a trace the backend
524+
* never received). This suite forces OTEL_TRACES_SAMPLER=always_on, so every request IS sampled
525+
* and both ids are always populated and non-empty. Under reduced sampling an unsampled request
526+
* would still emit a complete IncidentSnapshot, but with the correlation ids omitted. (Matches
527+
* the Node + Python serviceevents contract suites.)
524528
*/
525529
protected void assertTelemetryCorrelation(JsonNode record) {
526530
JsonNode correlation = record.path("telemetry_correlation");
527531
assertThat(correlation.isMissingNode()).isFalse();
528-
// telemetry_correlation should have at least one identifier present: the synchronous
529-
// IncidentSnapshot path provides request_id, and trace_id/span_id when the request is sampled.
530-
boolean hasAnyCorrelation =
531-
correlation.has("request_id") || correlation.has("trace_id") || correlation.has("span_id");
532-
assertThat(hasAnyCorrelation)
533-
.as("telemetry_correlation should have at least one of: request_id, trace_id, span_id")
532+
assertThat(correlation.has("trace_id"))
533+
.as("telemetry_correlation should carry trace_id (request is sampled under always_on)")
534534
.isTrue();
535+
assertThat(correlation.path("trace_id").asText()).isNotEmpty();
536+
assertThat(correlation.has("span_id"))
537+
.as("telemetry_correlation should carry span_id (request is sampled under always_on)")
538+
.isTrue();
539+
assertThat(correlation.path("span_id").asText()).isNotEmpty();
535540
}
536541

537542
/**

appsignals-tests/contract-tests/src/test/java/software/amazon/opentelemetry/appsignals/test/serviceevents/springmvc/ServiceEventsSpringMvcTest.java

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -567,21 +567,16 @@ void testIncidentSnapshotRequestContext() {
567567
}
568568

569569
/**
570-
* Verifies telemetry_correlation field is present on incident snapshots. The correlation object
571-
* may contain trace_id/span_id (from SpanProcessor) or request_id (from direct collector), or may
572-
* be empty if correlation data was not available at capture time.
570+
* Verifies telemetry_correlation on incident snapshots carries trace_id/span_id. Correlation is
571+
* sampling-conditional; this suite forces always_on so both ids are always populated. See {@link
572+
* ServiceEventsContractTestBase#assertTelemetryCorrelation} for the full rationale.
573573
*/
574574
@Test
575575
@Order(1)
576576
void testIncidentSnapshotTelemetryCorrelation() {
577577
assumeFileSignalsPresent(incidentRecords, "IncidentSnapshot");
578578
assertThat(incidentRecords).isNotEmpty();
579-
JsonNode correlation = incidentRecords.get(0).path("telemetry_correlation");
580-
assertThat(correlation.isMissingNode()).isFalse();
581-
// The telemetry_correlation object should exist as a JSON object.
582-
// It may contain trace_id, span_id, request_id, or be empty if the incident
583-
// was recorded without an active OTel span context.
584-
assertThat(correlation.isObject()).isTrue();
579+
assertTelemetryCorrelation(incidentRecords.get(0));
585580
}
586581

587582
@Test

instrumentation/serviceevents/src/main/java/software/amazon/opentelemetry/javaagent/instrumentation/serviceevents/ServiceEventsInstrumentation.java

Lines changed: 7 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -326,9 +326,8 @@ public synchronized void initialize() {
326326
}
327327
}
328328

329-
// Build the shared IncidentSnapshotRecordBuilder. Used by:
330-
// - bytecode mode: synchronous IncidentSnapshotEmitter
331-
// - lite mode (no bytecode): asynchronous LiteIncidentDrainer
329+
// Build the shared IncidentSnapshotRecordBuilder for the synchronous IncidentSnapshotEmitter
330+
// (used in both bytecode and lite mode; lite mode simply omits the call_path).
332331
software.amazon.opentelemetry.javaagent.instrumentation.serviceevents.utils
333332
.IncidentSnapshotRecordBuilder
334333
incidentRecordBuilder =
@@ -357,8 +356,11 @@ public synchronized void initialize() {
357356
+ ", maxSameError="
358357
+ config.getIncidentSnapshotMaxSameError());
359358

360-
// Always install the direct-emit IncidentSnapshot bridge so incidents emit synchronously.
361-
// Call path is only captured when bytecode instrumentation is enabled.
359+
// Install the direct-emit IncidentSnapshot bridge so incidents emit synchronously in both
360+
// modes. Call path is only captured when bytecode instrumentation is enabled; in lite mode
361+
// the
362+
// snapshot carries exception_info.stack_trace but no call_path (matches Python lite-mode
363+
// shape).
362364
software.amazon.opentelemetry.javaagent.instrumentation.serviceevents.exporter
363365
.IncidentSnapshotEmitter
364366
incidentEmitter =
@@ -368,29 +370,6 @@ public synchronized void initialize() {
368370
.setIncidentSnapshotEmitterBridge(incidentEmitter);
369371
logger().info("Installed IncidentSnapshotEmitter: incidents emit synchronously");
370372

371-
if (!config.isBytecodeEnabled()) {
372-
// Lite mode: no bytecode advice. Install the in-memory drainer as the
373-
// MetadataWriterBridge so DataStore.recordPotentialIncident dispatches to its
374-
// queue, and ticks records out via the existing OTLP path on the configured
375-
// flush interval. Snapshots carry exception_info.stack_trace but no call_path
376-
// (matches Python lite-mode shape).
377-
software.amazon.opentelemetry.javaagent.instrumentation.serviceevents.collectors
378-
.LiteIncidentDrainer
379-
liteDrainer =
380-
new software.amazon.opentelemetry.javaagent.instrumentation.serviceevents.collectors
381-
.LiteIncidentDrainer(10_000, incidentRecordBuilder, otlpEmitter);
382-
software.amazon.opentelemetry.serviceevents.ServiceEventsDataStore.setMetadataWriterBridge(
383-
liteDrainer);
384-
collectors.add(liteDrainer);
385-
liteDrainer.start();
386-
logger()
387-
.info(
388-
"Installed LiteIncidentDrainer (lite mode: no bytecode). "
389-
+ "Incidents queue in-memory and emit every "
390-
+ 10_000
391-
+ "ms.");
392-
}
393-
394373
initialized = true;
395374
logger()
396375
.info(

instrumentation/serviceevents/src/main/java/software/amazon/opentelemetry/javaagent/instrumentation/serviceevents/ServiceEventsSpanProcessor.java

Lines changed: 81 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,6 @@ private static Logger logger() {
7878
AttributeKey.stringKey("exception.stacktrace");
7979

8080
// HTTP semantic convention attribute keys
81-
private static final AttributeKey<String> HTTP_ROUTE = AttributeKey.stringKey("http.route");
82-
private static final AttributeKey<String> URL_PATH = AttributeKey.stringKey("url.path");
8381
private static final AttributeKey<String> HTTP_REQUEST_METHOD =
8482
AttributeKey.stringKey("http.request.method");
8583
private static final AttributeKey<Long> HTTP_RESPONSE_STATUS_CODE =
@@ -144,10 +142,16 @@ public void onEnd(ReadableSpan span) {
144142
private void processRequestSpan(ReadableSpan span) {
145143
SpanData spanData = span.toSpanData();
146144

147-
// Extract trace context
145+
// Extract trace context. Trace correlation is best-effort and sampling-conditional: under
146+
// reduced sampling, AlwaysRecordSampler keeps this span recording (so App Signals metrics see
147+
// every request) even though its trace was dropped before export — a valid but unsampled
148+
// (RECORD_ONLY) span. Capturing its ids would emit a correlation link to a trace the backend
149+
// never received, so gate on isSampled() in addition to isValid(). An unsampled request still
150+
// emits a complete (self-contained) IncidentSnapshot, just without trace_id/span_id.
148151
SpanContext spanContext = spanData.getSpanContext();
149-
String traceId = spanContext.isValid() ? spanContext.getTraceId() : null;
150-
String spanId = spanContext.isValid() ? spanContext.getSpanId() : null;
152+
boolean sampled = spanContext.isValid() && spanContext.isSampled();
153+
String traceId = sampled ? spanContext.getTraceId() : null;
154+
String spanId = sampled ? spanContext.getSpanId() : null;
151155

152156
// Extract exception from span events
153157
String exceptionType = null;
@@ -163,19 +167,17 @@ private void processRequestSpan(ReadableSpan span) {
163167
}
164168
}
165169

166-
// Record endpoint/incident/span data directly from SpanData. This gives us the correct
167-
// status code (set by the container after the servlet exits) and exception data in one
168-
// place.
169-
String route = spanData.getAttributes().get(HTTP_ROUTE);
170-
if (route == null || route.isEmpty()) {
171-
route = spanData.getAttributes().get(URL_PATH);
172-
}
173-
if (route == null || route.isEmpty()) {
170+
String method = spanData.getAttributes().get(HTTP_REQUEST_METHOD);
171+
if (method == null || method.isEmpty()) {
174172
return;
175173
}
176174

177-
String method = spanData.getAttributes().get(HTTP_REQUEST_METHOD);
178-
if (method == null || method.isEmpty()) {
175+
// Derive the operation via the shared App Signals path (span-name primary, first-path-segment
176+
// fallback) — consistent with Python/JS and with what App Signals reports. Then back the
177+
// route out of the operation so the collector rebuilds the identical operation string.
178+
String operation = AwsSpanProcessingUtil.getIngressOperation(spanData);
179+
String route = routeFromOperation(operation, method);
180+
if (route == null) {
179181
return;
180182
}
181183

@@ -199,8 +201,6 @@ private void processRequestSpan(ReadableSpan span) {
199201
threadName = Thread.currentThread().getName();
200202
}
201203

202-
String operation = AwsSpanProcessingUtil.getIngressOperation(spanData);
203-
204204
// Derive simple error type from FQCN (e.g. "java.lang.RuntimeException" ->
205205
// "RuntimeException")
206206
String errorType = null;
@@ -230,8 +230,13 @@ private void processRequestSpan(ReadableSpan span) {
230230
}
231231

232232
// 1. Record endpoint request
233-
ServiceEventsDataStore.recordEndpointRequest(
234-
operation, route, method, statusCode, durationNs, errorType, errorFunctionId, operation);
233+
try {
234+
ServiceEventsDataStore.recordEndpointRequest(
235+
operation, route, method, statusCode, durationNs, errorType, errorFunctionId, operation);
236+
} catch (Exception e) {
237+
logger()
238+
.log(Level.WARNING, "[SERVICE_EVENTS-SPAN-PROCESSOR] recordEndpointRequest failed", e);
239+
}
235240

236241
// Set currentOperation so recordPotentialIncident can attach exemplars
237242
// to the EndpointAggregation. The thread-local may have been cleared by
@@ -241,22 +246,27 @@ private void processRequestSpan(ReadableSpan span) {
241246
ServiceEventsDataStore.setCurrentOperation(operation);
242247

243248
// 2. Record potential incident
244-
ServiceEventsDataStore.recordPotentialIncident(
245-
route,
246-
method,
247-
statusCode,
248-
durationMs,
249-
exceptionType,
250-
exceptionMessage,
251-
stackTrace,
252-
null, // headers
253-
null, // queryParams
254-
threadName,
255-
startTimeNs,
256-
endTimeNs,
257-
traceId,
258-
spanId,
259-
operation);
249+
try {
250+
ServiceEventsDataStore.recordPotentialIncident(
251+
route,
252+
method,
253+
statusCode,
254+
durationMs,
255+
exceptionType,
256+
exceptionMessage,
257+
stackTrace,
258+
null, // headers
259+
null, // queryParams
260+
threadName,
261+
startTimeNs,
262+
endTimeNs,
263+
traceId,
264+
spanId,
265+
operation);
266+
} catch (Exception e) {
267+
logger()
268+
.log(Level.WARNING, "[SERVICE_EVENTS-SPAN-PROCESSOR] recordPotentialIncident failed", e);
269+
}
260270
}
261271

262272
/**
@@ -270,6 +280,42 @@ private static boolean isLocalRoot(ReadableSpan span) {
270280
return !parentContext.isValid() || parentContext.isRemote();
271281
}
272282

283+
/**
284+
* Back the route out of the App Signals operation so the collector rebuilds the identical {@code
285+
* method + " " + route} operation string.
286+
*
287+
* <p>Handles the three shapes {@code getIngressOperation} returns:
288+
*
289+
* <ul>
290+
* <li>{@code "METHOD /route"} — common case. Strip the method prefix.
291+
* <li>{@code "/route"} — bare path (no method prefix). Use verbatim.
292+
* <li>{@code InternalOperation / UnknownOperation / bare method / lambda} — no resolvable
293+
* route. Return null so the caller skips.
294+
* </ul>
295+
*
296+
* <p>Matches Python's {@code _route_from_operation} and JS's {@code routeFromOperation}.
297+
*/
298+
static String routeFromOperation(String operation, String method) {
299+
if (operation == null || operation.isEmpty()) {
300+
return null;
301+
}
302+
if ("InternalOperation".equals(operation) || "UnknownOperation".equals(operation)) {
303+
return null;
304+
}
305+
if (operation.equals(method)) {
306+
return null;
307+
}
308+
String prefix = method + " ";
309+
if (operation.startsWith(prefix)) {
310+
String route = operation.substring(prefix.length());
311+
return route.isEmpty() ? null : route;
312+
}
313+
if (operation.startsWith("/")) {
314+
return operation;
315+
}
316+
return null;
317+
}
318+
273319
/**
274320
* Extract "com.example.MyClass.myMethod" from the first "at" line of a Java stack trace.
275321
*

0 commit comments

Comments
 (0)