From d9061388dde99e932bb45443f250bdb1cee70a82 Mon Sep 17 00:00:00 2001 From: Remy Moll <me@moll.re> Date: Sat, 28 Dec 2024 13:57:04 +0100 Subject: [PATCH 1/6] use additional loki logger --- backend/Pipfile | 1 + backend/Pipfile.lock | 11 +- backend/deployment | 2 +- backend/report.html | 1094 ---------------------- backend/src/{persistence.py => cache.py} | 0 backend/src/constants.py | 30 +- backend/src/main.py | 2 +- backend/src/tests/test_utils.py | 2 +- 8 files changed, 36 insertions(+), 1106 deletions(-) delete mode 100644 backend/report.html rename backend/src/{persistence.py => cache.py} (100%) diff --git a/backend/Pipfile b/backend/Pipfile index 4d06a94..1f9a632 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -25,3 +25,4 @@ pymemcache = "*" fastapi-cli = "*" scikit-learn = "*" pyqt6 = "*" +loki-logger-handler = "*" diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock index 880b9a6..b13bd13 100644 --- a/backend/Pipfile.lock +++ b/backend/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "bb22b4e28c7aa199c94b688ad93d3ab0ccf1089a172131f4aec03b78e7bd7f1c" + "sha256": "6edd6644586e8814a0b4526adb3352dfc17828ca129de7a68c1d5929efe94daa" }, "pipfile-spec": 6, "requires": {}, @@ -507,6 +507,15 @@ "markers": "python_version >= '3.8'", "version": "==1.4.7" }, + "loki-logger-handler": { + "hashes": [ + "sha256:aa1a9c933282c134a1e4271aba3cbaa2a3660eab6ea415bad7a072444ab98aa8", + "sha256:f6114727a9e5e6f3f2058b9b5324d1cab6d1a04e802079f7b57a8aeb7bd0a112" + ], + "index": "pypi", + "markers": "python_version >= '2.7'", + "version": "==1.0.2" + }, "lxml": { "hashes": [ "sha256:01220dca0d066d1349bd6a1726856a78f7929f3878f7e2ee83c296c69495309e", diff --git a/backend/deployment b/backend/deployment index 718df09..4f0a028 160000 --- a/backend/deployment +++ b/backend/deployment @@ -1 +1 @@ -Subproject commit 718df09e88b63c9524c882ccbb8247ca1448d3ff +Subproject commit 4f0a0289fcf8a7755d7dc1a76b443fe1233685ae diff --git a/backend/report.html b/backend/report.html deleted file mode 100644 index d46b72d..0000000 --- a/backend/report.html +++ /dev/null @@ -1,1094 +0,0 @@ -<!DOCTYPE html> -<html> - <head> - <meta charset="utf-8"/> - <title id="head-title">Backend Testing Report</title> - <style type="text/css">body { - font-family: Helvetica, Arial, sans-serif; - font-size: 12px; - /* do not increase min-width as some may use split screens */ - min-width: 800px; - color: #999; -} - -h1 { - font-size: 24px; - color: black; -} - -h2 { - font-size: 16px; - color: black; -} - -p { - color: black; -} - -a { - color: #999; -} - -table { - border-collapse: collapse; -} - -/****************************** - * SUMMARY INFORMATION - ******************************/ -#environment td { - padding: 5px; - border: 1px solid #e6e6e6; - vertical-align: top; -} -#environment tr:nth-child(odd) { - background-color: #f6f6f6; -} -#environment ul { - margin: 0; - padding: 0 20px; -} - -/****************************** - * TEST RESULT COLORS - ******************************/ -span.passed, -.passed .col-result { - color: green; -} - -span.skipped, -span.xfailed, -span.rerun, -.skipped .col-result, -.xfailed .col-result, -.rerun .col-result { - color: orange; -} - -span.error, -span.failed, -span.xpassed, -.error .col-result, -.failed .col-result, -.xpassed .col-result { - color: red; -} - -.col-links__extra { - margin-right: 3px; -} - -/****************************** - * RESULTS TABLE - * - * 1. Table Layout - * 2. Extra - * 3. Sorting items - * - ******************************/ -/*------------------ - * 1. Table Layout - *------------------*/ -#results-table { - border: 1px solid #e6e6e6; - color: #999; - font-size: 12px; - width: 100%; -} -#results-table th, -#results-table td { - padding: 5px; - border: 1px solid #e6e6e6; - text-align: left; -} -#results-table th { - font-weight: bold; -} - -/*------------------ - * 2. Extra - *------------------*/ -.logwrapper { - max-height: 230px; - overflow-y: scroll; - background-color: #e6e6e6; -} -.logwrapper.expanded { - max-height: none; -} -.logwrapper.expanded .logexpander:after { - content: "collapse [-]"; -} -.logwrapper .logexpander { - z-index: 1; - position: sticky; - top: 10px; - width: max-content; - border: 1px solid; - border-radius: 3px; - padding: 5px 7px; - margin: 10px 0 10px calc(100% - 80px); - cursor: pointer; - background-color: #e6e6e6; -} -.logwrapper .logexpander:after { - content: "expand [+]"; -} -.logwrapper .logexpander:hover { - color: #000; - border-color: #000; -} -.logwrapper .log { - min-height: 40px; - position: relative; - top: -50px; - height: calc(100% + 50px); - border: 1px solid #e6e6e6; - color: black; - display: block; - font-family: "Courier New", Courier, monospace; - padding: 5px; - padding-right: 80px; - white-space: pre-wrap; -} - -div.media { - border: 1px solid #e6e6e6; - float: right; - height: 240px; - margin: 0 5px; - overflow: hidden; - width: 320px; -} - -.media-container { - display: grid; - grid-template-columns: 25px auto 25px; - align-items: center; - flex: 1 1; - overflow: hidden; - height: 200px; -} - -.media-container--fullscreen { - grid-template-columns: 0px auto 0px; -} - -.media-container__nav--right, -.media-container__nav--left { - text-align: center; - cursor: pointer; -} - -.media-container__viewport { - cursor: pointer; - text-align: center; - height: inherit; -} -.media-container__viewport img, -.media-container__viewport video { - object-fit: cover; - width: 100%; - max-height: 100%; -} - -.media__name, -.media__counter { - display: flex; - flex-direction: row; - justify-content: space-around; - flex: 0 0 25px; - align-items: center; -} - -.collapsible td:not(.col-links) { - cursor: pointer; -} -.collapsible td:not(.col-links):hover::after { - color: #bbb; - font-style: italic; - cursor: pointer; -} - -.col-result { - width: 130px; -} -.col-result:hover::after { - content: " (hide details)"; -} - -.col-result.collapsed:hover::after { - content: " (show details)"; -} - -#environment-header h2:hover::after { - content: " (hide details)"; - color: #bbb; - font-style: italic; - cursor: pointer; - font-size: 12px; -} - -#environment-header.collapsed h2:hover::after { - content: " (show details)"; - color: #bbb; - font-style: italic; - cursor: pointer; - font-size: 12px; -} - -/*------------------ - * 3. Sorting items - *------------------*/ -.sortable { - cursor: pointer; -} -.sortable.desc:after { - content: " "; - position: relative; - left: 5px; - bottom: -12.5px; - border: 10px solid #4caf50; - border-bottom: 0; - border-left-color: transparent; - border-right-color: transparent; -} -.sortable.asc:after { - content: " "; - position: relative; - left: 5px; - bottom: 12.5px; - border: 10px solid #4caf50; - border-top: 0; - border-left-color: transparent; - border-right-color: transparent; -} - -.hidden, .summary__reload__button.hidden { - display: none; -} - -.summary__data { - flex: 0 0 550px; -} -.summary__reload { - flex: 1 1; - display: flex; - justify-content: center; -} -.summary__reload__button { - flex: 0 0 300px; - display: flex; - color: white; - font-weight: bold; - background-color: #4caf50; - text-align: center; - justify-content: center; - align-items: center; - border-radius: 3px; - cursor: pointer; -} -.summary__reload__button:hover { - background-color: #46a049; -} -.summary__spacer { - flex: 0 0 550px; -} - -.controls { - display: flex; - justify-content: space-between; -} - -.filters, -.collapse { - display: flex; - align-items: center; -} -.filters button, -.collapse button { - color: #999; - border: none; - background: none; - cursor: pointer; - text-decoration: underline; -} -.filters button:hover, -.collapse button:hover { - color: #ccc; -} - -.filter__label { - margin-right: 10px; -} - - </style> - - </head> - <body> - <h1 id="title">Backend Testing Report</h1> - <p>Report generated on 13-Dec-2024 at 08:58:37 by <a href="https://pypi.python.org/pypi/pytest-html">pytest-html</a> - v4.1.1</p> - <div id="environment-header"> - <h2>Environment</h2> - </div> - <table id="environment"></table> - <!-- TEMPLATES --> - <template id="template_environment_row"> - <tr> - <td></td> - <td></td> - </tr> - </template> - <template id="template_results-table__body--empty"> - <tbody class="results-table-row"> - <tr id="not-found-message"> - <td colspan="7">No results found. Check the filters.</th> - </tr> - </template> - <template id="template_results-table__tbody"> - <tbody class="results-table-row"> - <tr class="collapsible"> - </tr> - <tr class="extras-row"> - <td class="extra" colspan="7"> - <div class="extraHTML"></div> - <div class="media"> - <div class="media-container"> - <div class="media-container__nav--left"><</div> - <div class="media-container__viewport"> - <img src="" /> - <video controls> - <source src="" type="video/mp4"> - </video> - </div> - <div class="media-container__nav--right">></div> - </div> - <div class="media__name"></div> - <div class="media__counter"></div> - </div> - <div class="logwrapper"> - <div class="logexpander"></div> - <div class="log"></div> - </div> - </td> - </tr> - </tbody> - </template> - <!-- END TEMPLATES --> - <div class="summary"> - <div class="summary__data"> - <h2>Summary</h2> - <div class="additional-summary prefix"> - </div> - <p class="run-count">12 tests took 865 ms.</p> - <p class="filter">(Un)check the boxes to filter the results.</p> - <div class="summary__reload"> - <div class="summary__reload__button hidden" onclick="location.reload()"> - <div>There are still tests running. <br />Reload this page to get the latest results!</div> - </div> - </div> - <div class="summary__spacer"></div> - <div class="controls"> - <div class="filters"> - <input checked="true" class="filter" name="filter_checkbox" type="checkbox" data-test-result="failed" /> - <span class="failed">2 Failed,</span> - <input checked="true" class="filter" name="filter_checkbox" type="checkbox" data-test-result="passed" /> - <span class="passed">10 Passed,</span> - <input checked="true" class="filter" name="filter_checkbox" type="checkbox" data-test-result="skipped" disabled/> - <span class="skipped">0 Skipped,</span> - <input checked="true" class="filter" name="filter_checkbox" type="checkbox" data-test-result="xfailed" disabled/> - <span class="xfailed">0 Expected failures,</span> - <input checked="true" class="filter" name="filter_checkbox" type="checkbox" data-test-result="xpassed" disabled/> - <span class="xpassed">0 Unexpected passes,</span> - <input checked="true" class="filter" name="filter_checkbox" type="checkbox" data-test-result="error" disabled/> - <span class="error">0 Errors,</span> - <input checked="true" class="filter" name="filter_checkbox" type="checkbox" data-test-result="rerun" disabled/> - <span class="rerun">0 Reruns</span> - </div> - <div class="collapse"> - <button id="show_all_details">Show all details</button> / <button id="hide_all_details">Hide all details</button> - </div> - </div> - </div> - <div class="additional-summary summary"> - </div> - <div class="additional-summary postfix"> - </div> - </div> - <table id="results-table"> - <thead id="results-table-head"> - <tr> - <th class="sortable" data-column-type="result">Result</th> - <th class="sortable" data-column-type="testId">Test</th> - <th>Detailed trip</th> - <th>Trip Duration</th> - <th>Target Duration</th> - <th>Execution time</th> - <th>Links</th> - </tr> - </thead> - </table> - </body> - <footer> - <div id="data-container" data-jsonblob="{"environment": {"Python": "3.12.3", "Platform": "Linux-6.8.0-50-generic-x86_64-with-glibc2.39", "Packages": {"pytest": "8.3.4", "pluggy": "1.5.0"}, "Plugins": {"html": "4.1.1", "metadata": "3.1.1", "anyio": "4.6.2.post1"}}, "tests": {"src/tests/test_cache.py::test_cache": [{"extras": [], "result": "Passed", "testId": "src/tests/test_cache.py::test_cache", "resultsTableRow": ["<td class=\"col-result\">Passed</td>", "<td class=\"col-testId\">src/tests/test_cache.py::test_cache</td>", "<td>N/A</td>", "<td>N/A</td>", "<td>N/A</td>", "<td class=\"col-duration\">77 ms</td>", "<td class=\"col-links\"></td>"], "log": "No log output captured."}], "src/tests/test_invalid_input.py::test_input[start0-preferences0-422]": [{"extras": [], "result": "Passed", "testId": "src/tests/test_invalid_input.py::test_input[start0-preferences0-422]", "resultsTableRow": ["<td class=\"col-result\">Passed</td>", "<td class=\"col-testId\">src/tests/test_invalid_input.py::test_input[start0-preferences0-422]</td>", "<td>N/A</td>", "<td>N/A</td>", "<td>N/A</td>", "<td class=\"col-duration\">2 ms</td>", "<td class=\"col-links\"></td>"], "log": "No log output captured."}], "src/tests/test_invalid_input.py::test_input[start1-preferences1-422]": [{"extras": [], "result": "Passed", "testId": "src/tests/test_invalid_input.py::test_input[start1-preferences1-422]", "resultsTableRow": ["<td class=\"col-result\">Passed</td>", "<td class=\"col-testId\">src/tests/test_invalid_input.py::test_input[start1-preferences1-422]</td>", "<td>N/A</td>", "<td>N/A</td>", "<td>N/A</td>", "<td class=\"col-duration\">3 ms</td>", "<td class=\"col-links\"></td>"], "log": "No log output captured."}], "src/tests/test_invalid_input.py::test_input[start2-preferences2-422]": [{"extras": [], "result": "Passed", "testId": "src/tests/test_invalid_input.py::test_input[start2-preferences2-422]", "resultsTableRow": ["<td class=\"col-result\">Passed</td>", "<td class=\"col-testId\">src/tests/test_invalid_input.py::test_input[start2-preferences2-422]</td>", "<td>N/A</td>", "<td>N/A</td>", "<td>N/A</td>", "<td class=\"col-duration\">3 ms</td>", "<td class=\"col-links\"></td>"], "log": "No log output captured."}], "src/tests/test_invalid_input.py::test_input[start3-preferences3-422]": [{"extras": [], "result": "Passed", "testId": "src/tests/test_invalid_input.py::test_input[start3-preferences3-422]", "resultsTableRow": ["<td class=\"col-result\">Passed</td>", "<td class=\"col-testId\">src/tests/test_invalid_input.py::test_input[start3-preferences3-422]</td>", "<td>N/A</td>", "<td>N/A</td>", "<td>N/A</td>", "<td class=\"col-duration\">3 ms</td>", "<td class=\"col-links\"></td>"], "log": "No log output captured."}], "src/tests/test_invalid_input.py::test_input[start4-preferences4-422]": [{"extras": [], "result": "Passed", "testId": "src/tests/test_invalid_input.py::test_input[start4-preferences4-422]", "resultsTableRow": ["<td class=\"col-result\">Passed</td>", "<td class=\"col-testId\">src/tests/test_invalid_input.py::test_input[start4-preferences4-422]</td>", "<td>N/A</td>", "<td>N/A</td>", "<td>N/A</td>", "<td class=\"col-duration\">4 ms</td>", "<td class=\"col-links\"></td>"], "log": "No log output captured."}], "src/tests/test_invalid_input.py::test_input[start5-preferences5-422]": [{"extras": [], "result": "Passed", "testId": "src/tests/test_invalid_input.py::test_input[start5-preferences5-422]", "resultsTableRow": ["<td class=\"col-result\">Passed</td>", "<td class=\"col-testId\">src/tests/test_invalid_input.py::test_input[start5-preferences5-422]</td>", "<td>N/A</td>", "<td>N/A</td>", "<td>N/A</td>", "<td class=\"col-duration\">4 ms</td>", "<td class=\"col-links\"></td>"], "log": "No log output captured."}], "src/tests/test_invalid_input.py::test_input[start6-preferences6-422]": [{"extras": [], "result": "Passed", "testId": "src/tests/test_invalid_input.py::test_input[start6-preferences6-422]", "resultsTableRow": ["<td class=\"col-result\">Passed</td>", "<td class=\"col-testId\">src/tests/test_invalid_input.py::test_input[start6-preferences6-422]</td>", "<td>N/A</td>", "<td>N/A</td>", "<td>N/A</td>", "<td class=\"col-duration\">2 ms</td>", "<td class=\"col-links\"></td>"], "log": "No log output captured."}], "src/tests/test_invalid_input.py::test_input[start7-preferences7-422]": [{"extras": [], "result": "Passed", "testId": "src/tests/test_invalid_input.py::test_input[start7-preferences7-422]", "resultsTableRow": ["<td class=\"col-result\">Passed</td>", "<td class=\"col-testId\">src/tests/test_invalid_input.py::test_input[start7-preferences7-422]</td>", "<td>N/A</td>", "<td>N/A</td>", "<td>N/A</td>", "<td class=\"col-duration\">2 ms</td>", "<td class=\"col-links\"></td>"], "log": "No log output captured."}], "src/tests/test_main.py::test_turckheim": [{"extras": [], "result": "Passed", "testId": "src/tests/test_main.py::test_turckheim", "resultsTableRow": ["<td class=\"col-result\">Passed</td>", "<td class=\"col-testId\">src/tests/test_main.py::test_turckheim</td>", "<td>start (0 | 0) - 4 - La Fecht (99 | 5) - 4 - finish (0 | 0) - 0</td>", "<td>13 min</td>", "<td>15 min</td>", "<td class=\"col-duration\">19 ms</td>", "<td class=\"col-links\"></td>"], "log": "No log output captured."}], "src/tests/test_main.py::test_bellecour": [{"extras": [], "result": "Failed", "testId": "src/tests/test_main.py::test_bellecour", "resultsTableRow": ["<td class=\"col-result\">Failed</td>", "<td class=\"col-testId\">src/tests/test_main.py::test_bellecour</td>", "<td>start (0 | 0) - 3 - M\u00e9morial Lyonnais du G\u00e9nocide Arm\u00e9nien (265 | 5) - 4 - \u00c9glise Saint-Fran\u00e7ois-de-Sales (211 | 5) - 4 - Chapelle des J\u00e9suites (161 | 5) - 7 - finish (0 | 0) - 0</td>", "<td>33 min</td>", "<td>30 min</td>", "<td class=\"col-duration\">129 ms</td>", "<td class=\"col-links\"></td>"], "log": "client = &lt;starlette.testclient.TestClient object at 0x7eb8bb831310&gt;\nrequest = &lt;FixtureRequest for &lt;Function test_bellecour&gt;&gt;\n\n def test_bellecour(client, request) : # pylint: disable=redefined-outer-name\n &quot;&quot;&quot;\n Test n\u00b02 : Custom test in Lyon centre to ensure proper decision making in crowded area.\n \n Args:\n client:\n request:\n &quot;&quot;&quot;\n duration_minutes = 30\n response = client.post(\n &quot;/trip/new&quot;,\n json={\n &quot;preferences&quot;: {&quot;sightseeing&quot;: {&quot;type&quot;: &quot;sightseeing&quot;, &quot;score&quot;: 5},\n &quot;nature&quot;: {&quot;type&quot;: &quot;nature&quot;, &quot;score&quot;: 5},\n &quot;shopping&quot;: {&quot;type&quot;: &quot;shopping&quot;, &quot;score&quot;: 5},\n &quot;max_time_minute&quot;: duration_minutes,\n &quot;detour_tolerance_minute&quot;: 0},\n &quot;start&quot;: [45.7576485, 4.8330241]\n }\n )\n result = response.json()\n landmarks = load_trip_landmarks(client, result[&#x27;first_landmark_uuid&#x27;])\n osm_ids = landmarks_to_osmid(landmarks)\n \n # Add details to report\n log_trip_details(request, landmarks, result[&#x27;total_time&#x27;], duration_minutes)\n \n # checks :\n assert response.status_code == 200 # check for successful planning\n assert duration_minutes*0.8 &lt; int(result[&#x27;total_time&#x27;]) &lt; duration_minutes*1.2\n&gt; assert 136200148 in osm_ids # check for Cath\u00e9drale St. Jean in trip\nE assert 136200148 in [0, 265922306, 82260098, 144904193, 0]\n\nsrc/tests/test_main.py:78: AssertionError\n"}], "src/tests/test_main.py::test_shopping": [{"extras": [], "result": "Failed", "testId": "src/tests/test_main.py::test_shopping", "resultsTableRow": ["<td class=\"col-result\">Failed</td>", "<td class=\"col-testId\">src/tests/test_main.py::test_shopping</td>", "<td>start (0 | 0) - 39 - P\u00f4le de Commerces et de Loisirs Confluence (237 | 30) - 47 - Cordeliers (554 | 45) - 8 - Grand H\u00f4tel-Dieu (519 | 30) - 25 - Muji (377 | 30) - 3 - Galeries Lafayette (267 | 30) - 2 - Cour Oxyg\u00e8ne (108 | 30) - 1 - HEMA (195 | 30) - 2 - Westfield La Part-Dieu (598 | 30) - 19 - Chinatown (106 | 45) - 16 - finish (0 | 0) - 0</td>", "<td>462 min</td>", "<td>600 min</td>", "<td class=\"col-duration\">616 ms</td>", "<td class=\"col-links\"></td>"], "log": "client = &lt;starlette.testclient.TestClient object at 0x7eb8bb831310&gt;\nrequest = &lt;FixtureRequest for &lt;Function test_shopping&gt;&gt;\n\n def test_shopping(client, request) : # pylint: disable=redefined-outer-name\n &quot;&quot;&quot;\n Test n\u00b03 : Custom test in Lyon centre to ensure shopping clusters are found.\n \n Args:\n client:\n request:\n &quot;&quot;&quot;\n duration_minutes = 600\n response = client.post(\n &quot;/trip/new&quot;,\n json={\n &quot;preferences&quot;: {&quot;sightseeing&quot;: {&quot;type&quot;: &quot;sightseeing&quot;, &quot;score&quot;: 0},\n &quot;nature&quot;: {&quot;type&quot;: &quot;nature&quot;, &quot;score&quot;: 0},\n &quot;shopping&quot;: {&quot;type&quot;: &quot;shopping&quot;, &quot;score&quot;: 5},\n &quot;max_time_minute&quot;: duration_minutes,\n &quot;detour_tolerance_minute&quot;: 0},\n &quot;start&quot;: [45.7576485, 4.8330241]\n }\n )\n result = response.json()\n landmarks = load_trip_landmarks(client, result[&#x27;first_landmark_uuid&#x27;])\n # osm_ids = landmarks_to_osmid(landmarks)\n \n # Add details to report\n log_trip_details(request, landmarks, result[&#x27;total_time&#x27;], duration_minutes)\n \n # checks :\n assert response.status_code == 200 # check for successful planning\n&gt; assert duration_minutes*0.8 &lt; int(result[&#x27;total_time&#x27;]) &lt; duration_minutes*1.2\nE assert (600 * 0.8) &lt; 462\nE + where 462 = int(462)\n\nsrc/tests/test_main.py:110: AssertionError\n"}]}, "renderCollapsed": ["passed"], "initialSort": "result", "title": "Backend Testing Report"}"></div> - <script> - (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){ -const { getCollapsedCategory, setCollapsedIds } = require('./storage.js') - -class DataManager { - setManager(data) { - const collapsedCategories = [...getCollapsedCategory(data.renderCollapsed)] - const collapsedIds = [] - const tests = Object.values(data.tests).flat().map((test, index) => { - const collapsed = collapsedCategories.includes(test.result.toLowerCase()) - const id = `test_${index}` - if (collapsed) { - collapsedIds.push(id) - } - return { - ...test, - id, - collapsed, - } - }) - const dataBlob = { ...data, tests } - this.data = { ...dataBlob } - this.renderData = { ...dataBlob } - setCollapsedIds(collapsedIds) - } - - get allData() { - return { ...this.data } - } - - resetRender() { - this.renderData = { ...this.data } - } - - setRender(data) { - this.renderData.tests = [...data] - } - - toggleCollapsedItem(id) { - this.renderData.tests = this.renderData.tests.map((test) => - test.id === id ? { ...test, collapsed: !test.collapsed } : test, - ) - } - - set allCollapsed(collapsed) { - this.renderData = { ...this.renderData, tests: [...this.renderData.tests.map((test) => ( - { ...test, collapsed } - ))] } - } - - get testSubset() { - return [...this.renderData.tests] - } - - get environment() { - return this.renderData.environment - } - - get initialSort() { - return this.data.initialSort - } -} - -module.exports = { - manager: new DataManager(), -} - -},{"./storage.js":8}],2:[function(require,module,exports){ -const mediaViewer = require('./mediaviewer.js') -const templateEnvRow = document.getElementById('template_environment_row') -const templateResult = document.getElementById('template_results-table__tbody') - -function htmlToElements(html) { - const temp = document.createElement('template') - temp.innerHTML = html - return temp.content.childNodes -} - -const find = (selector, elem) => { - if (!elem) { - elem = document - } - return elem.querySelector(selector) -} - -const findAll = (selector, elem) => { - if (!elem) { - elem = document - } - return [...elem.querySelectorAll(selector)] -} - -const dom = { - getStaticRow: (key, value) => { - const envRow = templateEnvRow.content.cloneNode(true) - const isObj = typeof value === 'object' && value !== null - const values = isObj ? Object.keys(value).map((k) => `${k}: ${value[k]}`) : null - - const valuesElement = htmlToElements( - values ? `<ul>${values.map((val) => `<li>${val}</li>`).join('')}<ul>` : `<div>${value}</div>`)[0] - const td = findAll('td', envRow) - td[0].textContent = key - td[1].appendChild(valuesElement) - - return envRow - }, - getResultTBody: ({ testId, id, log, extras, resultsTableRow, tableHtml, result, collapsed }) => { - const resultBody = templateResult.content.cloneNode(true) - resultBody.querySelector('tbody').classList.add(result.toLowerCase()) - resultBody.querySelector('tbody').id = testId - resultBody.querySelector('.collapsible').dataset.id = id - - resultsTableRow.forEach((html) => { - const t = document.createElement('template') - t.innerHTML = html - resultBody.querySelector('.collapsible').appendChild(t.content) - }) - - if (log) { - // Wrap lines starting with "E" with span.error to color those lines red - const wrappedLog = log.replace(/^E.*$/gm, (match) => `<span class="error">${match}</span>`) - resultBody.querySelector('.log').innerHTML = wrappedLog - } else { - resultBody.querySelector('.log').remove() - } - - if (collapsed) { - resultBody.querySelector('.collapsible > td')?.classList.add('collapsed') - resultBody.querySelector('.extras-row').classList.add('hidden') - } else { - resultBody.querySelector('.collapsible > td')?.classList.remove('collapsed') - } - - const media = [] - extras?.forEach(({ name, format_type, content }) => { - if (['image', 'video'].includes(format_type)) { - media.push({ path: content, name, format_type }) - } - - if (format_type === 'html') { - resultBody.querySelector('.extraHTML').insertAdjacentHTML('beforeend', `<div>${content}</div>`) - } - }) - mediaViewer.setup(resultBody, media) - - // Add custom html from the pytest_html_results_table_html hook - tableHtml?.forEach((item) => { - resultBody.querySelector('td[class="extra"]').insertAdjacentHTML('beforeend', item) - }) - - return resultBody - }, -} - -module.exports = { - dom, - htmlToElements, - find, - findAll, -} - -},{"./mediaviewer.js":6}],3:[function(require,module,exports){ -const { manager } = require('./datamanager.js') -const { doSort } = require('./sort.js') -const storageModule = require('./storage.js') - -const getFilteredSubSet = (filter) => - manager.allData.tests.filter(({ result }) => filter.includes(result.toLowerCase())) - -const doInitFilter = () => { - const currentFilter = storageModule.getVisible() - const filteredSubset = getFilteredSubSet(currentFilter) - manager.setRender(filteredSubset) -} - -const doFilter = (type, show) => { - if (show) { - storageModule.showCategory(type) - } else { - storageModule.hideCategory(type) - } - - const currentFilter = storageModule.getVisible() - const filteredSubset = getFilteredSubSet(currentFilter) - manager.setRender(filteredSubset) - - const sortColumn = storageModule.getSort() - doSort(sortColumn, true) -} - -module.exports = { - doFilter, - doInitFilter, -} - -},{"./datamanager.js":1,"./sort.js":7,"./storage.js":8}],4:[function(require,module,exports){ -const { redraw, bindEvents, renderStatic } = require('./main.js') -const { doInitFilter } = require('./filter.js') -const { doInitSort } = require('./sort.js') -const { manager } = require('./datamanager.js') -const data = JSON.parse(document.getElementById('data-container').dataset.jsonblob) - -function init() { - manager.setManager(data) - doInitFilter() - doInitSort() - renderStatic() - redraw() - bindEvents() -} - -init() - -},{"./datamanager.js":1,"./filter.js":3,"./main.js":5,"./sort.js":7}],5:[function(require,module,exports){ -const { dom, find, findAll } = require('./dom.js') -const { manager } = require('./datamanager.js') -const { doSort } = require('./sort.js') -const { doFilter } = require('./filter.js') -const { - getVisible, - getCollapsedIds, - setCollapsedIds, - getSort, - getSortDirection, - possibleFilters, -} = require('./storage.js') - -const removeChildren = (node) => { - while (node.firstChild) { - node.removeChild(node.firstChild) - } -} - -const renderStatic = () => { - const renderEnvironmentTable = () => { - const environment = manager.environment - const rows = Object.keys(environment).map((key) => dom.getStaticRow(key, environment[key])) - const table = document.getElementById('environment') - removeChildren(table) - rows.forEach((row) => table.appendChild(row)) - } - renderEnvironmentTable() -} - -const addItemToggleListener = (elem) => { - elem.addEventListener('click', ({ target }) => { - const id = target.parentElement.dataset.id - manager.toggleCollapsedItem(id) - - const collapsedIds = getCollapsedIds() - if (collapsedIds.includes(id)) { - const updated = collapsedIds.filter((item) => item !== id) - setCollapsedIds(updated) - } else { - collapsedIds.push(id) - setCollapsedIds(collapsedIds) - } - redraw() - }) -} - -const renderContent = (tests) => { - const sortAttr = getSort(manager.initialSort) - const sortAsc = JSON.parse(getSortDirection()) - const rows = tests.map(dom.getResultTBody) - const table = document.getElementById('results-table') - const tableHeader = document.getElementById('results-table-head') - - const newTable = document.createElement('table') - newTable.id = 'results-table' - - // remove all sorting classes and set the relevant - findAll('.sortable', tableHeader).forEach((elem) => elem.classList.remove('asc', 'desc')) - tableHeader.querySelector(`.sortable[data-column-type="${sortAttr}"]`)?.classList.add(sortAsc ? 'desc' : 'asc') - newTable.appendChild(tableHeader) - - if (!rows.length) { - const emptyTable = document.getElementById('template_results-table__body--empty').content.cloneNode(true) - newTable.appendChild(emptyTable) - } else { - rows.forEach((row) => { - if (!!row) { - findAll('.collapsible td:not(.col-links', row).forEach(addItemToggleListener) - find('.logexpander', row).addEventListener('click', - (evt) => evt.target.parentNode.classList.toggle('expanded'), - ) - newTable.appendChild(row) - } - }) - } - - table.replaceWith(newTable) -} - -const renderDerived = () => { - const currentFilter = getVisible() - possibleFilters.forEach((result) => { - const input = document.querySelector(`input[data-test-result="${result}"]`) - input.checked = currentFilter.includes(result) - }) -} - -const bindEvents = () => { - const filterColumn = (evt) => { - const { target: element } = evt - const { testResult } = element.dataset - - doFilter(testResult, element.checked) - const collapsedIds = getCollapsedIds() - const updated = manager.renderData.tests.map((test) => { - return { - ...test, - collapsed: collapsedIds.includes(test.id), - } - }) - manager.setRender(updated) - redraw() - } - - const header = document.getElementById('environment-header') - header.addEventListener('click', () => { - const table = document.getElementById('environment') - table.classList.toggle('hidden') - header.classList.toggle('collapsed') - }) - - findAll('input[name="filter_checkbox"]').forEach((elem) => { - elem.addEventListener('click', filterColumn) - }) - - findAll('.sortable').forEach((elem) => { - elem.addEventListener('click', (evt) => { - const { target: element } = evt - const { columnType } = element.dataset - doSort(columnType) - redraw() - }) - }) - - document.getElementById('show_all_details').addEventListener('click', () => { - manager.allCollapsed = false - setCollapsedIds([]) - redraw() - }) - document.getElementById('hide_all_details').addEventListener('click', () => { - manager.allCollapsed = true - const allIds = manager.renderData.tests.map((test) => test.id) - setCollapsedIds(allIds) - redraw() - }) -} - -const redraw = () => { - const { testSubset } = manager - - renderContent(testSubset) - renderDerived() -} - -module.exports = { - redraw, - bindEvents, - renderStatic, -} - -},{"./datamanager.js":1,"./dom.js":2,"./filter.js":3,"./sort.js":7,"./storage.js":8}],6:[function(require,module,exports){ -class MediaViewer { - constructor(assets) { - this.assets = assets - this.index = 0 - } - - nextActive() { - this.index = this.index === this.assets.length - 1 ? 0 : this.index + 1 - return [this.activeFile, this.index] - } - - prevActive() { - this.index = this.index === 0 ? this.assets.length - 1 : this.index -1 - return [this.activeFile, this.index] - } - - get currentIndex() { - return this.index - } - - get activeFile() { - return this.assets[this.index] - } -} - - -const setup = (resultBody, assets) => { - if (!assets.length) { - resultBody.querySelector('.media').classList.add('hidden') - return - } - - const mediaViewer = new MediaViewer(assets) - const container = resultBody.querySelector('.media-container') - const leftArrow = resultBody.querySelector('.media-container__nav--left') - const rightArrow = resultBody.querySelector('.media-container__nav--right') - const mediaName = resultBody.querySelector('.media__name') - const counter = resultBody.querySelector('.media__counter') - const imageEl = resultBody.querySelector('img') - const sourceEl = resultBody.querySelector('source') - const videoEl = resultBody.querySelector('video') - - const setImg = (media, index) => { - if (media?.format_type === 'image') { - imageEl.src = media.path - - imageEl.classList.remove('hidden') - videoEl.classList.add('hidden') - } else if (media?.format_type === 'video') { - sourceEl.src = media.path - - videoEl.classList.remove('hidden') - imageEl.classList.add('hidden') - } - - mediaName.innerText = media?.name - counter.innerText = `${index + 1} / ${assets.length}` - } - setImg(mediaViewer.activeFile, mediaViewer.currentIndex) - - const moveLeft = () => { - const [media, index] = mediaViewer.prevActive() - setImg(media, index) - } - const doRight = () => { - const [media, index] = mediaViewer.nextActive() - setImg(media, index) - } - const openImg = () => { - window.open(mediaViewer.activeFile.path, '_blank') - } - if (assets.length === 1) { - container.classList.add('media-container--fullscreen') - } else { - leftArrow.addEventListener('click', moveLeft) - rightArrow.addEventListener('click', doRight) - } - imageEl.addEventListener('click', openImg) -} - -module.exports = { - setup, -} - -},{}],7:[function(require,module,exports){ -const { manager } = require('./datamanager.js') -const storageModule = require('./storage.js') - -const genericSort = (list, key, ascending, customOrder) => { - let sorted - if (customOrder) { - sorted = list.sort((a, b) => { - const aValue = a.result.toLowerCase() - const bValue = b.result.toLowerCase() - - const aIndex = customOrder.findIndex((item) => item.toLowerCase() === aValue) - const bIndex = customOrder.findIndex((item) => item.toLowerCase() === bValue) - - // Compare the indices to determine the sort order - return aIndex - bIndex - }) - } else { - sorted = list.sort((a, b) => a[key] === b[key] ? 0 : a[key] > b[key] ? 1 : -1) - } - - if (ascending) { - sorted.reverse() - } - return sorted -} - -const durationSort = (list, ascending) => { - const parseDuration = (duration) => { - if (duration.includes(':')) { - // If it's in the format "HH:mm:ss" - const [hours, minutes, seconds] = duration.split(':').map(Number) - return (hours * 3600 + minutes * 60 + seconds) * 1000 - } else { - // If it's in the format "nnn ms" - return parseInt(duration) - } - } - const sorted = list.sort((a, b) => parseDuration(a['duration']) - parseDuration(b['duration'])) - if (ascending) { - sorted.reverse() - } - return sorted -} - -const doInitSort = () => { - const type = storageModule.getSort(manager.initialSort) - const ascending = storageModule.getSortDirection() - const list = manager.testSubset - const initialOrder = ['Error', 'Failed', 'Rerun', 'XFailed', 'XPassed', 'Skipped', 'Passed'] - - storageModule.setSort(type) - storageModule.setSortDirection(ascending) - - if (type?.toLowerCase() === 'original') { - manager.setRender(list) - } else { - let sortedList - switch (type) { - case 'duration': - sortedList = durationSort(list, ascending) - break - case 'result': - sortedList = genericSort(list, type, ascending, initialOrder) - break - default: - sortedList = genericSort(list, type, ascending) - break - } - manager.setRender(sortedList) - } -} - -const doSort = (type, skipDirection) => { - const newSortType = storageModule.getSort(manager.initialSort) !== type - const currentAsc = storageModule.getSortDirection() - let ascending - if (skipDirection) { - ascending = currentAsc - } else { - ascending = newSortType ? false : !currentAsc - } - storageModule.setSort(type) - storageModule.setSortDirection(ascending) - - const list = manager.testSubset - const sortedList = type === 'duration' ? durationSort(list, ascending) : genericSort(list, type, ascending) - manager.setRender(sortedList) -} - -module.exports = { - doInitSort, - doSort, -} - -},{"./datamanager.js":1,"./storage.js":8}],8:[function(require,module,exports){ -const possibleFilters = [ - 'passed', - 'skipped', - 'failed', - 'error', - 'xfailed', - 'xpassed', - 'rerun', -] - -const getVisible = () => { - const url = new URL(window.location.href) - const settings = new URLSearchParams(url.search).get('visible') - const lower = (item) => { - const lowerItem = item.toLowerCase() - if (possibleFilters.includes(lowerItem)) { - return lowerItem - } - return null - } - return settings === null ? - possibleFilters : - [...new Set(settings?.split(',').map(lower).filter((item) => item))] -} - -const hideCategory = (categoryToHide) => { - const url = new URL(window.location.href) - const visibleParams = new URLSearchParams(url.search).get('visible') - const currentVisible = visibleParams ? visibleParams.split(',') : [...possibleFilters] - const settings = [...new Set(currentVisible)].filter((f) => f !== categoryToHide).join(',') - - url.searchParams.set('visible', settings) - window.history.pushState({}, null, unescape(url.href)) -} - -const showCategory = (categoryToShow) => { - if (typeof window === 'undefined') { - return - } - const url = new URL(window.location.href) - const currentVisible = new URLSearchParams(url.search).get('visible')?.split(',').filter(Boolean) || - [...possibleFilters] - const settings = [...new Set([categoryToShow, ...currentVisible])] - const noFilter = possibleFilters.length === settings.length || !settings.length - - noFilter ? url.searchParams.delete('visible') : url.searchParams.set('visible', settings.join(',')) - window.history.pushState({}, null, unescape(url.href)) -} - -const getSort = (initialSort) => { - const url = new URL(window.location.href) - let sort = new URLSearchParams(url.search).get('sort') - if (!sort) { - sort = initialSort || 'result' - } - return sort -} - -const setSort = (type) => { - const url = new URL(window.location.href) - url.searchParams.set('sort', type) - window.history.pushState({}, null, unescape(url.href)) -} - -const getCollapsedCategory = (renderCollapsed) => { - let categories - if (typeof window !== 'undefined') { - const url = new URL(window.location.href) - const collapsedItems = new URLSearchParams(url.search).get('collapsed') - switch (true) { - case !renderCollapsed && collapsedItems === null: - categories = ['passed'] - break - case collapsedItems?.length === 0 || /^["']{2}$/.test(collapsedItems): - categories = [] - break - case /^all$/.test(collapsedItems) || collapsedItems === null && /^all$/.test(renderCollapsed): - categories = [...possibleFilters] - break - default: - categories = collapsedItems?.split(',').map((item) => item.toLowerCase()) || renderCollapsed - break - } - } else { - categories = [] - } - return categories -} - -const getSortDirection = () => JSON.parse(sessionStorage.getItem('sortAsc')) || false -const setSortDirection = (ascending) => sessionStorage.setItem('sortAsc', ascending) - -const getCollapsedIds = () => JSON.parse(sessionStorage.getItem('collapsedIds')) || [] -const setCollapsedIds = (list) => sessionStorage.setItem('collapsedIds', JSON.stringify(list)) - -module.exports = { - getVisible, - hideCategory, - showCategory, - getCollapsedIds, - setCollapsedIds, - getSort, - setSort, - getSortDirection, - setSortDirection, - getCollapsedCategory, - possibleFilters, -} - -},{}]},{},[4]); - </script> - </footer> -</html> \ No newline at end of file diff --git a/backend/src/persistence.py b/backend/src/cache.py similarity index 100% rename from backend/src/persistence.py rename to backend/src/cache.py diff --git a/backend/src/constants.py b/backend/src/constants.py index 21ed016..303e7ed 100644 --- a/backend/src/constants.py +++ b/backend/src/constants.py @@ -1,4 +1,4 @@ -"""Module allowing to access the parameters of route generation""" +"""Module setting global parameters for the application, such as logging, cache, route generation, etc.""" import logging import os @@ -16,19 +16,33 @@ cache_dir_string = os.getenv('OSM_CACHE_DIR', './cache') OSM_CACHE_DIR = Path(cache_dir_string) -# if we are in a debug session, set verbose and rich logging -if os.getenv('DEBUG', "false") == "true": +# if we are in a debug (local) session, set verbose and rich logging +debug = os.getenv('DEBUG', "false") == "true" +if os.getenv('KUBERNETES_SERVICE_HOST', None) is not None: + # in that case we want to log to stdout and also to loki + from loki_logger_handler.loki_logger_handler import LokiLoggerHandler + loki_handler = LokiLoggerHandler( + url=os.getenv('LOKI_URL', 'http://loki:3100'), + labels={'app': 'anyway', 'env': 'production' if debug else 'staging'} + ) + if debug: + logging.basicConfig( + level=logging.DEBUG, + handlers=[loki_handler, logging.StreamHandler()] + ) + else: + logging.basicConfig( + level=logging.INFO, + handlers=[loki_handler, logging.StreamHandler()] + ) +else: + # in that case we are local and we want to log to stdout only, but make it pretty from rich.logging import RichHandler logging.basicConfig( level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[RichHandler()] ) -else: - logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - ) MEMCACHED_HOST_PATH = os.getenv('MEMCACHED_HOST_PATH', None) diff --git a/backend/src/main.py b/backend/src/main.py index a2efbf1..eea3bad 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -11,7 +11,7 @@ from .utils.landmarks_manager import LandmarkManager from .utils.toilets_manager import ToiletsManager from .utils.optimizer import Optimizer from .utils.refiner import Refiner -from .persistence import client as cache_client +from .cache import client as cache_client logger = logging.getLogger(__name__) diff --git a/backend/src/tests/test_utils.py b/backend/src/tests/test_utils.py index d5b69ad..73f4ec7 100644 --- a/backend/src/tests/test_utils.py +++ b/backend/src/tests/test_utils.py @@ -4,7 +4,7 @@ from fastapi import HTTPException from pydantic import ValidationError from ..structs.landmark import Landmark -from ..persistence import client as cache_client +from ..cache import client as cache_client def landmarks_to_osmid(landmarks: list[Landmark]) -> list[int] : From c448e2dfb76ca0a42caeb50899b44a4d31a9ce78 Mon Sep 17 00:00:00 2001 From: Remy Moll <me@moll.re> Date: Sat, 28 Dec 2024 15:52:29 +0100 Subject: [PATCH 2/6] more verbose logger setup --- .vscode/settings.json | 3 --- backend/deployment | 2 +- backend/src/constants.py | 12 ++++++++++-- 3 files changed, 11 insertions(+), 6 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 9ddf6b2..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "cmake.ignoreCMakeListsMissing": true -} \ No newline at end of file diff --git a/backend/deployment b/backend/deployment index 4f0a028..904f16b 160000 --- a/backend/deployment +++ b/backend/deployment @@ -1 +1 @@ -Subproject commit 4f0a0289fcf8a7755d7dc1a76b443fe1233685ae +Subproject commit 904f16bfc0624b6ab8569e0a70050aaa3bd64b3f diff --git a/backend/src/constants.py b/backend/src/constants.py index 303e7ed..208bfa6 100644 --- a/backend/src/constants.py +++ b/backend/src/constants.py @@ -21,11 +21,19 @@ debug = os.getenv('DEBUG', "false") == "true" if os.getenv('KUBERNETES_SERVICE_HOST', None) is not None: # in that case we want to log to stdout and also to loki from loki_logger_handler.loki_logger_handler import LokiLoggerHandler + loki_url = os.getenv('LOKI_URL') + if loki_url is None: + raise ValueError("LOKI_URL environment variable is not set") + loki_handler = LokiLoggerHandler( - url=os.getenv('LOKI_URL', 'http://loki:3100'), - labels={'app': 'anyway', 'env': 'production' if debug else 'staging'} + url = loki_url, + labels = {'app': 'anyway', 'environment': 'production' if debug else 'staging'} ) + print(f"Logging to Loki at {loki_url} with {loki_handler.labels} and {debug=}") if debug: + # we need to silence the debug logs made by the loki handler + logging.getLogger('urllib3.connectionpool').setLevel(logging.INFO) + logging.basicConfig( level=logging.DEBUG, handlers=[loki_handler, logging.StreamHandler()] From fa083a1080277cb68df711fd92cae6cad8eacf40 Mon Sep 17 00:00:00 2001 From: Remy Moll <me@moll.re> Date: Sat, 28 Dec 2024 22:25:42 +0100 Subject: [PATCH 3/6] logging cleanup --- .gitea/workflows/backend_run_lint.yaml | 2 - .gitea/workflows/backend_run_test.yaml | 1 - .vscode/launch.json | 10 +--- backend/Dockerfile | 4 +- backend/launcher.py | 82 ++++++++++++++++++++++++++ backend/logging_config.yaml | 29 +++++++++ backend/src/constants.py | 40 +------------ backend/src/structs/landmark.py | 2 +- 8 files changed, 118 insertions(+), 52 deletions(-) create mode 100644 backend/launcher.py create mode 100644 backend/logging_config.yaml diff --git a/.gitea/workflows/backend_run_lint.yaml b/.gitea/workflows/backend_run_lint.yaml index e3fd418..5db2228 100644 --- a/.gitea/workflows/backend_run_lint.yaml +++ b/.gitea/workflows/backend_run_lint.yaml @@ -25,8 +25,6 @@ jobs: ls -la # only install dev-packages pipenv install --categories=dev-packages - pipenv run pip freeze - working-directory: backend - name: Run linter diff --git a/.gitea/workflows/backend_run_test.yaml b/.gitea/workflows/backend_run_test.yaml index 36b5ee8..82ad499 100644 --- a/.gitea/workflows/backend_run_test.yaml +++ b/.gitea/workflows/backend_run_test.yaml @@ -25,7 +25,6 @@ jobs: ls -la # install all packages, including dev-packages pipenv install --dev - pipenv run pip freeze working-directory: backend - name: Run Tests diff --git a/.vscode/launch.json b/.vscode/launch.json index 48d1c86..f5cc3b6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,21 +4,15 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ - // backend - python using fastapi + // backend - python app that launches a uvicorn server { "name": "Backend - debug", "type": "debugpy", "request": "launch", - "module": "uvicorn", + "program": "launcher.py", "env": { "DEBUG": "true" }, - "args": [ - // "--app-dir", - // "src", - "src.main:app", - "--reload", - ], "jinja": true, "cwd": "${workspaceFolder}/backend" }, diff --git a/backend/Dockerfile b/backend/Dockerfile index 25a5e31..fae8d25 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,6 +7,7 @@ RUN pip install pipenv RUN pipenv install --deploy --system COPY src src +COPY launcher.py . EXPOSE 8000 @@ -14,5 +15,6 @@ EXPOSE 8000 ENV NUM_WORKERS=1 ENV OSM_CACHE_DIR=/cache ENV MEMCACHED_HOST_PATH=none +ENV LOKI_URL=none -CMD fastapi run src/main.py --port 8000 --workers $NUM_WORKERS +CMD ["python", "launcher.py"] diff --git a/backend/launcher.py b/backend/launcher.py new file mode 100644 index 0000000..269433b --- /dev/null +++ b/backend/launcher.py @@ -0,0 +1,82 @@ +"""Launcher for the FastAPI application. Fundametally this replicates the functionality of the uvicorn and fastapi CLI interfaces, but we need this to setup the logging correctly (and most importantly globally)""" + +import os +import uvicorn +import logging + +logger = logging.getLogger(__name__) +is_debug = os.getenv('DEBUG', "false") == "true" +is_kubernetes = os.getenv('KUBERNETES_SERVICE_HOST', None) is not None + +def logger_setup(): + """ + Setup the global logging configuration + """ + logging_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + + # Make uvicorn conform to the global logging configuration + uvicorn_logger = logging.getLogger('uvicorn') + uvicorn_logger.propagate = True + uvicorn_logger.handlers = [] # Remove default handlers to avoid duplicate logs + + if is_kubernetes: + # in that case we want to log to stdout and also to loki + from loki_logger_handler.loki_logger_handler import LokiLoggerHandler + loki_url = os.getenv('LOKI_URL') + if loki_url is None: + raise ValueError("LOKI_URL environment variable is not set") + + loki_handler = LokiLoggerHandler( + url = loki_url, + labels = {'app': 'anyway', 'environment': 'staging' if is_debug else 'production'} + ) + + logger.info(f"Logging to Loki at {loki_url} with {loki_handler.labels} and {is_debug=}") + if is_debug: + logging.basicConfig( + format = logging_format, + level = logging.DEBUG, + handlers = [loki_handler, logging.StreamHandler()] + ) + # we need to silence the debug logs made by the loki handler + logging.getLogger('urllib3.connectionpool').setLevel(logging.INFO) + else: + logging.basicConfig( + format = logging_format, + level = logging.INFO, + handlers = [loki_handler, logging.StreamHandler()] + ) + else: + # if we are in a debug (local) session, set verbose and rich logging + from rich.logging import RichHandler + logging.basicConfig( + format = logging_format, + level = logging.DEBUG, + handlers = [RichHandler()] + ) + + +def uvicorn_run(): + """ + Run the FastAPI application using uvicorn + """ + num_workers = os.getenv('NUM_WORKERS', 1) + logger.info(f"Starting FastAPI+uvicorn with {num_workers=}, {is_debug=}, {is_kubernetes=}") + uvicorn.run( + # we could in theory directly import the app and pass it as an object + # this 'import string' is required for hot reloading and scaling + 'src.main:app', + host = '0.0.0.0', + port = 8000, + log_config = None, + access_log = True, + # Disable uvicorn's logging configuration so that it inherits the global one + workers = num_workers, + # Hot reload breaks logging, so we leave it disabled + # reload = is_debug + ) + + +if __name__ == '__main__': + logger_setup() + uvicorn_run() diff --git a/backend/logging_config.yaml b/backend/logging_config.yaml new file mode 100644 index 0000000..d6b7a1d --- /dev/null +++ b/backend/logging_config.yaml @@ -0,0 +1,29 @@ +version: 1 +disable_existing_loggers: False +formatters: + standard: + format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s' +handlers: + console: + class: logging.StreamHandler + formatter: standard + level: DEBUG + rich: + class: rich.logging.RichHandler + level: DEBUG + loki: + class: loki_logger_handler.loki_logger_handler.LokiLoggerHandler + level: DEBUG + formatter: standard + url: ${LOKI_URL} + labels: + app: anyway + environment: ${ENVIRONMENT} +loggers: + uvicorn: + handlers: [console, rich, loki] + level: DEBUG + propagate: False +root: + handlers: [console, rich, loki] + level: DEBUG \ No newline at end of file diff --git a/backend/src/constants.py b/backend/src/constants.py index 208bfa6..60a26c7 100644 --- a/backend/src/constants.py +++ b/backend/src/constants.py @@ -1,6 +1,5 @@ -"""Module setting global parameters for the application, such as logging, cache, route generation, etc.""" +"""Module setting global parameters for the application such as cache, route generation, etc.""" -import logging import os from pathlib import Path @@ -16,43 +15,6 @@ cache_dir_string = os.getenv('OSM_CACHE_DIR', './cache') OSM_CACHE_DIR = Path(cache_dir_string) -# if we are in a debug (local) session, set verbose and rich logging -debug = os.getenv('DEBUG', "false") == "true" -if os.getenv('KUBERNETES_SERVICE_HOST', None) is not None: - # in that case we want to log to stdout and also to loki - from loki_logger_handler.loki_logger_handler import LokiLoggerHandler - loki_url = os.getenv('LOKI_URL') - if loki_url is None: - raise ValueError("LOKI_URL environment variable is not set") - - loki_handler = LokiLoggerHandler( - url = loki_url, - labels = {'app': 'anyway', 'environment': 'production' if debug else 'staging'} - ) - print(f"Logging to Loki at {loki_url} with {loki_handler.labels} and {debug=}") - if debug: - # we need to silence the debug logs made by the loki handler - logging.getLogger('urllib3.connectionpool').setLevel(logging.INFO) - - logging.basicConfig( - level=logging.DEBUG, - handlers=[loki_handler, logging.StreamHandler()] - ) - else: - logging.basicConfig( - level=logging.INFO, - handlers=[loki_handler, logging.StreamHandler()] - ) -else: - # in that case we are local and we want to log to stdout only, but make it pretty - from rich.logging import RichHandler - logging.basicConfig( - level=logging.DEBUG, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[RichHandler()] - ) - - MEMCACHED_HOST_PATH = os.getenv('MEMCACHED_HOST_PATH', None) if MEMCACHED_HOST_PATH == "none": MEMCACHED_HOST_PATH = None diff --git a/backend/src/structs/landmark.py b/backend/src/structs/landmark.py index a494896..60ef72a 100644 --- a/backend/src/structs/landmark.py +++ b/backend/src/structs/landmark.py @@ -139,4 +139,4 @@ class Toilets(BaseModel) : class Config: # This allows us to easily convert the model to and from dictionaries - orm_mode = True \ No newline at end of file + from_attributes = True \ No newline at end of file From bc63b57154bb3cf002d8848f09254ff888a4ea0b Mon Sep 17 00:00:00 2001 From: Remy Moll <me@moll.re> Date: Sat, 28 Dec 2024 22:34:14 +0100 Subject: [PATCH 4/6] dumb type conversion --- backend/launcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/launcher.py b/backend/launcher.py index 269433b..5de65ca 100644 --- a/backend/launcher.py +++ b/backend/launcher.py @@ -60,7 +60,7 @@ def uvicorn_run(): """ Run the FastAPI application using uvicorn """ - num_workers = os.getenv('NUM_WORKERS', 1) + num_workers = int(os.getenv('NUM_WORKERS', 1)) logger.info(f"Starting FastAPI+uvicorn with {num_workers=}, {is_debug=}, {is_kubernetes=}") uvicorn.run( # we could in theory directly import the app and pass it as an object From 4e07c10969565c816a1ecd55f12126aabf35255f Mon Sep 17 00:00:00 2001 From: Remy Moll <me@moll.re> Date: Sun, 29 Dec 2024 14:45:41 +0100 Subject: [PATCH 5/6] actually use fastapi lifetime manager to setup logging --- .vscode/launch.json | 10 ++-- backend/Dockerfile | 3 +- backend/launcher.py | 82 --------------------------------- backend/logging_config.yaml | 29 ------------ backend/src/logging_config.py | 56 ++++++++++++++++++++++ backend/src/main.py | 17 ++++++- backend/src/structs/landmark.py | 10 ++-- backend/src/structs/trip.py | 8 ++-- 8 files changed, 88 insertions(+), 127 deletions(-) delete mode 100644 backend/launcher.py delete mode 100644 backend/logging_config.yaml create mode 100644 backend/src/logging_config.py diff --git a/.vscode/launch.json b/.vscode/launch.json index f5cc3b6..6f1ce86 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,17 +4,21 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ - // backend - python app that launches a uvicorn server + // backend - python using fastapi { "name": "Backend - debug", "type": "debugpy", "request": "launch", - "program": "launcher.py", "env": { "DEBUG": "true" }, "jinja": true, - "cwd": "${workspaceFolder}/backend" + "cwd": "${workspaceFolder}/backend", + "module": "fastapi", + "args": [ + "dev", + "src/main.py" + ] }, { "name": "Backend - tester", diff --git a/backend/Dockerfile b/backend/Dockerfile index fae8d25..0f1f475 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,7 +7,6 @@ RUN pip install pipenv RUN pipenv install --deploy --system COPY src src -COPY launcher.py . EXPOSE 8000 @@ -17,4 +16,4 @@ ENV OSM_CACHE_DIR=/cache ENV MEMCACHED_HOST_PATH=none ENV LOKI_URL=none -CMD ["python", "launcher.py"] +CMD ["fastapi", "src/main.py", "--port", "8000", "--workers", "$NUM_WORKERS"] diff --git a/backend/launcher.py b/backend/launcher.py deleted file mode 100644 index 5de65ca..0000000 --- a/backend/launcher.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Launcher for the FastAPI application. Fundametally this replicates the functionality of the uvicorn and fastapi CLI interfaces, but we need this to setup the logging correctly (and most importantly globally)""" - -import os -import uvicorn -import logging - -logger = logging.getLogger(__name__) -is_debug = os.getenv('DEBUG', "false") == "true" -is_kubernetes = os.getenv('KUBERNETES_SERVICE_HOST', None) is not None - -def logger_setup(): - """ - Setup the global logging configuration - """ - logging_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' - - # Make uvicorn conform to the global logging configuration - uvicorn_logger = logging.getLogger('uvicorn') - uvicorn_logger.propagate = True - uvicorn_logger.handlers = [] # Remove default handlers to avoid duplicate logs - - if is_kubernetes: - # in that case we want to log to stdout and also to loki - from loki_logger_handler.loki_logger_handler import LokiLoggerHandler - loki_url = os.getenv('LOKI_URL') - if loki_url is None: - raise ValueError("LOKI_URL environment variable is not set") - - loki_handler = LokiLoggerHandler( - url = loki_url, - labels = {'app': 'anyway', 'environment': 'staging' if is_debug else 'production'} - ) - - logger.info(f"Logging to Loki at {loki_url} with {loki_handler.labels} and {is_debug=}") - if is_debug: - logging.basicConfig( - format = logging_format, - level = logging.DEBUG, - handlers = [loki_handler, logging.StreamHandler()] - ) - # we need to silence the debug logs made by the loki handler - logging.getLogger('urllib3.connectionpool').setLevel(logging.INFO) - else: - logging.basicConfig( - format = logging_format, - level = logging.INFO, - handlers = [loki_handler, logging.StreamHandler()] - ) - else: - # if we are in a debug (local) session, set verbose and rich logging - from rich.logging import RichHandler - logging.basicConfig( - format = logging_format, - level = logging.DEBUG, - handlers = [RichHandler()] - ) - - -def uvicorn_run(): - """ - Run the FastAPI application using uvicorn - """ - num_workers = int(os.getenv('NUM_WORKERS', 1)) - logger.info(f"Starting FastAPI+uvicorn with {num_workers=}, {is_debug=}, {is_kubernetes=}") - uvicorn.run( - # we could in theory directly import the app and pass it as an object - # this 'import string' is required for hot reloading and scaling - 'src.main:app', - host = '0.0.0.0', - port = 8000, - log_config = None, - access_log = True, - # Disable uvicorn's logging configuration so that it inherits the global one - workers = num_workers, - # Hot reload breaks logging, so we leave it disabled - # reload = is_debug - ) - - -if __name__ == '__main__': - logger_setup() - uvicorn_run() diff --git a/backend/logging_config.yaml b/backend/logging_config.yaml deleted file mode 100644 index d6b7a1d..0000000 --- a/backend/logging_config.yaml +++ /dev/null @@ -1,29 +0,0 @@ -version: 1 -disable_existing_loggers: False -formatters: - standard: - format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s' -handlers: - console: - class: logging.StreamHandler - formatter: standard - level: DEBUG - rich: - class: rich.logging.RichHandler - level: DEBUG - loki: - class: loki_logger_handler.loki_logger_handler.LokiLoggerHandler - level: DEBUG - formatter: standard - url: ${LOKI_URL} - labels: - app: anyway - environment: ${ENVIRONMENT} -loggers: - uvicorn: - handlers: [console, rich, loki] - level: DEBUG - propagate: False -root: - handlers: [console, rich, loki] - level: DEBUG \ No newline at end of file diff --git a/backend/src/logging_config.py b/backend/src/logging_config.py new file mode 100644 index 0000000..0f93fa7 --- /dev/null +++ b/backend/src/logging_config.py @@ -0,0 +1,56 @@ +"""Sets up global logging configuration for the application.""" + +import logging +import os + +logger = logging.getLogger(__name__) + + +def configure_logging(): + """ + Called at startup of a FastAPI application instance to setup logging. Depending on the environment, it will log to stdout or to Loki. + """ + + is_debug = os.getenv('DEBUG', "false") == "true" + is_kubernetes = os.getenv('KUBERNETES_SERVICE_HOST') is not None + + + if not is_kubernetes: + # in that case we want to log to stdout and also to loki + from loki_logger_handler.loki_logger_handler import LokiLoggerHandler + loki_url = os.getenv('LOKI_URL') + loki_url = "http://localhost:3100/loki/api/v1/push" + if loki_url is None: + raise ValueError("LOKI_URL environment variable is not set") + + loki_handler = LokiLoggerHandler( + url = loki_url, + labels = {'app': 'anyway', 'environment': 'staging' if is_debug else 'production'} + ) + + logger.info(f"Logging to Loki at {loki_url} with {loki_handler.labels} and {is_debug=}") + logging_handlers = [loki_handler, logging.StreamHandler()] + logging_level = logging.DEBUG if is_debug else logging.INFO + # no need for time since it's added by loki or can be shown in kube logs + logging_format = '%(name)s - %(levelname)s - %(message)s' + + else: + # if we are in a debug (local) session, set verbose and rich logging + from rich.logging import RichHandler + logging_handlers = [RichHandler()] + logging_level = logging.DEBUG if is_debug else logging.INFO + logging_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + + + + logging.basicConfig( + level = logging_level, + format = logging_format, + handlers = logging_handlers + ) + + # also overwrite the uvicorn loggers + logging.getLogger('uvicorn').handlers = logging_handlers + logging.getLogger('uvicorn.access').handlers = logging_handlers + logging.getLogger('uvicorn.error').handlers = logging_handlers + diff --git a/backend/src/main.py b/backend/src/main.py index eea3bad..c8c1f40 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -2,7 +2,9 @@ import logging from fastapi import FastAPI, HTTPException, Query +from contextlib import asynccontextmanager +from .logging_config import configure_logging from .structs.landmark import Landmark, Toilets from .structs.preferences import Preferences from .structs.linked_landmarks import LinkedLandmarks @@ -13,15 +15,26 @@ from .utils.optimizer import Optimizer from .utils.refiner import Refiner from .cache import client as cache_client - logger = logging.getLogger(__name__) -app = FastAPI() manager = LandmarkManager() optimizer = Optimizer() refiner = Refiner(optimizer=optimizer) +@asynccontextmanager +async def lifespan(app: FastAPI): + """Function to run at the start of the app""" + logger.info("Setting up logging") + configure_logging() + yield + logger.info("Shutting down logging") + + +app = FastAPI(lifespan=lifespan) + + + @app.post("/trip/new") def new_trip(preferences: Preferences, start: tuple[float, float], diff --git a/backend/src/structs/landmark.py b/backend/src/structs/landmark.py index 60ef72a..da0f122 100644 --- a/backend/src/structs/landmark.py +++ b/backend/src/structs/landmark.py @@ -1,7 +1,7 @@ """Definition of the Landmark class to handle visitable objects across the world.""" from typing import Optional, Literal -from uuid import uuid4 +from uuid import uuid4, UUID from pydantic import BaseModel, Field @@ -29,12 +29,12 @@ class Landmark(BaseModel) : description (Optional[str]): A text description of the landmark. duration (Optional[int]): The estimated time to visit the landmark (in minutes). name_en (Optional[str]): The English name of the landmark. - uuid (str): A unique identifier for the landmark, generated by default using uuid4. + uuid (UUID): A unique identifier for the landmark, generated by default using uuid4. must_do (Optional[bool]): Whether the landmark is a "must-do" attraction. must_avoid (Optional[bool]): Whether the landmark should be avoided. is_secondary (Optional[bool]): Whether the landmark is secondary or less important. time_to_reach_next (Optional[int]): Estimated time (in minutes) to reach the next landmark. - next_uuid (Optional[str]): UUID of the next landmark in sequence (if applicable). + next_uuid (Optional[UUID]): UUID of the next landmark in sequence (if applicable). """ # Properties of the landmark @@ -52,7 +52,7 @@ class Landmark(BaseModel) : name_en : Optional[str] = None # Unique ID of a given landmark - uuid: str = Field(default_factory=uuid4) + uuid: UUID = Field(default_factory=uuid4) # Additional properties depending on specific tour must_do : Optional[bool] = False @@ -60,7 +60,7 @@ class Landmark(BaseModel) : is_secondary : Optional[bool] = False time_to_reach_next : Optional[int] = 0 - next_uuid : Optional[str] = None + next_uuid : Optional[UUID] = None def __str__(self) -> str: """ diff --git a/backend/src/structs/trip.py b/backend/src/structs/trip.py index c00f591..d9b19d7 100644 --- a/backend/src/structs/trip.py +++ b/backend/src/structs/trip.py @@ -1,6 +1,6 @@ """Definition of the Trip class.""" -import uuid +from uuid import uuid4, UUID from pydantic import BaseModel, Field from pymemcache.client.base import Client @@ -19,9 +19,9 @@ class Trip(BaseModel): Methods: from_linked_landmarks: create a Trip from LinkedLandmarks object. """ - uuid: str = Field(default_factory=uuid.uuid4) + uuid: UUID = Field(default_factory=uuid4) total_time: int - first_landmark_uuid: str + first_landmark_uuid: UUID @classmethod @@ -31,7 +31,7 @@ class Trip(BaseModel): """ trip = Trip( total_time = landmarks.total_time, - first_landmark_uuid = str(landmarks[0].uuid) + first_landmark_uuid = landmarks[0].uuid ) # Store the trip in the cache From 86187d906976767e834eba4e4bab6172713ff7a8 Mon Sep 17 00:00:00 2001 From: Remy Moll <me@moll.re> Date: Sun, 29 Dec 2024 14:51:28 +0100 Subject: [PATCH 6/6] launch adjustments --- backend/Dockerfile | 3 ++- backend/src/logging_config.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 0f1f475..b363917 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -16,4 +16,5 @@ ENV OSM_CACHE_DIR=/cache ENV MEMCACHED_HOST_PATH=none ENV LOKI_URL=none -CMD ["fastapi", "src/main.py", "--port", "8000", "--workers", "$NUM_WORKERS"] +# explicitly use a string instead of an argument list to force a shell and variable expansion +CMD fastapi run src/main.py --port 8000 --workers $NUM_WORKERS diff --git a/backend/src/logging_config.py b/backend/src/logging_config.py index 0f93fa7..c43a246 100644 --- a/backend/src/logging_config.py +++ b/backend/src/logging_config.py @@ -15,7 +15,7 @@ def configure_logging(): is_kubernetes = os.getenv('KUBERNETES_SERVICE_HOST') is not None - if not is_kubernetes: + if is_kubernetes: # in that case we want to log to stdout and also to loki from loki_logger_handler.loki_logger_handler import LokiLoggerHandler loki_url = os.getenv('LOKI_URL') @@ -31,6 +31,8 @@ def configure_logging(): logger.info(f"Logging to Loki at {loki_url} with {loki_handler.labels} and {is_debug=}") logging_handlers = [loki_handler, logging.StreamHandler()] logging_level = logging.DEBUG if is_debug else logging.INFO + # silence the chatty logs loki generates itself + logging.getLogger('urllib3.connectionpool').setLevel(logging.WARNING) # no need for time since it's added by loki or can be shown in kube logs logging_format = '%(name)s - %(levelname)s - %(message)s'