1 /* 2 * Copyright (C) 2025 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.tradefed.result.resultdb; 17 18 import com.android.resultdb.proto.CreateInvocationRequest; 19 import com.android.resultdb.proto.FailureReason; 20 import com.android.resultdb.proto.Invocation; 21 import com.android.resultdb.proto.StringPair; 22 import com.android.resultdb.proto.TestResult; 23 import com.android.resultdb.proto.TestStatus; 24 import com.android.resultdb.proto.Variant; 25 import com.android.tradefed.build.IBuildInfo; 26 import com.android.tradefed.config.IConfiguration; 27 import com.android.tradefed.config.IConfigurationReceiver; 28 import com.android.tradefed.config.Option; 29 import com.android.tradefed.config.OptionClass; 30 import com.android.tradefed.invoker.IInvocationContext; 31 import com.android.tradefed.log.LogUtil.CLog; 32 import com.android.tradefed.metrics.proto.MetricMeasurement; 33 import com.android.tradefed.result.FailureDescription; 34 import com.android.tradefed.result.ILogSaver; 35 import com.android.tradefed.result.ILogSaverListener; 36 import com.android.tradefed.result.ITestSummaryListener; 37 import com.android.tradefed.result.InputStreamSource; 38 import com.android.tradefed.result.LogDataType; 39 import com.android.tradefed.result.LogFile; 40 import com.android.tradefed.result.TestDescription; 41 import com.android.tradefed.result.TestSummary; 42 import com.android.tradefed.result.proto.TestRecordProto.FailureStatus; 43 import com.android.tradefed.result.retry.ISupportGranularResults; 44 import com.android.tradefed.result.skipped.SkipReason; 45 import com.android.tradefed.testtype.suite.ModuleDefinition; 46 import com.android.tradefed.util.MultiMap; 47 48 import com.google.common.annotations.VisibleForTesting; 49 import com.google.common.base.Strings; 50 import com.google.common.collect.ImmutableSet; 51 import com.google.protobuf.util.Durations; 52 import com.google.protobuf.util.Timestamps; 53 54 import java.security.NoSuchAlgorithmException; 55 import java.security.SecureRandom; 56 import java.util.Arrays; 57 import java.util.HashMap; 58 import java.util.HashSet; 59 import java.util.List; 60 import java.util.Map; 61 import java.util.Set; 62 import java.util.UUID; 63 import java.util.concurrent.atomic.AtomicInteger; 64 65 @OptionClass(alias = "resultdb-reporter") 66 /** Result reporter that uploads test results to ResultDB. */ 67 public class ResultDBReporter 68 implements ITestSummaryListener, 69 ILogSaverListener, 70 ISupportGranularResults, 71 IConfigurationReceiver { 72 73 public static final int MAX_SUMMARY_HTML_BYTES = 4096; 74 75 public static final int MAX_PRIMARY_ERROR_MESSAGE_BYTES = 1024; 76 77 // Set containing the allowed variant module parameter keys 78 private static final Set<String> ALLOWED_MODULE_PARAMETERS = 79 ImmutableSet.of(ModuleDefinition.MODULE_ABI, ModuleDefinition.MODULE_PARAMETERIZATION); 80 // Tag name for the test mapping source 81 private static final String TEST_MAPPING_TAG = "test_mapping_source"; 82 83 @Option(name = "disable", description = "Set to true if reporter is disabled") 84 private boolean mDisable = false; 85 86 // Option used to test Tradefed ResultDB integration without invocation created by ATE. 87 @Option( 88 name = "create-local-invocation", 89 description = "Create a local invocation if invocation is not provided in the context") 90 private boolean mCreateLocalInvocation = false; 91 92 private Invocation mInvocation; 93 // Set to true if the reporter is responsible for updating and finalizing the invocation. 94 private boolean mManageInvocation = false; 95 private IRecorderClient mRecorder; 96 97 // Common variant values for all test in this TF invocation. 98 private Variant mBaseVariant; 99 // Module level variant for test in the same test module. 100 private Variant mModuleVariant; 101 private String mCurrentModule; 102 private TestResult mCurrentTestResult; 103 // Counter for generate test result ID. 104 private AtomicInteger mResultCounter = new AtomicInteger(0); 105 // Base for generate test result ID. 106 private String mResultIdBase; 107 108 @Override setConfiguration(IConfiguration configuration)109 public void setConfiguration(IConfiguration configuration) { 110 // TODO: implement this method. 111 } 112 113 @Override testLog(String dataName, LogDataType dataType, InputStreamSource dataStream)114 public void testLog(String dataName, LogDataType dataType, InputStreamSource dataStream) { 115 // TODO: implement this method. 116 } 117 118 @Override logAssociation(String dataName, LogFile logFile)119 public void logAssociation(String dataName, LogFile logFile) { 120 // TODO: implement this method. 121 } 122 123 @Override setLogSaver(ILogSaver logSaver)124 public void setLogSaver(ILogSaver logSaver) { 125 // TODO: implement this method. 126 } 127 128 @Override getSummary()129 public TestSummary getSummary() { 130 // TODO: implement this method. 131 return null; 132 } 133 134 @VisibleForTesting createRecorderClient(String invocationId, String updateToken)135 IRecorderClient createRecorderClient(String invocationId, String updateToken) { 136 return Client.create(invocationId, updateToken); 137 } 138 139 @VisibleForTesting createRecorderClient(CreateInvocationRequest request)140 IRecorderClient createRecorderClient(CreateInvocationRequest request) { 141 return Client.createWithNewInvocation(request); 142 } 143 144 // Generate a random hexadecimal string of length 8. 145 @VisibleForTesting randomHexString()146 String randomHexString() throws NoSuchAlgorithmException { 147 SecureRandom random = SecureRandom.getInstance("SHA1PRNG"); 148 byte[] bytes = new byte[4]; 149 random.nextBytes(bytes); 150 return ResultDBUtil.bytesToHex(bytes); 151 } 152 153 @Override invocationStarted(IInvocationContext context)154 public void invocationStarted(IInvocationContext context) { 155 if (mDisable) { 156 CLog.i("ResultDBReporter is disabled"); 157 return; 158 } 159 try { 160 // Obtain invocation ID from context. 161 String invocationId = context.getAttribute("resultdb_invocation_id"); 162 String updateToken = context.getAttribute("resultdb_invocation_update_token"); 163 if (!invocationId.isEmpty() && !updateToken.isEmpty()) { 164 mRecorder = createRecorderClient(invocationId, updateToken); 165 } else if (mCreateLocalInvocation) { 166 mInvocation = Invocation.newBuilder().setRealm("android:ants-experiment").build(); 167 invocationId = randomUUIDString().toString(); 168 mRecorder = 169 createRecorderClient( 170 CreateInvocationRequest.newBuilder() 171 .setInvocation(mInvocation) 172 .setInvocationId("u-" + invocationId) 173 .build()); 174 mManageInvocation = true; 175 176 } else { 177 mDisable = true; 178 CLog.i( 179 "ResultDBReporter is disabled as invocation ID or update token is not" 180 + " provided."); 181 return; 182 } 183 } catch (RuntimeException e) { 184 mDisable = true; 185 CLog.e("Failed to create ResultDB client."); 186 if (mRecorder != null) { 187 // Make sure we cancel the client, otherwise it will leak a thread since 188 // invocationEnded will be skipped. 189 mRecorder.finalizeTestResults(); 190 } 191 throw new RuntimeException(e); 192 } 193 try { 194 mResultIdBase = this.randomHexString(); 195 } catch (NoSuchAlgorithmException e) { 196 mDisable = true; 197 CLog.e("Failed to generate random result ID base."); 198 return; 199 } 200 // Variant contains properties in go/consistent-test-identifiers, excluding 201 // properties in ResultDB test identifier. 202 // TODO: Add Test definition properties eg. cluster_id. 203 Variant.Builder mBaseVariantBuilder = 204 Variant.newBuilder() 205 .putDef("scheduler", "ATP") // ATP is the only scheduler supported for now. 206 .putDef("name", Strings.nullToEmpty(context.getTestTag())); 207 208 if (!context.getBuildInfos().isEmpty()) { 209 IBuildInfo primaryBuild = context.getBuildInfos().get(0); 210 mBaseVariantBuilder = 211 mBaseVariantBuilder 212 .putDef("build_provider", "androidbuild") 213 .putDef("branch", Strings.nullToEmpty(primaryBuild.getBuildBranch())) 214 .putDef("target", Strings.nullToEmpty(primaryBuild.getBuildFlavor())); 215 } 216 mBaseVariant = mBaseVariantBuilder.build(); 217 } 218 219 @Override invocationFailed(Throwable cause)220 public void invocationFailed(Throwable cause) { 221 // TODO: implement this method. 222 } 223 224 @Override invocationFailed(FailureDescription failure)225 public void invocationFailed(FailureDescription failure) { 226 // TODO: implement this method. 227 } 228 229 @Override invocationSkipped(SkipReason reason)230 public void invocationSkipped(SkipReason reason) { 231 // TODO: implement this method. 232 } 233 234 @Override invocationEnded(long elapsedTime)235 public void invocationEnded(long elapsedTime) { 236 if (mDisable) { 237 return; 238 } 239 mRecorder.finalizeTestResults(); 240 if (mManageInvocation) { 241 mRecorder.finalizeInvocation(); 242 } 243 // TODO: Update ResultDB invocation with information from TF invocation. 244 } 245 246 @Override testModuleStarted(IInvocationContext moduleContext)247 public void testModuleStarted(IInvocationContext moduleContext) { 248 if (mDisable) { 249 return; 250 } 251 // Extract module informations. 252 mCurrentModule = moduleContext.getConfigurationDescriptor().getModuleName(); 253 mModuleVariant = getModuleVariant(moduleContext.getAttributes()); 254 } 255 256 /* 257 * Only module-abi and module-param are used in the variant, so filter other values. 258 */ getModuleVariant(MultiMap<String, String> properties)259 private Variant getModuleVariant(MultiMap<String, String> properties) { 260 Variant.Builder variantBuilder = Variant.newBuilder(); 261 for (Map.Entry<String, String> property : properties.entries()) { 262 if (ALLOWED_MODULE_PARAMETERS.contains(property.getKey())) { 263 variantBuilder.putDef( 264 ResultDBUtil.makeValidKey(property.getKey()), property.getValue()); 265 } 266 } 267 return variantBuilder.build(); 268 } 269 270 @Override testModuleEnded()271 public void testModuleEnded() { 272 // Clear module variant. 273 mModuleVariant = null; 274 } 275 276 @Override testRunEnded( long elapsedTimeMillis, HashMap<String, MetricMeasurement.Metric> runMetrics)277 public void testRunEnded( 278 long elapsedTimeMillis, HashMap<String, MetricMeasurement.Metric> runMetrics) { 279 // TODO: implement this method. 280 } 281 282 @Override testRunFailed(String errorMessage)283 public void testRunFailed(String errorMessage) { 284 // TODO: implement this method. 285 } 286 287 @Override testRunFailed(FailureDescription failure)288 public void testRunFailed(FailureDescription failure) { 289 // TODO: implement this method. 290 } 291 292 @Override testRunStarted(String runName, int testCount)293 public void testRunStarted(String runName, int testCount) { 294 // TODO: implement this method. 295 } 296 297 @Override testRunStarted(String runName, int testCount, int attemptNumber)298 public void testRunStarted(String runName, int testCount, int attemptNumber) { 299 // TODO: implement this method. 300 } 301 302 @Override testRunStarted(String runName, int testCount, int attemptNumber, long startTime)303 public void testRunStarted(String runName, int testCount, int attemptNumber, long startTime) { 304 // TODO: implement this method. 305 } 306 307 @VisibleForTesting currentTimestamp()308 long currentTimestamp() { 309 return System.currentTimeMillis(); 310 } 311 312 @VisibleForTesting randomUUIDString()313 String randomUUIDString() { 314 return UUID.randomUUID().toString(); 315 } 316 317 @Override testRunStopped(long elapsedTime)318 public void testRunStopped(long elapsedTime) { 319 // TODO: implement this method. 320 } 321 322 @Override testStarted(TestDescription test)323 public void testStarted(TestDescription test) { 324 testStarted(test, currentTimestamp()); 325 } 326 327 @Override testStarted(TestDescription test, long startTime)328 public void testStarted(TestDescription test, long startTime) { 329 if (mDisable) { 330 return; 331 } 332 Variant.Builder variantBuilder = Variant.newBuilder(); 333 if (mModuleVariant != null) { 334 variantBuilder = variantBuilder.mergeFrom(mModuleVariant); 335 } 336 if (mBaseVariant != null) { 337 variantBuilder = variantBuilder.mergeFrom(mBaseVariant); 338 } 339 mCurrentTestResult = 340 TestResult.newBuilder() 341 // TODO: Use test id format designed in go/resultdb-test-hierarchy-proposal 342 .setTestId( 343 String.format( 344 "ants://%s/%s/%s", 345 mCurrentModule, test.getClassName(), test.getTestName())) 346 .setResultId( 347 String.format( 348 "%s-%05d", mResultIdBase, mResultCounter.incrementAndGet())) 349 .setStartTime(Timestamps.fromMillis(startTime)) 350 .setStatus(TestStatus.PASS) 351 .setExpected(true) 352 .setVariant(variantBuilder.build()) 353 .build(); 354 } 355 356 @Override testAssumptionFailure(TestDescription test, String trace)357 public void testAssumptionFailure(TestDescription test, String trace) { 358 testAssumptionFailure(test, FailureDescription.create(trace)); 359 } 360 361 @Override testAssumptionFailure(TestDescription test, FailureDescription failure)362 public void testAssumptionFailure(TestDescription test, FailureDescription failure) { 363 if (mDisable) { 364 return; 365 } 366 if (mCurrentTestResult == null) { 367 CLog.e("Received #testAssumptionFailure(%s) without a valid testStart before.", test); 368 return; 369 } 370 371 mCurrentTestResult = 372 mCurrentTestResult.toBuilder() 373 .setStatus(TestStatus.SKIP) 374 .setExpected(true) 375 // This is not set in the test result failure reason field, because 376 // test assumption failure is treated as a ResultDB skip status 377 // (instead of fail). We will likely re-visit this once we have more 378 // information on how this is used by downstream. 379 .setSummaryHtml( 380 extractFailureReason( 381 failure.getErrorMessage(), MAX_SUMMARY_HTML_BYTES)) 382 .build(); 383 // TODO: Full error message is too long to fit in any test result field. 384 // Upload it as test artifact. 385 } 386 387 @Override testSkipped(TestDescription test, SkipReason reason)388 public void testSkipped(TestDescription test, SkipReason reason) { 389 if (mDisable) { 390 return; 391 } 392 if (mCurrentTestResult == null) { 393 CLog.e("Received #testIgnored(%s) without a valid testStart before.", test); 394 return; 395 } 396 397 // ResultDB does not yet have a skip reason field, we put them in the 398 // summary HTML field and test artifact for now. 399 String summaryHtml = ""; 400 if (!Strings.isNullOrEmpty(reason.getBugId())) { 401 summaryHtml += "bug_id: " + reason.getBugId() + "<br>"; 402 } 403 if (!Strings.isNullOrEmpty(reason.getTrigger())) { 404 summaryHtml += "trigger: " + reason.getTrigger() + "<br>"; 405 } 406 // TODO: Skip reason can be too long to fit in any test result field. 407 // Upload it as test artifact. 408 409 mCurrentTestResult = 410 mCurrentTestResult.toBuilder() 411 .setStatus(TestStatus.SKIP) 412 .setExpected(true) 413 .setSummaryHtml(summaryHtml) 414 .build(); 415 } 416 417 @Override testFailed(TestDescription test, String trace)418 public void testFailed(TestDescription test, String trace) { 419 if (mDisable) { 420 return; 421 } 422 if (mCurrentTestResult == null) { 423 CLog.e("Received #testFailed(%s) without a valid testStart before.", test); 424 return; 425 } 426 String failureReason = extractFailureReason(trace, MAX_PRIMARY_ERROR_MESSAGE_BYTES); 427 mCurrentTestResult = 428 mCurrentTestResult.toBuilder() 429 .setFailureReason( 430 FailureReason.newBuilder().setPrimaryErrorMessage(failureReason)) 431 .setStatus(TestStatus.FAIL) 432 .setExpected(false) 433 .build(); 434 // TODO: extract local instruction from test description and set in ResultDB test result. 435 // TODO: trace is too long to fit in any test result field. Upload it as test artifact. 436 } 437 438 @Override testFailed(TestDescription test, FailureDescription failure)439 public void testFailed(TestDescription test, FailureDescription failure) { 440 if (mDisable) { 441 return; 442 } 443 if (mCurrentTestResult == null) { 444 CLog.e("Received #testFailed(%s) without a valid testStart before.", test); 445 return; 446 } 447 TestStatus status = TestStatus.FAIL; 448 Set<FailureStatus> crashStatus = 449 new HashSet<>( 450 Arrays.asList( 451 FailureStatus.TIMED_OUT, 452 FailureStatus.CANCELLED, 453 FailureStatus.INFRA_FAILURE, 454 FailureStatus.SYSTEM_UNDER_TEST_CRASHED)); 455 if (crashStatus.contains(failure.getFailureStatus())) { 456 status = TestStatus.CRASH; 457 } 458 String failureReason = 459 extractFailureReason(failure.getErrorMessage(), MAX_PRIMARY_ERROR_MESSAGE_BYTES); 460 461 mCurrentTestResult = 462 mCurrentTestResult.toBuilder() 463 .setFailureReason( 464 FailureReason.newBuilder().setPrimaryErrorMessage(failureReason)) 465 .setStatus(status) 466 .setExpected(false) 467 .build(); 468 469 // Set the TF error type in the summary HTML. 470 if (failure.getFailureStatus() != null) { 471 mCurrentTestResult = 472 mCurrentTestResult.toBuilder() 473 .setSummaryHtml("TF error type: " + failure.getFailureStatus()) 474 .build(); 475 } 476 // TODO: extract local instruction from test description and set in ResultDB test result. 477 // TODO: trace is too long to fit in any test result field. Upload it as test artifact. 478 } 479 480 @Override testIgnored(TestDescription test)481 public void testIgnored(TestDescription test) { 482 if (mDisable) { 483 return; 484 } 485 if (mCurrentTestResult == null) { 486 CLog.e("Received #testIgnored(%s) without a valid testStart before.", test); 487 return; 488 } 489 mCurrentTestResult = 490 mCurrentTestResult.toBuilder().setStatus(TestStatus.SKIP).setExpected(true).build(); 491 } 492 493 @Override testEnded( TestDescription test, HashMap<String, MetricMeasurement.Metric> testMetrics)494 public void testEnded( 495 TestDescription test, HashMap<String, MetricMeasurement.Metric> testMetrics) { 496 testEnded(test, currentTimestamp(), testMetrics); 497 } 498 499 @Override testEnded( TestDescription test, long endTime, HashMap<String, MetricMeasurement.Metric> testMetrics)500 public void testEnded( 501 TestDescription test, 502 long endTime, 503 HashMap<String, MetricMeasurement.Metric> testMetrics) { 504 if (mDisable) { 505 return; 506 } 507 long startTimeMillis = Timestamps.toMillis(mCurrentTestResult.getStartTime()); 508 TestResult.Builder testResultBuilder = 509 mCurrentTestResult.toBuilder() 510 .setDuration(Durations.fromMillis(endTime - startTimeMillis)); 511 512 // Add test mapping sources to test result as tags. 513 if (testMetrics.get(TEST_MAPPING_TAG) != null) { 514 // Get Test Mapping sources from string formatting with list such as "[path1, path2]". 515 // Note: Some test mapping sources may not be recorded. This is because a test module 516 // can be defined across multiple TEST_MAPPING files, and TF doesn't run it again if 517 // it's passed in the previous run. 518 String testMappingMeasurement = 519 testMetrics 520 .get(TEST_MAPPING_TAG) 521 .getMeasurements() 522 .getSingleString() 523 .replaceAll("^\\[| |\\]$", ""); 524 List<String> testMappingSources = Arrays.asList(testMappingMeasurement.split(",")); 525 526 for (String testMappingSource : testMappingSources) { 527 testResultBuilder.addTags( 528 StringPair.newBuilder() 529 .setKey(ResultDBUtil.makeValidKey(TEST_MAPPING_TAG)) 530 .setValue(testMappingSource)); 531 } 532 } 533 mCurrentTestResult = testResultBuilder.build(); 534 mRecorder.uploadTestResult(mCurrentTestResult); 535 mCurrentTestResult = null; 536 } 537 538 @Override supportGranularResults()539 public boolean supportGranularResults() { 540 return true; 541 } 542 543 /** 544 * Extract the first line of the stack trace as the error message, and truncate the string to 545 * the given max bytes. 546 * 547 * <p>In most cases, this ends up being the exception + error message. 548 */ extractFailureReason(String trace, int maxBytes)549 String extractFailureReason(String trace, int maxBytes) { 550 String firstLine = trace.split("[\\r\\n]+", 2)[0]; 551 if (!firstLine.trim().isEmpty()) { 552 return ResultDBUtil.truncateString(firstLine, maxBytes); 553 } 554 return ""; 555 } 556 } 557