Skip to content

Commit 30cbb14

Browse files
committed
Python: Add telemetry for parser usage
Adds statistics on how many files were extracted using the old parser and using the tree-sitter parser. Because parsing is done in parallel across many workers, I opted not to consolidate these statistics for the entire run. Instead, we emit the statistics for each worker and then need to aggregate themselves after the telemetry has been ingested. (In practice the number of workers is ~16 at most, so is unlikely to be an issue.) In terms of implementation, I opted to simply extend the existing `DiagnosticsWriter` object (instantiatied once per worker) with methods for counting the number of parsed files, and then thread this object through to `modules.py` where the magic happens. Finally, this also required instantiating such an object in cases where we call directly into the extractor for debugging purposes (e.g. dumping the AST or CFG). Note that in these cases we do not actually print any diagnostics, so it's harmless to create these objects.
1 parent 8116060 commit 30cbb14

10 files changed

Lines changed: 176 additions & 10 deletions

File tree

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,31 @@
11
import os
22
import sys
3+
import glob
4+
import json
35
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "..", "integration-tests"))
46
import diagnostics_test_utils
57

68
test_db = "db"
7-
diagnostics_test_utils.check_diagnostics(".", test_db, skip_attributes=True)
9+
diagnostics = []
10+
diagnostic_dir = os.path.join(test_db, "diagnostic", "extractors", "python")
11+
for path in glob.glob(os.path.join(diagnostic_dir, "*.jsonl")):
12+
with open(path) as diagnostic_file:
13+
diagnostics.extend(json.loads(line) for line in diagnostic_file)
14+
parser_statistics = [
15+
diagnostic
16+
for diagnostic in diagnostics
17+
if diagnostic["source"]["id"] == "py/extractor/parser-statistics"
18+
]
19+
assert sum(
20+
diagnostic["attributes"]["old_parser_file_count"]
21+
+ diagnostic["attributes"]["tree_sitter_parser_file_count"]
22+
for diagnostic in parser_statistics
23+
) == 2
24+
diagnostics = [
25+
diagnostic
26+
for diagnostic in diagnostics
27+
if diagnostic["source"]["id"] != "py/extractor/parser-statistics"
28+
]
29+
diagnostics_test_utils.check_diagnostics(
30+
".", test_db, actual=json.dumps(diagnostics), skip_attributes=True
31+
)

python/extractor/semmle/extractors/module_printer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ class ModulePrinter(object):
66

77
name = "module printer"
88

9-
def __init__(self, options, trap_folder, src_archive, renamer, logger):
9+
def __init__(self, options, trap_folder, src_archive, renamer, logger, diagnostics_writer):
1010
self.logger = logger
11-
self.py_extractor = PythonExtractor(options, trap_folder, src_archive, logger)
11+
self.py_extractor = PythonExtractor(options, trap_folder, src_archive, logger, diagnostics_writer)
1212

1313
def process(self, unit):
1414
imports = ()

python/extractor/semmle/extractors/py_extractor.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def __init__(self, options, trap_folder, src_archive, logger: Logger, diagnostic
1616
self.module_extractor = extractor.Extractor.from_options(options, trap_folder, src_archive, logger, diagnostics_writer)
1717
self.finder = finder.Finder.from_options_and_env(options, logger)
1818
self.importer = imports.importer_from_options(options, self.finder, logger)
19+
self.diagnostics_writer = diagnostics_writer
1920

2021
def _get_module_and_imports(self, unit):
2122
if not isinstance(unit, util.FileExtractable):
@@ -24,7 +25,7 @@ def _get_module_and_imports(self, unit):
2425
module = self.finder.from_extractable(unit)
2526
if module is None:
2627
return None, ()
27-
py_module = module.load(self.logger)
28+
py_module = module.load(self.logger, self.diagnostics_writer)
2829
if py_module is None:
2930
return None, ()
3031
imports = set(mod.get_extractable() for mod in self.importer.get_imports(module, py_module))

python/extractor/semmle/logging.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,14 @@ def extractor_telemetry_message():
367367
.telemetry()
368368
)
369369

