diff --git a/README.md b/README.md index d508630a..38346e28 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,10 @@ It seems that right now, you have to make a manual step on Azure to use this new Docker container and update it on Azure. Please ask Lorenz for more information. +#### Prod Monitoring on New Relic + +See docs/new-relic.md + ### CapRover vbv-develop Bitbucket Pipelines name: develop diff --git a/compose/django/docker_start.sh b/compose/django/docker_start.sh index ce278dcf..d78b21d7 100644 --- a/compose/django/docker_start.sh +++ b/compose/django/docker_start.sh @@ -15,4 +15,4 @@ else python /app/manage.py migrate fi -/usr/local/bin/gunicorn config.asgi --bind 0.0.0.0:7555 --chdir=/app -k uvicorn.workers.UvicornWorker +newrelic-admin run-program gunicorn config.asgi --bind 0.0.0.0:7555 --chdir=/app -k uvicorn.workers.UvicornWorker diff --git a/docs/azure-tricks.md b/docs/azure-tricks.md index 08fc8876..3aaa3c9d 100644 --- a/docs/azure-tricks.md +++ b/docs/azure-tricks.md @@ -3,9 +3,20 @@ This document should help the user with a few commands, how to handle the azure app. -# Get logs of the container +## Get logs of the container ```bash az webapp log tail --resource-group VBV +``` +## Query for logs + +``` +AppServiceConsoleLogs +| extend logLevel = parse_json(ResultDescription).level +| where logLevel == "error" +| take 10 +``` + +![azure_logs01.png](azure_logs01.png) diff --git a/docs/azure_logs01.png b/docs/azure_logs01.png new file mode 100644 index 00000000..4d25401a Binary files /dev/null and b/docs/azure_logs01.png differ diff --git a/docs/new-relic.md b/docs/new-relic.md new file mode 100644 index 00000000..5e3d575c --- /dev/null +++ b/docs/new-relic.md @@ -0,0 +1,15 @@ +# New Relic + +Die Applikation via Docker direkt in NewRelic als APM "vbv-prod-azure" eingebunden: +https://one.newrelic.com/nr1-core/apm/overview/MTgwMTYwfEFQTXxBUFBMSUNBVElPTnwxMDQ5Njk0MDU0 + +Ausserdem können die Applikations-Logs direkt im NewRelic eingesehen werden, innerhalb der APM Applikation +https://one.newrelic.com/nr1-core/logger/logs-summary/MTgwMTYwfEFQTXxBUFBMSUNBVElPTnwxMDQ5Njk0MDU0 + +Hier eine Query wie man nur die relevanten Logs mit einem "event"-Attribute und ohne label==security anzeigen kann: + +``` +event:* -"label":"security" +``` + +![new_relic_logs01.png](new_relic_logs01.png) diff --git a/docs/new_relic_logs01.png b/docs/new_relic_logs01.png new file mode 100644 index 00000000..20d0520c Binary files /dev/null and b/docs/new_relic_logs01.png differ diff --git a/env_secrets/prod-azure.json b/env_secrets/prod-azure.json index 704a2f0a..453296ed 100644 Binary files a/env_secrets/prod-azure.json and b/env_secrets/prod-azure.json differ diff --git a/server/config/settings/base.py b/server/config/settings/base.py index a1c631bf..87ffd78f 100644 --- a/server/config/settings/base.py +++ b/server/config/settings/base.py @@ -8,7 +8,10 @@ import structlog from environs import Env from vbv_lernwelt.core.constants import DEFAULT_RICH_TEXT_FEATURES -from vbv_lernwelt.core.utils import structlog_add_app_info +from vbv_lernwelt.core.log_utils import ( + structlog_add_app_info, + structlog_convert_to_json_for_new_relic, +) SERVER_ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent.parent APPS_DIR = SERVER_ROOT_DIR / "vbv_lernwelt" @@ -427,36 +430,16 @@ if IT_DJANGO_LOGGING_CONF == "IT_DJANGO_LOGGING_CONF_CONSOLE_COLOR": cache_logger_on_first_use=True, ) else: - shared_processors = [ - structlog.threadlocal.merge_threadlocal, - structlog.stdlib.add_log_level, - structlog.stdlib.add_logger_name, - structlog_add_app_info, - structlog.processors.TimeStamper(fmt="iso"), - structlog.processors.CallsiteParameterAdder(), - ] - LOGGING = { "version": 1, "disable_existing_loggers": True, "formatters": { "json": { - "()": structlog.stdlib.ProcessorFormatter, - "processors": [ - structlog.stdlib.ProcessorFormatter.remove_processors_meta, - structlog.processors.JSONRenderer(), - ], - "foreign_pre_chain": shared_processors, + "()": "pythonjsonlogger.jsonlogger.JsonFormatter", + "format": "%(asctime)s %(levelname)s %(process)d %(thread)d %(name)s %(lineno)d %(funcName)s %(message)s", }, }, "handlers": { - "file": { - "class": "concurrent_log_handler.ConcurrentRotatingFileHandler", - "filename": f"{SERVER_ROOT_DIR}/log/myservice.log", - "maxBytes": 1024 * 1024 * 100, - "backupCount": 50, - "formatter": "json", - }, "console": { "level": "DEBUG", "class": "logging.StreamHandler", @@ -465,30 +448,36 @@ else: }, "loggers": { "": { - "handlers": ["console", "file"], + "handlers": ["console"], "level": "INFO", }, "django": { - "handlers": ["console", "file"], + "handlers": ["console"], "level": "WARNING", "propagate": False, }, "vbv_lernwelt": { - "handlers": ["console", "file"], + "handlers": ["console"], "level": "DEBUG", "propagate": False, }, "sentry_sdk": { "level": "ERROR", - "handlers": ["console", "file"], + "handlers": ["console"], "propagate": False, }, }, } structlog.configure( - processors=shared_processors - + [ + processors=[ + structlog.stdlib.filter_by_level, + structlog.threadlocal.merge_threadlocal, + structlog_add_app_info, + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.UnicodeDecoder(), + structlog_convert_to_json_for_new_relic, structlog.stdlib.ProcessorFormatter.wrap_for_formatter, ], context_class=dict, diff --git a/server/config/urls.py b/server/config/urls.py index b95c509d..98bf6f48 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -141,17 +141,21 @@ urlpatterns = [ name='file_upload_local'), # feedback - path(r'api/core/feedback//summary/', get_expert_feedbacks_for_course, + path(r'api/core/feedback//summary/', + get_expert_feedbacks_for_course, name='feedback_summary'), - path(r'api/core/feedback///', get_feedback_for_circle, + path(r'api/core/feedback///', + get_feedback_for_circle, name='feedback_for_circle'), # edoniq test path(r'api/core/edoniq-test/redirect/', get_edoniq_token_redirect, name='get_edoniq_token_redirect'), - path(r'api/core/edoniq-test/export-users/', export_students, name='edoniq_export_students'), - path(r'api/core/edoniq-test/export-trainers/', export_trainers, name='edoniq_export_trainers'), + path(r'api/core/edoniq-test/export-users/', export_students, + name='edoniq_export_students'), + path(r'api/core/edoniq-test/export-trainers/', export_trainers, + name='edoniq_export_trainers'), path(r'api/core/edoniq-test/export-users-trainers/', export_students_and_trainers, name='edoniq_export_students_and_trainers'), @@ -183,16 +187,16 @@ urlpatterns = [ ] urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) -if settings.DEBUG: - # Static file serving when using Gunicorn + Uvicorn for local web socket development - urlpatterns += staticfiles_urlpatterns() - -if settings.APP_ENVIRONMENT != 'production': +if not settings.APP_ENVIRONMENT.startswith('prod'): urlpatterns += [ re_path(r'api/core/cypressreset/$', cypress_reset_view, name='cypress_reset_view'), ] +if settings.DEBUG: + # Static file serving when using Gunicorn + Uvicorn for local web socket development + urlpatterns += staticfiles_urlpatterns() + # fmt: on diff --git a/server/requirements/requirements-dev.txt b/server/requirements/requirements-dev.txt index 2a2abafb..04cf3105 100644 --- a/server/requirements/requirements-dev.txt +++ b/server/requirements/requirements-dev.txt @@ -1,8 +1,8 @@ # -# This file is autogenerated by pip-compile with python 3.10 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: # -# pip-compile requirements-dev.in +# pip-compile --output-file=requirements-dev.txt requirements-dev.in # aniso8601==9.0.1 # via graphene @@ -313,6 +313,8 @@ mypy-extensions==1.0.0 # black # mypy # typing-inspect +newrelic==8.11.0 + # via -r requirements.in nodeenv==1.8.0 # via pre-commit openpyxl==3.1.2 diff --git a/server/requirements/requirements.in b/server/requirements/requirements.in index 2a9a18ca..a47be82c 100644 --- a/server/requirements/requirements.in +++ b/server/requirements/requirements.in @@ -50,3 +50,4 @@ azure-identity boto3 openpyxl +newrelic diff --git a/server/requirements/requirements.txt b/server/requirements/requirements.txt index c205e184..30ae967d 100644 --- a/server/requirements/requirements.txt +++ b/server/requirements/requirements.txt @@ -1,8 +1,8 @@ # -# This file is autogenerated by pip-compile with python 3.10 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: # -# pip-compile requirements.in +# pip-compile --output-file=requirements.txt requirements.in # aniso8601==9.0.1 # via graphene @@ -196,6 +196,8 @@ msal==1.23.0 # msal-extensions msal-extensions==1.0.0 # via azure-identity +newrelic==8.11.0 + # via -r requirements.in openpyxl==3.1.2 # via # -r requirements.in diff --git a/server/vbv_lernwelt/core/log_utils.py b/server/vbv_lernwelt/core/log_utils.py new file mode 100644 index 00000000..118ab60b --- /dev/null +++ b/server/vbv_lernwelt/core/log_utils.py @@ -0,0 +1,26 @@ +import logging + +from django.conf import settings +from structlog.typing import EventDict + +from vbv_lernwelt.core.utils import safe_json_dumps + + +def structlog_convert_to_json_for_new_relic( + _: logging.Logger, __: str, event_dict: EventDict +) -> str: + """ + The *event_dict* is serialized to a json string, so that in New Relic logs + the nested keys will show up as attributes. + """ + return safe_json_dumps(event_dict) + + +def structlog_add_app_info( + _: logging.Logger, __: str, event_dict: EventDict +) -> EventDict: + event_dict["django_app"] = "vbv_lernwelt" + event_dict["app_environment"] = settings.APP_ENVIRONMENT + event_dict["django_app_dev_mode"] = f"vbv_lernwelt_{settings.APP_ENVIRONMENT}" + + return event_dict diff --git a/server/vbv_lernwelt/core/utils.py b/server/vbv_lernwelt/core/utils.py index 8291ac53..1f4e16fc 100644 --- a/server/vbv_lernwelt/core/utils.py +++ b/server/vbv_lernwelt/core/utils.py @@ -1,34 +1,19 @@ -import logging +import json import re -import structlog -from django.conf import settings from rest_framework.throttling import UserRateThrottle -from structlog.types import EventDict -def structlog_add_app_info( - logger: logging.Logger, method_name: str, event_dict: EventDict -) -> EventDict: - event_dict["django_app"] = "vbv_lernwelt" - event_dict["APP_ENVIRONMENT"] = settings.APP_ENVIRONMENT - event_dict["django_app_dev_mode"] = f"vbv_lernwelt_{settings.APP_ENVIRONMENT}" - - return event_dict +class FailSafeJSONEncoder(json.JSONEncoder): + def default(self, obj): + try: + return super(FailSafeJSONEncoder, self).default(obj) + except Exception: + return str(obj) -def structlog_inject_context_dict(test, level, event_dict): - """ - Add the structlog context dict to log events generated by the stdlib logging library. - """ - context_class = structlog.get_config().get("context_class") - - if context_class: - for key, value in context_class().items(): - if key not in event_dict: - event_dict[key] = value - - return event_dict +def safe_json_dumps(data, **kwargs): + return json.dumps(data, cls=FailSafeJSONEncoder, **kwargs) class HourUserRateThrottle(UserRateThrottle): diff --git a/server/vbv_lernwelt/core/views.py b/server/vbv_lernwelt/core/views.py index f7b815f1..3660cea1 100644 --- a/server/vbv_lernwelt/core/views.py +++ b/server/vbv_lernwelt/core/views.py @@ -133,7 +133,7 @@ def check_rate_limit(request): @authentication_classes((authentication.SessionAuthentication,)) @permission_classes((IsAdminUser,)) def cypress_reset_view(request): - if settings.APP_ENVIRONMENT != "production": + if not settings.APP_ENVIRONMENT.startswith("prod"): call_command("cypress_reset") return HttpResponseRedirect("/server/admin/") diff --git a/trufflehog-allow.json b/trufflehog-allow.json index a183a7c5..ec85115f 100644 --- a/trufflehog-allow.json +++ b/trufflehog-allow.json @@ -6,6 +6,7 @@ "ignore hash 5": "1LhwZ0DvP4cGBgbBdCfaBQV7eiaOc4jWKdzO9WEXLFT7AaqBN6jqd0uyaZeAZ19K", "ignore hash 6": "A035C8C19219BA821ECEA86B64E628F8D684696D", "ignore hash 7": "96334b4eb6a7ae5b0d86abd7febcbcc67323bb94", + "ignore hash 8": "MTgwMTYwfEFQTXxBUFBMSUNBVElPTnwxMDQ5Njk0MDU0", "json base64 content": "regex:\"content\": \"", "img base64 content": "regex:data:image/png;base64,.*", "sentry url": "https://2df6096a4fd94bd6b4802124d10e4b8d@o8544.ingest.sentry.io/4504157846372352",