Files
Clark Boylan 7e9649038a Fix data accuracy bugs
There are two data accuracy bugs fixed here. First we address the scroll
context ttl. It was only 1m which seems to not be enough for some of our
queries and we get inconsistent results. Bump that up to 10 minutes
which is what the opensearch api docs use. Note we try to clear the
scroll context when we are done but we don't appear to have enough
permissions for that so we leave the code commented out.

Then the other issue had to do with calculating the median patchsets per
review. We need to index into the sorted list not th raw list to find
the median. Fix that.

Change-Id: Iac0df1b476bf88552e829a3d1edb41790a09d99b
2025-05-29 14:15:58 -07:00

510 lines
17 KiB
Python

# Copyright OpenDev Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an "AS
# IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
# express or implied. See the License for the specific language
# governing permissions and limitations under the License.
import argparse
import copy
import datetime
import getpass
import math
# Install opensearch-py
from opensearchpy import OpenSearch
# Base query parameters to get human authored changes
BASE_CHANGES_QUERY = {
"query": {
"bool": {
# Should is logical OR
"should": [
],
# Must is logical AND
"must": [
],
# NOT(A OR B)
"must_not": [
],
"filter": [
{
"term": {
"type": "changeset"
}
},
{
"term": {
"author_bot": False
}
},
]
}
},
}
BASE_COMMENTS_QUERY = {
"query": {
"bool": {
# Should is logical OR
"should": [
],
# Must is logical AND
"must": [
],
# NOT(A OR B)
"must_not": [
],
"filter": [
{
"term": {
"type": "comment"
}
},
{
"term": {
"author_bot": False
}
},
]
}
},
}
BASE_APPROVALS_QUERY = {
"query": {
"bool": {
# Should is logical OR
"should": [
],
# Must is logical AND
"must": [
],
# NOT(A OR B)
"must_not": [
],
"filter": [
{
"term": {
"type": "approval"
}
},
{
"term": {
"author_bot": False
}
},
]
}
},
}
CLOSED_STATUS = [
{
"term": {
"status": "MERGED"
}
},
{
"term": {
"status": "ABANDONED"
}
},
]
MERGED_STATUS = [
{
"term": {
"status": "MERGED"
}
},
]
def set_date_range(start_date, end_date, key='created'):
if key == 'created':
date_range = {
"range": {
"grimoire_creation_date": {
"gte": start_date,
"lte": end_date,
"format": "strict_date_optional_time"
}
}
}
elif key == 'closed':
date_range = {
"range": {
"closed": {
"gte": start_date,
"lte": end_date,
"format": "strict_date_optional_time"
}
}
}
return date_range
def count_opened_changes(client, project_name, start_date, end_date):
date_range = set_date_range(start_date, end_date, key='created')
project_term = {
"term": {
"project": project_name
}
}
open_query = copy.deepcopy(BASE_CHANGES_QUERY)
open_query["query"]["bool"]["filter"].append(date_range)
open_query["query"]["bool"]["must"].append(project_term)
open_query["track_total_hits"] = True
open_query["size"] = 0
r = client.search(index='openstack_gerrit', body=open_query)
open_total = r['hits']['total']['value']
return open_total, None
def count_closed_changes(client, project_name, start_date, end_date):
date_range = set_date_range(start_date, end_date, key='closed')
project_term = {
"term": {
"project": project_name
}
}
closed_query = copy.deepcopy(BASE_CHANGES_QUERY)
closed_query["query"]["bool"]["should"] = CLOSED_STATUS
closed_query["query"]["bool"]["filter"].append(date_range)
closed_query["query"]["bool"]["must"].append(project_term)
closed_query["track_total_hits"] = True
closed_query["size"] = 0
r = client.search(index='openstack_gerrit', body=closed_query)
closed_total = r['hits']['total']['value']
return closed_total, None
def calculate_review_efficiency(client, project_name, start_date, end_date):
date_range = set_date_range(start_date, end_date, key='closed')
project_term = {
"term": {
"project": project_name
}
}
closed_query = copy.deepcopy(BASE_CHANGES_QUERY)
closed_query["query"]["bool"]["should"] = CLOSED_STATUS
closed_query["query"]["bool"]["filter"].append(date_range)
closed_query["query"]["bool"]["must"].append(project_term)
closed_query["track_total_hits"] = True
closed_query["size"] = 0
date_range = set_date_range(start_date, end_date, key='created')
open_query = copy.deepcopy(BASE_CHANGES_QUERY)
open_query["query"]["bool"]["filter"].append(date_range)
open_query["query"]["bool"]["must"].append(project_term)
open_query["track_total_hits"] = True
open_query["size"] = 0
r = client.search(index='openstack_gerrit', body=closed_query)
closed_total = r['hits']['total']['value']
r = client.search(index='openstack_gerrit', body=open_query)
open_total = r['hits']['total']['value']
rei = float(closed_total / open_total)
return rei, None
def calculate_merge_time(client, project_name, start_date, end_date):
date_range = set_date_range(start_date, end_date, key='closed')
project_term = {
"term": {
"project": project_name
}
}
closed_query = copy.deepcopy(BASE_CHANGES_QUERY)
closed_query["query"]["bool"]["should"] = MERGED_STATUS
closed_query["query"]["bool"]["filter"].append(date_range)
closed_query["query"]["bool"]["must"].append(project_term)
closed_query["size"] = 100
times_to_merge = []
r = client.search(index='openstack_gerrit',
scroll='10m', body=closed_query)
hits = r['hits']['hits']
while hits:
for hit in hits:
open_date = datetime.datetime.fromisoformat(
hit["_source"]["grimoire_creation_date"])
close_date = datetime.datetime.fromisoformat(
hit["_source"]["closed"])
times_to_merge.append(close_date - open_date)
# Beware normal pagination. Results are not consistent.
# Use scroll instead
r = client.scroll(scroll_id=r["_scroll_id"], scroll="10m")
hits = r['hits']['hits']
# TODO(clarkb) currently lacking permissions to do this.
# client.clear_scroll(scroll_id=r["_scroll_id"])
times_sorted = sorted(times_to_merge)
middle = math.floor(len(times_to_merge) / 2)
median = times_sorted[middle]
average_seconds = \
sum(map(lambda x: x.total_seconds(), times_sorted)) / len(times_sorted)
average = datetime.timedelta(seconds=average_seconds)
return median, average
def calculate_time_to_review(client, project_name, start_date, end_date):
date_range = set_date_range(start_date, end_date, key='closed')
project_term = {
"term": {
"project": project_name
}
}
closed_query = copy.deepcopy(BASE_CHANGES_QUERY)
closed_query["query"]["bool"]["should"] = MERGED_STATUS
closed_query["query"]["bool"]["filter"].append(date_range)
closed_query["query"]["bool"]["must"].append(project_term)
closed_query["size"] = 100
times_to_review = []
r = client.search(index='openstack_gerrit',
scroll='10m', body=closed_query)
hits = r['hits']['hits']
while hits:
for hit in hits:
# Note time_to_first_review appears to be storing a float count
# of the number of days to the first review. This is an odd
# way to store the value so I'm documenting it here.
time_to_first_review = hit["_source"]["time_to_first_review"]
if time_to_first_review:
# We can apparently get None values back. Ignore them.
times_to_review.append(time_to_first_review)
# Beware normal pagination. Results are not consistent.
# Use scroll instead
r = client.scroll(scroll_id=r["_scroll_id"], scroll="10m")
hits = r['hits']['hits']
# TODO(clarkb) currently lacking permissions to do this.
# client.clear_scroll(scroll_id=r["_scroll_id"])
times_sorted = sorted(times_to_review)
middle = math.floor(len(times_to_review) / 2)
median = datetime.timedelta(days=times_sorted[middle])
average = datetime.timedelta(days=sum(times_sorted) / len(times_sorted))
return median, average
def calculate_patchset_per_review(client, project_name, start_date, end_date):
date_range = set_date_range(start_date, end_date, key='closed')
project_term = {
"term": {
"project": project_name
}
}
closed_query = copy.deepcopy(BASE_CHANGES_QUERY)
closed_query["query"]["bool"]["should"] = CLOSED_STATUS
closed_query["query"]["bool"]["filter"].append(date_range)
closed_query["query"]["bool"]["must"].append(project_term)
closed_query["size"] = 100
patchsets_list = []
r = client.search(index='openstack_gerrit',
scroll='10m', body=closed_query)
hits = r['hits']['hits']
while hits:
for hit in hits:
patchsets = hit["_source"]["patchsets"]
patchsets_list.append(patchsets)
# Beware normal pagination. Results are not consistent.
# Use scroll instead
r = client.scroll(scroll_id=r["_scroll_id"], scroll="10m")
hits = r['hits']['hits']
# TODO(clarkb) currently lacking permissions to do this.
# client.clear_scroll(scroll_id=r["_scroll_id"])
patchsets_sorted = sorted(patchsets_list)
middle = math.floor(len(patchsets_list) / 2)
median = patchsets_sorted[middle]
average = sum(patchsets_sorted) / len(patchsets_sorted)
return median, average
def collect_reviewers(client, project_name, start_date, end_date):
date_range = set_date_range(start_date, end_date, key='created')
project_term = {
"term": {
"project": project_name
}
}
query = copy.deepcopy(BASE_COMMENTS_QUERY)
query["query"]["bool"]["filter"].append(date_range)
query["query"]["bool"]["must"].append(project_term)
query["size"] = 100
reviewers = set()
r = client.search(index='openstack_gerrit', scroll='10m', body=query)
hits = r['hits']['hits']
while hits:
for hit in hits:
reviewer = hit["_source"]["reviewer_id"]
reviewers.add(reviewer)
# Beware normal pagination. Results are not consistent.
# Use scroll instead
r = client.scroll(scroll_id=r["_scroll_id"], scroll="10m")
hits = r['hits']['hits']
# TODO(clarkb) currently lacking permissions to do this.
# client.clear_scroll(scroll_id=r["_scroll_id"])
return len(reviewers), None
def collect_maintainers(client, project_name, start_date, end_date):
date_range = set_date_range(start_date, end_date, key='created')
project_term = {
"term": {
"project": project_name
}
}
query = copy.deepcopy(BASE_APPROVALS_QUERY)
query["query"]["bool"]["filter"].append(date_range)
query["query"]["bool"]["must"].append(project_term)
query["size"] = 100
maintainers = set()
r = client.search(index='openstack_gerrit', scroll='10m', body=query)
hits = r['hits']['hits']
while hits:
for hit in hits:
maintainer = hit["_source"]["author_id"]
if hit["_source"]["approval_type"] == "Code-Review":
if hit["_source"]["approval_value"] in ["-2", "2"]:
maintainers.add(maintainer)
elif hit["_source"]["approval_type"] == "Workflow":
if hit["_source"]["approval_value"] == "1":
maintainers.add(maintainer)
# Beware normal pagination. Results are not consistent.
# Use scroll instead
r = client.scroll(scroll_id=r["_scroll_id"], scroll="10m")
hits = r['hits']['hits']
# TODO(clarkb) currently lacking permissions to do this.
# client.clear_scroll(scroll_id=r["_scroll_id"])
return len(maintainers), None
QUERIES = {
"rei": calculate_review_efficiency,
"time-to-merge": calculate_merge_time,
"time-to-review": calculate_time_to_review,
"patchset-per-review": calculate_patchset_per_review,
"opened-changes": count_opened_changes,
"closed-changes": count_closed_changes,
"reviewers": collect_reviewers,
"maintainers": collect_maintainers,
}
def gather_metrics(client, args):
if args.csv:
print("metric,project,starttime,endtime,value")
projects = [p for p in args.project.split(',') if p]
if args.query == "ALL":
queries = QUERIES.items()
else:
queries = [(args.query, QUERIES[args.query])]
for query, func in queries:
for project in projects:
median, average = func(
client, project, args.start_date, args.end_date)
if args.csv:
if isinstance(median, datetime.timedelta):
median = median.total_seconds()
if isinstance(average, datetime.timedelta):
average = average.total_seconds()
if not average:
# Some values are singletons overload use of median
print("%s,%s,%s,%s,%s" %
(query, project,
args.start_date, args.end_date, median))
else:
print("median-%s,%s,%s,%s,%s" %
(query, project,
args.start_date, args.end_date, median))
print("average-%s,%s,%s,%s,%s" %
(query, project,
args.start_date, args.end_date, average))
else:
if not average:
# Some values are singletons overload use of median
print("%s %s %s to %s: %s" %
(project, query,
args.start_date, args.end_date, median))
else:
print("%s median %s %s to %s: %s" %
(project, query,
args.start_date, args.end_date, median))
print("%s average %s %s to %s: %s" %
(project, query,
args.start_date, args.end_date, average))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--host",
help="Bitergia opensearch host to connect to",
default="openstack.biterg.io")
parser.add_argument("--port",
help="Bitergia opensearch port to connect to",
type=int, default=443)
parser.add_argument("--url-prefix",
help="Bitergia opensearch url prefix to connect to",
default='data')
parser.add_argument("--user",
help="Bitergia opensearch username", required=True)
parser.add_argument("--query",
help="Metric to query",
default='ALL')
parser.add_argument("--csv", help="Emit csv output", action="store_true")
parser.add_argument("project",
help="Project to filter results for. This must "
"match bitergia's idea of a project name. May "
"be a comma separated list.")
parser.add_argument("start_date",
help="Start date for results. "
"eg 2025-01-01T00:00:00.000Z")
parser.add_argument("end_date",
help="End date for results. "
"eg 2025-01-01T00:00:00.000Z")
args = parser.parse_args()
host = args.host
port = args.port
url_prefix = args.url_prefix
passwd = getpass.getpass('Password: ')
auth = (args.user, passwd)
# indexes appear to be openstack_git, openstack_gerrit, openstack_mbox
client = OpenSearch(
hosts=[{'host': host, 'port': port}],
url_prefix=url_prefix,
http_compress=True, # enables gzip compression for request bodies
http_auth=auth,
use_ssl=True,
verify_certs=True,
)
gather_metrics(client, args)
if __name__ == "__main__":
main()