logdetective 2.12.0__py3-none-any.whl → 3.0.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.
@@ -41,31 +41,15 @@ def setup_args():
41
41
  )
42
42
  parser.add_argument(
43
43
  "-F",
44
- "--filename_suffix",
44
+ "--filename-suffix",
45
45
  help="Suffix of the model file name to be retrieved from Hugging Face.\
46
46
  Makes sense only if the model is specified with Hugging Face name.",
47
47
  default="Q4_K.gguf",
48
48
  )
49
49
  parser.add_argument("-n", "--no-stream", action="store_true")
50
- parser.add_argument(
51
- "-S",
52
- "--summarizer",
53
- type=str,
54
- default="drain",
55
- help="DISABLED: LLM summarization option was removed. \
56
- Argument is kept for backward compatibility only.",
57
- )
58
- parser.add_argument(
59
- "-N",
60
- "--n_lines",
61
- type=int,
62
- default=None,
63
- help="DISABLED: LLM summarization option was removed. \
64
- Argument is kept for backward compatibility only.",
65
- )
66
50
  parser.add_argument(
67
51
  "-C",
68
- "--n_clusters",
52
+ "--n-clusters",
69
53
  type=int,
70
54
  default=8,
71
55
  help="Number of clusters for Drain to organize log chunks into.\
@@ -86,7 +70,7 @@ def setup_args():
86
70
  help="Temperature for inference.",
87
71
  )
88
72
  parser.add_argument(
89
- "--skip_snippets",
73
+ "--skip-snippets",
90
74
  type=str,
91
75
  default=f"{os.path.dirname(__file__)}/skip_snippets.yml",
92
76
  help="Path to patterns for skipping snippets.",
@@ -105,10 +89,6 @@ async def run(): # pylint: disable=too-many-statements,too-many-locals,too-many
105
89
  sys.stderr.write("Error: --quiet and --verbose is mutually exclusive.\n")
106
90
  sys.exit(2)
107
91
 
108
- # Emit warning about use of discontinued args
109
- if args.n_lines or args.summarizer != "drain":
110
- LOG.warning("LLM based summarization was removed. Drain will be used instead.")
111
-
112
92
  # Logging facility setup
113
93
  log_level = logging.INFO
114
94
  if args.verbose >= 1:
@@ -22,7 +22,7 @@ sqlalchemy_echo = getenv("SQLALCHEMY_ECHO", "False").lower() in (
22
22
  "y",
23
23
  "1",
24
24
  )
25
- engine = create_async_engine(get_pg_url(), echo=sqlalchemy_echo)
25
+ engine = create_async_engine(get_pg_url(), echo=sqlalchemy_echo, pool_pre_ping=True)
26
26
  SessionFactory = async_sessionmaker(autoflush=True, bind=engine) # pylint: disable=invalid-name
27
27
 
28
28
 
@@ -314,10 +314,13 @@ class AnalyzeRequestMetrics(Base):
314
314
  "time_range"
315
315
  ),
316
316
  (
317
- func.avg(
318
- func.extract( # pylint: disable=not-callable
319
- "epoch", cls.response_sent_at - cls.request_received_at
320
- )
317
+ func.coalesce(
318
+ func.avg(
319
+ func.extract( # pylint: disable=not-callable
320
+ "epoch", cls.response_sent_at - cls.request_received_at
321
+ )
322
+ ),
323
+ 0
321
324
  )
322
325
  ).label("average_response_seconds"),
323
326
  )
@@ -1,17 +1,24 @@
1
1
  import inspect
2
+ from collections import defaultdict
2
3
  import datetime
3
-
4
- from typing import Optional, Union
4
+ from typing import Optional, Union, Dict
5
5
  from functools import wraps
6
6
 
7
7
  import aiohttp
8
-
8
+ import numpy
9
9
  from starlette.responses import StreamingResponse
10
- from logdetective.server import models
10
+
11
11
  from logdetective.remote_log import RemoteLog
12
12
  from logdetective.server.config import LOG
13
13
  from logdetective.server.compressors import LLMResponseCompressor, RemoteLogCompressor
14
- from logdetective.server.database.models import EndpointType, AnalyzeRequestMetrics
14
+ from logdetective.server.models import (
15
+ TimePeriod,
16
+ MetricTimeSeries,
17
+ StagedResponse,
18
+ Response,
19
+ Explanation,
20
+ )
21
+ from logdetective.server.database.models import EndpointType, AnalyzeRequestMetrics, Reactions
15
22
  from logdetective.server.exceptions import LogDetectiveMetricsError
16
23
 
17
24
 
