logdetective 2.0.1__py3-none-any.whl → 2.11.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.
Files changed (32) hide show
  1. logdetective/extractors.py +134 -23
  2. logdetective/logdetective.py +39 -23
  3. logdetective/models.py +26 -0
  4. logdetective/prompts-summary-first.yml +0 -2
  5. logdetective/prompts.yml +0 -3
  6. logdetective/server/compressors.py +7 -10
  7. logdetective/server/config.py +3 -2
  8. logdetective/server/database/base.py +31 -26
  9. logdetective/server/database/models/__init__.py +2 -2
  10. logdetective/server/database/models/exceptions.py +4 -0
  11. logdetective/server/database/models/koji.py +47 -30
  12. logdetective/server/database/models/merge_request_jobs.py +205 -186
  13. logdetective/server/database/models/metrics.py +87 -61
  14. logdetective/server/emoji.py +57 -55
  15. logdetective/server/exceptions.py +4 -0
  16. logdetective/server/gitlab.py +18 -11
  17. logdetective/server/llm.py +19 -10
  18. logdetective/server/metric.py +18 -13
  19. logdetective/server/models.py +65 -48
  20. logdetective/server/plot.py +13 -11
  21. logdetective/server/server.py +52 -30
  22. logdetective/server/templates/base_response.html.j2 +59 -0
  23. logdetective/server/templates/gitlab_full_comment.md.j2 +58 -53
  24. logdetective/server/templates/gitlab_short_comment.md.j2 +52 -47
  25. logdetective/server/utils.py +15 -27
  26. logdetective/utils.py +115 -49
  27. {logdetective-2.0.1.dist-info → logdetective-2.11.0.dist-info}/METADATA +95 -21
  28. logdetective-2.11.0.dist-info/RECORD +40 -0
  29. {logdetective-2.0.1.dist-info → logdetective-2.11.0.dist-info}/WHEEL +1 -1
  30. logdetective-2.0.1.dist-info/RECORD +0 -39
  31. {logdetective-2.0.1.dist-info → logdetective-2.11.0.dist-info}/entry_points.txt +0 -0
  32. {logdetective-2.0.1.dist-info → logdetective-2.11.0.dist-info/licenses}/LICENSE +0 -0
@@ -1,57 +1,168 @@
1
1
  import os
2
2
  import logging
3
+ import subprocess as sp
3
4
  from typing import Tuple
4
5
 
5
6
  import drain3
6
7
  from drain3.template_miner_config import TemplateMinerConfig
8
+ from pydantic import ValidationError
7
9
 
8
10
  from logdetective.utils import get_chunks, filter_snippet_patterns
9
- from logdetective.models import SkipSnippets
11
+ from logdetective.models import SkipSnippets, CSGrepOutput
10
12
 
11
13
  LOG = logging.getLogger("logdetective")
12
14
 
13
15
 
14
- class DrainExtractor:
15
- """A class that extracts information from logs using a template miner algorithm."""
16
+ class Extractor:
17
+ """Base extractor class."""
16
18
 
17
19
  def __init__(
18
20
  self,
19
21
  verbose: bool = False,
20
- context: bool = False,
21
- max_clusters=8,
22
22
  skip_snippets: SkipSnippets = SkipSnippets({}),
23
- max_snippet_len: int = 2000
24
- ): # pylint: disable=R0913,R0917
25
- config = TemplateMinerConfig()
26
- config.load(f"{os.path.dirname(__file__)}/drain3.ini")
27
- config.profiling_enabled = verbose
28
- config.drain_max_clusters = max_clusters
29
- self.miner = drain3.TemplateMiner(config=config)
23
+ max_snippet_len: int = 2000,
24
+ ):
30
25
  self.verbose = verbose
31
- self.context = context
32
26
  self.skip_snippets = skip_snippets
33
27
  self.max_snippet_len = max_snippet_len
34
28
 
29
+ if self.verbose:
30
+ LOG.setLevel(logging.DEBUG)
31
+
35
32
  def __call__(self, log: str) -> list[Tuple[int, str]]:
