Skip to content

Commit 866c822

Browse files
committed
Collect build data
1 parent f0037b4 commit 866c822

File tree

15 files changed

+799
-42
lines changed

15 files changed

+799
-42
lines changed

readthedocs/api/v2/views/model_views.py

+13-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""Endpoints for listing Projects, Versions, Builds, etc."""
22

33
import json
4-
import structlog
54

5+
import structlog
66
from allauth.socialaccount.models import SocialAccount
77
from django.conf import settings
88
from django.db.models import BooleanField, Case, Value, When
@@ -19,6 +19,7 @@
1919
from readthedocs.oauth.services import GitHubService, registry
2020
from readthedocs.projects.models import Domain, Project
2121
from readthedocs.storage import build_commands_storage
22+
from readthedocs.telemetry.models import BuildData
2223

2324
from ..permissions import APIPermission, APIRestrictedPermission, IsOwner
2425
from ..serializers import (
@@ -285,6 +286,17 @@ def reset(self, request, **kwargs):
285286
instance.reset()
286287
return Response(status=status.HTTP_204_NO_CONTENT)
287288

289+
@decorators.action(
290+
detail=True,
291+
permission_classes=[permissions.IsAdminUser],
292+
methods=["post"],
293+
)
294+
def telemetry(self, request, **kwargs):
295+
"""Collect telemetry data from the build."""
296+
build = self.get_object()
297+
BuildData.objects.collect(build, request.data)
298+
return Response(status=status.HTTP_204_NO_CONTENT)
299+
288300

289301
class BuildCommandViewSet(DisableListEndpoint, UserSelectViewSet):
290302
parser_classes = [JSONParser, MultiPartParser]

readthedocs/builds/admin.py

+4-35
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,7 @@
11
"""Django admin interface for `~builds.models.Build` and related models."""
22

3-
import json
43
from django.contrib import admin, messages
5-
from django.utils.safestring import mark_safe
6-
from polymorphic.admin import (
7-
PolymorphicChildModelAdmin,
8-
PolymorphicParentModelAdmin,
9-
)
10-
11-
from pygments import highlight
12-
from pygments.lexers import JsonLexer
13-
from pygments.formatters import HtmlFormatter
4+
from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicParentModelAdmin
145

156
from readthedocs.builds.models import (
167
Build,
@@ -20,33 +11,11 @@
2011
VersionAutomationRule,
2112
)
2213
from readthedocs.core.utils import trigger_build
14+
from readthedocs.core.utils.admin import pretty_json_field
2315
from readthedocs.projects.models import HTMLFile
2416
from readthedocs.search.utils import _indexing_helper
2517

2618

27-
def _pretty_config(instance):
28-
"""
29-
Function to display pretty version of our data.
30-
31-
Thanks to PyDanny: https://www.pydanny.com/pretty-formatting-json-django-admin.html
32-
"""
33-
34-
# Convert the data to sorted, indented JSON
35-
response = json.dumps(instance.config, sort_keys=True, indent=2)
36-
37-
# Get the Pygments formatter
38-
formatter = HtmlFormatter()
39-
40-
# Highlight the data
41-
response = highlight(response, JsonLexer(), formatter)
42-
43-
# Get the stylesheet
44-
style = "<style>" + formatter.get_style_defs() + "</style><br>"
45-
46-
# Safe the output
47-
return mark_safe(style + response)
48-
49-
5019
class BuildCommandResultInline(admin.TabularInline):
5120
model = BuildCommandResult
5221
fields = ('command', 'exit_code', 'output')
@@ -96,7 +65,7 @@ def version_slug(self, obj):
9665
return obj.version.slug
9766

9867
def pretty_config(self, instance):
99-
return _pretty_config(instance)
68+
return pretty_json_field(instance, "config")
10069

10170
pretty_config.short_description = 'Config File'
10271

@@ -123,7 +92,7 @@ def project_slug(self, obj):
12392
return obj.project.slug
12493

12594
def pretty_config(self, instance):
126-
return _pretty_config(instance)
95+
return pretty_json_field(instance, "config")
12796

12897
pretty_config.short_description = 'Config File'
12998

readthedocs/config/config.py

+1
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ class BuildConfigBase:
180180
def __init__(self, env_config, raw_config, source_file):
181181
self.env_config = env_config
182182
self._raw_config = copy.deepcopy(raw_config)
183+
self.source_config = copy.deepcopy(raw_config)
183184
self.source_file = source_file
184185
if os.path.isdir(self.source_file):
185186
self.base_path = self.source_file

readthedocs/core/utils/admin.py

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import json
2+
3+
from django.utils.safestring import mark_safe
4+
from pygments import highlight
5+
from pygments.formatters import HtmlFormatter
6+
from pygments.lexers import JsonLexer
7+
8+
9+
def pretty_json_field(instance, field):
10+
"""
11+
Display a pretty version of a JSON field in the admin.
12+
13+
Thanks to PyDanny: https://www.pydanny.com/pretty-formatting-json-django-admin.html
14+
"""
15+
# Convert the data to sorted, indented JSON
16+
response = json.dumps(getattr(instance, field), sort_keys=True, indent=2)
17+
18+
# Get the Pygments formatter
19+
formatter = HtmlFormatter()
20+
21+
# Highlight the data
22+
response = highlight(response, JsonLexer(), formatter)
23+
24+
# Get the stylesheet
25+
style = "<style>" + formatter.get_style_defs() + "</style><br>"
26+
27+
# Safe the output
28+
return mark_safe(style + response)

readthedocs/projects/tasks/builds.py

+40-6
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
YAMLParseError,
4747
)
4848
from readthedocs.storage import build_media_storage
49+
from readthedocs.telemetry.collectors import BuildDataCollector
4950
from readthedocs.worker import app
5051

5152
from ..exceptions import (
@@ -341,6 +342,8 @@ def before_start(self, task_id, args, kwargs):
341342
# Reset any previous build error reported to the user
342343
self.data.build['error'] = ''
343344

345+
self.data.build_data = None
346+
344347
# Also note there are builds that are triggered without a commit
345348
# because they just build the latest commit for that version
346349
self.data.build_commit = kwargs.get('build_commit')
@@ -535,6 +538,7 @@ def after_return(self, status, retval, task_id, args, kwargs, einfo):
535538
self.data.build['length'] = (timezone.now() - self.data.start_time).seconds
536539

537540
self.update_build(BUILD_STATE_FINISHED)
541+
self.upload_build_data()
538542

539543
build_complete.send(sender=Build, build=self.data.build)
540544

@@ -590,13 +594,43 @@ def execute(self):
590594
# ``__exit__``
591595
self.data.build_director.create_build_environment()
592596
with self.data.build_director.build_environment:
593-
# Installing
594-
self.update_build(state=BUILD_STATE_INSTALLING)
595-
self.data.build_director.setup_environment()
597+
try:
598+
# Installing
599+
self.update_build(state=BUILD_STATE_INSTALLING)
600+
self.data.build_director.setup_environment()
601+
602+
# Building
603+
self.update_build(state=BUILD_STATE_BUILDING)
604+
self.data.build_director.build()
605+
finally:
606+
self.data.build_data = self.collect_build_data()
607+
608+
def collect_build_data(self):
609+
"""
610+
Collect data from the current build.
611+
612+
The data is collected from inside the container,
613+
to this must be called before killing the container.
614+
"""
615+
try:
616+
return BuildDataCollector(
617+
self.data.build_director.build_environment
618+
).collect()
619+
except Exception:
620+
log.exception("Error while collecting build data")
621+
622+
def upload_build_data(self):
623+
"""
624+
Upload data collected from the build after the build has ended.
596625
597-
# Building
598-
self.update_build(state=BUILD_STATE_BUILDING)
599-
self.data.build_director.build()
626+
This must be called after the build has finished updating its state,
627+
otherwise some attributes like ``length`` won't be available.
628+
"""
629+
try:
630+
if self.data.build_data:
631+
api_v2.build(self.data.build_pk).telemetry.post(self.data.build_data)
632+
except Exception:
633+
log.exception("Error while uploading build data")
600634

601635
@staticmethod
602636
def get_project(project_pk):

readthedocs/settings/base.py

+1
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ def INSTALLED_APPS(self): # noqa
198198
'readthedocs.sphinx_domains',
199199
'readthedocs.search',
200200
'readthedocs.embed',
201+
'readthedocs.telemetry',
201202

202203
# allauth
203204
'allauth',

readthedocs/telemetry/__init__.py

Whitespace-only changes.

readthedocs/telemetry/admin.py

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Telemetry admin."""
2+
3+
4+
from django.contrib import admin
5+
6+
from readthedocs.core.utils.admin import pretty_json_field
7+
from readthedocs.telemetry.models import BuildData
8+
9+
10+
@admin.register(BuildData)
11+
class BuildDataAdmin(admin.ModelAdmin):
12+
13+
fields = ("pretty_data",)
14+
readonly_fields = ("pretty_data",)
15+
16+
# pylint: disable=no-self-use
17+
def pretty_data(self, instance):
18+
return pretty_json_field(instance, "data")

readthedocs/telemetry/apps.py

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""
2+
Telemetry application.
3+
4+
Collect relevant data to be analyzed later.
5+
"""
6+
7+
from django.apps import AppConfig
8+
9+
10+
class TelemetryConfig(AppConfig):
11+
default_auto_field = "django.db.models.BigAutoField"
12+
name = "readthedocs.telemetry"

0 commit comments

Comments
 (0)