logdetective 1.0.1__py3-none-any.whl → 1.1.0__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.
@@ -0,0 +1,22 @@
1
+ # This file is intended for customization of prompts
2
+ # It is used only in server mode.
3
+ # On command line you have to load it using --prompts
4
+ # The defaults are stored in constants.py
5
+
6
+ prompt_template: |
7
+ Given following log snippets, and nothing else, explain what failure, if any, occured during build of this package.
8
+
9
+ Please start with concise, one sentence long, summary describing the problem and recommend solution to fix it. And then follow with analysis.
10
+
11
+ Analysis of the snippets must be in a format of [X] : [Y], where [X] is a log snippet, and [Y] is the explanation.
12
+ Snippets themselves must not be altered in any way whatsoever.
13
+
14
+ Snippets are delimited with '================'.
15
+
16
+ Explanation of the issue, and recommended solution, should take handful of sentences.
17
+
18
+ Snippets:
19
+
20
+ {}
21
+
22
+ Analysis:
@@ -0,0 +1,13 @@
1
+ # This file is intended for customization of prompts
2
+ # It is used only in server mode.
3
+ # On command line you have to load it using --prompts
4
+ # The defaults are stored in constants.py
5
+
6
+ prompt_template: |
7
+ Given following log snippets, and nothing else, explain what failure, if any, occured during build of this package.
8
+
9
+ Provide concise, one paragraph long, summary describing the problem of most probable culprit and recommend solution to fix it.
10
+
11
+ Snippets:
12
+
13
+ {}
@@ -1,6 +1,6 @@
1
1
  import asyncio
2
2
 
3
- from typing import List
3
+ from typing import List, Callable
4
4
  from collections import Counter
5
5
 
6
6
  import gitlab
@@ -11,6 +11,7 @@ from logdetective.server.database.models import (
11
11
  Reactions,
12
12
  GitlabMergeRequestJobs,
13
13
  )
14
+ from logdetective.server.config import LOG
14
15
 
15
16
 
16
17
  async def collect_emojis(gitlab_conn: gitlab.Gitlab, period: TimePeriod):