36
- out = []
37
- # Create chunks
38
- chunks = list(get_chunks(log, self.max_snippet_len))
39
- # Keep only chunks that don't match any of the excluded patterns
33
+ raise NotImplementedError
34
+
35
+ def filter_snippet_patterns(
36
+ self, chunks: list[tuple[int, str]]
37
+ ) -> list[tuple[int, str]]:
38
+ """Keep only chunks that don't match any of the excluded patterns"""
40
39
  chunks = [
41
40
  (_, chunk)
42
41
  for _, chunk in chunks
43
42
  if not filter_snippet_patterns(chunk, self.skip_snippets)
44
43
  ]
45
- # First pass create clusters
44
+ return chunks
45
+
46
+
47
+ class DrainExtractor(Extractor):
48
+ """A class that extracts information from logs using a template miner algorithm."""
49
+
50
+ _clusters: list
51
+
52
+ def __init__(
53
+ self,
54
+ verbose: bool = False,
55
+ skip_snippets: SkipSnippets = SkipSnippets({}),
56
+ max_snippet_len: int = 2000,
57
+ max_clusters: int = 8,
58
+ ):
59
+ super().__init__(verbose, skip_snippets, max_snippet_len)
60
+ config = TemplateMinerConfig()
61
+ config.load(f"{os.path.dirname(__file__)}/drain3.ini")
62
+ config.profiling_enabled = verbose
63
+ config.drain_max_clusters = max_clusters
64
+ self.miner = drain3.TemplateMiner(config=config)
65
+
66
+ def __call__(self, log: str) -> list[Tuple[int, str]]:
67
+ # Create chunks
68
+ chunks = list(get_chunks(log, self.max_snippet_len))
69
+
70
+ chunks = self.filter_snippet_patterns(chunks)
71
+
72
+ # First pass to create clusters
73
+ self._create_clusters(chunks=chunks)
74
+
75
+ # Second pass, only matching lines with clusters,
76
+ # to recover original text
77
+ snippets = self._extract_messages(chunks=chunks)
78
+ return snippets
79
+
80
+ def _create_clusters(self, chunks: list[tuple[int, str]]):
81
+ """First pass to create clusters"""
46
82
  for _, chunk in chunks:
47
83
  processed_chunk = self.miner.add_log_message(chunk)
48
84
  LOG.debug(processed_chunk)
49
- clusters = list(self.miner.drain.clusters)
50
- # Second pass, only matching lines with clusters,
51
- # to recover original text
85
+ self._clusters = list(self.miner.drain.clusters)
86
+
87
+ def _extract_messages(self, chunks: list[tuple[int, str]]) -> list[tuple[int, str]]:
88
+ """Second pass with drain using patterns from the first,
89
+ to extract matching lines and their numbers."""
90
+ out = []
91
+
52
92
  for chunk_start, chunk in chunks:
53
93
  cluster = self.miner.match(chunk, "always")
54
- if cluster in clusters:
94
+ if cluster in self._clusters:
55
95
  out.append((chunk_start, chunk))
56
- clusters.remove(cluster)
96
+ self._clusters.remove(cluster)
57
97
  return out
