Repository navigation
Add support to track the duration of fuzz task steps #5555
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
273c186
094fc33
78a140e
3f8e433
1152b9e
9dbb27e
36d7941
a39c77d
f0d4b6d
a9d1ada
2198cb2
4192450
9d9304a
167fbe0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,84 +35,108 @@ | |
|
|
||
| STATS_FILE_EXTENSION = '.stats2' | ||
|
|
||
| _STEPS_DURATION_FIELDS = [] | ||
| JOB_RUN_SCHEMA = { | ||
| 'fields': [{ | ||
|
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', | ||
|
brun0-matheus marked this conversation as resolved.
Resolved
|
||
| 'type': 'RECORD', | ||
| 'mode': 'NULLABLE', | ||
| 'fields': _STEPS_DURATION_FIELDS | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| } | ||
| ] | ||
| } | ||
|
|
||
|
|
||
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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', | ||
|
|
@@ -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): | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ | |
| import bisect | ||
| import collections | ||
| import contextlib | ||
| import datetime | ||
| import functools | ||
| import itertools | ||
| import re | ||
|
|
@@ -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.""" | ||
|
|
@@ -658,6 +665,74 @@ def fail(self): | |
| self.failed = True | ||
|
|
||
|
|
||
| class TimeStatTrackerFailureException(Exception): | ||
| pass | ||
|
|
||
|
|
||
| class TimeStatTracker: | ||
|
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: | ||
|
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: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done, now the time measurements are accumulated.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: Also, by sending the measurements in each step you gain granularity, so I don't see any problem in doing this.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I updated the metric description to include this scenario.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
|
||
| 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 | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.