Skip to content
Open
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
31 changes: 23 additions & 8 deletions src/clusterfuzz/_internal/bot/tasks/utasks/fuzz_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -841,14 +841,15 @@ def upload_job_run_stats(
testcases_generated: int = None,
testcase_generation_duration: datetime.timedelta = None,
testcase_execution_duration: datetime.timedelta = None,
fuzzing_duration: datetime.timedelta = None):
fuzzing_duration: datetime.timedelta = None,
steps_duration: dict[str, datetime.timedelta] | None = None):
"""Upload job run stats."""
# New format.
job_run = fuzzer_stats.JobRun(fuzzer_name, job_type, revision, timestamp,
testcases_executed, new_crash_count,
known_crash_count, groups, testcases_generated,
testcase_generation_duration,
testcase_execution_duration, fuzzing_duration)
job_run = fuzzer_stats.JobRun(
Comment thread
ViniciustCosta marked this conversation as resolved.
Resolved
fuzzer_name, job_type, revision, timestamp, testcases_executed,
new_crash_count, known_crash_count, groups, testcases_generated,
testcase_generation_duration, testcase_execution_duration,
fuzzing_duration, steps_duration)
fuzzer_stats.upload_stats([job_run])

_track_testcase_run_result(fuzzer_name, job_type, new_crash_count,
Expand Down Expand Up @@ -1111,6 +1112,10 @@ def postprocess_process_crashes(uworker_input: uworker_msg_pb2.Input,
# Artificial delay to throttle appengine updates.
time.sleep(1)

steps_duration = {
name: duration.ToTimedelta()
for name, duration in fuzz_task_output.steps_duration.items()
}
# TODO(metzman): Replace fuzz_task_output.fully_qualified_fuzzer_name` with
# `fuzz_task_input.fuzz_target` instead.
upload_job_run_stats(
Expand All @@ -1123,8 +1128,8 @@ def postprocess_process_crashes(uworker_input: uworker_msg_pb2.Input,
if fuzz_task_output.HasField('testcase_generation_duration') else None,
fuzz_task_output.testcase_execution_duration.ToTimedelta()
if fuzz_task_output.HasField('testcase_execution_duration') else None,
fuzz_task_output.fuzzing_duration.ToTimedelta()
if fuzz_task_output.HasField('fuzzing_duration') else None)
fuzz_task_output.fuzzing_duration.ToTimedelta() if
fuzz_task_output.HasField('fuzzing_duration') else None, steps_duration)

logs.info(f'Finished processing crashes.\nNew crashes: {new_crash_count}, '
f'known crashes: {known_crash_count}, '
Expand Down Expand Up @@ -1559,6 +1564,14 @@ def __init__(self, uworker_input, test_timeout):

self.gcs_corpus = None
self.fuzz_task_output = uworker_msg_pb2.FuzzTaskOutput() # pylint: disable=no-member
self.steps_time_tracker = monitor.TimeStatTracker(
monitoring_metrics.FUZZ_TASK_STEPS_DURATION,
common_labels={
'fuzzer': self.fuzzer_name,
'job': self.job_type,
'platform': environment.platform(),
'runtime': environment.get_runtime().value
})

@property
def fully_qualified_fuzzer_name(self):
Expand Down Expand Up @@ -2291,6 +2304,8 @@ def run(self):
self.fuzz_task_output.fuzzing_duration.FromTimedelta(
datetime.timedelta(seconds=fuzzing_session_duration_seconds))

for name, duration in self.steps_time_tracker.stats.items():
self.fuzz_task_output.steps_duration[name].FromTimedelta(duration)
return uworker_msg_pb2.Output(fuzz_task_output=self.fuzz_task_output) # pylint: disable=no-member

def postprocess(self, uworker_output):
Expand Down
184 changes: 113 additions & 71 deletions src/clusterfuzz/_internal/metrics/fuzzer_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,84 +35,108 @@

STATS_FILE_EXTENSION = '.stats2'

_STEPS_DURATION_FIELDS = []
JOB_RUN_SCHEMA = {
'fields': [{
Comment thread
brun0-matheus marked this conversation as resolved.
Resolved
'name': 'testcases_executed',
'type': 'INTEGER',
'mode': 'NULLABLE'
}, {
'name': 'build_revision',
'type': 'INTEGER',
'mode': 'NULLABLE'
}, {
'name': 'new_crashes',
'type': 'INTEGER',
'mode': 'NULLABLE'
}, {
'name': 'job',
'type': 'STRING',
'mode': 'NULLABLE'
}, {
'name': 'timestamp',
'type': 'FLOAT',
'mode': 'NULLABLE'
}, {
'name':
'crashes',
'type':
'RECORD',
'mode':
'REPEATED',
'fields': [{
'name': 'crash_type',
'fields': [
{
'name': 'testcases_executed',
'type': 'INTEGER',
'mode': 'NULLABLE'
},
{
'name': 'build_revision',
'type': 'INTEGER',
'mode': 'NULLABLE'
},
{
'name': 'new_crashes',
'type': 'INTEGER',
'mode': 'NULLABLE'
},
{
'name': 'job',
'type': 'STRING',
'mode': 'NULLABLE'
}, {
'name': 'is_new',
'type': 'BOOLEAN',
},
{
'name': 'timestamp',
'type': 'FLOAT',
'mode': 'NULLABLE'
}, {
'name': 'crash_state',
},
{
'name':
'crashes',
'type':
'RECORD',
'mode':
'REPEATED',
'fields': [{
'name': 'crash_type',
'type': 'STRING',
'mode': 'NULLABLE'
}, {
'name': 'is_new',
'type': 'BOOLEAN',
'mode': 'NULLABLE'
}, {
'name': 'crash_state',
'type': 'STRING',
'mode': 'NULLABLE'
}, {
'name': 'security_flag',
'type': 'BOOLEAN',
'mode': 'NULLABLE'
}, {
'name': 'count',
'type': 'INTEGER',
'mode': 'NULLABLE'
}]
},
{
'name': 'known_crashes',
'type': 'INTEGER',
'mode': 'NULLABLE'
},
{
'name': 'fuzzer',
'type': 'STRING',
'mode': 'NULLABLE'
}, {
'name': 'security_flag',
'type': 'BOOLEAN',
},
{
'name': 'kind',
'type': 'STRING',
'mode': 'NULLABLE'
}, {
'name': 'count',
},
{
'name': 'testcases_generated',
'type': 'INTEGER',
'mode': 'NULLABLE'
}]
}, {
'name': 'known_crashes',
'type': 'INTEGER',
'mode': 'NULLABLE'
}, {
'name': 'fuzzer',
'type': 'STRING',
'mode': 'NULLABLE'
}, {
'name': 'kind',
'type': 'STRING',
'mode': 'NULLABLE'
}, {
'name': 'testcases_generated',
'type': 'INTEGER',
'mode': 'NULLABLE'
}, {
'name': 'testcase_generation_duration',
'type': 'INTERVAL',
'mode': 'NULLABLE'
}, {
'name': 'testcase_execution_duration',
'type': 'INTERVAL',
'mode': 'NULLABLE'
}, {
'name': 'fuzzing_duration',
'type': 'INTERVAL',
'mode': 'NULLABLE'
}]
},
{
'name': 'testcase_generation_duration',
'type': 'INTERVAL',
'mode': 'NULLABLE'
},
{
'name': 'testcase_execution_duration',
'type': 'INTERVAL',
'mode': 'NULLABLE'
},
{
'name': 'fuzzing_duration',
'type': 'INTERVAL',
'mode': 'NULLABLE'
},
# Measures the duration of each step of the fuzz task for successful
# executions, in contrast to the FUZZ_TASK_STEPS_DURATION metric
# in Cloud Monitoring, which includes failed executions.
{
'name': 'steps_duration',
Comment thread
brun0-matheus marked this conversation as resolved.
Resolved
'type': 'RECORD',
'mode': 'NULLABLE',
'fields': _STEPS_DURATION_FIELDS

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this break the JobRun load since STEPS_DURATION_FIELDS is empty for now?

Maybe we should leve the BQ schema field to be add it in the first real step.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will rerun the tests in the dev environment once we're done with reviewing, so let's see if this breaks.

}
]
}


Expand Down Expand Up @@ -225,7 +249,8 @@ def from_json(json_data):
data.get('crashes'), data.get('testcases_generated'),
data.get('testcase_generation_duration'),
data.get('testcase_execution_duration'),
data.get('fuzzing_duration'))
data.get('fuzzing_duration'),
data.get('steps_duration'))
except KeyError:
return None

Expand All @@ -252,7 +277,8 @@ def __init__(self,
testcases_generated=None,
testcase_generation_duration=None,
testcase_execution_duration=None,
fuzzing_duration=None):
fuzzing_duration=None,
steps_duration=None):
super().__init__(fuzzer, job, build_revision, timestamp)
self._stats_data.update({
'kind': 'JobRun',
Expand All @@ -274,6 +300,22 @@ def __init__(self,
if fuzzing_duration is not None:
self._stats_data['fuzzing_duration'] = _timedelta_to_duration_string(
fuzzing_duration)
if steps_duration:
valid_names = frozenset(field['name'] for field in _STEPS_DURATION_FIELDS)
valid_steps_duration = {}
dropped_keys = []
for name, duration in steps_duration.items():
if name in valid_names:
valid_steps_duration[name] = _timedelta_to_duration_string(duration)
else:
dropped_keys.append(name)

if dropped_keys:
keys_str = ', '.join(f'"{key}"' for key in dropped_keys)
logs.error(f'Dropped keys {keys_str} because they are not accepted '
'by any field in JobRun.steps_duration schema.')
if valid_steps_duration:
self._stats_data['steps_duration'] = valid_steps_duration


class TestcaseRun(BaseRun):
Expand Down
75 changes: 75 additions & 0 deletions src/clusterfuzz/_internal/metrics/monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import bisect
import collections
import contextlib
import datetime
import functools
import itertools
import re
Expand Down Expand Up @@ -424,6 +425,12 @@ def monitoring_v3_time_series(self, time_series, labels, start_time, end_time,

return time_series

def has_field(self, field):
for f in self.field_spec:
if f.value_type == field.value_type and f.name == field.name:
return True
return False


class _CounterMetric(Metric):
"""Counter metric."""
Expand Down Expand Up @@ -658,6 +665,74 @@ def fail(self):
self.failed = True


class TimeStatTrackerFailureException(Exception):
pass


class TimeStatTracker:
Comment thread
ViniciustCosta marked this conversation as resolved.
Resolved
"""Reusable context manager to keep track of time stats.

Time measurements are emitted to the supplied Cloud Monitoring metric and
are saved as datetime.timedelta in the |self.stats| dictionary as well.
"""

def __init__(self, metric: Metric | None = None, common_labels=None):
"""Initializes the tracker.

Args:
metric: The Cloud Monitoring metric to submit measurements to, if not
None. The metric must contain the fields "step" and "success", which
are automatically filled by the tracker upon emission.
common_labels: The dictionary of labels to be submitted to the metric.
The "step" and "success" labels are ignored if present.
"""
self.stats: dict[str, datetime.timedelta] = {}
self.metric = metric
self.common_labels = dict(common_labels) if common_labels else {}
if metric:
Comment thread
ViniciustCosta marked this conversation as resolved.
Resolved
if not metric.has_field(StringField('step')):
logs.error('Missing "step" string field in the metric.')
self.metric = None
if not metric.has_field(BooleanField('success')):
logs.error('Missing "success" boolean field in the metric.')
self.metric = None
if 'step' in self.common_labels or 'success' in self.common_labels:
logs.warning('Labels "step" or "success" are in common_labels '
'and will be overwritten.')

@contextlib.contextmanager
def track(self, name: str):
"""Tracks the stat named |name|.

Any raised exception is treated as a failure and re-raised, except for
TimeStatTrackerFailureException, which marks the step as failed without
propagating.
"""
start = time.time()
success = True
try:
yield
except TimeStatTrackerFailureException:
success = False
except:
success = False
raise
finally:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there cases when the same step runs more than once (loops, retries)? Right now it warns and overwrites. Should durations be summed instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, there are. In this case I opted to make a tracker for each loop (as in PR #5569). Maybe summing it up is a simpler solution.

As a matter of fact, I opted for the first solution (make a tracker for each loop) in order to get the stats for TestcaseRun, but later on (PR #5571) I had to do the same for blackbox fuzzing, so now I don't think it's the best solution anymore.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, now the time measurements are accumulated.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm with this accumulation, stats holds the total per session, but the metric still gets one sample per call in Cloud Monitorign.

For steps inside loops, BQ (session total) and CM (per-iteration distribution) will mean different things. We could send the total once per step at the end.

In any case, please document it in the metric description.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, yes, that's intended, I forgot to add a comment explaining this. Sending the total once the steps end is a little bit difficult to do because of the following reasons:
1 - you need to define an extra method to mark the step as finished. This can add to confusion if you track the same step after you marked it as finished.
2 - you can't easily define success. If you had 5 steps, 4 were successful and 1 wasn't, what do you do?

Also, by sending the measurements in each step you gain granularity, so I don't see any problem in doing this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated the metric description to include this scenario.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me! As we discussed offline, we might need to revisit that since some steps can loop over more than 100 iterations.

  1. Not sure if that's a problem, we can guarantee consistency in the code itself.
  2. Yeah, that's a good point, not sure the most correct approach here.

duration_secs = time.time() - start
duration = datetime.timedelta(seconds=duration_secs)
if name in self.stats:
self.stats[name] += duration
else:
self.stats[name] = duration
if self.metric:
self.metric.insert(
duration_secs,
labels={
**self.common_labels, 'step': name,
'success': success
})


# Global state.
_metrics_store = _MetricsStore()
_monitoring_v3_client = None
Expand Down
Loading
Loading