logdetective 0.9.1__py3-none-any.whl → 0.11.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- logdetective/constants.py +4 -0
- logdetective/{server/remote_log.py → remote_log.py} +3 -43
- logdetective/server/compressors.py +49 -4
- logdetective/server/{utils.py → config.py} +12 -13
- logdetective/server/database/models/merge_request_jobs.py +95 -7
- logdetective/server/emoji.py +104 -0
- logdetective/server/gitlab.py +413 -0
- logdetective/server/llm.py +284 -0
- logdetective/server/metric.py +27 -9
- logdetective/server/models.py +78 -6
- logdetective/server/plot.py +157 -9
- logdetective/server/server.py +181 -639
- logdetective/utils.py +1 -1
- {logdetective-0.9.1.dist-info → logdetective-0.11.1.dist-info}/METADATA +5 -3
- logdetective-0.11.1.dist-info/RECORD +31 -0
- logdetective-0.9.1.dist-info/RECORD +0 -28
- {logdetective-0.9.1.dist-info → logdetective-0.11.1.dist-info}/LICENSE +0 -0
- {logdetective-0.9.1.dist-info → logdetective-0.11.1.dist-info}/WHEEL +0 -0
- {logdetective-0.9.1.dist-info → logdetective-0.11.1.dist-info}/entry_points.txt +0 -0
logdetective/server/server.py
CHANGED
|
@@ -1,76 +1,59 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import json
|
|
3
1
|
import os
|
|
4
|
-
import
|
|
5
|
-
import
|
|
2
|
+
import asyncio
|
|
3
|
+
import datetime
|
|
6
4
|
from enum import Enum
|
|
7
5
|
from contextlib import asynccontextmanager
|
|
8
|
-
from
|
|
9
|
-
from tempfile import TemporaryFile
|
|
10
|
-
from typing import List, Annotated, Tuple, Dict, Any, Union
|
|
6
|
+
from typing import Annotated
|
|
11
7
|
from io import BytesIO
|
|
12
8
|
|
|
13
|
-
import backoff
|
|
14
9
|
import matplotlib
|
|
15
10
|
import matplotlib.pyplot
|
|
16
|
-
from aiohttp import StreamReader
|
|
17
11
|
from fastapi import FastAPI, HTTPException, BackgroundTasks, Depends, Header, Request
|
|
18
12
|
|
|
19
13
|
from fastapi.responses import StreamingResponse
|
|
20
14
|
from fastapi.responses import Response as BasicResponse
|
|
21
|
-
import gitlab
|
|
22
|
-
import gitlab.v4
|
|
23
|
-
import gitlab.v4.objects
|
|
24
|
-
import jinja2
|
|
25
15
|
import aiohttp
|
|
26
|
-
import sqlalchemy
|
|
27
16
|
import sentry_sdk
|
|
28
17
|
|
|
29
18
|
import logdetective.server.database.base
|
|
30
19
|
|
|
31
|
-
from logdetective.extractors import DrainExtractor
|
|
32
20
|
from logdetective.utils import (
|
|
33
21
|
compute_certainty,
|
|
34
22
|
format_snippets,
|
|
35
|
-
load_prompts,
|
|
36
23
|
)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
24
|
+
|
|
25
|
+
from logdetective.server.config import SERVER_CONFIG, PROMPT_CONFIG, LOG
|
|
26
|
+
from logdetective.remote_log import RemoteLog
|
|
27
|
+
from logdetective.server.llm import (
|
|
28
|
+
mine_logs,
|
|
29
|
+
perform_staged_analysis,
|
|
30
|
+
submit_text,
|
|
31
|
+
submit_text_chat_completions,
|
|
41
32
|
)
|
|
42
|
-
from logdetective.server.
|
|
33
|
+
from logdetective.server.gitlab import process_gitlab_job_event
|
|
34
|
+
from logdetective.server.metric import track_request
|
|
43
35
|
from logdetective.server.models import (
|
|
44
36
|
BuildLog,
|
|
37
|
+
EmojiHook,
|
|
45
38
|
JobHook,
|
|
46
39
|
Response,
|
|
47
40
|
StagedResponse,
|
|
48
|
-
Explanation,
|
|
49
|
-
AnalyzedSnippet,
|
|
50
41
|
TimePeriod,
|
|
51
42
|
)
|
|
52
43
|
from logdetective.server import plot as plot_engine
|
|
53
|
-
from logdetective.server.remote_log import RemoteLog
|
|
54
44
|
from logdetective.server.database.models import (
|
|
55
|
-
Comments,
|
|
56
45
|
EndpointType,
|
|
57
46
|
Forge,
|
|
58
47
|
)
|
|
59
|
-
from logdetective.server.
|
|
48
|
+
from logdetective.server.emoji import (
|
|
49
|
+
collect_emojis,
|
|
50
|
+
collect_emojis_for_mr,
|
|
51
|
+
)
|
|
52
|
+
|
|
60
53
|
|
|
61
|
-
LLM_CPP_SERVER_TIMEOUT = os.environ.get("LLAMA_CPP_SERVER_TIMEOUT", 600)
|
|
62
54
|
LOG_SOURCE_REQUEST_TIMEOUT = os.environ.get("LOG_SOURCE_REQUEST_TIMEOUT", 60)
|
|
63
55
|
API_TOKEN = os.environ.get("LOGDETECTIVE_TOKEN", None)
|
|
64
|
-
SERVER_CONFIG_PATH = os.environ.get("LOGDETECTIVE_SERVER_CONF", None)
|
|
65
|
-
SERVER_PROMPT_PATH = os.environ.get("LOGDETECTIVE_PROMPTS", None)
|
|
66
|
-
|
|
67
|
-
SERVER_CONFIG = load_server_config(SERVER_CONFIG_PATH)
|
|
68
|
-
PROMPT_CONFIG = load_prompts(SERVER_PROMPT_PATH)
|
|
69
56
|
|
|
70
|
-
MR_REGEX = re.compile(r"refs/merge-requests/(\d+)/.*$")
|
|
71
|
-
FAILURE_LOG_REGEX = re.compile(r"(\w*\.log)")
|
|
72
|
-
|
|
73
|
-
LOG = get_log(SERVER_CONFIG)
|
|
74
57
|
|
|
75
58
|
if sentry_dsn := SERVER_CONFIG.general.sentry_dsn:
|
|
76
59
|
sentry_sdk.init(dsn=str(sentry_dsn), traces_sample_rate=1.0)
|
|
@@ -90,7 +73,11 @@ async def lifespan(fapp: FastAPI):
|
|
|
90
73
|
# Ensure that the database is initialized.
|
|
91
74
|
logdetective.server.database.base.init()
|
|
92
75
|
|
|
76
|
+
# Start the background task scheduler for collecting emojis
|
|
77
|
+
asyncio.create_task(schedule_collect_emojis_task())
|
|
78
|
+
|
|
93
79
|
yield
|
|
80
|
+
|
|
94
81
|
await fapp.http.close()
|
|
95
82
|
|
|
96
83
|
|
|
@@ -132,203 +119,6 @@ def requires_token_when_set(authentication: Annotated[str | None, Header()] = No
|
|
|
132
119
|
|
|
133
120
|
|
|
134
121
|
app = FastAPI(dependencies=[Depends(requires_token_when_set)], lifespan=lifespan)
|
|
135
|
-
app.gitlab_conn = gitlab.Gitlab(
|
|
136
|
-
url=SERVER_CONFIG.gitlab.url, private_token=SERVER_CONFIG.gitlab.api_token
|
|
137
|
-
)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def mine_logs(log: str) -> List[Tuple[int, str]]:
|
|
141
|
-
"""Extract snippets from log text"""
|
|
142
|
-
extractor = DrainExtractor(
|
|
143
|
-
verbose=True, context=True, max_clusters=SERVER_CONFIG.extractor.max_clusters
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
LOG.info("Getting summary")
|
|
147
|
-
log_summary = extractor(log)
|
|
148
|
-
|
|
149
|
-
ratio = len(log_summary) / len(log.split("\n"))
|
|
150
|
-
LOG.debug("Log summary: \n %s", log_summary)
|
|
151
|
-
LOG.info("Compression ratio: %s", ratio)
|
|
152
|
-
|
|
153
|
-
return log_summary
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
async def submit_to_llm_endpoint(
|
|
157
|
-
http: aiohttp.ClientSession,
|
|
158
|
-
url: str,
|
|
159
|
-
data: Dict[str, Any],
|
|
160
|
-
headers: Dict[str, str],
|
|
161
|
-
stream: bool,
|
|
162
|
-
) -> Any:
|
|
163
|
-
"""Send request to selected API endpoint. Verifying successful request unless
|
|
164
|
-
the using the stream response.
|
|
165
|
-
|
|
166
|
-
url:
|
|
167
|
-
data:
|
|
168
|
-
headers:
|
|
169
|
-
stream:
|
|
170
|
-
"""
|
|
171
|
-
LOG.debug("async request %s headers=%s data=%s", url, headers, data)
|
|
172
|
-
response = await http.post(
|
|
173
|
-
url,
|
|
174
|
-
headers=headers,
|
|
175
|
-
# we need to use the `json=` parameter here and let aiohttp
|
|
176
|
-
# handle the json-encoding
|
|
177
|
-
json=data,
|
|
178
|
-
timeout=int(LLM_CPP_SERVER_TIMEOUT),
|
|
179
|
-
# Docs says chunked takes int, but:
|
|
180
|
-
# DeprecationWarning: Chunk size is deprecated #1615
|
|
181
|
-
# So let's make sure we either put True or None here
|
|
182
|
-
chunked=True if stream else None,
|
|
183
|
-
raise_for_status=True,
|
|
184
|
-
)
|
|
185
|
-
if stream:
|
|
186
|
-
return response
|
|
187
|
-
try:
|
|
188
|
-
return json.loads(await response.text())
|
|
189
|
-
except UnicodeDecodeError as ex:
|
|
190
|
-
LOG.error("Error encountered while parsing llama server response: %s", ex)
|
|
191
|
-
raise HTTPException(
|
|
192
|
-
status_code=400,
|
|
193
|
-
detail=f"Couldn't parse the response.\nError: {ex}\nData: {response.text}",
|
|
194
|
-
) from ex
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def should_we_giveup(exc: aiohttp.ClientResponseError) -> bool:
|
|
198
|
-
"""
|
|
199
|
-
From backoff's docs:
|
|
200
|
-
|
|
201
|
-
> a function which accepts the exception and returns
|
|
202
|
-
> a truthy value if the exception should not be retried
|
|
203
|
-
"""
|
|
204
|
-
LOG.info("Should we give up on retrying error %s", exc)
|
|
205
|
-
return exc.status < 500
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
def we_give_up(details: backoff._typing.Details):
|
|
209
|
-
"""
|
|
210
|
-
retries didn't work (or we got a different exc)
|
|
211
|
-
we give up and raise proper 500 for our API endpoint
|
|
212
|
-
"""
|
|
213
|
-
LOG.error("Inference error: %s", details["args"])
|
|
214
|
-
raise HTTPException(500, "Request to the inference API failed")
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
@backoff.on_exception(
|
|
218
|
-
backoff.expo,
|
|
219
|
-
aiohttp.ClientResponseError,
|
|
220
|
-
max_tries=3,
|
|
221
|
-
giveup=should_we_giveup,
|
|
222
|
-
raise_on_giveup=False,
|
|
223
|
-
on_giveup=we_give_up,
|
|
224
|
-
)
|
|
225
|
-
async def submit_text( # pylint: disable=R0913,R0917
|
|
226
|
-
http: aiohttp.ClientSession,
|
|
227
|
-
text: str,
|
|
228
|
-
max_tokens: int = -1,
|
|
229
|
-
log_probs: int = 1,
|
|
230
|
-
stream: bool = False,
|
|
231
|
-
model: str = "default-model",
|
|
232
|
-
) -> Explanation:
|
|
233
|
-
"""Submit prompt to LLM using a selected endpoint.
|
|
234
|
-
max_tokens: number of tokens to be produces, 0 indicates run until encountering EOS
|
|
235
|
-
log_probs: number of token choices to produce log probs for
|
|
236
|
-
"""
|
|
237
|
-
LOG.info("Analyzing the text")
|
|
238
|
-
|
|
239
|
-
headers = {"Content-Type": "application/json"}
|
|
240
|
-
|
|
241
|
-
if SERVER_CONFIG.inference.api_token:
|
|
242
|
-
headers["Authorization"] = f"Bearer {SERVER_CONFIG.inference.api_token}"
|
|
243
|
-
|
|
244
|
-
if SERVER_CONFIG.inference.api_endpoint == "/chat/completions":
|
|
245
|
-
return await submit_text_chat_completions(
|
|
246
|
-
http, text, headers, max_tokens, log_probs > 0, stream, model
|
|
247
|
-
)
|
|
248
|
-
return await submit_text_completions(
|
|
249
|
-
http, text, headers, max_tokens, log_probs, stream, model
|
|
250
|
-
)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
async def submit_text_completions( # pylint: disable=R0913,R0917
|
|
254
|
-
http: aiohttp.ClientSession,
|
|
255
|
-
text: str,
|
|
256
|
-
headers: dict,
|
|
257
|
-
max_tokens: int = -1,
|
|
258
|
-
log_probs: int = 1,
|
|
259
|
-
stream: bool = False,
|
|
260
|
-
model: str = "default-model",
|
|
261
|
-
) -> Explanation:
|
|
262
|
-
"""Submit prompt to OpenAI API completions endpoint.
|
|
263
|
-
max_tokens: number of tokens to be produces, 0 indicates run until encountering EOS
|
|
264
|
-
log_probs: number of token choices to produce log probs for
|
|
265
|
-
"""
|
|
266
|
-
LOG.info("Submitting to /v1/completions endpoint")
|
|
267
|
-
data = {
|
|
268
|
-
"prompt": text,
|
|
269
|
-
"max_tokens": max_tokens,
|
|
270
|
-
"logprobs": log_probs,
|
|
271
|
-
"stream": stream,
|
|
272
|
-
"model": model,
|
|
273
|
-
"temperature": SERVER_CONFIG.inference.temperature,
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
response = await submit_to_llm_endpoint(
|
|
277
|
-
http,
|
|
278
|
-
f"{SERVER_CONFIG.inference.url}/v1/completions",
|
|
279
|
-
data,
|
|
280
|
-
headers,
|
|
281
|
-
stream,
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
return Explanation(
|
|
285
|
-
text=response["choices"][0]["text"], logprobs=response["choices"][0]["logprobs"]
|
|
286
|
-
)
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
async def submit_text_chat_completions( # pylint: disable=R0913,R0917
|
|
290
|
-
http: aiohttp.ClientSession,
|
|
291
|
-
text: str,
|
|
292
|
-
headers: dict,
|
|
293
|
-
max_tokens: int = -1,
|
|
294
|
-
log_probs: int = 1,
|
|
295
|
-
stream: bool = False,
|
|
296
|
-
model: str = "default-model",
|
|
297
|
-
) -> Union[Explanation, StreamReader]:
|
|
298
|
-
"""Submit prompt to OpenAI API /chat/completions endpoint.
|
|
299
|
-
max_tokens: number of tokens to be produces, 0 indicates run until encountering EOS
|
|
300
|
-
log_probs: number of token choices to produce log probs for
|
|
301
|
-
"""
|
|
302
|
-
LOG.info("Submitting to /v1/chat/completions endpoint")
|
|
303
|
-
|
|
304
|
-
data = {
|
|
305
|
-
"messages": [
|
|
306
|
-
{
|
|
307
|
-
"role": "user",
|
|
308
|
-
"content": text,
|
|
309
|
-
}
|
|
310
|
-
],
|
|
311
|
-
"max_tokens": max_tokens,
|
|
312
|
-
"logprobs": log_probs,
|
|
313
|
-
"stream": stream,
|
|
314
|
-
"model": model,
|
|
315
|
-
"temperature": SERVER_CONFIG.inference.temperature,
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
response = await submit_to_llm_endpoint(
|
|
319
|
-
http,
|
|
320
|
-
f"{SERVER_CONFIG.inference.url}/v1/chat/completions",
|
|
321
|
-
data,
|
|
322
|
-
headers,
|
|
323
|
-
stream,
|
|
324
|
-
)
|
|
325
|
-
|
|
326
|
-
if stream:
|
|
327
|
-
return response
|
|
328
|
-
return Explanation(
|
|
329
|
-
text=response["choices"][0]["message"]["content"],
|
|
330
|
-
logprobs=response["choices"][0]["logprobs"]["content"],
|
|
331
|
-
)
|
|
332
122
|
|
|
333
123
|
|
|
334
124
|
@app.post("/analyze", response_model=Response)
|
|
@@ -346,6 +136,7 @@ async def analyze_log(
|
|
|
346
136
|
log_text = await remote_log.process_url()
|
|
347
137
|
log_summary = mine_logs(log_text)
|
|
348
138
|
log_summary = format_snippets(log_summary)
|
|
139
|
+
|
|
349
140
|
response = await submit_text(
|
|
350
141
|
http_session,
|
|
351
142
|
PROMPT_CONFIG.prompt_template.format(log_summary),
|
|
@@ -367,8 +158,8 @@ async def analyze_log(
|
|
|
367
158
|
return Response(explanation=response, response_certainty=certainty)
|
|
368
159
|
|
|
369
160
|
|
|
370
|
-
@track_request()
|
|
371
161
|
@app.post("/analyze/staged", response_model=StagedResponse)
|
|
162
|
+
@track_request()
|
|
372
163
|
async def analyze_log_staged(
|
|
373
164
|
build_log: BuildLog, http_session: aiohttp.ClientSession = Depends(get_http_session)
|
|
374
165
|
):
|
|
@@ -381,61 +172,27 @@ async def analyze_log_staged(
|
|
|
381
172
|
remote_log = RemoteLog(build_log.url, http_session)
|
|
382
173
|
log_text = await remote_log.process_url()
|
|
383
174
|
|
|
384
|
-
return await perform_staged_analysis(
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
async def perform_staged_analysis(
|
|
388
|
-
http: aiohttp.ClientSession, log_text: str
|
|
389
|
-
) -> StagedResponse:
|
|
390
|
-
"""Submit the log file snippets to the LLM and retrieve their results"""
|
|
391
|
-
log_summary = mine_logs(log_text)
|
|
392
|
-
|
|
393
|
-
# Process snippets asynchronously
|
|
394
|
-
analyzed_snippets = await asyncio.gather(
|
|
395
|
-
*[
|
|
396
|
-
submit_text(
|
|
397
|
-
http,
|
|
398
|
-
PROMPT_CONFIG.snippet_prompt_template.format(s),
|
|
399
|
-
model=SERVER_CONFIG.inference.model,
|
|
400
|
-
max_tokens=SERVER_CONFIG.inference.max_tokens,
|
|
401
|
-
)
|
|
402
|
-
for s in log_summary
|
|
403
|
-
]
|
|
175
|
+
return await perform_staged_analysis(
|
|
176
|
+
http_session,
|
|
177
|
+
log_text=log_text,
|
|
404
178
|
)
|
|
405
179
|
|
|
406
|
-
analyzed_snippets = [
|
|
407
|
-
AnalyzedSnippet(line_number=e[0][0], text=e[0][1], explanation=e[1])
|
|
408
|
-
for e in zip(log_summary, analyzed_snippets)
|
|
409
|
-
]
|
|
410
|
-
final_prompt = PROMPT_CONFIG.prompt_template_staged.format(
|
|
411
|
-
format_analyzed_snippets(analyzed_snippets)
|
|
412
|
-
)
|
|
413
180
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
max_tokens=SERVER_CONFIG.inference.max_tokens,
|
|
419
|
-
)
|
|
181
|
+
@app.get("/queue/print")
|
|
182
|
+
async def queue_print(msg: str):
|
|
183
|
+
"""Debug endpoint to test the LLM request queue"""
|
|
184
|
+
LOG.info("Will print %s", msg)
|
|
420
185
|
|
|
421
|
-
|
|
186
|
+
result = await async_log(msg)
|
|
422
187
|
|
|
423
|
-
|
|
424
|
-
try:
|
|
425
|
-
certainty = compute_certainty(final_analysis.logprobs)
|
|
426
|
-
except ValueError as ex:
|
|
427
|
-
LOG.error("Error encountered while computing certainty: %s", ex)
|
|
428
|
-
raise HTTPException(
|
|
429
|
-
status_code=400,
|
|
430
|
-
detail=f"Couldn't compute certainty with data:\n"
|
|
431
|
-
f"{final_analysis.logprobs}",
|
|
432
|
-
) from ex
|
|
188
|
+
LOG.info("Printed %s and returned it", result)
|
|
433
189
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
190
|
+
|
|
191
|
+
async def async_log(msg):
|
|
192
|
+
"""Debug function to test the LLM request queue"""
|
|
193
|
+
async with SERVER_CONFIG.inference.get_limiter():
|
|
194
|
+
LOG.critical(msg)
|
|
195
|
+
return msg
|
|
439
196
|
|
|
440
197
|
|
|
441
198
|
@app.post("/analyze/stream", response_class=StreamingResponse)
|
|
@@ -459,7 +216,7 @@ async def analyze_log_stream(
|
|
|
459
216
|
headers["Authorization"] = f"Bearer {SERVER_CONFIG.inference.api_token}"
|
|
460
217
|
|
|
461
218
|
try:
|
|
462
|
-
stream =
|
|
219
|
+
stream = submit_text_chat_completions(
|
|
463
220
|
http_session,
|
|
464
221
|
PROMPT_CONFIG.prompt_template.format(log_summary),
|
|
465
222
|
stream=True,
|
|
@@ -497,387 +254,125 @@ async def receive_gitlab_job_event_webhook(
|
|
|
497
254
|
LOG.critical("%s is not a recognized forge. Ignoring.", x_gitlab_instance)
|
|
498
255
|
return BasicResponse(status_code=400)
|
|
499
256
|
|
|
500
|
-
# Handle the message in the background so we can return
|
|
501
|
-
|
|
257
|
+
# Handle the message in the background so we can return 204 immediately
|
|
258
|
+
gitlab_cfg = SERVER_CONFIG.gitlab.instances[forge.value]
|
|
259
|
+
background_tasks.add_task(
|
|
260
|
+
process_gitlab_job_event,
|
|
261
|
+
http,
|
|
262
|
+
gitlab_cfg,
|
|
263
|
+
forge,
|
|
264
|
+
job_hook,
|
|
265
|
+
)
|
|
502
266
|
|
|
503
267
|
# No return value or body is required for a webhook.
|
|
504
268
|
# 204: No Content
|
|
505
269
|
return BasicResponse(status_code=204)
|
|
506
270
|
|
|
507
271
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
):
|
|
513
|
-
|
|
514
|
-
LOG.debug("Received webhook message from %s:\n%s", forge.value, job_hook)
|
|
272
|
+
# A lookup table for whether we are currently processing a given merge request
|
|
273
|
+
# The key is the tuple (Forge, ProjectID, MRID) and the value is a boolean
|
|
274
|
+
# indicating whether we need to re-trigger the lookup immediately after
|
|
275
|
+
# completion due to another request coming in during processing.
|
|
276
|
+
# For example: {("https://gitlab.example.com", 23, 2): False}
|
|
277
|
+
emoji_lookup = {}
|
|
515
278
|
|
|
516
|
-
# Look up the project this job belongs to
|
|
517
|
-
project = await asyncio.to_thread(app.gitlab_conn.projects.get, job_hook.project_id)
|
|
518
|
-
LOG.info("Processing failed job for %s", project.name)
|
|
519
279
|
|
|
520
|
-
|
|
521
|
-
|
|
280
|
+
@app.post("/webhook/gitlab/emoji_events")
|
|
281
|
+
async def receive_gitlab_emoji_event_webhook(
|
|
282
|
+
x_gitlab_instance: Annotated[str | None, Header()],
|
|
283
|
+
emoji_hook: EmojiHook,
|
|
284
|
+
background_tasks: BackgroundTasks,
|
|
285
|
+
):
|
|
286
|
+
"""Webhook endpoint for receiving emoji event notifications from Gitlab
|
|
287
|
+
https://docs.gitlab.com/user/project/integrations/webhook_events/#emoji-events
|
|
288
|
+
lists the full specification for the messages sent for emoji events"""
|
|
522
289
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
290
|
+
try:
|
|
291
|
+
forge = Forge(x_gitlab_instance)
|
|
292
|
+
except ValueError:
|
|
293
|
+
LOG.critical("%s is not a recognized forge. Ignoring.", x_gitlab_instance)
|
|
294
|
+
return BasicResponse(status_code=400)
|
|
527
295
|
|
|
528
|
-
|
|
529
|
-
|
|
296
|
+
if not emoji_hook.merge_request:
|
|
297
|
+
# This is not a merge request event. It is probably an emoji applied
|
|
298
|
+
# to some other "awardable" entity. Just ignore it and return.
|
|
299
|
+
LOG.debug("Emoji event is not related to a merge request. Ignoring.")
|
|
300
|
+
return BasicResponse(status_code=204)
|
|
530
301
|
|
|
531
|
-
#
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
return
|
|
302
|
+
# We will re-process all the emojis on this merge request, to ensure that
|
|
303
|
+
# we haven't missed any messages, since webhooks do not provide delivery
|
|
304
|
+
# guarantees.
|
|
535
305
|
|
|
536
|
-
#
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
merge_request_iid = int(match.group(1))
|
|
544
|
-
|
|
545
|
-
LOG.debug("Retrieving log artifacts")
|
|
546
|
-
# Retrieve the build logs from the merge request artifacts and preprocess them
|
|
547
|
-
try:
|
|
548
|
-
log_url, preprocessed_log = await retrieve_and_preprocess_koji_logs(http, job)
|
|
549
|
-
except LogsTooLargeError:
|
|
550
|
-
LOG.error("Could not retrieve logs. Too large.")
|
|
551
|
-
raise
|
|
552
|
-
|
|
553
|
-
# Submit log to Log Detective and await the results.
|
|
554
|
-
log_text = preprocessed_log.read().decode(encoding="utf-8")
|
|
555
|
-
metrics_id = await add_new_metrics(
|
|
556
|
-
api_name=EndpointType.ANALYZE_GITLAB_JOB,
|
|
557
|
-
url=log_url,
|
|
558
|
-
http_session=http,
|
|
559
|
-
compressed_log_content=RemoteLog.zip_text(log_text),
|
|
306
|
+
# Check whether this request is already in progress.
|
|
307
|
+
# We are single-threaded, so we can guarantee that the table won't change
|
|
308
|
+
# between here and when we schedule the lookup.
|
|
309
|
+
key = (
|
|
310
|
+
forge,
|
|
311
|
+
emoji_hook.merge_request.target_project_id,
|
|
312
|
+
emoji_hook.merge_request.iid,
|
|
560
313
|
)
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
314
|
+
if key in emoji_lookup:
|
|
315
|
+
# It's already in progress, so we do not want to start another pass
|
|
316
|
+
# concurrently. We'll set the value to True to indicate that we should
|
|
317
|
+
# re-enqueue this lookup after the currently-running one concludes. It
|
|
318
|
+
# is always safe to set this to True, even if it's already True. If
|
|
319
|
+
# multiple requests come in during processing, we only need to re-run
|
|
320
|
+
# it a single time, since it will pick up all the ongoing changes. The
|
|
321
|
+
# worst-case situation is the one where we receive new requests just
|
|
322
|
+
# after processing starts, which will cause the cycle to repeat again.
|
|
323
|
+
# This should be very infrequent, as emoji events are computationally
|
|
324
|
+
# rare and very quick to process.
|
|
325
|
+
emoji_lookup[key] = True
|
|
326
|
+
LOG.info("MR Emojis already being processed for %s. Rescheduling.", key)
|
|
327
|
+
return BasicResponse(status_code=204)
|
|
328
|
+
|
|
329
|
+
# Inform the lookup table that we are processing this emoji
|
|
330
|
+
emoji_lookup[key] = False
|
|
331
|
+
|
|
332
|
+
# Create a background task to process the emojis on this Merge Request.
|
|
333
|
+
background_tasks.add_task(
|
|
334
|
+
schedule_emoji_collection_for_mr,
|
|
572
335
|
forge,
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
log_url,
|
|
577
|
-
staged_response,
|
|
578
|
-
metrics_id,
|
|
336
|
+
emoji_hook.merge_request.target_project_id,
|
|
337
|
+
emoji_hook.merge_request.iid,
|
|
338
|
+
background_tasks,
|
|
579
339
|
)
|
|
580
340
|
|
|
581
|
-
return
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
class LogsTooLargeError(RuntimeError):
|
|
585
|
-
"""The log archive exceeds the configured maximum size"""
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
async def retrieve_and_preprocess_koji_logs(
|
|
589
|
-
http: aiohttp.ClientSession, job: gitlab.v4.objects.ProjectJob
|
|
590
|
-
): # pylint: disable=too-many-branches
|
|
591
|
-
"""Download logs from the merge request artifacts
|
|
592
|
-
|
|
593
|
-
This function will retrieve the build logs and do some minimal
|
|
594
|
-
preprocessing to determine which log is relevant for analysis.
|
|
595
|
-
|
|
596
|
-
returns: The URL pointing to the selected log file and an open, file-like
|
|
597
|
-
object containing the log contents to be sent for processing by Log
|
|
598
|
-
Detective. The calling function is responsible for closing this object."""
|
|
599
|
-
|
|
600
|
-
# Make sure the file isn't too large to process.
|
|
601
|
-
if not await check_artifacts_file_size(http, job):
|
|
602
|
-
raise LogsTooLargeError(
|
|
603
|
-
f"Oversized logs for job {job.id} in project {job.project_id}"
|
|
604
|
-
)
|
|
605
|
-
|
|
606
|
-
# Create a temporary file to store the downloaded log zipfile.
|
|
607
|
-
# This will be automatically deleted when the last reference into it
|
|
608
|
-
# (returned by this function) is closed.
|
|
609
|
-
tempfile = TemporaryFile(mode="w+b")
|
|
610
|
-
await asyncio.to_thread(job.artifacts, streamed=True, action=tempfile.write)
|
|
611
|
-
tempfile.seek(0)
|
|
612
|
-
|
|
613
|
-
failed_arches = {}
|
|
614
|
-
artifacts_zip = zipfile.ZipFile(tempfile, mode="r") # pylint: disable=consider-using-with
|
|
615
|
-
for zipinfo in artifacts_zip.infolist():
|
|
616
|
-
if zipinfo.filename.endswith("task_failed.log"):
|
|
617
|
-
# The koji logs store this file in two places: 1) in the
|
|
618
|
-
# directory with the failed architecture and 2) in the parent
|
|
619
|
-
# directory. Most of the time, we want to ignore the one in the
|
|
620
|
-
# parent directory, since the rest of the information is in the
|
|
621
|
-
# specific task directory. However, there are some situations
|
|
622
|
-
# where non-build failures (such as "Target build already exists")
|
|
623
|
-
# may be presented only at the top level.
|
|
624
|
-
# The paths look like `kojilogs/noarch-XXXXXX/task_failed.log`
|
|
625
|
-
# or `kojilogs/noarch-XXXXXX/x86_64-XXXXXX/task_failed.log`
|
|
626
|
-
path = PurePath(zipinfo.filename)
|
|
627
|
-
if len(path.parts) <= 3:
|
|
628
|
-
failed_arches["toplevel"] = path
|
|
629
|
-
continue
|
|
630
|
-
|
|
631
|
-
# Extract the architecture from the immediate parent path
|
|
632
|
-
architecture = path.parent.parts[-1].split("-")[0]
|
|
633
|
-
|
|
634
|
-
# Open this file and read which log failed.
|
|
635
|
-
# The string in this log has the format
|
|
636
|
-
# `see <log> for more information`.
|
|
637
|
-
# Note: it may sometimes say
|
|
638
|
-
# `see build.log or root.log for more information`, but in
|
|
639
|
-
# that situation, we only want to handle build.log (for now),
|
|
640
|
-
# which means accepting only the first match for the regular
|
|
641
|
-
# expression.
|
|
642
|
-
with artifacts_zip.open(zipinfo.filename) as task_failed_log:
|
|
643
|
-
contents = task_failed_log.read().decode("utf-8")
|
|
644
|
-
match = FAILURE_LOG_REGEX.search(contents)
|
|
645
|
-
if not match:
|
|
646
|
-
LOG.error(
|
|
647
|
-
"task_failed.log does not indicate which log contains the failure."
|
|
648
|
-
)
|
|
649
|
-
raise SyntaxError(
|
|
650
|
-
"task_failed.log does not indicate which log contains the failure."
|
|
651
|
-
)
|
|
652
|
-
failure_log_name = match.group(1)
|
|
653
|
-
|
|
654
|
-
failed_arches[architecture] = PurePath(path.parent, failure_log_name)
|
|
655
|
-
|
|
656
|
-
if not failed_arches:
|
|
657
|
-
# No failed task found in the sub-tasks.
|
|
658
|
-
raise FileNotFoundError("Could not detect failed architecture.")
|
|
659
|
-
|
|
660
|
-
# We only want to handle one arch, so we'll check them in order of
|
|
661
|
-
# "most to least likely for the maintainer to have access to hardware"
|
|
662
|
-
# This means: x86_64 > aarch64 > riscv > ppc64le > s390x
|
|
663
|
-
if "x86_64" in failed_arches:
|
|
664
|
-
failed_arch = "x86_64"
|
|
665
|
-
elif "aarch64" in failed_arches:
|
|
666
|
-
failed_arch = "aarch64"
|
|
667
|
-
elif "riscv" in failed_arches:
|
|
668
|
-
failed_arch = "riscv"
|
|
669
|
-
elif "ppc64le" in failed_arches:
|
|
670
|
-
failed_arch = "ppc64le"
|
|
671
|
-
elif "s390x" in failed_arches:
|
|
672
|
-
failed_arch = "s390x"
|
|
673
|
-
elif "noarch" in failed_arches:
|
|
674
|
-
# May have failed during BuildSRPMFromSCM phase
|
|
675
|
-
failed_arch = "noarch"
|
|
676
|
-
elif "toplevel" in failed_arches:
|
|
677
|
-
# Probably a Koji-specific error, not a build error
|
|
678
|
-
failed_arch = "toplevel"
|
|
679
|
-
else:
|
|
680
|
-
# We have one or more architectures that we don't know about? Just
|
|
681
|
-
# pick the first alphabetically.
|
|
682
|
-
failed_arch = sorted(list(failed_arches.keys()))[0]
|
|
683
|
-
|
|
684
|
-
LOG.debug("Failed architecture: %s", failed_arch)
|
|
685
|
-
|
|
686
|
-
log_path = failed_arches[failed_arch].as_posix()
|
|
687
|
-
|
|
688
|
-
log_url = f"{SERVER_CONFIG.gitlab.api_url}/projects/{job.project_id}/jobs/{job.id}/artifacts/{log_path}" # pylint: disable=line-too-long
|
|
689
|
-
LOG.debug("Returning contents of %s", log_url)
|
|
690
|
-
|
|
691
|
-
# Return the log as a file-like object with .read() function
|
|
692
|
-
return log_url, artifacts_zip.open(log_path)
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
async def check_artifacts_file_size(
|
|
696
|
-
http: aiohttp.ClientSession,
|
|
697
|
-
job: gitlab.v4.objects.ProjectJob,
|
|
698
|
-
):
|
|
699
|
-
"""Method to determine if the artifacts are too large to process"""
|
|
700
|
-
# First, make sure that the artifacts are of a reasonable size. The
|
|
701
|
-
# zipped artifact collection will be stored in memory below. The
|
|
702
|
-
# python-gitlab library doesn't expose a way to check this value directly,
|
|
703
|
-
# so we need to interact with directly with the headers.
|
|
704
|
-
artifacts_url = f"{SERVER_CONFIG.gitlab.api_url}/projects/{job.project_id}/jobs/{job.id}/artifacts" # pylint: disable=line-too-long
|
|
705
|
-
LOG.debug("checking artifact URL %s", artifacts_url)
|
|
706
|
-
try:
|
|
707
|
-
head_response = await http.head(
|
|
708
|
-
artifacts_url,
|
|
709
|
-
allow_redirects=True,
|
|
710
|
-
headers={"Authorization": f"Bearer {SERVER_CONFIG.gitlab.api_token}"},
|
|
711
|
-
timeout=5,
|
|
712
|
-
raise_for_status=True,
|
|
713
|
-
)
|
|
714
|
-
except aiohttp.ClientResponseError as ex:
|
|
715
|
-
raise HTTPException(
|
|
716
|
-
status_code=400,
|
|
717
|
-
detail=f"Unable to check artifact URL: [{ex.status}] {ex.message}",
|
|
718
|
-
) from ex
|
|
719
|
-
content_length = int(head_response.headers.get("content-length"))
|
|
720
|
-
LOG.debug(
|
|
721
|
-
"URL: %s, content-length: %d, max length: %d",
|
|
722
|
-
artifacts_url,
|
|
723
|
-
content_length,
|
|
724
|
-
SERVER_CONFIG.gitlab.max_artifact_size,
|
|
725
|
-
)
|
|
726
|
-
return content_length <= SERVER_CONFIG.gitlab.max_artifact_size
|
|
341
|
+
# No return value or body is required for a webhook.
|
|
342
|
+
# 204: No Content
|
|
343
|
+
return BasicResponse(status_code=204)
|
|
727
344
|
|
|
728
345
|
|
|
729
|
-
async def
|
|
730
|
-
forge: Forge,
|
|
731
|
-
project: gitlab.v4.objects.Project,
|
|
732
|
-
merge_request_iid: int,
|
|
733
|
-
job: gitlab.v4.objects.ProjectJob,
|
|
734
|
-
log_url: str,
|
|
735
|
-
response: StagedResponse,
|
|
736
|
-
metrics_id: int,
|
|
346
|
+
async def schedule_emoji_collection_for_mr(
|
|
347
|
+
forge: Forge, project_id: int, mr_iid: int, background_tasks: BackgroundTasks
|
|
737
348
|
):
|
|
738
|
-
"""
|
|
739
|
-
LOG.debug(
|
|
740
|
-
"Primary Explanation for %s MR %d: %s",
|
|
741
|
-
project.name,
|
|
742
|
-
merge_request_iid,
|
|
743
|
-
response.explanation.text,
|
|
744
|
-
)
|
|
745
|
-
|
|
746
|
-
# First, we'll see if there's an existing comment on this Merge Request
|
|
747
|
-
# and wrap it in <details></details> to reduce noise.
|
|
748
|
-
await suppress_latest_comment(forge, project, merge_request_iid)
|
|
749
|
-
|
|
750
|
-
# Get the formatted short comment.
|
|
751
|
-
short_comment = await generate_mr_comment(job, log_url, response, full=False)
|
|
752
|
-
|
|
753
|
-
# Look up the merge request
|
|
754
|
-
merge_request = await asyncio.to_thread(
|
|
755
|
-
project.mergerequests.get, merge_request_iid
|
|
756
|
-
)
|
|
757
|
-
|
|
758
|
-
# Submit a new comment to the Merge Request using the Gitlab API
|
|
759
|
-
discussion = await asyncio.to_thread(
|
|
760
|
-
merge_request.discussions.create, {"body": short_comment}
|
|
761
|
-
)
|
|
349
|
+
"""Background task to update the database on emoji reactions"""
|
|
762
350
|
|
|
763
|
-
|
|
764
|
-
note_id = discussion.attributes["notes"][0]["id"]
|
|
765
|
-
note = discussion.notes.get(note_id)
|
|
351
|
+
key = (forge, project_id, mr_iid)
|
|
766
352
|
|
|
767
|
-
#
|
|
768
|
-
|
|
769
|
-
# notifications with a massive message. Gitlab doesn't send email for
|
|
770
|
-
# comment edits.
|
|
771
|
-
full_comment = await generate_mr_comment(job, log_url, response, full=True)
|
|
772
|
-
note.body = full_comment
|
|
353
|
+
# FIXME: Look up the connection from the Forge # pylint: disable=fixme
|
|
354
|
+
gitlab_conn = SERVER_CONFIG.gitlab.instances[forge.value]
|
|
773
355
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
# message in email.
|
|
777
|
-
await asyncio.sleep(5)
|
|
778
|
-
await asyncio.to_thread(note.save)
|
|
356
|
+
LOG.debug("Looking up emojis for %s, %d, %d", forge, project_id, mr_iid)
|
|
357
|
+
await collect_emojis_for_mr(project_id, mr_iid, gitlab_conn)
|
|
779
358
|
|
|
780
|
-
#
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
359
|
+
# Check whether we've been asked to re-schedule this lookup because
|
|
360
|
+
# another request came in while it was processing.
|
|
361
|
+
if emoji_lookup[key]:
|
|
362
|
+
# The value is Truthy, which tells us to re-schedule
|
|
363
|
+
# Reset the boolean value to indicate that we're underway again.
|
|
364
|
+
emoji_lookup[key] = False
|
|
365
|
+
background_tasks.add_task(
|
|
366
|
+
schedule_emoji_collection_for_mr,
|
|
784
367
|
forge,
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
discussion.id,
|
|
789
|
-
metrics,
|
|
368
|
+
project_id,
|
|
369
|
+
mr_iid,
|
|
370
|
+
background_tasks,
|
|
790
371
|
)
|
|
791
|
-
except sqlalchemy.exc.IntegrityError:
|
|
792
|
-
# We most likely attempted to save a new comment for the same
|
|
793
|
-
# build job. This is somewhat common during development when we're
|
|
794
|
-
# submitting requests manually. It shouldn't really happen in
|
|
795
|
-
# production.
|
|
796
|
-
if not SERVER_CONFIG.general.devmode:
|
|
797
|
-
raise
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
async def suppress_latest_comment(
|
|
801
|
-
gitlab_instance: str,
|
|
802
|
-
project: gitlab.v4.objects.Project,
|
|
803
|
-
merge_request_iid: int,
|
|
804
|
-
) -> None:
|
|
805
|
-
"""Look up the latest comment on this Merge Request, if any, and wrap it
|
|
806
|
-
in a <details></details> block with a comment indicating that it has been
|
|
807
|
-
superseded by a new push."""
|
|
808
|
-
|
|
809
|
-
# Ask the database for the last known comment for this MR
|
|
810
|
-
previous_comment = Comments.get_latest_comment(
|
|
811
|
-
gitlab_instance, project.id, merge_request_iid
|
|
812
|
-
)
|
|
813
|
-
|
|
814
|
-
if previous_comment is None:
|
|
815
|
-
# No existing comment, so nothing to do.
|
|
816
372
|
return
|
|
817
373
|
|
|
818
|
-
#
|
|
819
|
-
|
|
820
|
-
# Look up the merge request
|
|
821
|
-
merge_request = await asyncio.to_thread(
|
|
822
|
-
project.mergerequests.get, merge_request_iid
|
|
823
|
-
)
|
|
824
|
-
|
|
825
|
-
# Find the discussion matching the latest comment ID
|
|
826
|
-
discussion = await asyncio.to_thread(
|
|
827
|
-
merge_request.discussions.get, previous_comment.comment_id
|
|
828
|
-
)
|
|
829
|
-
|
|
830
|
-
# Get the ID of the first note
|
|
831
|
-
note_id = discussion.attributes["notes"][0]["id"]
|
|
832
|
-
note = discussion.notes.get(note_id)
|
|
833
|
-
|
|
834
|
-
# Wrap the note in <details>, indicating why.
|
|
835
|
-
note.body = (
|
|
836
|
-
"This comment has been superseded by a newer "
|
|
837
|
-
f"Log Detective analysis.\n<details>\n{note.body}\n</details>"
|
|
838
|
-
)
|
|
839
|
-
await asyncio.to_thread(note.save)
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
async def generate_mr_comment(
|
|
843
|
-
job: gitlab.v4.objects.ProjectJob,
|
|
844
|
-
log_url: str,
|
|
845
|
-
response: StagedResponse,
|
|
846
|
-
full: bool = True,
|
|
847
|
-
) -> str:
|
|
848
|
-
"""Use a template to generate a comment string to submit to Gitlab"""
|
|
849
|
-
|
|
850
|
-
# Locate and load the comment template
|
|
851
|
-
script_path = Path(__file__).resolve().parent
|
|
852
|
-
template_path = Path(script_path, "templates")
|
|
853
|
-
jinja_env = jinja2.Environment(loader=jinja2.FileSystemLoader(template_path))
|
|
854
|
-
|
|
855
|
-
if full:
|
|
856
|
-
tpl = jinja_env.get_template("gitlab_full_comment.md.j2")
|
|
857
|
-
else:
|
|
858
|
-
tpl = jinja_env.get_template("gitlab_short_comment.md.j2")
|
|
859
|
-
|
|
860
|
-
artifacts_url = f"{job.project_url}/-/jobs/{job.id}/artifacts/download"
|
|
861
|
-
|
|
862
|
-
if response.response_certainty >= 90:
|
|
863
|
-
emoji_face = ":slight_smile:"
|
|
864
|
-
elif response.response_certainty >= 70:
|
|
865
|
-
emoji_face = ":neutral_face:"
|
|
866
|
-
else:
|
|
867
|
-
emoji_face = ":frowning2:"
|
|
868
|
-
|
|
869
|
-
# Generate the comment from the template
|
|
870
|
-
content = tpl.render(
|
|
871
|
-
package=job.project_name,
|
|
872
|
-
explanation=response.explanation.text,
|
|
873
|
-
certainty=f"{response.response_certainty:.2f}",
|
|
874
|
-
emoji_face=emoji_face,
|
|
875
|
-
snippets=response.snippets,
|
|
876
|
-
log_url=log_url,
|
|
877
|
-
artifacts_url=artifacts_url,
|
|
878
|
-
)
|
|
879
|
-
|
|
880
|
-
return content
|
|
374
|
+
# We're all done, so clear this entry out of the lookup
|
|
375
|
+
del emoji_lookup[key]
|
|
881
376
|
|
|
882
377
|
|
|
883
378
|
def _svg_figure_response(fig: matplotlib.figure.Figure):
|
|
@@ -925,6 +420,7 @@ class Plot(str, Enum):
|
|
|
925
420
|
|
|
926
421
|
REQUESTS = "requests"
|
|
927
422
|
RESPONSES = "responses"
|
|
423
|
+
EMOJIS = "emojis"
|
|
928
424
|
BOTH = ""
|
|
929
425
|
|
|
930
426
|
|
|
@@ -955,12 +451,16 @@ async def get_metrics(
|
|
|
955
451
|
period_since_now, endpoint_type
|
|
956
452
|
)
|
|
957
453
|
return _svg_figure_response(fig)
|
|
454
|
+
if plot == Plot.EMOJIS:
|
|
455
|
+
fig = plot_engine.emojis_per_time(period_since_now)
|
|
456
|
+
return _svg_figure_response(fig)
|
|
958
457
|
# BOTH
|
|
959
458
|
fig_requests = plot_engine.requests_per_time(period_since_now, endpoint_type)
|
|
960
459
|
fig_responses = plot_engine.average_time_per_responses(
|
|
961
460
|
period_since_now, endpoint_type
|
|
962
461
|
)
|
|
963
|
-
|
|
462
|
+
fig_emojis = plot_engine.emojis_per_time(period_since_now)
|
|
463
|
+
return _multiple_svg_figures_response([fig_requests, fig_responses, fig_emojis])
|
|
964
464
|
|
|
965
465
|
descriptions = {
|
|
966
466
|
Plot.REQUESTS: (
|
|
@@ -971,6 +471,10 @@ async def get_metrics(
|
|
|
971
471
|
"Show statistics for responses given in the specified period of time "
|
|
972
472
|
f"for the /{endpoint_type.value} API endpoint."
|
|
973
473
|
),
|
|
474
|
+
Plot.EMOJIS: (
|
|
475
|
+
"Show statistics for emoji feedback in the specified period of time "
|
|
476
|
+
f"for the /{endpoint_type.value} API endpoint."
|
|
477
|
+
),
|
|
974
478
|
Plot.BOTH: (
|
|
975
479
|
"Show statistics for requests and responses in the given period of time "
|
|
976
480
|
f"for the /{endpoint_type.value} API endpoint."
|
|
@@ -979,3 +483,41 @@ async def get_metrics(
|
|
|
979
483
|
handler.__doc__ = descriptions[plot]
|
|
980
484
|
|
|
981
485
|
return await handler()
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
async def collect_emoji_task():
|
|
489
|
+
"""Collect emoji feedback.
|
|
490
|
+
Query only comments created in the last year.
|
|
491
|
+
"""
|
|
492
|
+
|
|
493
|
+
for instance in SERVER_CONFIG.gitlab.instances.values():
|
|
494
|
+
LOG.info(
|
|
495
|
+
"Collect emoji feedback for %s started at %s",
|
|
496
|
+
instance.url,
|
|
497
|
+
datetime.datetime.now(datetime.timezone.utc),
|
|
498
|
+
)
|
|
499
|
+
await collect_emojis(instance.get_connection(), TimePeriod(weeks="54"))
|
|
500
|
+
LOG.info(
|
|
501
|
+
"Collect emoji feedback finished at %s",
|
|
502
|
+
datetime.datetime.now(datetime.timezone.utc),
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
async def schedule_collect_emojis_task():
|
|
507
|
+
"""Schedule the collect_emojis_task to run every day at midnight"""
|
|
508
|
+
while True:
|
|
509
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
510
|
+
midnight = datetime.datetime.combine(
|
|
511
|
+
now.date() + datetime.timedelta(days=1),
|
|
512
|
+
datetime.time(0, 0),
|
|
513
|
+
datetime.timezone.utc,
|
|
514
|
+
)
|
|
515
|
+
seconds_until_run = (midnight - now).total_seconds()
|
|
516
|
+
|
|
517
|
+
LOG.info("Collect emojis in %d seconds", seconds_until_run)
|
|
518
|
+
await asyncio.sleep(seconds_until_run)
|
|
519
|
+
|
|
520
|
+
try:
|
|
521
|
+
await collect_emoji_task()
|
|
522
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
523
|
+
LOG.error("Error in collect_emoji_task: %s", e)
|