• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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