Skip to content

Commit 1845750

Browse files
[Logging] Add new log context for grouper-related structured logging (#4785)
Changes to the logs module in order to enable structured logging for the grouper task: * Adapting the testcase-based context to also accept the `TestcaseAttributes` class (used by the grouper). * Adding a new log context for grouping, which is an edge case where pairs of testcases are processed each step. In addition, this PR also adds unit tests and comments that were missing in the logs module.
1 parent 03196bf commit 1845750

File tree

2 files changed

+174
-15
lines changed

2 files changed

+174
-15
lines changed

src/clusterfuzz/_internal/metrics/logs.py

Lines changed: 70 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
# This is needed to avoid circular import
3535
if TYPE_CHECKING:
36+
from clusterfuzz._internal.cron.grouper import TestcaseAttributes
3637
from clusterfuzz._internal.datastore.data_types import FuzzTarget
3738
from clusterfuzz._internal.datastore.data_types import Testcase
3839

@@ -663,6 +664,7 @@ def log_fatal_and_exit(message, **extras):
663664

664665
def get_common_log_context() -> dict[str, str]:
665666
"""Return common context to be propagated by logs."""
667+
# Avoid circular imports on the top level.
666668
from clusterfuzz._internal.base import utils
667669
from clusterfuzz._internal.system import environment
668670

@@ -694,6 +696,18 @@ def get_common_log_context() -> dict[str, str]:
694696
return {}
695697

696698

699+
def get_testcase_id(
700+
testcase: 'Testcase | TestcaseAttributes') -> int | str | None:
701+
"""Return the ID for a testcase or testcase attributes object."""
702+
# Importing here as 3P libs becomes accessible during runtime, after modules
703+
# path search is resolved (and logs may be imported before that).
704+
from google.cloud import ndb
705+
706+
if isinstance(testcase, ndb.Model):
707+
return testcase.key.id() # type: ignore
708+
return getattr(testcase, 'id', None)
709+
710+
697711
class GenericLogStruct(NamedTuple):
698712
pass
699713

@@ -729,6 +743,13 @@ class TestcaseLogStruct(NamedTuple):
729743
testcase_id: str
730744

731745

746+
class GrouperLogStruct(NamedTuple):
747+
testcase_1_id: str
748+
testcase_1_group: str | int
749+
testcase_2_id: str
750+
testcase_2_group: str | int
751+
752+
732753
class LogContextType(enum.Enum):
733754
"""Log context types.
734755
@@ -740,6 +761,7 @@ class LogContextType(enum.Enum):
740761
FUZZER = 'fuzzer'
741762
TESTCASE = 'testcase'
742763
CRON = 'cron'
764+
GROUPER = 'grouper'
743765

744766
def get_extras(self) -> NamedTuple:
745767
"""Get the structured log for a given context"""
@@ -793,15 +815,8 @@ def get_extras(self) -> NamedTuple:
793815

794816
if self == LogContextType.TESTCASE:
795817
try:
796-
testcase: 'Testcase | None' = log_contexts.meta.get('testcase')
797-
if not testcase:
798-
error(
799-
'Testcase not found in log context metadata.',
800-
ignore_context=True)
801-
return GenericLogStruct()
802-
803-
return TestcaseLogStruct(testcase_id=testcase.key.id()) # type: ignore
804-
818+
return TestcaseLogStruct(
819+
testcase_id=log_contexts.meta.get('testcase_id', 'null'))
805820
except:
806821
error(
807822
'Error retrieving context for testcase-based logs.',
@@ -819,6 +834,19 @@ def get_extras(self) -> NamedTuple:
819834
ignore_context=True)
820835
return GenericLogStruct()
821836

837+
if self == LogContextType.GROUPER:
838+
try:
839+
return GrouperLogStruct(
840+
testcase_1_id=log_contexts.meta.get('testcase_1_id', 'null'),
841+
testcase_2_id=log_contexts.meta.get('testcase_2_id', 'null'),
842+
testcase_1_group=log_contexts.meta.get('testcase_1_group', 'null'),
843+
testcase_2_group=log_contexts.meta.get('testcase_2_group', 'null'))
844+
except:
845+
error(
846+
'Error retrieving context for grouper-based logs.',
847+
ignore_context=True)
848+
return GenericLogStruct()
849+
822850
return GenericLogStruct()
823851

824852

@@ -925,7 +953,7 @@ def fuzzer_log_context(fuzzer_name: str, job_type: str,
925953

926954

927955
@contextlib.contextmanager
928-
def testcase_log_context(testcase: 'Testcase',
956+
def testcase_log_context(testcase: 'Testcase | TestcaseAttributes',
929957
fuzz_target: 'FuzzTarget | None'):
930958
"""Creates a testcase-based context for a given testcase.
931959
@@ -936,10 +964,12 @@ def testcase_log_context(testcase: 'Testcase',
936964
with wrap_log_context(
937965
contexts=[LogContextType.FUZZER, LogContextType.TESTCASE]):
938966
try:
939-
log_contexts.add_metadata('testcase', testcase) # type: ignore
967+
log_contexts.add_metadata('testcase', testcase)
940968
if testcase:
941-
log_contexts.add_metadata('fuzzer_name', testcase.fuzzer_name)
942-
log_contexts.add_metadata('job_type', testcase.job_type)
969+
log_contexts.add_metadata('testcase_id', get_testcase_id(testcase))
970+
log_contexts.add_metadata('fuzzer_name',
971+
testcase.fuzzer_name) # type: ignore
972+
log_contexts.add_metadata('job_type', testcase.job_type) # type: ignore
943973
if fuzz_target and fuzz_target.binary:
944974
fuzz_target_bin = fuzz_target.binary
945975
else:
@@ -955,16 +985,43 @@ def testcase_log_context(testcase: 'Testcase',
955985
raise e
956986
finally:
957987
log_contexts.delete_metadata('testcase')
988+
log_contexts.delete_metadata('testcase_id')
958989
log_contexts.delete_metadata('fuzzer_name')
959990
log_contexts.delete_metadata('job_type')
960991
log_contexts.delete_metadata('fuzz_target')
961992

962993

963994
@contextlib.contextmanager
964995
def cron_log_context():
996+
"""Creates a cronjob log context, mainly for triage/cleanup tasks."""
965997
with wrap_log_context(contexts=[LogContextType.CRON]):
966998
try:
967999
yield
9681000
except Exception as e:
9691001
warning(message='Error during cronjob context.')
9701002
raise e
1003+
1004+
1005+
@contextlib.contextmanager
1006+
def grouper_log_context(testcase_1: 'Testcase | TestcaseAttributes',
1007+
testcase_2: 'Testcase | TestcaseAttributes'):
1008+
"""Creates a grouper context for a given pair of testcases."""
1009+
with wrap_log_context(contexts=[LogContextType.GROUPER]):
1010+
try:
1011+
if testcase_1:
1012+
log_contexts.add_metadata('testcase_1_id', get_testcase_id(testcase_1))
1013+
log_contexts.add_metadata('testcase_1_group',
1014+
getattr(testcase_1, 'group_id', 0))
1015+
if testcase_2:
1016+
log_contexts.add_metadata('testcase_2_id', get_testcase_id(testcase_2))
1017+
log_contexts.add_metadata('testcase_2_group',
1018+
getattr(testcase_2, 'group_id', 0))
1019+
yield
1020+
except Exception as e:
1021+
warning(message='Error during grouper context.')
1022+
raise e
1023+
finally:
1024+
log_contexts.delete_metadata('testcase_1_id')
1025+
log_contexts.delete_metadata('testcase_2_id')
1026+
log_contexts.delete_metadata('testcase_1_group')
1027+
log_contexts.delete_metadata('testcase_2_group')

src/clusterfuzz/_internal/tests/core/metrics/logs_test.py

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,7 @@ def test_testcase_log_context(self):
606606
logs.log_contexts.meta, {
607607
'common_ctx': self.common_context,
608608
'testcase': testcase,
609+
'testcase_id': 1,
609610
'fuzz_target': fuzz_target.binary,
610611
'fuzzer_name': testcase.fuzzer_name,
611612
'job_type': testcase.job_type
@@ -772,6 +773,7 @@ def test_missing_fuzz_target_in_testcase_context(self):
772773
logs.log_contexts.meta, {
773774
'common_ctx': self.common_context,
774775
'testcase': testcase,
776+
'testcase_id': 1,
775777
'fuzz_target': testcase.get_metadata('fuzzer_binary_name'),
776778
'fuzzer_name': testcase.fuzzer_name,
777779
'job_type': testcase.job_type
@@ -790,7 +792,7 @@ def test_missing_fuzz_target_in_testcase_context(self):
790792
},
791793
})
792794

793-
def test_fuzzer_logs_context(self):
795+
def test_fuzzer_log_context(self):
794796
"""Test the correct logger call for the fuzzer-based log context."""
795797
from clusterfuzz._internal.datastore import data_types
796798
logger = mock.MagicMock()
@@ -832,7 +834,107 @@ def test_fuzzer_logs_context(self):
832834
'location': {
833835
'path': os.path.abspath(__file__).rstrip('c'),
834836
'line': statement_line,
835-
'method': 'test_fuzzer_logs_context'
837+
'method': 'test_fuzzer_log_context'
838+
},
839+
})
840+
841+
@logs.cron_log_context()
842+
def test_cron_log_context(self):
843+
"""Test the correct logger call for the cron-based log context."""
844+
from clusterfuzz._internal.system.environment import set_task_id_vars
845+
task_name = 'cleanup'
846+
task_id = '12345-6789'
847+
set_task_id_vars(task_name, task_id)
848+
849+
logger = mock.MagicMock()
850+
self.mock.get_logger.return_value = logger
851+
self.assertEqual(logs.log_contexts.contexts,
852+
[logs.LogContextType.COMMON, logs.LogContextType.CRON])
853+
logs_extra = {'target': 'bot', 'test': 'yes'}
854+
logs_extra.update(self.common_context)
855+
logs_extra.update({
856+
'task_id': task_id,
857+
'task_name': task_name,
858+
})
859+
statement_line = inspect.currentframe().f_lineno + 1
860+
logs.emit(logging.ERROR, 'msg', exc_info='ex', target='bot', test='yes')
861+
# Assert that the common context was added after the first logs call.
862+
self.assertEqual(logs.log_contexts.meta, {
863+
'common_ctx': self.common_context,
864+
})
865+
logger.log.assert_called_once_with(
866+
logging.ERROR,
867+
'msg',
868+
exc_info='ex',
869+
extra={
870+
'extras': logs_extra,
871+
'location': {
872+
'path': os.path.abspath(__file__).rstrip('c'),
873+
'line': statement_line,
874+
'method': 'test_cron_log_context'
875+
},
876+
})
877+
878+
@logs.cron_log_context()
879+
def test_grouper_log_context(self):
880+
"""Test the logger call and metadata for a grouper-based context."""
881+
from clusterfuzz._internal.cron.grouper import TestcaseAttributes
882+
from clusterfuzz._internal.datastore import data_types
883+
from clusterfuzz._internal.system.environment import set_task_id_vars
884+
task_name = 'triage'
885+
task_id = 'abcd-12345'
886+
set_task_id_vars(task_name, task_id)
887+
888+
testcase_1 = data_types.Testcase()
889+
testcase_2 = data_types.Testcase(group_id=112233)
890+
testcase_1.put()
891+
testcase_2.put()
892+
893+
testcase_1_attr = TestcaseAttributes(testcase_1.key.id())
894+
testcase_1_attr.group_id = testcase_1.group_id
895+
testcase_2_attr = TestcaseAttributes(testcase_2.key.id())
896+
testcase_2_attr.group_id = testcase_2.group_id
897+
898+
logger = mock.MagicMock()
899+
self.mock.get_logger.return_value = logger
900+
901+
with logs.grouper_log_context(testcase_1_attr, testcase_2_attr):
902+
self.assertEqual(logs.log_contexts.contexts, [
903+
logs.LogContextType.COMMON, logs.LogContextType.CRON,
904+
logs.LogContextType.GROUPER
905+
])
906+
statement_line = inspect.currentframe().f_lineno + 1
907+
logs.emit(logging.ERROR, 'msg', exc_info='ex', target='bot', test='yes')
908+
# Assert metadata after emit to ensure that `common_ctx` has been added.
909+
self.assertEqual(
910+
logs.log_contexts.meta, {
911+
'common_ctx': self.common_context,
912+
'testcase_1_id': 1,
913+
'testcase_2_id': 2,
914+
'testcase_1_group': 0,
915+
'testcase_2_group': 112233
916+
})
917+
918+
logs_extra = {'target': 'bot', 'test': 'yes'}
919+
logs_extra.update(self.common_context)
920+
logs_extra.update({
921+
'task_id': task_id,
922+
'task_name': task_name,
923+
'testcase_1_id': 1,
924+
'testcase_2_id': 2,
925+
'testcase_1_group': 0,
926+
'testcase_2_group': 112233
927+
})
928+
logger.log.assert_called_with(
929+
logging.ERROR,
930+
'msg',
931+
exc_info='ex',
932+
extra={
933+
'extras': logs_extra,
934+
'location': {
935+
'path': os.path.abspath(__file__).rstrip('c'),
936+
'line': statement_line,
937+
'method': 'test_grouper_log_context'
836938
},
837939
})
838940

0 commit comments

Comments
 (0)