98
+
99
+
100
+ class CSGrepExtractor(DrainExtractor):
101
+ """Extract messages using csgrep
102
+ This extractor is only effective at retrieving messages from GCC
103
+ compiler and associated utilities, it is not capable of safely
104
+ extracting other messages from the logs. Therefore, it must only
105
+ be used together with the Drain based extractor."""
106
+
107
+ def __init__(
108
+ self,
109
+ verbose: bool = False,
110
+ skip_snippets: SkipSnippets = SkipSnippets({}),
111
+ max_snippet_len: int = 2000,
112
+ max_clusters: int = 8,
113
+ ):
114
+ super().__init__(verbose, skip_snippets, max_snippet_len, max_clusters)
115
+
116
+ def __call__(self, log: str) -> list[Tuple[int, str]]:
117
+ """Extract error messages from log using csgrep"""
118
+ chunks = []
119
+ try:
120
+ # We are not running binary in check mode, since csgrep
121
+ # can produce many errors due to log file syntax
122
+ result = sp.run(
123
+ [
124
+ "csgrep",
125
+ "--event=error",
126
+ "--remove-duplicates",
127
+ "--mode=json",
128
+ "--quiet",
129
+ ],
130
+ input=log,
131
+ shell=False,
132
+ check=False,
133
+ capture_output=True,
134
+ text=True,
135
+ timeout=1.0,
136
+ )
137
+ except sp.TimeoutExpired as ex:
138
+ LOG.exception("Exception encountered while parsing log with csgrep %s", ex)
139
+ raise ex
140
+ if result.returncode != 0:
141
+ # This can happen even if `csgrep` managed to extract useful info.
142
+ # Most commonly, when it encountered unexpected syntax in the log.
143
+ LOG.warning("csgrep call resulted in an error")
144
+ LOG.debug("csgrep error: `%s`", result.stderr)
145
+ if not result.stdout:
146
+ return []
147
+
148
+ # Parse JSON output from csgrep
149
+ try:
150
+ report = CSGrepOutput.model_validate_json(result.stdout)
151
+ except ValidationError as ex:
152
+ LOG.exception("Exception encountered while parsing csgrpe output %s", ex)
153
+ raise ex
154
+ for defect in report.defects:
155
+ # Single original error message can be split across multiple events
156
+ # before returning, we will turn them back into single string.
157
+ # We must also extract the original line number.
158
+ # Line number is NOT location of message in the log, but location of
159
+ # the issue in source, we can't really mix the two, so we'll set it to `0`.
160
+
161
+ chunks.append((0, "\n".join([event.message for event in defect.events])))
162
+
163
+ chunks = self.filter_snippet_patterns(chunks)
164
+ LOG.info("Total %d messages extracted with csgrep", len(chunks))
165
+ self._create_clusters(chunks=chunks)
166
+ snippets = self._extract_messages(chunks=chunks)
167
+
168
+ return snippets
@@ -15,8 +15,10 @@ from logdetective.utils import (
15
15
  compute_certainty,
16
16
  load_prompts,
17
17
  load_skip_snippet_patterns,
18
+ check_csgrep,
19
+ mine_logs,
18
20
  )
19
- from logdetective.extractors import DrainExtractor
21
+ from logdetective.extractors import DrainExtractor, CSGrepExtractor
20
22
 
21
23
  LOG = logging.getLogger("logdetective")
22
24
 
@@ -89,10 +91,13 @@ def setup_args():
89
91
  default=f"{os.path.dirname(__file__)}/skip_snippets.yml",
90
92
  help="Path to patterns for skipping snippets.",
91
93
  )
94
+ parser.add_argument(
95
+ "--csgrep", action="store_true", help="Use csgrep to process the log."
96
+ )
92
97
  return parser.parse_args()
93
98
 
94
99
 
95
- async def run(): # pylint: disable=too-many-statements,too-many-locals
100
+ async def run(): # pylint: disable=too-many-statements,too-many-locals,too-many-branches
96
101
  """Main execution function."""
97
102
  args = setup_args()
98
103
 
@@ -134,13 +139,25 @@ async def run(): # pylint: disable=too-many-statements,too-many-locals
134
139
  sys.exit(5)
135
140
 
136
141
  # Log file summarizer initialization
