Skip to content
Merged
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
4 changes: 4 additions & 0 deletions validator/src/main/java/com/amazon/aoc/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ public class App implements Callable<Integer> {
defaultValue = "defaultDnsName")
private String privateDnsName;

@CommandLine.Option(names = {"--trace-id"})
private String traceId;

private static final String TEST_CASE_DIM_KEY = "testcase";
private static final String CANARY_NAMESPACE = "Otel/Canary";
private static final String CANARY_METRIC_NAME = "Success";
Expand Down Expand Up @@ -196,6 +199,7 @@ public Integer call() throws Exception {
context.setInstanceAmi(this.instanceAmi);
context.setInstanceId(this.instanceId);
context.setPrivateDnsName(this.privateDnsName);
context.setTraceId(this.traceId);

log.info(context);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,11 @@ public enum PredefinedExpectedTemplate implements FileConfig {
/** Python EC2 ADOT SigV4 Log Exporter Test Case Validation */
PYTHON_EC2_ADOT_OTLP_LOG("/expected-data-template/python/ec2/adot-aws-otlp/application-log.mustache"),

/** Python EC2 ADOT Gen AI Test Case Validation */
PYTHON_EC2_ADOT_GENAI_LOG("/expected-data-template/python/ec2/adot-genai/genai-log.mustache"),
PYTHON_EC2_ADOT_GENAI_TRACE("/expected-data-template/python/ec2/adot-genai/genai-trace.mustache"),
PYTHON_EC2_ADOT_GENAI_METRIC("/expected-data-template/python/ec2/adot-genai/genai-metric.mustache"),

/** Python K8S Test Case Validations */
PYTHON_K8S_OUTGOING_HTTP_CALL_LOG("/expected-data-template/python/k8s/outgoing-http-call-log.mustache"),
PYTHON_K8S_OUTGOING_HTTP_CALL_METRIC("/expected-data-template/python/k8s/outgoing-http-call-metric.mustache"),
Expand Down
2 changes: 2 additions & 0 deletions validator/src/main/java/com/amazon/aoc/models/Context.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ public class Context {

private String privateDnsName;

private String traceId;

private ECSContext ecsContext;

private CloudWatchContext cloudWatchContext;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
import com.github.wnameless.json.flattener.FlattenMode;
import com.github.wnameless.json.flattener.JsonFlattener;
import com.github.wnameless.json.flattener.JsonifyArrayList;

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
Expand Down Expand Up @@ -68,7 +70,23 @@ public void validate() throws Exception {
Map<String, Object> actualLog;

if (isAwsOtlpLog(expectedAttributes)) {
actualLog = this.getActualAwsOtlpLog();
String otlpLogFilterPattern = String.format(
"{ ($.resource.attributes.['service.name'] = \"%s\") && ($.body = \"This is a custom log for validation testing\") }",
context.getServiceName()
);
String addTraceIdFilter = (context.getTraceId() != null ? "&& ($.traceId = \"" + context.getTraceId() + "\") " : "");
String genAILogFilterPattern = String.format(
"{ ($.resource.attributes.['service.name'] = \"%s\") " +
"&& ($.body.output.messages[0].role = \"assistant\") " +
"&& ($.body.input.messages[0].role = \"user\") " +
"&& ($.body.output.messages[1] NOT EXISTS) " +
"&& ($.body.input.messages[1] NOT EXISTS) " +
"%s" +
"}",
context.getServiceName(),
addTraceIdFilter
);
actualLog = this.getActualAwsOtlpLog(Arrays.asList(otlpLogFilterPattern, genAILogFilterPattern));
} else {
String operation = (String) expectedAttributes.get("Operation");
String remoteService = (String) expectedAttributes.get("RemoteService");
Expand Down Expand Up @@ -153,9 +171,12 @@ private JsonifyArrayList<Map<String, Object>> getExpectedAttributes() throws Exc

private boolean isAwsOtlpLog(Map<String, Object> expectedAttributes) {
// OTLP SigV4 logs have 'body' as a top-level attribute
return expectedAttributes.containsKey("body") &&
expectedAttributes.containsKey("severityNumber") &&
expectedAttributes.containsKey("severityText");
boolean hasBodyKey = expectedAttributes.keySet().stream()
.anyMatch(key -> key.startsWith("body"));

return expectedAttributes.containsKey("severityNumber") &&
expectedAttributes.containsKey("severityText") &&
hasBodyKey;
}

private Map<String, Object> getActualLog(
Expand Down Expand Up @@ -234,25 +255,33 @@ private Map<String, Object> getActualOtelSpanLog(String operation, String remote
return JsonFlattener.flattenAsMap(retrievedLogs.get(0).getMessage());
}

private Map<String, Object> getActualAwsOtlpLog() throws Exception {
String filterPattern= String.format(
"{ ($.resource.attributes.['service.name'] = \"%s\") && ($.body = \"This is a custom log for validation testing\") }",
context.getServiceName()
);
log.info("Filter Pattern for OTLP Log Search: " + filterPattern);
private Map<String, Object> getActualAwsOtlpLog(List<String> filterPatterns) throws Exception {
log.info("Filter patterns {}", filterPatterns);

List<FilteredLogEvent> retrievedLogs =
this.cloudWatchService.filterLogs(
context.getLogGroup(),
filterPattern,
System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5),
10);
List<FilteredLogEvent> retrievedLogs = null;

for (String pattern : filterPatterns) {
log.info("Attempting filter Pattern for OTLP Log Search: {}", pattern);

retrievedLogs = this.cloudWatchService.filterLogs(
context.getLogGroup(),
pattern,
System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5),
10);

if (retrievedLogs != null && !retrievedLogs.isEmpty()) {
log.info("Found logs for filter pattern {}", pattern);
break;
}
}

if (retrievedLogs == null || retrievedLogs.isEmpty()) {
throw new BaseException(ExceptionCode.EMPTY_LIST);
throw new BaseException(ExceptionCode.EMPTY_LIST);
}

return JsonFlattener.flattenAsMap(retrievedLogs.get(0).getMessage());
return new JsonFlattener(retrievedLogs.get(0).getMessage())
.withFlattenMode(FlattenMode.KEEP_ARRAYS)
.flattenAsMap();
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ public void validate() throws Exception {
RetryHelper.retry(
maxRetryCount,
() -> {
String httpPath = validationConfig.getHttpPath();
// Special handling for Genesis path - just check if any metrics exists in namespace
// since ADOT will just capture any OTel Metrics emitted from the instrumentation library used
// and convert them into EMF metrics, it's impossible to create a validation template for this.
if (httpPath != null && httpPath.contains("ai-chat")) {
validateAnyMetricExists();
return;
}
// We will query the Service, RemoteService, and RemoteTarget dimensions to ensure we
// get all metrics from all aggregations, specifically the [RemoteService] aggregation.
List<String> serviceNames =
Expand Down Expand Up @@ -210,6 +218,17 @@ private void compareMetricLists(List<Metric> expectedMetricList, List<Metric> ac
matchAny.stream().findAny().get(), actualMetricSnapshot));
}
}

private void validateAnyMetricExists() throws Exception {
// This will grab all metrics from last 3 hours
// See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_ListMetrics.html
List<Metric> allMetricsInNamespace = cloudWatchService.listMetrics(context.getMetricNamespace(), null, null, null);
log.info("Found {} metrics in namespace {}", allMetricsInNamespace.size(), context.getMetricNamespace());
if (allMetricsInNamespace.isEmpty()) {
throw new BaseException(ExceptionCode.EXPECTED_METRIC_NOT_FOUND, "No metrics found in namespace: " + context.getMetricNamespace());
}
log.info("validation is passed for path {}", validationConfig.getHttpPath());
}

private List<Metric> listMetricFromCloudWatch(
CloudWatchService cloudWatchService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,15 +147,15 @@ private Map<String, Object> getTrace() throws Exception {
validationConfig.getHttpMethod().toUpperCase(),
validationConfig.getHttpPath()));
}

if (validationConfig.getHttpPath() != null && validationConfig.getHttpPath().contains("ai-chat")) {
return this.getTraceById(Collections.singletonList(context.getTraceId()));
}

log.info("Trace Filter: {}", traceFilter);
List<TraceSummary> retrieveTraceLists = xrayService.searchTraces(traceFilter);
List<String> traceIdLists = Collections.singletonList(retrieveTraceLists.get(0).getId());
List<Trace> retrievedTraceList = xrayService.listTraceByIds(traceIdLists);

if (retrievedTraceList == null || retrievedTraceList.isEmpty()) {
throw new BaseException(ExceptionCode.EMPTY_LIST);
}
return this.flattenDocument(retrievedTraceList.get(0).getSegments());
return getTraceById(traceIdLists);
}

private Map<String, Object> flattenDocument(List<Segment> segmentList) {
Expand Down Expand Up @@ -190,6 +190,14 @@ private Map<String, Object> flattenDocument(List<Segment> segmentList) {
return JsonFlattener.flattenAsMap(segmentsJson.toString());
}

private Map<String, Object> getTraceById(List<String> traceIdLists) throws Exception {
List<Trace> retrievedTraceList = xrayService.listTraceByIds(traceIdLists);
if (retrievedTraceList == null || retrievedTraceList.isEmpty()) {
throw new BaseException(ExceptionCode.EMPTY_LIST);
}
return this.flattenDocument(retrievedTraceList.get(0).getSegments());
}

// This method will get the stored traces
private Map<String, Object> getStoredTrace() throws Exception {
Map<String, Object> flattenedJsonMapForStoredTraces = null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
[{
"resource": {
"attributes": {
"aws.local.service": "{{serviceName}}",
"aws.service.type": "gen_ai_agent",
"service.name": "{{serviceName}}"
}
},
"scope": {
"name": "openinference.instrumentation.langchain"
},
"severityNumber": "^[0-9]+$",
"severityText": ".*",
"body": {
"output": {
"messages": [
{
"content": "^.+$",
"role": "assistant"
}
]
},
"input": {
"messages": [
{
"content": "^.+$",
"role": "user"
}
]
}
},
"attributes": {
"event.name": "openinference.instrumentation.langchain"
},
"traceId": "{{traceId}}"
}]
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-
metricName: ANY_VALUE
namespace: {{metricNamespace}}
dimensions: []
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[{
"name": "^{{serviceName}}$",
"trace_id": "^{{traceId}}$",
"http": {
"request": {
"url": "^.*/ai-chat$",
"method": "^POST$"
}
},
"aws": {
"service.type": "^gen_ai_agent$"
},
"annotations": {
"aws.local.service": "^{{serviceName}}$",
"aws.local.operation": "^POST /ai-chat$"
},
"metadata": {
"service.name": "^{{serviceName}}$"
}
}]
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-
validationType: "cw-log"
httpPath: "ai-chat"
httpMethod: "post"
callingType: "http"
expectedLogStructureTemplate: "PYTHON_EC2_ADOT_GENAI_LOG"
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-
validationType: "cw-metric"
httpPath: "ai-chat"
httpMethod: "post"
callingType: "http"
expectedMetricTemplate: "PYTHON_EC2_ADOT_GENAI_METRIC"
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-
validationType: "trace"
httpPath: "ai-chat"
httpMethod: "post"
callingType: "http"
expectedTraceTemplate: "PYTHON_EC2_ADOT_GENAI_TRACE"
Loading