370+
def parser_statistics_telemetry_message(old_parser_file_count, tree_sitter_parser_file_count):
371+
return (DiagnosticMessage(Source("py/extractor/parser-statistics", "Python parser statistics"), Severity.NOTE)
372+
.markdown("Internal parser telemetry for the Python extractor.\n\nNo action needed.")
373+
.attribute("old_parser_file_count", old_parser_file_count)
374+
.attribute("tree_sitter_parser_file_count", tree_sitter_parser_file_count)
375+
.telemetry()
376+
)
377+
370378
def get_stack_trace_lines():
371379
"""Creates a stack trace for inclusion into the `attributes` part of a diagnostic message.
372380
Limits the size of the stack trace to 5000 characters, so as to not make the SARIF file overly big.

python/extractor/semmle/python/finder.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ def all_sub_modules(self):
6565
def get_extractable(self):
6666
return FileExtractable(self.path)
6767

68-
def load(self, logger=None):
69-
return PythonSourceModule(self.name, self.path, logger=logger)
68+
def load(self, logger, diagnostics_writer):
69+
return PythonSourceModule(self.name, self.path, logger=logger, diagnostics_writer=diagnostics_writer)
7070

7171
def __str__(self):
7272
return "Python module at %s" % self.path

python/extractor/semmle/python/modules.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class PythonSourceModule(object):
1818

1919
kind = None
2020

21-
def __init__(self, name, path, logger, bytes_source = None):
21+
def __init__(self, name, path, logger, diagnostics_writer, bytes_source = None):
2222
assert isinstance(path, str), path
2323
self.name = name # May be None
2424
self.path = path
@@ -34,6 +34,7 @@ def __init__(self, name, path, logger, bytes_source = None):
3434
self._line_types = None
3535
self._comments = None
3636
self._tokens = None
37+
self.diagnostics_writer = diagnostics_writer
3738
self.logger = logger
3839
with timers["decode"]:
3940
self.encoding, self.bytes_source = semmle.python.parser.tokenizer.encoding_from_source(bytes_source)
@@ -113,6 +114,7 @@ def old_py_ast(self):
113114
self.logger.debug("Trying old parser on %s", self.path)
114115
self._py_ast = semmle.python.parser.parse(self.tokens, self.logger)
115116
self.logger.debug("Old parser successful on %s", self.path)
117+
self.diagnostics_writer.record_old_parser()
116118
else:
117119
self.logger.debug("Found (during old_py_ast) parse tree for %s in cache", self.path)
118120
return self._py_ast
@@ -147,6 +149,7 @@ def py_ast(self):
147149
self.logger.debug("Trying tsg-python on %s", self.path)
148150
self._py_ast = semmle.python.parser.tsg_parser.parse(self.path, self.logger)
149151
self.logger.debug("tsg-python successful on %s", self.path)
152+
self.diagnostics_writer.record_tree_sitter_parser()
150153
else:
151154
self.logger.debug("Found (during py_ast) parse tree for %s in cache", self.path)
152155
return self._py_ast

python/extractor/semmle/python/parser/dump_ast.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ def reset_error_count(self):
119119
self.error_count = 0
120120

121121
def old_parser(inputfile, logger):
122-
mod = PythonSourceModule(None, inputfile, logger)
122+
from semmle.worker import DiagnosticsWriter
123+
mod = PythonSourceModule(None, inputfile, logger, DiagnosticsWriter(0))
123124
logger.close()
124125
return mod.old_py_ast
125126

python/extractor/semmle/python/passes/flow.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1916,7 +1916,8 @@ def write_ssa_phi(out, phi, arg):
19161916
import semmle.python.parser.tsg_parser
19171917
parsed_ast = semmle.python.parser.tsg_parser.parse(inputfile, FakeLogger())
19181918
else:
1919-
module = modules.PythonSourceModule("__main__", inputfile, FakeLogger())
1919+
from semmle.worker import DiagnosticsWriter
1920+
module = modules.PythonSourceModule("__main__", inputfile, FakeLogger(), DiagnosticsWriter(0))
19201921
parsed_ast = module.ast
19211922
FlowPass(options.split, options.prune, options.unroll).extract(parsed_ast, writer)
19221923
writer.close()

python/extractor/semmle/worker.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from semmle.profiling import get_profiler
1212
from semmle.path_rename import renamer_from_options_and_env
1313
from semmle.logging import WARN, recursion_error_message, internal_error_message, extractor_telemetry_message, Logger
14+
from semmle.logging import parser_statistics_telemetry_message
1415
from semmle.util import FileExtractable, FolderExtractable
1516

1617
class ExtractorFailure(Exception):
@@ -245,9 +246,29 @@ def _write_extractor_telemetry(diagnostics_writer, logger: Logger):
245246
except OSError as ex:
246247
logger.warning("Failed to write extractor telemetry: %s", ex)
247248

249+
def _write_parser_statistics_telemetry(diagnostics_writer, logger: Logger):
250+
counts = diagnostics_writer.parser_statistics()
251+
if counts == (0, 0):
252+
return
253+
try:
254+
diagnostics_writer.write(parser_statistics_telemetry_message(*counts))
255+
except OSError as ex:
256+
logger.warning("Failed to write parser statistics telemetry: %s", ex)
257+
248258
class DiagnosticsWriter(object):
249259
def __init__(self, proc_id):
250260
self.proc_id = proc_id
261+
self.old_parser_file_count = 0
262+
self.tree_sitter_parser_file_count = 0
263+
264+
def record_old_parser(self):
265+
self.old_parser_file_count += 1
266+
267+
def record_tree_sitter_parser(self):
268+
self.tree_sitter_parser_file_count += 1
269+
270+
def parser_statistics(self):
271+
return self.old_parser_file_count, self.tree_sitter_parser_file_count
251272

252273
def write(self, message):
253274
dir = os.environ.get("CODEQL_EXTRACTOR_PYTHON_DIAGNOSTIC_DIR")
@@ -286,7 +307,7 @@ def _extract_loop(proc_id, queue, trap_dir, archive, options, reply_queue, logge
286307
_write_extractor_telemetry(diagnostics_writer, logger)
287308
try:
288309
if options.trace_only:
289-
extractor = ModulePrinter(options, trap_dir, archive, renamer, logger)
310+
extractor = ModulePrinter(options, trap_dir, archive, renamer, logger, diagnostics_writer)
290311
else:
291312
extractor = SuperExtractor(options, trap_dir, archive, renamer, logger, diagnostics_writer)
292313
profiler = get_profiler(options, id, logger)
@@ -299,6 +320,7 @@ def _extract_loop(proc_id, queue, trap_dir, archive, options, reply_queue, logge
299320
if write_global_data:
300321
extractor.write_global_data()
301322
extractor.close()
323+
_write_parser_statistics_telemetry(diagnostics_writer, logger)
302324
return
303325
try:
304326
start = time.time()
@@ -352,4 +374,5 @@ def _extract_loop(proc_id, queue, trap_dir, archive, options, reply_queue, logge
352374
except _Empty:
353375
#Cleared queue enough to avoid deadlock.
354376
pass
377+
_write_parser_statistics_telemetry(diagnostics_writer, logger)
355378
sys.exit(2)

python/extractor/tests/test_diagnostics.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from semmle import logging
44
from semmle import util
55
from semmle import worker
6+
from semmle.python.modules import PythonSourceModule
67

78

89
def test_extractor_telemetry_message(mocker):
@@ -32,13 +33,44 @@ def test_extractor_telemetry_message(mocker):
3233
}
3334

3435

36+
def test_parser_statistics_telemetry_message():
37+
message = logging.parser_statistics_telemetry_message(
38+
old_parser_file_count=12, tree_sitter_parser_file_count=3
39+
).to_dict()
40+
message.pop("timestamp")
41+
42+
assert message == {
43+
"source": {
44+
"id": "py/extractor/parser-statistics",
45+
"name": "Python parser statistics",
46+
"extractorName": "python",
47+
},
48+
"severity": "note",
49+
"markdownMessage": "Internal parser telemetry for the Python extractor.\n\nNo action needed.",
50+
"visibility": {
51+
"statusPage": False,
52+
"cliSummaryTable": False,
53+
"telemetry": True,
54+
},
55+
"attributes": {
56+
"old_parser_file_count": 12,
57+
"tree_sitter_parser_file_count": 3,
58+
},
59+
}
60+
61+
3562
def test_write_extractor_telemetry(mocker):
3663
diagnostics_writer = mocker.Mock()
3764
logger = mocker.Mock()
3865

3966
worker._write_extractor_telemetry(diagnostics_writer, logger)
4067

4168
diagnostics_writer.write.assert_called_once()
69+
assert diagnostics_writer.write.call_args.args[0].to_dict()["attributes"] == {
70+
"python_analysis_version": util.get_analysis_version(),
71+
"python_runtime_version": platform.python_version(),
72+
"extractor_version": util.VERSION,
73+
}
4274
logger.warning.assert_not_called()
4375

4476

@@ -52,3 +84,76 @@ def test_write_extractor_telemetry_handles_io_error(mocker):
5284
logger.warning.assert_called_once_with(
5385
"Failed to write extractor telemetry: %s", diagnostics_writer.write.side_effect
5486
)
87+
88+
89+
def test_write_parser_statistics_telemetry(mocker):
90+
diagnostics_writer = mocker.Mock()
91+
diagnostics_writer.parser_statistics.return_value = (1, 1)
92+
logger = mocker.Mock()
93+
94+
worker._write_parser_statistics_telemetry(diagnostics_writer, logger)
95+
96+
diagnostics_writer.write.assert_called_once()
97+
assert diagnostics_writer.write.call_args.args[0].to_dict()["attributes"] == {
98+
"old_parser_file_count": 1,
99+
"tree_sitter_parser_file_count": 1,
100+
}
101+
logger.warning.assert_not_called()
102+
103+
104+
def test_does_not_write_empty_parser_statistics_telemetry(mocker):
105+
diagnostics_writer = mocker.Mock()
106+
diagnostics_writer.parser_statistics.return_value = (0, 0)
107+
logger = mocker.Mock()
108+
109+
worker._write_parser_statistics_telemetry(diagnostics_writer, logger)
110+
111+
diagnostics_writer.write.assert_not_called()
112+
logger.warning.assert_not_called()
113+
114+
115+
def test_records_old_parser_usage_once(mocker, monkeypatch):
116+
monkeypatch.delenv("CODEQL_PYTHON_DISABLE_OLD_PARSER", raising=False)
117+
monkeypatch.delenv("CODEQL_PYTHON_DISABLE_TSG_PARSER", raising=False)
118+
old_ast = object()
119+
mocker.patch("semmle.python.parser.parse", return_value=old_ast)
120+
diagnostics_writer = worker.DiagnosticsWriter(1)
121+
module = PythonSourceModule(
122+
None,
123+
"test.py",
124+
mocker.Mock(),
125+
diagnostics_writer,
126+
bytes_source=b"x = 1\n",
127+
)
128+
129+
parsed_ast = module.py_ast
130+
# Access the cached AST again to verify that it is not counted twice.
131+
_ = module.py_ast
132+
133+
assert parsed_ast is old_ast
134+
assert diagnostics_writer.parser_statistics() == (1, 0)
135+
136+
137+
def test_records_tree_sitter_parser_usage_once(mocker, monkeypatch):
138+
monkeypatch.delenv("CODEQL_PYTHON_DISABLE_OLD_PARSER", raising=False)
139+
monkeypatch.delenv("CODEQL_PYTHON_DISABLE_TSG_PARSER", raising=False)
140+
tree_sitter_ast = object()
141+
mocker.patch("semmle.python.parser.parse", side_effect=SyntaxError("old parser failed"))
142+
mocker.patch(
143+
"semmle.python.parser.tsg_parser.parse", return_value=tree_sitter_ast
144+
)
145+
diagnostics_writer = worker.DiagnosticsWriter(1)
146+
module = PythonSourceModule(
147+
None,
148+
"test.py",
149+
mocker.Mock(),
150+
diagnostics_writer,
151+
bytes_source=b"x = 1\n",
152+
)
153+
154+
parsed_ast = module.py_ast
155+
# Access the cached AST again to verify that it is not counted twice.
156+
_ = module.py_ast
157+
158+
assert parsed_ast is tree_sitter_ast
159+
assert diagnostics_writer.parser_statistics() == (0, 1)

0 commit comments

Comments
 (0)