137
- extractor = DrainExtractor(
138
- args.verbose > 1,
139
- context=True,
140
- max_clusters=args.n_clusters,
141
- skip_snippets=skip_snippets,
142
+ extractors = []
143
+ extractors.append(
144
+ DrainExtractor(
145
+ args.verbose > 1,
146
+ max_clusters=args.n_clusters,
147
+ skip_snippets=skip_snippets,
148
+ )
142
149
  )
143
150
 
151
+ if args.csgrep:
152
+ if not check_csgrep():
153
+ LOG.error(
154
+ "You have requested use of `csgrep` when it isn't available on your system."
155
+ )
156
+ sys.exit(6)
157
+ extractors.append(
158
+ CSGrepExtractor(args.verbose > 1, skip_snippets=skip_snippets)
159
+ )
160
+
144
161
  LOG.info("Getting summary")
145
162
 
146
163
  async with aiohttp.ClientSession() as http:
@@ -150,22 +167,13 @@ async def run(): # pylint: disable=too-many-statements,too-many-locals
150
167
  # file does not exist
151
168
  LOG.error(e)
152
169
  sys.exit(4)
153
- log_summary = extractor(log)
154
-
155
- ratio = len(log_summary) / len(log.split("\n"))
156
-
157
- LOG.info("Compression ratio: %s", ratio)
158
170
 
171
+ log_summary = mine_logs(log=log, extractors=extractors)
159
172
  LOG.info("Analyzing the text")
160
173
 
161
174
  log_summary = format_snippets(log_summary)
162
175
  LOG.info("Log summary: \n %s", log_summary)
163
176
 
164
- prompt = (
165
- f"{prompts_configuration.default_system_prompt}\n"
166
- f"{prompts_configuration.prompt_template}"
167
- )
168
-
169
177
  stream = True
170
178
  if args.no_stream:
171
179
  stream = False
@@ -173,30 +181,38 @@ async def run(): # pylint: disable=too-many-statements,too-many-locals
173
181
  log_summary,
174
182
  model,
175
183
  stream,
176
- prompt_template=prompt,
184
+ prompt_templates=prompts_configuration,
177
185
  temperature=args.temperature,
178
186
  )
179
187
  probs = []
180
188
  print("Explanation:")
181
189
  # We need to extract top token probability from the response
182
- # CreateCompletionResponse structure of llama-cpp-python.
190
+ # CreateChatCompletionResponse structure of llama-cpp-python.
183
191
  # `compute_certainty` function expects list of dictionaries with form
184
192
  # { 'logprob': <float> } as expected from the OpenAI API.
185
193
 
186
194
  if args.no_stream:
187
- print(response["choices"][0]["text"])
195
+ print(response["choices"][0]["message"]["content"])
188
196
  probs = [
189
- {"logprob": e} for e in response["choices"][0]["logprobs"]["token_logprobs"]
197
+ {"logprob": e["logprob"]} for e in response["choices"][0]["logprobs"]["content"]
190
198
  ]
191
199
 
192
200
  else:
193
201
  # Stream the output
194
202
  for chunk in response:
203
+ # What might happen, is that first (or possibly any other) chunk may not contain
204
+ # fields choices[0].delta.content or choices[0].logprobs -> if so, we just skip it
205
+ if any([
206
+ 'content' not in chunk["choices"][0]["delta"],
207
+ 'logprobs' not in chunk["choices"][0]
208
+ ]):
209
+ continue
210
+
195
211
  if isinstance(chunk["choices"][0]["logprobs"], dict):
196
212
  probs.append(
197
- {"logprob": chunk["choices"][0]["logprobs"]["token_logprobs"][0]}
213
+ {"logprob": chunk["choices"][0]["logprobs"]["content"][0]["logprob"]}
198
214
  )
199
- delta = chunk["choices"][0]["text"]
215
+ delta = chunk["choices"][0]["delta"]["content"]
200
216
  print(delta, end="", flush=True)
201
217
  certainty = compute_certainty(probs)
202
218
 
logdetective/models.py CHANGED
@@ -71,3 +71,29 @@ class SkipSnippets(BaseModel):
71
71
  ) from ex
72
72
 
73
73
  return data
74
+
75
+
76
+ class CSGrepEvent(BaseModel):
77
+ """`csgrep` splits error and warning messages into individual events."""
78
+
79
+ file_name: str
80
+ line: int
81
+ event: str
82
+ message: str
83
+ verbosity_level: int
84
+
85
+
86
+ class CSGrepDefect(BaseModel):
87
+ """Defects detected by `csgrep`"""
88
+
89
+ checker: str
90
+ language: str
91
+ tool: str
92
+ key_event_idx: int
93
+ events: list[CSGrepEvent]
94
+
95
+
96
+ class CSGrepOutput(BaseModel):
97
+ """Parsed output of `gsgrep`"""
98
+
99
+ defects: list[CSGrepDefect]
@@ -18,5 +18,3 @@ prompt_template: |
18
18
  Snippets:
19
19
 
20
20
  {}
21
-
22
- Analysis:
logdetective/prompts.yml CHANGED
@@ -19,7 +19,6 @@ prompt_template: |
19
19
 