@@ -36,6 +37,23 @@ async def collect_emojis_for_mr(
36
37
  await collect_emojis_in_comments(comments, gitlab_conn)
37
38
 
38
39
 
40
+ async def _handle_gitlab_operation(func: Callable, *args):
41
+ """
42
+ It handles errors for the specified GitLab operation.
43
+ After executing it in a separate thread.
44
+ """
45
+ try:
46
+ return await asyncio.to_thread(func, *args)
47
+ except gitlab.GitlabError as e:
48
+ log_msg = f"Error during GitLab operation {func}{args}: {e}"
49
+ if "Not Found" in str(e):
50
+ LOG.error(log_msg)
51
+ else:
52
+ LOG.exception(log_msg)
53
+ except Exception as e: # pylint: disable=broad-exception-caught
54
+ LOG.exception("Unexpected error during GitLab operation %s(%s): %s", func, args, e)
55
+
56
+
39
57
  async def collect_emojis_in_comments( # pylint: disable=too-many-locals
40
58
  comments: List[Comments], gitlab_conn: gitlab.Gitlab
41
59
  ):
@@ -47,24 +65,34 @@ async def collect_emojis_in_comments( # pylint: disable=too-many-locals
47
65
  for comment in comments:
48
66
  mr_job_db = GitlabMergeRequestJobs.get_by_id(comment.merge_request_job_id)
49
67
  if mr_job_db.id not in projects:
50
- projects[mr_job_db.id] = project = await asyncio.to_thread(
68
+ projects[mr_job_db.id] = project = await _handle_gitlab_operation(
51
69
  gitlab_conn.projects.get, mr_job_db.project_id
52
70
  )
71
+ if not project:
72
+ continue
53
73
  else:
54
74
  project = projects[mr_job_db.id]
55
75
  mr_iid = mr_job_db.mr_iid
56
76
  if mr_iid not in mrs:
57
- mrs[mr_iid] = mr = await asyncio.to_thread(
77
+ mrs[mr_iid] = mr = await _handle_gitlab_operation(
58
78
  project.mergerequests.get, mr_iid
59
79
  )
80
+ if not mr:
81
+ continue
60
82
  else:
61
83
  mr = mrs[mr_iid]
62
84
 
63
- discussion = mr.discussions.get(comment.comment_id)
85
+ discussion = await _handle_gitlab_operation(
86
+ mr.discussions.get, comment.comment_id
87
+ )
88
+ if not discussion:
89
+ continue
64
90
 
65
91
  # Get the ID of the first note
66
92
  note_id = discussion.attributes["notes"][0]["id"]
67
- note = mr.notes.get(note_id)
93
+ note = await _handle_gitlab_operation(mr.notes.get, note_id)
94
+ if not note:
95
+ continue
68
96
 
69
97
  emoji_counts = Counter(emoji.name for emoji in note.awardemojis.list())
70
98
 
@@ -11,7 +11,6 @@ import gitlab.v4
11
11
  import gitlab.v4.objects
12
12
  import jinja2
13
13
  import aiohttp
14
- import sqlalchemy
15
14
 
16
15
  from logdetective.server.config import SERVER_CONFIG, LOG
17
16
  from logdetective.server.llm import perform_staged_analysis
@@ -22,10 +21,11 @@ from logdetective.server.models import (
22
21
  StagedResponse,
23
22
  )
24
23
  from logdetective.server.database.models import (
24
+ AnalyzeRequestMetrics,
25
25
  Comments,
26
26
  EndpointType,
27
27
  Forge,
28
- AnalyzeRequestMetrics,
28
+ GitlabMergeRequestJobs,
29
29
  )
30
30
  from logdetective.server.compressors import RemoteLogCompressor
31
31
 
@@ -34,7 +34,6 @@ FAILURE_LOG_REGEX = re.compile(r"(\w*\.log)")
34
34
 
35
35
 
36
36
  async def process_gitlab_job_event(
37
- http: aiohttp.ClientSession,
38
37
  gitlab_cfg: GitLabInstanceConfig,
39
38
  forge: Forge,
40
39
  job_hook: JobHook,
@@ -63,19 +62,34 @@ async def process_gitlab_job_event(
63
62
  LOG.info("Not a merge request pipeline. Ignoring.")
64
63
  return
65
64
 
66
- # Extract the merge-request ID from the job
65
+ # Extract the merge-request IID from the job
67
66
  match = MR_REGEX.search(pipeline.ref)
68
67
  if not match:
69
68
  LOG.error(
70
- "Pipeline source is merge_request_event but no merge request ID was provided."
69
+ "Pipeline source is merge_request_event but no merge request IID was provided."
71
70
  )
72
71
  return
73
72
  merge_request_iid = int(match.group(1))
74
73
 
74
+ # Check if this is a resubmission of an existing, completed job.
75
+ # If it is, we'll exit out here and not waste time retrieving the logs,
76
+ # running a new analysis or trying to submit a new comment.
77
+ mr_job_db = GitlabMergeRequestJobs.get_by_details(
78
+ forge=forge,
79
+ project_id=project.id,
80
+ mr_iid=merge_request_iid,
81
+ job_id=job_hook.build_id,
82
+ )
83
+ if mr_job_db:
84
+ LOG.info("Resubmission of an existing build. Skipping.")
85
+ return
86
+
75
87
  LOG.debug("Retrieving log artifacts")
76
88
  # Retrieve the build logs from the merge request artifacts and preprocess them
77
89
  try:
78
- log_url, preprocessed_log = await retrieve_and_preprocess_koji_logs(gitlab_cfg, http, job)
90
+ log_url, preprocessed_log = await retrieve_and_preprocess_koji_logs(
91
+ gitlab_cfg, job
92
+ )
79
93
  except LogsTooLargeError:
80
94
  LOG.error("Could not retrieve logs. Too large.")
81
95
  raise
@@ -85,10 +99,10 @@ async def process_gitlab_job_event(
85
99
  metrics_id = await add_new_metrics(
86
100
  api_name=EndpointType.ANALYZE_GITLAB_JOB,
87
101
  url=log_url,
88
- http_session=http,
102
+ http_session=gitlab_cfg.get_http_session(),
89
103
  compressed_log_content=RemoteLogCompressor.zip_text(log_text),
90
104
  )
91
- staged_response = await perform_staged_analysis(http, log_text=log_text)
105
+ staged_response = await perform_staged_analysis(log_text=log_text)
92
106
  update_metrics(metrics_id, staged_response)
93
107
  preprocessed_log.close()
94
108
 
@@ -142,8 +156,7 @@ class LogsTooLargeError(RuntimeError):
142
156
 
143
157
  async def retrieve_and_preprocess_koji_logs(
144
158
  gitlab_cfg: GitLabInstanceConfig,
145
- http: aiohttp.ClientSession,
146
- job: gitlab.v4.objects.ProjectJob
159
+ job: gitlab.v4.objects.ProjectJob,
147
160
  ): # pylint: disable=too-many-branches,too-many-locals
148
161
  """Download logs from the merge request artifacts
149
162
 
@@ -155,7 +168,7 @@ async def retrieve_and_preprocess_koji_logs(
155
168
  Detective. The calling function is responsible for closing this object."""
156
169
 
157
170
  # Make sure the file isn't too large to process.
158
- if not await check_artifacts_file_size(gitlab_cfg, http, job):
171
+ if not await check_artifacts_file_size(gitlab_cfg, job):
159
172
  raise LogsTooLargeError(
160
173
  f"Oversized logs for job {job.id} in project {job.project_id}"
161
174
  )
@@ -201,7 +214,9 @@ async def retrieve_and_preprocess_koji_logs(
201
214
  match = FAILURE_LOG_REGEX.search(contents)
202
215
  if match:
203
216
  failure_log_name = match.group(1)
204
- failed_arches[architecture] = PurePath(path.parent, failure_log_name)
217
+ failed_arches[architecture] = PurePath(
218
+ path.parent, failure_log_name
219
+ )
205
220
  else:
206
221
  LOG.info(
207
222
  "task_failed.log does not indicate which log contains the failure."
@@ -243,8 +258,8 @@ async def retrieve_and_preprocess_koji_logs(
243
258
 
244
259
  log_path = failed_arches[failed_arch].as_posix()
245
260
 
246
- log_url = f"{gitlab_cfg.api_url}/projects/{job.project_id}/jobs/{job.id}/artifacts/{log_path}" # pylint: disable=line-too-long
247
- LOG.debug("Returning contents of %s", log_url)
261
+ log_url = f"{gitlab_cfg.api_path}/projects/{job.project_id}/jobs/{job.id}/artifacts/{log_path}" # pylint: disable=line-too-long
262
+ LOG.debug("Returning contents of %s%s", gitlab_cfg.url, log_url)
248
263
 
249
264
  # Return the log as a file-like object with .read() function
250
265
  return log_url, artifacts_zip.open(log_path)
@@ -252,7 +267,6 @@ async def retrieve_and_preprocess_koji_logs(
252
267
 
253
268
  async def check_artifacts_file_size(
254
269
  gitlab_cfg: GitLabInstanceConfig,
255
- http: aiohttp.ClientSession,
256
270
  job: gitlab.v4.objects.ProjectJob,
257
271
  ):
258
272
  """Method to determine if the artifacts are too large to process"""
@@ -260,14 +274,14 @@ async def check_artifacts_file_size(
260
274
  # zipped artifact collection will be stored in memory below. The
261
275
  # python-gitlab library doesn't expose a way to check this value directly,
262
276
  # so we need to interact with directly with the headers.
263
- artifacts_url = f"{gitlab_cfg.api_url}/projects/{job.project_id}/jobs/{job.id}/artifacts" # pylint: disable=line-too-long
264
- LOG.debug("checking artifact URL %s", artifacts_url)
277
+ artifacts_path = (
278
+ f"{gitlab_cfg.api_path}/projects/{job.project_id}/jobs/{job.id}/artifacts"
279
+ )
280
+ LOG.debug("checking artifact URL %s%s", gitlab_cfg.url, artifacts_path)
265
281
  try:
266
- head_response = await http.head(
267
- artifacts_url,
282
+ head_response = await gitlab_cfg.get_http_session().head(
283
+ artifacts_path,
268
284
  allow_redirects=True,
269
- headers={"Authorization": f"Bearer {gitlab_cfg.api_token}"},
270
- timeout=5,
271
285
  raise_for_status=True,
272
286
  )
273
287
  except aiohttp.ClientResponseError as ex:
@@ -278,7 +292,7 @@ async def check_artifacts_file_size(
278
292
  content_length = int(head_response.headers.get("content-length"))
279
293
  LOG.debug(
280
294
  "URL: %s, content-length: %d, max length: %d",
281
- artifacts_url,
295
+ artifacts_path,
282
296
  content_length,
283
297
  gitlab_cfg.max_artifact_size,
284
298
  )
@@ -337,23 +351,15 @@ async def comment_on_mr( # pylint: disable=too-many-arguments disable=too-many-
337
351
  await asyncio.to_thread(note.save)
338
352
 
339
353
  # Save the new comment to the database
340
- try:
341
- metrics = AnalyzeRequestMetrics.get_metric_by_id(metrics_id)
342
- Comments.create(
343
- forge,
344
- project.id,
345
- merge_request_iid,
346
- job.id,
347
- discussion.id,
348
- metrics,
349
- )
350
- except sqlalchemy.exc.IntegrityError:
351
- # We most likely attempted to save a new comment for the same
352
- # build job. This is somewhat common during development when we're
353
- # submitting requests manually. It shouldn't really happen in
354
- # production.
355
- if not SERVER_CONFIG.general.devmode:
356
- raise
354
+ metrics = AnalyzeRequestMetrics.get_metric_by_id(metrics_id)
355
+ Comments.create(
356
+ forge,
357
+ project.id,
358
+ merge_request_iid,
359
+ job.id,
360
+ discussion.id,
361
+ metrics,
362
+ )
357
363
 
358
364
 
359
365
  async def suppress_latest_comment(
@@ -17,9 +17,10 @@ from logdetective.utils import (
17
17
  )
18
18
  from logdetective.server.config import LOG, SERVER_CONFIG, PROMPT_CONFIG
19
19
  from logdetective.server.models import (
20
- StagedResponse,
21
- Explanation,
22
20
  AnalyzedSnippet,
21
+ InferenceConfig,
22
+ Explanation,
23
+ StagedResponse,
23
24
  )
24
25
 
25
26
 
@@ -54,24 +55,33 @@ def mine_logs(log: str) -> List[Tuple[int, str]]:
54
55
 
55
56
 
56
57
  async def submit_to_llm_endpoint(
57
- http: aiohttp.ClientSession,
58
- url: str,
58
+ url_path: str,
59
59
  data: Dict[str, Any],
60
60
  headers: Dict[str, str],
61
61
  stream: bool,
62
+ inference_cfg: InferenceConfig = SERVER_CONFIG.inference,
62
63
  ) -> Any:
63
- """Send request to selected API endpoint. Verifying successful request unless
64
+ """Send request to an API endpoint. Verifying successful request unless
64
65
  the using the stream response.
65
66
 
66
- url:
67
+ url_path: The endpoint path to query. (e.g. "/v1/chat/completions"). It should
68
+ not include the scheme and netloc of the URL, which is stored in the
69
+ InferenceConfig.
67
70
  data:
68
71
  headers:
69
72
  stream:
73
+ inference_cfg: An InferenceConfig object containing the URL, max_tokens
74
+ and other relevant configuration for talking to an inference server.
70
75
  """
71
- async with SERVER_CONFIG.inference.get_limiter():
72
- LOG.debug("async request %s headers=%s data=%s", url, headers, data)
73
- response = await http.post(
74
- url,
76
+ async with inference_cfg.get_limiter():
77
+ LOG.debug("async request %s headers=%s data=%s", url_path, headers, data)
78
+ session = inference_cfg.get_http_session()
79
+
80
+ if inference_cfg.api_token:
81
+ headers["Authorization"] = f"Bearer {inference_cfg.api_token}"
82
+
83
+ response = await session.post(
84
+ url_path,
75
85
  headers=headers,
76
86
  # we need to use the `json=` parameter here and let aiohttp
77
87
  # handle the json-encoding
@@ -88,7 +98,9 @@ async def submit_to_llm_endpoint(
88
98
  try:
89
99
  return json.loads(await response.text())
90
100
  except UnicodeDecodeError as ex:
91
- LOG.error("Error encountered while parsing llama server response: %s", ex)
101
+ LOG.error(
102
+ "Error encountered while parsing llama server response: %s", ex
103
+ )
92
104
  raise HTTPException(
93
105
  status_code=400,
94
106
  detail=f"Couldn't parse the response.\nError: {ex}\nData: {response.text}",
@@ -125,16 +137,14 @@ def we_give_up(details: backoff._typing.Details):
125
137
  raise_on_giveup=False,
126
138
  on_giveup=we_give_up,
127
139
  )
128
- async def submit_text( # pylint: disable=R0913,R0917
129
- http: aiohttp.ClientSession,
140
+ async def submit_text(
130
141
  text: str,
131
- max_tokens: int = -1,
132
- log_probs: int = 1,
142
+ inference_cfg: InferenceConfig,
133
143
  stream: bool = False,
134
- model: str = "default-model",
135
- ) -> Explanation:
136
- """Submit prompt to LLM using a selected endpoint.
137
- max_tokens: number of tokens to be produces, 0 indicates run until encountering EOS
144
+ ) -> Union[Explanation, StreamReader]:
145
+ """Submit prompt to LLM.
146
+ inference_cfg: The configuration section from the config.json representing
147
+ the relevant inference server for this request.
138
148
  log_probs: number of token choices to produce log probs for
139
149
  """
140
150
  LOG.info("Analyzing the text")
@@ -144,64 +154,6 @@ async def submit_text( # pylint: disable=R0913,R0917
144
154
  if SERVER_CONFIG.inference.api_token:
145
155
  headers["Authorization"] = f"Bearer {SERVER_CONFIG.inference.api_token}"
146
156
 
147
- if SERVER_CONFIG.inference.api_endpoint == "/chat/completions":
148
- return await submit_text_chat_completions(
149
- http, text, headers, max_tokens, log_probs > 0, stream, model
150
- )
151
- return await submit_text_completions(
152
- http, text, headers, max_tokens, log_probs, stream, model
153
- )
154
-
155
-
156
- async def submit_text_completions( # pylint: disable=R0913,R0917
157
- http: aiohttp.ClientSession,
158
- text: str,
159
- headers: dict,
160
- max_tokens: int = -1,
161
- log_probs: int = 1,
162
- stream: bool = False,
163
- model: str = "default-model",
164
- ) -> Explanation:
165
- """Submit prompt to OpenAI API completions endpoint.
166
- max_tokens: number of tokens to be produces, 0 indicates run until encountering EOS
167
- log_probs: number of token choices to produce log probs for
168
- """
169
- LOG.info("Submitting to /v1/completions endpoint")
170
- data = {
171
- "prompt": text,
172
- "max_tokens": max_tokens,
173
- "logprobs": log_probs,
174
- "stream": stream,
175
- "model": model,
176
- "temperature": SERVER_CONFIG.inference.temperature,
177
- }
178
-
179
- response = await submit_to_llm_endpoint(
180
- http,
181
- f"{SERVER_CONFIG.inference.url}/v1/completions",
182
- data,
183
- headers,
184
- stream,
185
- )
186
-
187
- return Explanation(
188
- text=response["choices"][0]["text"], logprobs=response["choices"][0]["logprobs"]
189
- )
190
-
191
-
192
- async def submit_text_chat_completions( # pylint: disable=R0913,R0917
193
- http: aiohttp.ClientSession,
194
- text: str,
195
- headers: dict,
196
- max_tokens: int = -1,
197
- log_probs: int = 1,
198
- stream: bool = False,
199
- model: str = "default-model",
200
- ) -> Union[Explanation, StreamReader]:
201
- """Submit prompt to OpenAI API /chat/completions endpoint.
202
- max_tokens: number of tokens to be produces, 0 indicates run until encountering EOS
203
- log_probs: number of token choices to produce log probs for
204
- """
205
157
  LOG.info("Submitting to /v1/chat/completions endpoint")
206
158
 
207
159
  data = {
@@ -211,19 +163,19 @@ async def submit_text_chat_completions( # pylint: disable=R0913,R0917
211
163
  "content": text,
212
164
  }
213
165
  ],
214
- "max_tokens": max_tokens,
215
- "logprobs": log_probs,
166
+ "max_tokens": inference_cfg.max_tokens,
167
+ "logprobs": inference_cfg.log_probs,
216
168
  "stream": stream,
217
- "model": model,
218
- "temperature": SERVER_CONFIG.inference.temperature,
169
+ "model": inference_cfg.model,
170
+ "temperature": inference_cfg.temperature,
219
171
  }
220
172
 
221
173
  response = await submit_to_llm_endpoint(
222
- http,
223
- f"{SERVER_CONFIG.inference.url}/v1/chat/completions",
174
+ "/v1/chat/completions",
224
175
  data,
225
176
  headers,
226
- stream,
177
+ inference_cfg=inference_cfg,
178
+ stream=stream,
227
179
  )
228
180
 
229
181
  if stream:
@@ -234,19 +186,15 @@ async def submit_text_chat_completions( # pylint: disable=R0913,R0917
234
186
  )
235
187
 
236
188
 
237
- async def perform_staged_analysis(
238
- http: aiohttp.ClientSession, log_text: str
239
- ) -> StagedResponse:
189
+ async def perform_staged_analysis(log_text: str) -> StagedResponse:
240
190
  """Submit the log file snippets to the LLM and retrieve their results"""
241
191
  log_summary = mine_logs(log_text)
242
192
 
243
193
  # Process snippets asynchronously
244
194
  awaitables = [
245
195
  submit_text(
246
- http,
247
196
  PROMPT_CONFIG.snippet_prompt_template.format(s),
248
- model=SERVER_CONFIG.inference.model,
249
- max_tokens=SERVER_CONFIG.inference.max_tokens,
197
+ inference_cfg=SERVER_CONFIG.snippet_inference,
250
198
  )
251
199
  for s in log_summary
252
200
  ]
@@ -261,10 +209,8 @@ async def perform_staged_analysis(
261
209
  )
262
210
 
263
211
  final_analysis = await submit_text(
264
- http,
265
212
  final_prompt,
266
- model=SERVER_CONFIG.inference.model,
267
- max_tokens=SERVER_CONFIG.inference.max_tokens,
213
+ inference_cfg=SERVER_CONFIG.inference,
268
214
  )
269
215
 
270
216
  certainty = 0
@@ -1,6 +1,7 @@
1
+ import asyncio
1
2
  import datetime
2
3
  from logging import BASIC_FORMAT
3
- from typing import List, Dict, Optional, Literal
4
+ from typing import List, Dict, Optional
4
5
  from pydantic import (
5
6
  BaseModel,
6
7
  Field,
@@ -10,6 +11,8 @@ from pydantic import (
10
11
  HttpUrl,
11
12
  )
12
13
 
14
+ import aiohttp
15
+
13
16
  from aiolimiter import AsyncLimiter
14
17
  from gitlab import Gitlab
15
18
 
@@ -131,16 +134,14 @@ class InferenceConfig(BaseModel): # pylint: disable=too-many-instance-attribute
131
134
  """Model for inference configuration of logdetective server."""
132
135
 
133
136
  max_tokens: int = -1
134
- log_probs: int = 1
135
- api_endpoint: Optional[Literal["/chat/completions", "/completions"]] = (
136
- "/chat/completions"
137
- )
137
+ log_probs: bool = True
138
138
  url: str = ""
139
139
  api_token: str = ""
140
140
  model: str = ""
141
141
  temperature: NonNegativeFloat = DEFAULT_TEMPERATURE
142
142
  max_queue_size: int = LLM_DEFAULT_MAX_QUEUE_SIZE
143
- request_period: float = 60.0 / LLM_DEFAULT_REQUESTS_PER_MINUTE
143
+ http_timeout: float = 5.0
144
+ _http_session: aiohttp.ClientSession = None
144
145
  _limiter: AsyncLimiter = AsyncLimiter(LLM_DEFAULT_REQUESTS_PER_MINUTE)
145
146
 
146
147
  def __init__(self, data: Optional[dict] = None):
@@ -149,9 +150,9 @@ class InferenceConfig(BaseModel): # pylint: disable=too-many-instance-attribute
149
150
  return
150
151
 
151
152
  self.max_tokens = data.get("max_tokens", -1)
152
- self.log_probs = data.get("log_probs", 1)
153
- self.api_endpoint = data.get("api_endpoint", "/chat/completions")
153
+ self.log_probs = data.get("log_probs", True)
154
154
  self.url = data.get("url", "")
155
+ self.http_timeout = data.get("http_timeout", 5.0)
155
156
  self.api_token = data.get("api_token", "")
156
157
  self.model = data.get("model", "default-model")
157
158
  self.temperature = data.get("temperature", DEFAULT_TEMPERATURE)
@@ -162,6 +163,40 @@ class InferenceConfig(BaseModel): # pylint: disable=too-many-instance-attribute
162
163
  )
163
164
  self._limiter = AsyncLimiter(self._requests_per_minute)
164
165
 
166
+ def __del__(self):
167
+ # Close connection when this object is destroyed
168
+ if self._http_session:
169
+ try:
170
+ loop = asyncio.get_running_loop()
171
+ loop.create_task(self._http_session.close())
172
+ except RuntimeError:
173
+ # No loop running, so create one to close the session
174
+ loop = asyncio.new_event_loop()
175
+ loop.run_until_complete(self._http_session.close())
176
+ loop.close()
177
+ except Exception: # pylint: disable=broad-exception-caught
178
+ # We should only get here if we're shutting down, so we don't
179
+ # really care if the close() completes cleanly.
180
+ pass
181
+
182
+ def get_http_session(self):
183
+ """Return the internal HTTP session so it can be used to contect the
184
+ LLM server. May be used as a context manager."""
185
+
186
+ # Create the session on the first attempt. We need to do this "lazily"
187
+ # because it needs to happen once the event loop is running, even
188
+ # though the initialization itself is synchronous.
189
+ if not self._http_session:
190
+ self._http_session = aiohttp.ClientSession(
191
+ base_url=self.url,
192
+ timeout=aiohttp.ClientTimeout(
193
+ total=self.http_timeout,
194
+ connect=3.07,
195
+ ),
196
+ )
197
+
198
+ return self._http_session
199
+
165
200
  def get_limiter(self):
166
201
  """Return the limiter object so it can be used as a context manager"""
167
202
  return self._limiter
@@ -184,14 +219,25 @@ class ExtractorConfig(BaseModel):
184
219
  self.verbose = data.get("verbose", False)
185
220
 
186
221
 
187
- class GitLabInstanceConfig(BaseModel):
222
+ class GitLabInstanceConfig(BaseModel): # pylint: disable=too-many-instance-attributes
188
223
  """Model for GitLab configuration of logdetective server."""
189
224
 
190
225
  name: str = None
191
226
  url: str = None
192
- api_url: str = None
227
+ api_path: str = None
193
228
  api_token: str = None
229
+
230
+ # This is a list to support key rotation.
231
+ # When the key is being changed, we will add the new key as a new entry in
232
+ # the configuration and then remove the old key once all of the client
233
+ # webhook configurations have been updated.
234
+ # If this option is left empty or unspecified, all requests will be
235
+ # considered authorized.
236
+ webhook_secrets: Optional[List[str]] = None
237
+
238
+ timeout: float = 5.0
194
239
  _conn: Gitlab = None
240
+ _http_session: aiohttp.ClientSession = None
195
241
 
196
242
  # Maximum size of artifacts.zip in MiB. (default: 300 MiB)
197
243
  max_artifact_size: int = 300
@@ -203,16 +249,57 @@ class GitLabInstanceConfig(BaseModel):
203
249
 
204
250
  self.name = name
205
251
  self.url = data.get("url", "https://gitlab.com")
206
- self.api_url = f"{self.url}/api/v4"
252
+ self.api_path = data.get("api_path", "/api/v4")
207
253
  self.api_token = data.get("api_token", None)
254
+ self.webhook_secrets = data.get("webhook_secrets", None)
208
255
  self.max_artifact_size = int(data.get("max_artifact_size")) * 1024 * 1024
209
256
 
210
- self._conn = Gitlab(url=self.url, private_token=self.api_token)
257
+ self.timeout = data.get("timeout", 5.0)
258
+ self._conn = Gitlab(
259
+ url=self.url,
260
+ private_token=self.api_token,
261
+ timeout=self.timeout,
262
+ )
211
263
 
212
264
  def get_connection(self):
213
265
  """Get the Gitlab connection object"""
214
266
  return self._conn
215
267
 
268
+ def get_http_session(self):
269
+ """Return the internal HTTP session so it can be used to contect the
270
+ Gitlab server. May be used as a context manager."""
271
+
272
+ # Create the session on the first attempt. We need to do this "lazily"
273
+ # because it needs to happen once the event loop is running, even
274
+ # though the initialization itself is synchronous.
275
+ if not self._http_session:
276
+ self._http_session = aiohttp.ClientSession(
277
+ base_url=self.url,
278
+ headers={"Authorization": f"Bearer {self.api_token}"},
279
+ timeout=aiohttp.ClientTimeout(
280
+ total=self.timeout,
281
+ connect=3.07,
282
+ ),
283
+ )
284
+
285
+ return self._http_session
286
+
287
+ def __del__(self):
288
+ # Close connection when this object is destroyed
289
+ if self._http_session:
290
+ try:
291
+ loop = asyncio.get_running_loop()
292
+ loop.create_task(self._http_session.close())
293
+ except RuntimeError:
294
+ # No loop running, so create one to close the session
295
+ loop = asyncio.new_event_loop()
296
+ loop.run_until_complete(self._http_session.close())
297
+ loop.close()
298
+ except Exception: # pylint: disable=broad-exception-caught
299
+ # We should only get here if we're shutting down, so we don't
300
+ # really care if the close() completes cleanly.
301
+ pass
302
+
216
303
 
217
304
  class GitLabConfig(BaseModel):
218
305
  """Model for GitLab configuration of logdetective server."""
@@ -257,6 +344,7 @@ class GeneralConfig(BaseModel):
257
344
  excluded_packages: List[str] = None
258
345
  devmode: bool = False
259
346
  sentry_dsn: HttpUrl | None = None
347
+ collect_emojis_interval: int = 60 * 60 # seconds
260
348
 
261
349
  def __init__(self, data: Optional[dict] = None):
262
350
  super().__init__()
@@ -267,6 +355,9 @@ class GeneralConfig(BaseModel):
267
355
  self.excluded_packages = data.get("excluded_packages", [])
268
356
  self.devmode = data.get("devmode", False)
269
357
  self.sentry_dsn = data.get("sentry_dsn")
358
+ self.collect_emojis_interval = data.get(
359
+ "collect_emojis_interval", 60 * 60
360
+ ) # seconds
270
361
 
271
362
 
272
363
  class Config(BaseModel):
@@ -274,6 +365,7 @@ class Config(BaseModel):
274
365
 
275
366
  log: LogConfig = LogConfig()
276
367
  inference: InferenceConfig = InferenceConfig()
368
+ snippet_inference: InferenceConfig = InferenceConfig()
277
369
  extractor: ExtractorConfig = ExtractorConfig()
278
370
  gitlab: GitLabConfig = GitLabConfig()
279
371
  general: GeneralConfig = GeneralConfig()
@@ -290,6 +382,11 @@ class Config(BaseModel):
290
382
  self.gitlab = GitLabConfig(data.get("gitlab"))
291
383
  self.general = GeneralConfig(data.get("general"))
292
384
 
385
+ if snippet_inference := data.get("snippet_inference", None):
386
+ self.snippet_inference = InferenceConfig(snippet_inference)
387
+ else:
388
+ self.snippet_inference = self.inference
389
+
293
390
 
294
391
  class TimePeriod(BaseModel):
295
392
  """Specification for a period of time.
@@ -28,7 +28,6 @@ from logdetective.server.llm import (
28
28
  mine_logs,
29
29
  perform_staged_analysis,
30
30
  submit_text,
31
- submit_text_chat_completions,
32
31
  )
33
32
  from logdetective.server.gitlab import process_gitlab_job_event
34
33
  from logdetective.server.metric import track_request
@@ -138,10 +137,8 @@ async def analyze_log(
138
137
  log_summary = format_snippets(log_summary)
139
138
 
140
139
  response = await submit_text(
141
- http_session,
142
140
  PROMPT_CONFIG.prompt_template.format(log_summary),
143
- model=SERVER_CONFIG.inference.model,
144
- max_tokens=SERVER_CONFIG.inference.max_tokens,
141
+ inference_cfg=SERVER_CONFIG.inference,
145
142
  )
146
143
  certainty = 0
147
144
 
@@ -172,10 +169,7 @@ async def analyze_log_staged(
172
169
  remote_log = RemoteLog(build_log.url, http_session)
173
170
  log_text = await remote_log.process_url()
174
171
 
175
- return await perform_staged_analysis(
176
- http_session,
177
- log_text=log_text,
178
- )
172
+ return await perform_staged_analysis(log_text)
179
173
 
180
174
 
181
175
  @app.get("/queue/print")
@@ -210,19 +204,12 @@ async def analyze_log_stream(
210
204
  log_text = await remote_log.process_url()
211
205
  log_summary = mine_logs(log_text)
212
206
  log_summary = format_snippets(log_summary)
213
- headers = {"Content-Type": "application/json"}
214
-
215
- if SERVER_CONFIG.inference.api_token:
216
- headers["Authorization"] = f"Bearer {SERVER_CONFIG.inference.api_token}"
217
207
 
218
208
  try:
219
- stream = submit_text_chat_completions(
220
- http_session,
209
+ stream = submit_text(
221
210
  PROMPT_CONFIG.prompt_template.format(log_summary),
211
+ inference_cfg=SERVER_CONFIG.inference,
222
212
  stream=True,
223
- headers=headers,
224
- model=SERVER_CONFIG.inference.model,
225
- max_tokens=SERVER_CONFIG.inference.max_tokens,
226
213
  )
227
214
  except aiohttp.ClientResponseError as ex:
228
215
  raise HTTPException(
@@ -237,12 +224,29 @@ async def analyze_log_stream(
237
224
  return StreamingResponse(stream)
238
225
 
239
226
 
227
+ def is_valid_webhook_secret(forge, x_gitlab_token):
228
+ """Check whether the provided x_gitlab_token matches the webhook secret
229
+ specified in the configuration"""
230
+
231
+ gitlab_cfg = SERVER_CONFIG.gitlab.instances[forge.value]
232
+
233
+ if not gitlab_cfg.webhook_secrets:
234
+ # No secrets specified, so don't bother validating.
235
+ # This is mostly to be used for development.
236
+ return True
237
+
238
+ if x_gitlab_token in gitlab_cfg.webhook_secrets:
239
+ return True
240
+
241
+ return False
242
+
243
+
240
244
  @app.post("/webhook/gitlab/job_events")
241
245
  async def receive_gitlab_job_event_webhook(
242
- x_gitlab_instance: Annotated[str | None, Header()],
243
246
  job_hook: JobHook,
244
247
  background_tasks: BackgroundTasks,
245
- http: aiohttp.ClientSession = Depends(get_http_session),
248
+ x_gitlab_instance: Annotated[str | None, Header()],
249
+ x_gitlab_token: Annotated[str | None, Header()] = None,
246
250
  ):
247
251
  """Webhook endpoint for receiving job_events notifications from GitLab
248
252
  https://docs.gitlab.com/user/project/integrations/webhook_events/#job-events
@@ -254,11 +258,15 @@ async def receive_gitlab_job_event_webhook(
254
258
  LOG.critical("%s is not a recognized forge. Ignoring.", x_gitlab_instance)
255
259
  return BasicResponse(status_code=400)
256
260
 
261
+ if not is_valid_webhook_secret(forge, x_gitlab_token):
262
+ # This request could not be validated, so return a 401
263
+ # (Unauthorized) error.
264
+ return BasicResponse(status_code=401)
265
+
257
266
  # Handle the message in the background so we can return 204 immediately
258
267
  gitlab_cfg = SERVER_CONFIG.gitlab.instances[forge.value]
259
268
  background_tasks.add_task(
260
269
  process_gitlab_job_event,
261
- http,
262
270
  gitlab_cfg,
263
271
  forge,
264
272
  job_hook,
@@ -280,6 +288,7 @@ emoji_lookup = {}
280
288
  @app.post("/webhook/gitlab/emoji_events")
281
289
  async def receive_gitlab_emoji_event_webhook(
282
290
  x_gitlab_instance: Annotated[str | None, Header()],
291
+ x_gitlab_token: Annotated[str | None, Header()],
283
292
  emoji_hook: EmojiHook,
284
293
  background_tasks: BackgroundTasks,
285
294
  ):
@@ -293,6 +302,11 @@ async def receive_gitlab_emoji_event_webhook(
293
302
  LOG.critical("%s is not a recognized forge. Ignoring.", x_gitlab_instance)
294
303
  return BasicResponse(status_code=400)
295
304
 
305
+ if not is_valid_webhook_secret(forge, x_gitlab_token):
306
+ # This request could not be validated, so return a 401
307
+ # (Unauthorized) error.
308
+ return BasicResponse(status_code=401)
309
+
296
310
  if not emoji_hook.merge_request:
297
311
  # This is not a merge request event. It is probably an emoji applied
298
312
  # to some other "awardable" entity. Just ignore it and return.
@@ -504,20 +518,13 @@ async def collect_emoji_task():
504
518
 
505
519
 
506
520
  async def schedule_collect_emojis_task():
507
- """Schedule the collect_emojis_task to run every day at midnight"""
521
+ """Schedule the collect_emojis_task to run on a configured interval"""
508
522
  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
-
523
+ seconds_until_run = SERVER_CONFIG.general.collect_emojis_interval
517
524
  LOG.info("Collect emojis in %d seconds", seconds_until_run)
518
525
  await asyncio.sleep(seconds_until_run)
519
526
 
520
527
  try:
521
528
  await collect_emoji_task()
522
529
  except Exception as e: # pylint: disable=broad-exception-caught
523
- LOG.error("Error in collect_emoji_task: %s", e)
530
+ LOG.exception("Error in collect_emoji_task: %s", e)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: logdetective
3
- Version: 1.0.1
3
+ Version: 1.1.0
4
4
  Summary: Log using LLM AI to search for build/test failures and provide ideas for fixing these.
5
5
  License: Apache-2.0
6
6
  Author: Jiri Podivin
@@ -218,6 +218,10 @@ or
218
218
 
219
219
  tox run -e lint # to run pylint
220
220
 
221
+ To run full test suite you will need postgresql client utilities.
222
+
223
+ dnf install postgresql
224
+
221
225
  Visual Studio Code testing with podman/docker-compose
222
226
  -----------------------------------------------------
223
227
 
@@ -4,6 +4,8 @@ logdetective/drain3.ini,sha256=ni91eCT1TwTznZwcqWoOVMQcGEnWhEDNCoTPF7cfGfY,1360
4
4
  logdetective/extractors.py,sha256=7ahzWbTtU9MveG1Q7wU9LO8OJgs85X-cHmWltUhCe9M,3491
5
5
  logdetective/logdetective.py,sha256=cC2oL4yPNo94AB2nS4v1jpZi-Qo1g0_FEchL_yQL1UU,5832
6
6
  logdetective/models.py,sha256=nrGBmMRu8i6UhFflQKAp81Y3Sd_Aaoor0i_yqSJoLT0,1115
7
+ logdetective/prompts-summary-first.yml,sha256=3Zfp4NNOfaFYq5xBlBjeQa5PdjYfS4v17OtJqQ-DRpU,821
8
+ logdetective/prompts-summary-only.yml,sha256=8U9AMJV8ePW-0CoXOXlQoO92DAJDeutIT8ntSkkm6W0,470
7
9
  logdetective/prompts.yml,sha256=urPKG068TYxi58EicFVUH6FavZq_q36oM1LvfI4ddjg,1729
8
10
  logdetective/remote_log.py,sha256=Zbv8g29jko8uQnzFUznnr8Nd9RSJCRs1PmPV7viqX9M,2267
9
11
  logdetective/server/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -14,18 +16,18 @@ logdetective/server/database/base.py,sha256=1mcjEbhwLl4RalvT3oy6XVctjJoWIW3H9aI_
14
16
  logdetective/server/database/models/__init__.py,sha256=xy2hkygyw6_87zPKkG20i7g7_LXTGR__PUeojhbvv94,496
15
17
  logdetective/server/database/models/merge_request_jobs.py,sha256=hw88wV1-3x7i53sX7ZotKClc6OsH1njPpbRSZofnqr4,18670
16
18
  logdetective/server/database/models/metrics.py,sha256=yl9fS4IPVFWDeFvPAxO6zOVu6oLF319ApvVLAgnD5yU,13928
17
- logdetective/server/emoji.py,sha256=g9GtMChwznD8g1xonsh-I_3xqRn6LBeg3sjPJWcI0Yg,3333
18
- logdetective/server/gitlab.py,sha256=1Qz62I8xIjwdk7vPhGTTPFkeWVrany8-GV5hfK6weNI,16233
19
- logdetective/server/llm.py,sha256=JtSCZj8SLnoyTCUdhA0TwcsMZfmHFFru2bJ9txI3GuU,8727
19
+ logdetective/server/emoji.py,sha256=Jh8RM36K8XDLefS8STm-nvhSLuoaFwlKeZJ5HLmRmdY,4298
20
+ logdetective/server/gitlab.py,sha256=HxepI9I7j6VaIn2gu0FLz34e_1qodHQNR8xdrRpEMLI,16277
21
+ logdetective/server/llm.py,sha256=8OnyCErDxKZA7FJSUzNjgPHu0eAjagCz7Yx7LzW98EE,7207
20
22
  logdetective/server/metric.py,sha256=B3ew_qSmtEMj6xl-FoOtS4F_bkplp-shhtfHF1cG_Io,4010
21
- logdetective/server/models.py,sha256=mUBGzc0w6l-v1Q9lwDEcISn6SlFrrwbF3ypSmjNXbbs,11355
23
+ logdetective/server/models.py,sha256=V8haEsnIYap1lRj2NlOCBtM7bJxWDdZehw4Whf_hnIE,15336
22
24
  logdetective/server/plot.py,sha256=eZs4r9gua-nW3yymSMIz1leL9mb4QKlh6FJZSeOfZ5M,14872
23
- logdetective/server/server.py,sha256=9shFgRkWcJVM2L7HHoQBMCfKuJamh2L4tC96duFPEOA,18127
25
+ logdetective/server/server.py,sha256=-JJnHj8fPzx8aCJD3q2wRwidxoHPCmwOP8FTWwc1C14,18386
24
26
  logdetective/server/templates/gitlab_full_comment.md.j2,sha256=DQZ2WVFedpuXI6znbHIW4wpF9BmFS8FaUkowh8AnGhE,1627
25
27
  logdetective/server/templates/gitlab_short_comment.md.j2,sha256=fzScpayv2vpRLczP_0O0YxtA8rsKvR6gSv4ntNdWb98,1443
26
28
  logdetective/utils.py,sha256=hdExAC8FtDIxvdgIq-Ro6LVM-JZ-k_UofaMzaDAHvzM,6088
27
- logdetective-1.0.1.dist-info/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
28
- logdetective-1.0.1.dist-info/METADATA,sha256=dkso00EVQfwxoBekYT6KW48WvFdFqlBjTPw9U5S0wCg,17136
29
- logdetective-1.0.1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
30
- logdetective-1.0.1.dist-info/entry_points.txt,sha256=3K_vXja6PmcA8sNdUi63WdImeiNhVZcEGPTaoJmltfA,63
31
- logdetective-1.0.1.dist-info/RECORD,,
29
+ logdetective-1.1.0.dist-info/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
30
+ logdetective-1.1.0.dist-info/METADATA,sha256=5oZU-qB24EoQ05ezESAuiDo0MH3o-y3_ZdLYbFiRldo,17231
31
+ logdetective-1.1.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
32
+ logdetective-1.1.0.dist-info/entry_points.txt,sha256=3K_vXja6PmcA8sNdUi63WdImeiNhVZcEGPTaoJmltfA,63
33
+ logdetective-1.1.0.dist-info/RECORD,,