@@ -47,7 +54,7 @@ async def add_new_metrics(
47
54
 
48
55
  async def update_metrics(
49
56
  metrics_id: int,
50
- response: Union[models.Response, models.StagedResponse, StreamingResponse],
57
+ response: Union[Response, StagedResponse, StreamingResponse],
51
58
  sent_at: Optional[datetime.datetime] = None,
52
59
  ) -> None:
53
60
  """Update a database metric entry for a received request,
@@ -71,7 +78,7 @@ async def update_metrics(
71
78
  )
72
79
  response_length = None
73
80
  if hasattr(response, "explanation") and isinstance(
74
- response.explanation, models.Explanation
81
+ response.explanation, Explanation
75
82
  ):
76
83
  response_length = len(response.explanation.text)
77
84
  response_certainty = (
@@ -125,3 +132,189 @@ def track_request(name=None):
125
132
  raise NotImplementedError("An async coroutine is needed")
126
133
 
127
134
  return decorator
135
+
136
+
137
+ # TODO: Refactor aggregation to use database operations, instead of timestamp formatting # pylint: disable=fixme
138
+ class TimeDefinition:
139
+ """Define time format details, given a time period."""
140
+
141
+ def __init__(self, time_period: TimePeriod):
142
+ self.time_period = time_period
143
+ self.days_diff = time_period.get_time_period().days
144
+ if self.time_period.hours:
145
+ self._time_format = "%Y-%m-%d %H"
146
+ self._time_delta = datetime.timedelta(hours=1)
147
+ elif self.time_period.days:
148
+ self._time_format = "%Y-%m-%d"
149
+ self._time_delta = datetime.timedelta(days=1)
150
+ elif self.time_period.weeks:
151
+ self._time_format = "%Y-%m-%d"
152
+ self._time_delta = datetime.timedelta(weeks=1)
153
+
154
+ @property
155
+ def time_format(self):
156
+ # pylint: disable=missing-function-docstring
157
+ return self._time_format
158
+
159
+ @property
160
+ def time_delta(self):
161
+ # pylint: disable=missing-function-docstring
162
+ return self._time_delta
163
+
164
+
165
+ def create_time_series_arrays(
166
+ values_dict: dict[datetime.datetime, int],
167
+ ) -> tuple[list, list]:
168
+ """Create time series arrays from a dictionary of values.
169
+
170
+ This function generates two aligned lists:
171
+ 1. An array of timestamps from start_time to end_time
172
+ 2. A corresponding array of values for each timestamp
173
+
174
+ Args:
175
+ values_dict: Dictionary mapping timestamps to their respective values
176
+ Returns:
177
+ A tuple containing:
178
+ - list: Array of timestamps
179
+ - list: Array of corresponding values
180
+ """
181
+
182
+ timestamps = []
183
+ values = []
184
+
185
+ for timestamp, count in values_dict.items():
186
+ timestamps.append(timestamp)
187
+ values.append(count)
188
+
189
+ return timestamps, numpy.nan_to_num(values).tolist()
190
+
191
+
192
+ async def requests_per_time(
193
+ period_of_time: TimePeriod,
194
+ endpoint: EndpointType = EndpointType.ANALYZE,
195
+ end_time: Optional[datetime.datetime] = None,
196
+ ) -> MetricTimeSeries:
197
+ """
198
+ Get request counts over a specified time period.
199
+
200
+ The time intervals are determined by the provided TimePeriod object, which defines
201
+ the granularity.
202
+
203
+ Args:
204
+ period_of_time: A TimePeriod object that defines the time period and interval
205
+ for the analysis (e.g., hourly, daily, weekly)
206
+ endpoint: One of the API endpoints
207
+ end_time: The end time for the analysis period. If None, defaults to the current
208
+ UTC time
209
+
210
+ Returns:
211
+ A dictionary with timestamps and associated number of requests
212
+ """
213
+ end_time = end_time or datetime.datetime.now(datetime.timezone.utc)
214
+ start_time = period_of_time.get_period_start_time(end_time)
215
+ time_def = TimeDefinition(period_of_time)
216
+ requests_counts = await AnalyzeRequestMetrics.get_requests_in_period(
217
+ start_time, end_time, time_def.time_format, endpoint
218
+ )
219
+ timestamps, counts = create_time_series_arrays(requests_counts)
220
+
221
+ return MetricTimeSeries(metric="requests", timestamps=timestamps, values=counts)
222
+
223
+
224
+ async def average_time_per_responses(
225
+ period_of_time: TimePeriod,
226
+ endpoint: EndpointType = EndpointType.ANALYZE,
227
+ end_time: Optional[datetime.datetime] = None,
228
+ ) -> MetricTimeSeries:
229
+ """
230
+ Get average response time and length over a specified time period.
231
+
232
+ The time intervals are determined by the provided TimePeriod object, which defines
233
+ the granularity.
234
+
235
+ Args:
236
+ period_of_time: A TimePeriod object that defines the time period and interval
237
+ for the analysis (e.g., hourly, daily, weekly)
238
+ endpoint: One of the API endpoints
239
+ end_time: The end time for the analysis period. If None, defaults to the current
240
+ UTC time
241
+
242
+ Returns:
243
+ A dictionary of timestamps and average response times
244
+ """
245
+ end_time = end_time or datetime.datetime.now(datetime.timezone.utc)
246
+ start_time = period_of_time.get_period_start_time(end_time)
247
+ time_def = TimeDefinition(period_of_time)
248
+ responses_average_time = (
249
+ await AnalyzeRequestMetrics.get_responses_average_time_in_period(
250
+ start_time, end_time, time_def.time_format, endpoint
251
+ )
252
+ )
253
+ timestamps, average_time = create_time_series_arrays(
254
+ responses_average_time,
255
+ )
256
+
257
+ return MetricTimeSeries(metric="avg_response_time", timestamps=timestamps, values=average_time)
258
+
259
+
260
+ async def _collect_emoji_data(
261
+ start_time: datetime.datetime, time_def: TimeDefinition
262
+ ) -> Dict[str, Dict[str, list]]:
263
+ """Collect and organize emoji feedback data
264
+
265
+ For each reaction type, a dictionary is created with time stamps
266
+ as keys, and aggregate counts as values.
267
+ """
268
+ reactions = await Reactions.get_since(start_time)
269
+ reaction_values: defaultdict[str, Dict] = defaultdict(lambda: defaultdict(int))
270
+
271
+ for comment_timestamp, reaction in reactions:
272
+ formatted_timestamp = comment_timestamp.strptime(
273
+ comment_timestamp.strftime(time_def.time_format), time_def.time_format
274
+ )
275
+
276
+ reaction_values[reaction.reaction_type][formatted_timestamp] += reaction.count
277
+
278
+ reaction_time_series = {
279
+ reaction_type: {
280
+ "timestamps": reaction_data.keys(),
281
+ "values": reaction_data.values(),
282
+ }
283
+ for reaction_type, reaction_data in reaction_values.items()
284
+ }
285
+
286
+ return reaction_time_series
287
+
288
+
289
+ async def emojis_per_time(
290
+ period_of_time: TimePeriod,
291
+ end_time: Optional[datetime.datetime] = None,
292
+ ) -> list[MetricTimeSeries]:
293
+ """
294
+ Retrieve data of emoji feedback over time.
295
+
296
+ The time intervals are determined by the provided TimePeriod object, which defines
297
+ the granularity.
298
+
299
+ Args:
300
+ period_of_time: A TimePeriod object that defines the time period and interval
301
+ for the analysis (e.g., hourly, daily, weekly)
302
+ end_time: The end time for the analysis period. If None, defaults to the current
303
+ UTC time
304
+
305
+ Returns:
306
+ A list of `MetricTimeSeries` objects
307
+ """
308
+ time_def = TimeDefinition(period_of_time)
309
+ end_time = end_time or datetime.datetime.now(datetime.timezone.utc)
310
+ start_time = period_of_time.get_period_start_time(end_time)
311
+ reactions_values_dict = await _collect_emoji_data(start_time, time_def)
312
+
313
+ reaction_values: list[MetricTimeSeries] = []
314
+ for reaction, time_series in reactions_values_dict.items():
315
+ reaction_values.append(
316
+ MetricTimeSeries(
317
+ metric=f"emoji_{reaction}",
318
+ timestamps=time_series["timestamps"],
319
+ values=time_series["values"]))
320
+ return reaction_values
@@ -401,3 +401,15 @@ class TimePeriod(BaseModel):
401
401
  if time.tzinfo is None:
402
402
  time = time.replace(tzinfo=datetime.timezone.utc)
403
403
  return time - self.get_time_period()
404
+
405
+
406
+ class MetricTimeSeries(BaseModel):
407
+ """Recorded values of given metric"""
408
+ metric: str
409
+ timestamps: List[datetime.datetime]
410
+ values: List[float]
411
+
412
+
413
+ class MetricResponse(BaseModel):
414
+ """Requested metrics"""
415
+ time_series: List[MetricTimeSeries]
@@ -5,12 +5,8 @@ from enum import Enum
5
5
  from collections import defaultdict
6
6
  from contextlib import asynccontextmanager
7
7
  from typing import Annotated
8
- from io import BytesIO
9
8
 
10
9
  from aiolimiter import AsyncLimiter
11
- import matplotlib
12
- import matplotlib.figure
13
- import matplotlib.pyplot
14
10
  from koji import ClientSession
15
11
  from gitlab import Gitlab
16
12
  from fastapi import (
@@ -50,7 +46,14 @@ from logdetective.server.llm import (
50
46
  perform_analysis_stream,
51
47
  )
52
48
  from logdetective.server.gitlab import process_gitlab_job_event
53
- from logdetective.server.metric import track_request, add_new_metrics, update_metrics
49
+ from logdetective.server.metric import (
50
+ track_request,
51
+ add_new_metrics,
52
+ update_metrics,
53
+ requests_per_time,
54
+ average_time_per_responses,
55
+ emojis_per_time
56
+ )
54
57
  from logdetective.server.models import (
55
58
  BuildLog,
56
59
  Config,
@@ -62,8 +65,8 @@ from logdetective.server.models import (
62
65
  StagedResponse,
63
66
  TimePeriod,
64
67
  ExtractorConfig,
68
+ MetricResponse,
65
69
  )
66
- from logdetective.server import plot as plot_engine
67
70
  from logdetective.server.database.models import (
68
71
  EndpointType,
69
72
  Forge,
@@ -730,38 +733,6 @@ async def schedule_emoji_collection_for_mr(
730
733
  del emoji_lookup[key]
731
734
 
732
735
 
733
- def _svg_figure_response(fig: matplotlib.figure.Figure):
734
- """Create a response with the given svg figure."""
735
- buf = BytesIO()
736
- fig.savefig(buf, format="svg", bbox_inches="tight")
737
- matplotlib.pyplot.close(fig)
738
-
739
- buf.seek(0)
740
- return StreamingResponse(
741
- buf,
742
- media_type="image/svg+xml",
743
- headers={"Content-Disposition": "inline; filename=plot.svg"},
744
- )
745
-
746
-
747
- def _multiple_svg_figures_response(figures: list[matplotlib.figure.Figure]):
748
- """Create a response with multiple svg figures."""
749
- svg_contents = []
750
- for i, fig in enumerate(figures):
751
- buf = BytesIO()
752
- fig.savefig(buf, format="svg", bbox_inches="tight")
753
- matplotlib.pyplot.close(fig)
754
- buf.seek(0)
755
- svg_contents.append(buf.read().decode("utf-8"))
756
-
757
- html_content = "<html><body>\n"
758
- for i, svg in enumerate(svg_contents):
759
- html_content += f"<div id='figure-{i}'>\n{svg}\n</div>\n"
760
- html_content += "</body></html>"
761
-
762
- return BasicResponse(content=html_content, media_type="text/html")
763
-
764
-
765
736
  class MetricRoute(str, Enum):
766
737
  """Routes for metrics"""
767
738
 
@@ -770,13 +741,13 @@ class MetricRoute(str, Enum):
770
741
  ANALYZE_GITLAB_JOB = "analyze-gitlab"
771
742
 
772
743
 
773
- class Plot(str, Enum):
774
- """Type of served plots"""
744
+ class MetricType(str, Enum):
745
+ """Type of metric retrieved"""
775
746
 
776
747
  REQUESTS = "requests"
777
748
  RESPONSES = "responses"
778
749
  EMOJIS = "emojis"
779
- BOTH = ""
750
+ ALL = "all"
780
751
 
781
752
 
782
753
  ROUTE_TO_ENDPOINT_TYPES = {
@@ -786,58 +757,57 @@ ROUTE_TO_ENDPOINT_TYPES = {
786
757
  }
787
758
 
788
759
 
789
- @app.get("/metrics/{route}/", response_class=StreamingResponse)
790
- @app.get("/metrics/{route}/{plot}", response_class=StreamingResponse)
760
+ @app.get("/metrics/{route}/", response_model=MetricResponse)
761
+ @app.get("/metrics/{route}/{metric_type}", response_model=MetricResponse)
791
762
  async def get_metrics(
792
763
  route: MetricRoute,
793
- plot: Plot = Plot.BOTH,
764
+ metric_type: MetricType = MetricType.ALL,
794
765
  period_since_now: TimePeriod = Depends(TimePeriod),
795
766
  ):
796
- """Get an handler for visualize statistics for the specified endpoint and plot."""
767
+ """Get an handler returning statistics for the specified endpoint and metric_type."""
797
768
  endpoint_type = ROUTE_TO_ENDPOINT_TYPES[route]
798
769
 
799
- async def handler():
800
- """Show statistics for the specified endpoint and plot."""
801
- if plot == Plot.REQUESTS:
802
- fig = await plot_engine.requests_per_time(period_since_now, endpoint_type)
803
- return _svg_figure_response(fig)
804
- if plot == Plot.RESPONSES:
805
- fig = await plot_engine.average_time_per_responses(
770
+ async def handler() -> MetricResponse:
771
+ """Return statistics for the specified endpoint and metric type."""
772
+ statistics = []
773
+ if metric_type == MetricType.ALL:
774
+ statistics.append(await requests_per_time(
806
775
  period_since_now, endpoint_type
807
- )
808
- return _svg_figure_response(fig)
809
- if plot == Plot.EMOJIS:
810
- fig = await plot_engine.emojis_per_time(period_since_now)
811
- return _svg_figure_response(fig)
812
- # BOTH
813
- fig_requests = await plot_engine.requests_per_time(
814
- period_since_now, endpoint_type
815
- )
816
- fig_responses = await plot_engine.average_time_per_responses(
817
- period_since_now, endpoint_type
818
- )
819
- fig_emojis = await plot_engine.emojis_per_time(period_since_now)
820
- return _multiple_svg_figures_response([fig_requests, fig_responses, fig_emojis])
776
+ ))
777
+ statistics.append(await average_time_per_responses(
778
+ period_since_now, endpoint_type
779
+ ))
780
+ statistics.extend(await emojis_per_time(period_since_now))
781
+ return MetricResponse(time_series=statistics)
782
+ if metric_type == MetricType.REQUESTS:
783
+ statistics.append(await requests_per_time(period_since_now, endpoint_type))
784
+ elif metric_type == MetricType.RESPONSES:
785
+ statistics.append(await average_time_per_responses(
786
+ period_since_now, endpoint_type
787
+ ))
788
+ elif metric_type == MetricType.EMOJIS:
789
+ statistics = await emojis_per_time(period_since_now)
790
+ return MetricResponse(time_series=statistics)
821
791
 
822
792
  descriptions = {
823
- Plot.REQUESTS: (
824
- "Show statistics for the requests received in the given period of time "
793
+ MetricType.REQUESTS: (
794
+ "Get statistics for the requests received in the given period of time "
825
795
  f"for the /{endpoint_type.value} API endpoint."
826
796
  ),
827
- Plot.RESPONSES: (
828
- "Show statistics for responses given in the specified period of time "
797
+ MetricType.RESPONSES: (
798
+ "Get statistics for responses given in the specified period of time "
829
799
  f"for the /{endpoint_type.value} API endpoint."
830
800
  ),
831
- Plot.EMOJIS: (
832
- "Show statistics for emoji feedback in the specified period of time "
801
+ MetricType.EMOJIS: (
802
+ "Get statistics for emoji feedback in the specified period of time "
833
803
  f"for the /{endpoint_type.value} API endpoint."
834
804
  ),
835
- Plot.BOTH: (
836
- "Show statistics for requests and responses in the given period of time "
805
+ MetricType.ALL: (
806
+ "Get statistics for requests and responses in the given period of time "
837
807
  f"for the /{endpoint_type.value} API endpoint."
838
808
  ),
839
809
  }
840
- handler.__doc__ = descriptions[plot]
810
+ handler.__doc__ = descriptions[metric_type]
841
811
 
842
812
  return await handler()
843
813
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: logdetective
3
- Version: 2.12.0
3
+ Version: 3.0.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
  License-File: LICENSE
@@ -24,7 +24,7 @@ Provides-Extra: server
24
24
  Provides-Extra: server-testing
25
25
  Provides-Extra: testing
26
26
  Requires-Dist: aiohttp (>=3.7.4,<4.0.0)
27
- Requires-Dist: aiolimiter (>=1.0.0,<2.0.0) ; extra == "server"
27
+ Requires-Dist: aiolimiter (>=1.0.0,<2.0.0) ; extra == "server" or extra == "server-testing"
28
28
  Requires-Dist: aioresponses (>=0.7.8,<0.8.0) ; extra == "testing"
29
29
  Requires-Dist: alembic (>=1.13.3,<2.0.0) ; extra == "server" or extra == "server-testing"
30
30
  Requires-Dist: asciidoc[testing] (>=10.2.1,<11.0.0) ; extra == "testing"
@@ -36,7 +36,6 @@ Requires-Dist: flexmock (>=0.12.2,<0.13.0) ; extra == "testing"
36
36
  Requires-Dist: huggingface-hub (>=0.23.0,<1.4.0)
37
37
  Requires-Dist: koji (>=1.35.0,<2.0.0) ; extra == "server" or extra == "server-testing"
38
38
  Requires-Dist: llama-cpp-python (>0.2.56,!=0.2.86,<1.0.0)
39
- Requires-Dist: matplotlib (>=3.8.4,<4.0.0) ; extra == "server" or extra == "server-testing"
40
39
  Requires-Dist: numpy (>=1.26.0)
41
40
  Requires-Dist: openai (>=1.82.1,<2.0.0) ; extra == "server" or extra == "server-testing"
42
41
  Requires-Dist: pydantic (>=2.8.2,<3.0.0)
@@ -98,11 +97,13 @@ Usage
98
97
  To analyze a log file, run the script with the following command line arguments:
99
98
  - `file` (required): The path or URL of the log file to be analyzed.
100
99
  - `--model` (optional, default: "Mistral-7B-Instruct-v0.3-GGUF"): The path or Hugging space name of the language model for analysis. For models from Hugging Face, write them as `namespace/repo_name`. As we are using LLama.cpp we want this to be in the `gguf` format. If the model is already on your machine it will skip the download.
101
- - `--filename_suffix` (optional, default "Q4_K.gguf"): You can specify which suffix of the file to use. This option is applied when specifying model using the Hugging Face repository.
102
- - `--summarizer` DISABLED: LLM summarization option was removed. Argument is kept for backward compatibility only.(optional, default: "drain"): Choose between LLM and Drain template miner as the log summarizer. You can also provide the path to an existing language model file instead of using a URL.
103
- - `--n_lines` DISABLED: LLM summarization option was removed. Argument is kept for backward compatibility only. (optional, default: 8): The number of lines per chunk for LLM analysis. This only makes sense when you are summarizing with LLM.
104
- - `--n_clusters` (optional, default 8): Number of clusters for Drain to organize log chunks into. This only makes sense when you are summarizing with Drain.
105
- - `--skip_snippets` Path to patterns for skipping snippets (in YAML).
100
+ - `--filename-suffix` (optional, default "Q4_K.gguf"): You can specify which suffix of the file to use. This option is applied when specifying model using the Hugging Face repository.
101
+ - `--n-clusters` (optional, default 8): Number of clusters for Drain to organize log chunks into. This only makes sense when you are summarizing with Drain.
102
+ - `--skip-snippets` Path to patterns for skipping snippets (in YAML).
103
+ - `--prompts PROMPTS` Path to prompt configuration file.
104
+ - `--temperature` Temperature for inference.
105
+ - `--skip-snippets` Path to patterns for skipping snippets.
106
+ - `--csgrep` Use csgrep to process the log.
106
107
 
107
108
  Example usage:
108
109
 
@@ -112,9 +113,9 @@ Or if the log file is stored locally:
112
113
 
113
114
  logdetective ./data/logs.txt
114
115
 
115
- Examples of using different models. Note the use of `--filename_suffix` (or `-F`) option, useful for models that were quantized:
116
+ Examples of using different models. Note the use of `--filename-suffix` (or `-F`) option, useful for models that were quantized:
116
117
 
117
- logdetective https://example.com/logs.txt --model QuantFactory/Meta-Llama-3-8B-Instruct-GGUF --filename_suffix Q5_K_S.gguf
118
+ logdetective https://example.com/logs.txt --model QuantFactory/Meta-Llama-3-8B-Instruct-GGUF --filename-suffix Q5_K_S.gguf
118
119
  logdetective https://kojipkgs.fedoraproject.org//work/tasks/3367/131313367/build.log --model 'fedora-copr/granite-3.2-8b-instruct-GGUF' -F Q4_K_M.gguf
119
120
 
120
121
  Example of altered prompts:
@@ -126,7 +127,7 @@ Example of altered prompts:
126
127
 
127
128
  Note that streaming with some models (notably Meta-Llama-3) is broken and can be worked around by `no-stream` option:
128
129
 
129
- logdetective https://example.com/logs.txt --model QuantFactory/Meta-Llama-3-8B-Instruct-GGUF --filename_suffix Q5_K_M.gguf --no-stream
130
+ logdetective https://example.com/logs.txt --model QuantFactory/Meta-Llama-3-8B-Instruct-GGUF --filename-suffix Q5_K_M.gguf --no-stream
130
131
 
131
132
  Choice of LLM
132
133
  -------------
@@ -2,7 +2,7 @@ logdetective/__init__.py,sha256=VqRngDcuFT7JWms8Qc_MsOvajoXVOKPr-S1kqY3Pqhc,59
2
2
  logdetective/constants.py,sha256=aCwrkBrDdS_kbNESK-Z-ewg--DSzodV2OMgwEq3UE38,2456
3
3
  logdetective/drain3.ini,sha256=ni91eCT1TwTznZwcqWoOVMQcGEnWhEDNCoTPF7cfGfY,1360
4
4
  logdetective/extractors.py,sha256=vT-je4NkDgSj9rRtSeLpqBU52gIUnnVgJPHFbVihpCw,5993
5
- logdetective/logdetective.py,sha256=S0abGrAQH2oi0MRisCV64Sa1UXdQLIfXFBA4tYAYqhM,6896
5
+ logdetective/logdetective.py,sha256=W4yY5PDK0zO_6ObCnLQc6K6xY8zOd8MXJJDaE3LH6Wo,6224
6
6
  logdetective/models.py,sha256=uczmQtWFgSp_ZGssngdTM4qzPF1o64dCy0469GoSbjQ,2937
7
7
  logdetective/prompts-summary-first.yml,sha256=kmyMFQmqFXpojkz7p3CyCWCPxMpFLpfDdMGisB4YwL0,808
8
8
  logdetective/prompts-summary-only.yml,sha256=8U9AMJV8ePW-0CoXOXlQoO92DAJDeutIT8ntSkkm6W0,470
@@ -12,29 +12,28 @@ logdetective/server/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSu
12
12
  logdetective/server/compressors.py,sha256=y4aFYJ_9CbYdKuAI39Kc9GQSdPN8cSJ2c_VAz3T47EE,5249
13
13
  logdetective/server/config.py,sha256=dYoqvexnMo8LBXhXezMIEqUwzTsRD-eWvRIFIYNv388,2540
14
14
  logdetective/server/database/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- logdetective/server/database/base.py,sha256=HSV2tgye7iYTDzJD1Q5X7_nlLuTMIFP-hRVQMYxngHQ,2073
15
+ logdetective/server/database/base.py,sha256=bqMkhL2D96i_QiSnO5u1FqxYuJJu0m0wXLkqj_A9WBs,2093
16
16
  logdetective/server/database/models/__init__.py,sha256=zoZMCt1_7tewDa6eEIIX_xrdN-tLegSiPNg5NiYaV3o,850
17
17
  logdetective/server/database/models/exceptions.py,sha256=4ED7FSSA1liV9-7VIN2BwUiz6XlmP97Y1loKnsoNdD8,507
18
18
  logdetective/server/database/models/koji.py,sha256=HNWxHYDxf4JN9K2ue8-V8dH-0XY5ZmxqH7Y9lAIbILA,6436
19
19
  logdetective/server/database/models/merge_request_jobs.py,sha256=MxiAVKQIsQMbFylBsmYBmVXYvid-4_5mwwXLfWdp6_w,19965
20
- logdetective/server/database/models/metrics.py,sha256=4xsUdbtlp5PI1-iJQc5Dd8EPDgVVplD9hJRWeRDn43k,15443
20
+ logdetective/server/database/models/metrics.py,sha256=XpiGrZJ-SuHfePBOeek_WiV-i0p1wjoCBTekSMiZZM0,15559
21
21
  logdetective/server/emoji.py,sha256=zSaYtLpSkpRCXpjMWnHR1bYwkmobMJASZ7YNalrd85U,5274
22
22
  logdetective/server/exceptions.py,sha256=WN715KLL3ya6FiZ95v70VSbNuVhGuHFzxm2OeEPWQCw,981
23
23
  logdetective/server/gitlab.py,sha256=X9JSotUUlG9bOWYbUNKt9KqLUAj6Uocd2KNpfn35ccU,17192
24
24
  logdetective/server/koji.py,sha256=LG1pRiKUFvYFRKzgQoUG3pUHfcEwMoaMNjUSMKw_pBA,5640
25
25
  logdetective/server/llm.py,sha256=wHMxRbAjI0q3osR5mRDR1kqww_6Pkc7JpF1mh9e6Mg8,10855
26
- logdetective/server/metric.py,sha256=wLOpgcAch3rwhPA5P2YWUeMNAPsvRGseRjH5HlTb7JM,4529
27
- logdetective/server/models.py,sha256=iJ-5UgScKKSRL8fRCsM23Z34P3p98LaduwWO-q9rudo,13041
28
- logdetective/server/plot.py,sha256=8LERgY3vQckaHZV2PZfOrZT8CjCAiji57QCmRW24Rfo,14697
29
- logdetective/server/server.py,sha256=AM10P72tc_7N0GhH_N7msFhLr7ZGNgIfgTxt2sjasVE,30982
26
+ logdetective/server/metric.py,sha256=8ZhJNbl3eSzZiY0344YXMxLk_MkgjgZB6NcZsPozkkk,11317
27
+ logdetective/server/models.py,sha256=edAHzJoxMh-8v-JzSwHNS5FoV-v1PlmLI-3ZwxfBnf4,13303
28
+ logdetective/server/server.py,sha256=lCIctjXjkaOzto5H_qadYB6RLxAbbHvFOOwYdE_sIgY,29981
30
29
  logdetective/server/templates/base_response.html.j2,sha256=BJGGV_Xb0Lnue8kq32oG9lI5CQDf9vce7HMYsP-Pvb4,2040
31
30
  logdetective/server/templates/gitlab_full_comment.md.j2,sha256=4UujUzl3lmdbNEADsxn3HVrjfUiUu2FvUlp9MDFGXQI,2321
32
31
  logdetective/server/templates/gitlab_short_comment.md.j2,sha256=2krnMlGqqju2V_6pE0UqUR1P674OFaeX5BMyY5htTOQ,2022
33
32
  logdetective/server/utils.py,sha256=0BZ8WmzXNEtkUty1kOyFbBxDZWL0Icc8BUrxuHw9uvs,4015
34
33
  logdetective/skip_snippets.yml,sha256=reGlhPPCo06nNUJWiC2LY-OJOoPdcyOB7QBTSMeh0eg,487
35
34
  logdetective/utils.py,sha256=yalhySOF_Gzmqx_Ft9qad3TplAfZ6LOmauGXEJfKWiE,9803
36
- logdetective-2.12.0.dist-info/METADATA,sha256=q8qwE4AyHr0WfJZwNMbCb3-X0mBQfreXhuNtYxSfOSM,23273
37
- logdetective-2.12.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
38
- logdetective-2.12.0.dist-info/entry_points.txt,sha256=3K_vXja6PmcA8sNdUi63WdImeiNhVZcEGPTaoJmltfA,63
39
- logdetective-2.12.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
40
- logdetective-2.12.0.dist-info/RECORD,,
35
+ logdetective-3.0.0.dist-info/METADATA,sha256=nHYpSkE4pz3W557RWhBUwQMh3i52KTlx7_rDXoL3wzQ,22874
36
+ logdetective-3.0.0.dist-info/WHEEL,sha256=3ny-bZhpXrU6vSQ1UPG34FoxZBp3lVcvK0LkgUz6VLk,88
37
+ logdetective-3.0.0.dist-info/entry_points.txt,sha256=3K_vXja6PmcA8sNdUi63WdImeiNhVZcEGPTaoJmltfA,63
38
+ logdetective-3.0.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
39
+ logdetective-3.0.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.2.1
2
+ Generator: poetry-core 2.3.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,432 +0,0 @@
1
- import datetime
2
- from typing import Optional, Union, Dict
3
-
4
- import numpy
5
- from numpy.typing import ArrayLike
6
- from matplotlib import dates, colormaps, axes, pyplot, figure
7
-
8
- from logdetective.server.models import TimePeriod
9
- from logdetective.server.database.models import (
10
- AnalyzeRequestMetrics,
11
- EndpointType,
12
- Reactions,
13
- )
14
-
15
-
16
- class Definition:
17
- """Define plot details, given a time period."""
18
-
19
- def __init__(self, time_period: TimePeriod):
20
- self.time_period = time_period
21
- self.days_diff = time_period.get_time_period().days
22
- if self.time_period.hours:
23
- self._freq = "H"
24
- self._time_format = "%Y-%m-%d %H"
25
- self._locator = dates.HourLocator(interval=2)
26
- self._time_unit = "hour"
27
- self._time_delta = datetime.timedelta(hours=1)
28
- elif self.time_period.days:
29
- self._freq = "D"
30
- self._time_format = "%Y-%m-%d"
31
- self._locator = dates.DayLocator(interval=1)
32
- self._time_unit = "day"
33
- self._time_delta = datetime.timedelta(days=1)
34
- elif self.time_period.weeks:
35
- self._freq = "W"
36
- self._time_format = "%Y-%m-%d"
37
- self._locator = dates.WeekdayLocator(interval=1)
38
- self._time_unit = "week"
39
- self._time_delta = datetime.timedelta(weeks=1)
40
-
41
- @property
42
- def freq(self):
43
- # pylint: disable=missing-function-docstring
44
- return self._freq
45
-
46
- @property
47
- def time_format(self):
48
- # pylint: disable=missing-function-docstring
49
- return self._time_format
50
-
51
- @property
52
- def locator(self):
53
- # pylint: disable=missing-function-docstring
54
- return self._locator
55
-
56
- @property
57
- def time_unit(self):
58
- # pylint: disable=missing-function-docstring
59
- return self._time_unit
60
-
61
- @property
62
- def time_delta(self):
63
- # pylint: disable=missing-function-docstring
64
- return self._time_delta
65
-
66
-
67
- def create_time_series_arrays(
68
- values_dict: dict[datetime.datetime, int],
69
- plot_def: Definition,
70
- start_time: datetime.datetime,
71
- end_time: datetime.datetime,
72
- value_type: Optional[Union[type[int], type[float]]] = int,
73
- ) -> tuple[numpy.ndarray, numpy.ndarray]:
74
- """Create time series arrays from a dictionary of values.
75
-
76
- This function generates two aligned numpy arrays:
77
- 1. An array of timestamps from start_time to end_time
78
- 2. A corresponding array of valuesfor each timestamp
79
-
80
- The timestamps are truncated to the precision specified by time_format.
81
- If a timestamp in values_dict matches a generated timestamp, its values is used;
82
- otherwise, the value defaults to zero.
83
-
84
- Args:
85
- values_dict: Dictionary mapping timestamps to their respective values
86
- start_time: The starting timestamp of the time series
87
- end_time: The ending timestamp of the time series
88
- time_delta: The time interval between consecutive timestamps
89
- time_format: String format for datetime truncation (e.g., '%Y-%m-%d %H:%M')
90
-
91
- Returns:
92
- A tuple containing:
93
- - numpy.ndarray: Array of timestamps
94
- - numpy.ndarray: Array of corresponding values
95
- """
96
- num_intervals = int((end_time - start_time) / plot_def.time_delta) + 1
97
-
98
- timestamps = numpy.array(
99
- [
100
- datetime.datetime.strptime(
101
- (start_time + i * plot_def.time_delta).strftime(
102
- format=plot_def.time_format
103
- ),
104
- plot_def.time_format,
105
- )
106
- for i in range(num_intervals)
107
- ]
108
- )
109
- values = numpy.zeros(num_intervals, dtype=value_type)
110
-
111
- timestamp_to_index = {timestamp: i for i, timestamp in enumerate(timestamps)}
112
-
113
- for timestamp, count in values_dict.items():
114
- if timestamp in timestamp_to_index:
115
- values[timestamp_to_index[timestamp]] = count
116
-
117
- return timestamps, values
118
-
119
-
120
- def _add_bar_chart(
121
- ax: axes.Axes,
122
- plot_def: Definition,
123
- timestamps: ArrayLike,
124
- values: ArrayLike,
125
- label: str,
126
- ) -> None:
127
- """Add a blue bar chart"""
128
- bar_width = (
129
- 0.8 * plot_def.time_delta.total_seconds() / 86400
130
- ) # Convert to days for matplotlib
131
- ax.bar(
132
- timestamps,
133
- values,
134
- width=bar_width,
135
- alpha=0.7,
136
- color="skyblue",
137
- label=label,
138
- )
139
- ax.set_xlabel("Time")
140
- ax.set_ylabel(label, color="blue")
141
- ax.tick_params(axis="y", labelcolor="blue")
142
-
143
- ax.xaxis.set_major_formatter(dates.DateFormatter(plot_def.time_format))
144
- ax.xaxis.set_major_locator(plot_def.locator)
145
-
146
- pyplot.xticks(rotation=45)
147
-
148
- ax.grid(True, alpha=0.3)
149
-
150
-
151
- def _add_line_chart( # pylint: disable=too-many-arguments disable=too-many-positional-arguments
152
- ax: axes.Axes,
153
- timestamps: ArrayLike,
154
- values: ArrayLike,
155
- label: str,
156
- color: str = "red",
157
- set_label: bool = True,
158
- ):
159
- """Add a red line chart"""
160
- ax.plot(timestamps, values, color=color, linestyle="-", linewidth=2, label=label)
161
- if set_label:
162
- ax.set_ylabel(label, color=color)
163
- ax.tick_params(axis="y", labelcolor=color)
164
-
165
-
166
- async def requests_per_time(
167
- period_of_time: TimePeriod,
168
- endpoint: EndpointType = EndpointType.ANALYZE,
169
- end_time: Optional[datetime.datetime] = None,
170
- ) -> figure.Figure:
171
- """
172
- Generate a visualization of request counts over a specified time period.
173
-
174
- This function creates a dual-axis plot showing:
175
- 1. A bar chart of request counts per time interval
176
- 2. A line chart showing the cumulative request count
177
-
178
- The time intervals are determined by the provided TimePeriod object, which defines
179
- the granularity and formatting of the time axis.
180
-
181
- Args:
182
- period_of_time: A TimePeriod object that defines the time period and interval
183
- for the analysis (e.g., hourly, daily, weekly)
184
- endpoint: One of the API endpoints
185
- end_time: The end time for the analysis period. If None, defaults to the current
186
- UTC time
187
-
188
- Returns:
189
- A matplotlib Figure object containing the generated visualization
190
- """
191
- end_time = end_time or datetime.datetime.now(datetime.timezone.utc)
192
- start_time = period_of_time.get_period_start_time(end_time)
193
- plot_def = Definition(period_of_time)
194
- requests_counts = await AnalyzeRequestMetrics.get_requests_in_period(
195
- start_time, end_time, plot_def.time_format, endpoint
196
- )
197
- timestamps, counts = create_time_series_arrays(
198
- requests_counts, plot_def, start_time, end_time
199
- )
200
-
201
- fig, ax1 = pyplot.subplots(figsize=(12, 6))
202
- _add_bar_chart(ax1, plot_def, timestamps, counts, "Requests")
203
-
204
- ax2 = ax1.twinx()
205
- _add_line_chart(ax2, timestamps, numpy.cumsum(counts), "Cumulative Requests")
206
-
207
- pyplot.title(
208
- f"Requests received for API {endpoint} ({start_time.strftime(plot_def.time_format)} "
209
- f"to {end_time.strftime(plot_def.time_format)})"
210
- )
211
-
212
- lines1, labels1 = ax1.get_legend_handles_labels()
213
- lines2, labels2 = ax2.get_legend_handles_labels()
214
- ax1.legend(lines1 + lines2, labels1 + labels2, loc="center")
215
-
216
- pyplot.tight_layout()
217
-
218
- return fig
219
-
220
-
221
- async def average_time_per_responses( # pylint: disable=too-many-locals
222
- period_of_time: TimePeriod,
223
- endpoint: EndpointType = EndpointType.ANALYZE,
224
- end_time: Optional[datetime.datetime] = None,
225
- ) -> figure.Figure:
226
- """
227
- Generate a visualization of average response time and length over a specified time period.
228
-
229
- This function creates a dual-axis plot showing:
230
- 1. A bar chart of average response time per time interval
231
- 1. A line chart of average response length per time interval
232
-
233
- The time intervals are determined by the provided TimePeriod object, which defines
234
- the granularity and formatting of the time axis.
235
-
236
- Args:
237
- period_of_time: A TimePeriod object that defines the time period and interval
238
- for the analysis (e.g., hourly, daily, weekly)
239
- endpoint: One of the API endpoints
240
- end_time: The end time for the analysis period. If None, defaults to the current
241
- UTC time
242
-
243
- Returns:
244
- A matplotlib Figure object containing the generated visualization
245
- """
246
- end_time = end_time or datetime.datetime.now(datetime.timezone.utc)
247
- start_time = period_of_time.get_period_start_time(end_time)
248
- plot_def = Definition(period_of_time)
249
- responses_average_time = (
250
- await AnalyzeRequestMetrics.get_responses_average_time_in_period(
251
- start_time, end_time, plot_def.time_format, endpoint
252
- )
253
- )
254
- timestamps, average_time = create_time_series_arrays(
255
- responses_average_time,
256
- plot_def,
257
- start_time,
258
- end_time,
259
- float,
260
- )
261
-
262
- fig, ax1 = pyplot.subplots(figsize=(12, 6))
263
- _add_bar_chart(
264
- ax1, plot_def, timestamps, average_time, "average response time (seconds)"
265
- )
266
-
267
- responses_average_length = (
268
- await AnalyzeRequestMetrics.get_responses_average_length_in_period(
269
- start_time, end_time, plot_def.time_format, endpoint
270
- )
271
- )
272
- timestamps, average_length = create_time_series_arrays(
273
- responses_average_length,
274
- plot_def,
275
- start_time,
276
- end_time,
277
- float,
278
- )
279
-
280
- ax2 = ax1.twinx()
281
- _add_line_chart(ax2, timestamps, average_length, "average response length (chars)")
282
-
283
- pyplot.title(
284
- f"average response time for API {endpoint} ({start_time.strftime(plot_def.time_format)} "
285
- f"to {end_time.strftime(plot_def.time_format)})"
286
- )
287
-
288
- lines1, labels1 = ax1.get_legend_handles_labels()
289
- lines2, labels2 = ax2.get_legend_handles_labels()
290
- ax1.legend(lines1 + lines2, labels1 + labels2, loc="center")
291
-
292
- pyplot.tight_layout()
293
-
294
- return fig
295
-
296
-
297
- async def _collect_emoji_data(
298
- start_time: datetime.datetime, plot_def: Definition
299
- ) -> Dict[str, Dict[datetime.datetime, int]]:
300
- """Collect and organize emoji feedback data
301
-
302
- Counts all emojis given to logdetective comments created since start_time.
303
- Collect counts in time accordingly to the plot definition.
304
- """
305
- reactions = await Reactions.get_since(start_time)
306
- reactions_values_dict: Dict[str, Dict] = {}
307
- for comment_created_at, reaction in reactions:
308
- comment_created_at_formatted = comment_created_at.strptime(
309
- comment_created_at.strftime(plot_def.time_format), plot_def.time_format
310
- )
311
- if reaction.reaction_type in reactions_values_dict:
312
- reaction_values_dict = reactions_values_dict[reaction.reaction_type]
313
- if comment_created_at_formatted in reaction_values_dict:
314
- reaction_values_dict[comment_created_at_formatted] += reaction.count
315
- else:
316
- reaction_values_dict[comment_created_at_formatted] = reaction.count
317
- else:
318
- reaction_values_dict = {comment_created_at_formatted: reaction.count}
319
- reactions_values_dict.update({reaction.reaction_type: reaction_values_dict})
320
-
321
- return reactions_values_dict
322
-
323
-
324
- def _plot_emoji_data( # pylint: disable=too-many-locals
325
- ax: axes.Axes,
326
- reactions_values_dict: Dict[str, Dict[datetime.datetime, int]],
327
- plot_def: Definition,
328
- start_time: datetime.datetime,
329
- end_time: datetime.datetime,
330
- ):
331
- """Plot each emoji's data on its own axis."""
332
- emoji_lines = {}
333
- emoji_labels = {}
334
-
335
- # Find global min and max y values to set consistent scale
336
- all_counts = []
337
- for emoji, dict_counts in reactions_values_dict.items():
338
- timestamps, counts = create_time_series_arrays(
339
- dict_counts, plot_def, start_time, end_time
340
- )
341
- all_counts.extend(counts)
342
-
343
- colors = [
344
- colormaps["viridis"](i)
345
- for i in numpy.linspace(0, 1, len(reactions_values_dict))
346
- ]
347
-
348
- first_emoji = True
349
- for i, (emoji, dict_counts) in enumerate(reactions_values_dict.items()):
350
- timestamps, counts = create_time_series_arrays(
351
- dict_counts, plot_def, start_time, end_time
352
- )
353
-
354
- if first_emoji:
355
- current_ax = ax
356
- first_emoji = False
357
- else:
358
- current_ax = ax.twinx()
359
- current_ax.spines["right"].set_position(("outward", 60 * (i - 1)))
360
-
361
- _add_line_chart(current_ax, timestamps, counts, f"{emoji}", colors[i], False)
362
- emoji_lines[emoji], emoji_labels[emoji] = current_ax.get_legend_handles_labels()
363
-
364
- # Set the same y-limits for all axes
365
- current_ax.set_ylim(0, max(all_counts) * 1.1)
366
-
367
- # Only show y-ticks on the first axis to avoid clutter
368
- if 0 < i < len(reactions_values_dict):
369
- current_ax.set_yticks([])
370
-
371
- return emoji_lines, emoji_labels
372
-
373
-
374
- async def emojis_per_time(
375
- period_of_time: TimePeriod,
376
- end_time: Optional[datetime.datetime] = None,
377
- ) -> figure.Figure:
378
- """
379
- Generate a visualization of overall emoji feedback
380
- over a specified time period.
381
-
382
- This function creates a multiple-axis plot showing
383
- a line chart for every found emoji
384
-
385
- The time intervals are determined by the provided TimePeriod object, which defines
386
- the granularity and formatting of the time axis.
387
-
388
- Args:
389
- period_of_time: A TimePeriod object that defines the time period and interval
390
- for the analysis (e.g., hourly, daily, weekly)
391
- end_time: The end time for the analysis period. If None, defaults to the current
392
- UTC time
393
-
394
- Returns:
395
- A matplotlib Figure object containing the generated visualization
396
- """
397
- plot_def = Definition(period_of_time)
398
- end_time = end_time or datetime.datetime.now(datetime.timezone.utc)
399
- start_time = period_of_time.get_period_start_time(end_time)
400
- reactions_values_dict = await _collect_emoji_data(start_time, plot_def)
401
-
402
- fig, ax = pyplot.subplots(figsize=(12, 6))
403
-
404
- emoji_lines, emoji_labels = _plot_emoji_data(
405
- ax, reactions_values_dict, plot_def, start_time, end_time
406
- )
407
-
408
- pyplot.title(
409
- f"Emoji feedback ({start_time.strftime(plot_def.time_format)} "
410
- f"to {end_time.strftime(plot_def.time_format)})"
411
- )
412
-
413
- all_lines = []
414
- for lines in emoji_lines.values():
415
- all_lines.extend(lines)
416
- all_labels = []
417
- for labels in emoji_labels.values():
418
- all_labels.extend(labels)
419
-
420
- ax.legend(all_lines, all_labels, loc="upper left")
421
- ax.set_xlabel("Time")
422
- ax.set_ylabel("Count")
423
-
424
- # Format x-axis
425
- ax.xaxis.set_major_formatter(dates.DateFormatter(plot_def.time_format))
426
- ax.xaxis.set_major_locator(plot_def.locator)
427
- ax.tick_params(axis="x", labelrotation=45)
428
- ax.grid(True, alpha=0.3)
429
-
430
- pyplot.tight_layout()
431
-
432
- return fig