20
20
  {}
21
21
 
22
- Analysis:
23
22
 
24
23
  snippet_prompt_template: |
25
24
  Analyse following RPM build log snippet. Describe contents accurately, without speculation or suggestions for resolution
@@ -30,7 +29,6 @@ snippet_prompt_template: |
30
29
 
31
30
  {}
32
31
 
33
- Analysis:
34
32
 
35
33
  prompt_template_staged: |
36
34
  Given following log snippets, their explanation, and nothing else, explain what failure, if any, occurred during build of this package.
@@ -47,7 +45,6 @@ prompt_template_staged: |
47
45
 
48
46
  {}
49
47
 
50
- Analysis:
51
48
 
52
49
  # System prompts
53
50
  # System prompts are meant to serve as general guide for model behavior,
@@ -36,20 +36,17 @@ class TextCompressor:
36
36
  zip_buffer.seek(0)
37
37
  return zip_buffer.getvalue()
38
38
 
39
- def unzip(self, zip_data: Union[bytes, io.BytesIO]) -> str:
39
+ def unzip(self, zip_data: bytes) -> Dict[str, str]:
40
40
  """
41
41
  Uncompress data created by TextCompressor.zip().
42
42
 
43
43
  Args:
44
- zip_data: A zipped stream of bytes or BytesIO object
44
+ zip_data: A zipped stream of bytes
45
45
 
46
46
  Returns:
47
47
  {file_name: str}: The decompressed content as a dict of file names and UTF-8 strings
48
48
  """
49
- if isinstance(zip_data, bytes):
50
- zip_buffer = io.BytesIO(zip_data)
51
- else:
52
- zip_buffer = zip_data
49
+ zip_buffer = io.BytesIO(zip_data)
53
50
 
54
51
  content = {}
55
52
  with zipfile.ZipFile(zip_buffer, "r") as zip_file:
@@ -95,12 +92,12 @@ class RemoteLogCompressor:
95
92
  return self.zip_text(content_text)
96
93
 
97
94
  @classmethod
98
- def unzip(cls, zip_data: Union[bytes, io.BytesIO]) -> str:
95
+ def unzip(cls, zip_data: bytes) -> str:
99
96
  """
100
97
  Uncompress the zipped content of the remote log.
101
98
 
102
99
  Args:
103
- zip_data: Compressed data as bytes or BytesIO
100
+ zip_data: Compressed data as bytes
104
101
 
105
102
  Returns:
106
103
  str: The decompressed log content
@@ -147,13 +144,13 @@ class LLMResponseCompressor:
147
144
 
148
145
  @classmethod
149
146
  def unzip(
150
- cls, zip_data: Union[bytes, io.BytesIO]
147
+ cls, zip_data: bytes
151
148
  ) -> Union[StagedResponse, Response]:
152
149
  """
153
150
  Uncompress the zipped content of the LLM response.
154
151
 
155
152
  Args:
156
- zip_data: Compressed data as bytes or BytesIO
153
+ zip_data: Compressed data as bytes
157
154
 
158
155
  Returns:
159
156
  Union[StagedResponse, Response]: The decompressed (partial) response object,
@@ -52,10 +52,11 @@ def get_log(config: Config):
52
52
  return log
53
53
 
54
54
 
55
- def get_openai_api_client(ineference_config: InferenceConfig):
55
+ def get_openai_api_client(inference_config: InferenceConfig):
56
56
  """Set up AsyncOpenAI client with default configuration."""
57
57
  return AsyncOpenAI(
58
- api_key=ineference_config.api_token, base_url=ineference_config.url
58
+ api_key=inference_config.api_token, base_url=inference_config.url,
59
+ timeout=inference_config.llm_api_timeout
59
60
  )
60
61
 
61
62
 
@@ -1,15 +1,14 @@
1
1
  from os import getenv
2
- from contextlib import contextmanager
3
- from sqlalchemy import create_engine
4
- from sqlalchemy.orm import sessionmaker, declarative_base
5
-
2
+ from contextlib import asynccontextmanager
3
+ from sqlalchemy.orm import DeclarativeBase
4
+ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
6
5
  from logdetective import logger
7
6
 
8
7
 
9
8
  def get_pg_url() -> str:
10
9
  """create postgresql connection string"""
11
10
  return (
12
- f"postgresql+psycopg2://{getenv('POSTGRESQL_USER')}"
11
+ f"postgresql+asyncpg://{getenv('POSTGRESQL_USER')}"
13
12
  f":{getenv('POSTGRESQL_PASSWORD')}@{getenv('POSTGRESQL_HOST', 'postgres')}"
14
13
  f":{getenv('POSTGRESQL_PORT', '5432')}/{getenv('POSTGRESQL_DATABASE')}"
15
14
  )
@@ -23,13 +22,16 @@ sqlalchemy_echo = getenv("SQLALCHEMY_ECHO", "False").lower() in (
23
22
  "y",
24
23
  "1",
25
24
  )
26
- engine = create_engine(get_pg_url(), echo=sqlalchemy_echo)
27
- SessionFactory = sessionmaker(autoflush=True, bind=engine)
28
- Base = declarative_base()
25
+ engine = create_async_engine(get_pg_url(), echo=sqlalchemy_echo)
26
+ SessionFactory = async_sessionmaker(autoflush=True, bind=engine) # pylint: disable=invalid-name
27
+
28
+
29
+ class Base(DeclarativeBase):
30
+ """Declarative base class for all ORM models."""
29
31
 
30
32
 
31
- @contextmanager
32
- def transaction(commit: bool = False):
33
+ @asynccontextmanager
34
+ async def transaction(commit: bool = False):
33
35
  """
34
36
  Context manager for 'framing' a db transaction.
35
37
 
@@ -39,27 +41,30 @@ def transaction(commit: bool = False):
39
41
  """
40
42
 
41
43
  session = SessionFactory()
42
- try:
43
- yield session
44
- if commit:
45
- session.commit()
46
- except Exception as ex:
47
- logger.warning("Exception while working with database: %s", str(ex))
48
- session.rollback()
49
- raise
50
- finally:
51
- session.close()
52
-
53
-
54
- def init():
44
+ async with session:
45
+ try:
46
+ yield session
47
+ if commit:
48
+ await session.commit()
49
+ except Exception as ex:
50
+ logger.warning("Exception while working with database: %s", str(ex))
51
+ await session.rollback()
52
+ raise
53
+ finally:
54
+ await session.close()
55
+
56
+
57
+ async def init():
55
58
  """Init db"""
56
- Base.metadata.create_all(engine)
59
+ async with engine.begin() as conn:
60
+ await conn.run_sync(Base.metadata.create_all)
57
61
  logger.debug("Database initialized")
58
62
 
59
63
 
60
- def destroy():
64
+ async def destroy():
61
65
  """Destroy db"""
62
- Base.metadata.drop_all(engine)
66
+ async with engine.begin() as conn:
67
+ await conn.run_sync(Base.metadata.drop_all)
63
68
  logger.warning("Database cleaned")
64
69
 
65
70
 
@@ -1,4 +1,3 @@
1
- from logdetective.server.database.base import Base
2
1
  from logdetective.server.database.models.merge_request_jobs import (
3
2
  Forge,
4
3
  GitlabMergeRequestJobs,
@@ -18,8 +17,9 @@ from logdetective.server.database.models.exceptions import (
18
17
  KojiTaskAnalysisTimeoutError,
19
18
  )
20
19
 
20
+ # pylint: disable=undefined-all-variable
21
+
21
22
  __all__ = [
22
- Base.__name__,
23
23
  GitlabMergeRequestJobs.__name__,
24
24
  Comments.__name__,
25
25
  Reactions.__name__,
@@ -11,3 +11,7 @@ class KojiTaskNotAnalyzedError(Exception):
11
11
 
12
12
  class KojiTaskAnalysisTimeoutError(Exception):
13
13
  """Exception raised when a koji task analysis has timed out"""
14
+
15
+
16
+ class AnalyzeRequestMetricsNotFroundError(Exception):
17
+ """Exception raised when AnalyzeRequestMetrics is not found"""