logdetective 2.12.0__tar.gz → 3.0.0__tar.gz

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 (41) hide show
  1. {logdetective-2.12.0 → logdetective-3.0.0}/PKG-INFO +12 -11
  2. {logdetective-2.12.0 → logdetective-3.0.0}/README.md +10 -8
  3. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/logdetective.py +3 -23
  4. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/database/base.py +1 -1
  5. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/database/models/metrics.py +7 -4
  6. logdetective-3.0.0/logdetective/server/metric.py +320 -0
  7. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/models.py +12 -0
  8. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/server.py +45 -75
  9. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective.1.asciidoc +6 -6
  10. {logdetective-2.12.0 → logdetective-3.0.0}/pyproject.toml +2 -4
  11. logdetective-2.12.0/logdetective/server/metric.py +0 -127
  12. logdetective-2.12.0/logdetective/server/plot.py +0 -432
  13. {logdetective-2.12.0 → logdetective-3.0.0}/LICENSE +0 -0
  14. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/__init__.py +0 -0
  15. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/constants.py +0 -0
  16. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/drain3.ini +0 -0
  17. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/extractors.py +0 -0
  18. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/models.py +0 -0
  19. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/prompts-summary-first.yml +0 -0
  20. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/prompts-summary-only.yml +0 -0
  21. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/prompts.yml +0 -0
  22. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/remote_log.py +0 -0
  23. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/__init__.py +0 -0
  24. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/compressors.py +0 -0
  25. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/config.py +0 -0
  26. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/database/__init__.py +0 -0
  27. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/database/models/__init__.py +0 -0
  28. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/database/models/exceptions.py +0 -0
  29. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/database/models/koji.py +0 -0
  30. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/database/models/merge_request_jobs.py +0 -0
  31. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/emoji.py +0 -0
  32. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/exceptions.py +0 -0
  33. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/gitlab.py +0 -0
  34. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/koji.py +0 -0
  35. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/llm.py +0 -0
  36. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/templates/base_response.html.j2 +0 -0
  37. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/templates/gitlab_full_comment.md.j2 +0 -0
  38. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/templates/gitlab_short_comment.md.j2 +0 -0
  39. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/server/utils.py +0 -0
  40. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/skip_snippets.yml +0 -0
  41. {logdetective-2.12.0 → logdetective-3.0.0}/logdetective/utils.py +0 -0
@@ -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
  -------------
@@ -43,11 +43,13 @@ Usage
43
43
  To analyze a log file, run the script with the following command line arguments:
44
44
  - `file` (required): The path or URL of the log file to be analyzed.
45
45
  - `--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.
46
- - `--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.
47
- - `--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.
48
- - `--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.
49
- - `--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.
50
- - `--skip_snippets` Path to patterns for skipping snippets (in YAML).
46
+ - `--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.
47
+ - `--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.
48
+ - `--skip-snippets` Path to patterns for skipping snippets (in YAML).
49
+ - `--prompts PROMPTS` Path to prompt configuration file.
50
+ - `--temperature` Temperature for inference.
51
+ - `--skip-snippets` Path to patterns for skipping snippets.
52
+ - `--csgrep` Use csgrep to process the log.
51
53
 
52
54
  Example usage:
53
55
 
@@ -57,9 +59,9 @@ Or if the log file is stored locally:
57
59
 
58
60
  logdetective ./data/logs.txt
59
61
 
60
- Examples of using different models. Note the use of `--filename_suffix` (or `-F`) option, useful for models that were quantized:
62
+ Examples of using different models. Note the use of `--filename-suffix` (or `-F`) option, useful for models that were quantized:
61
63
 
62
- logdetective https://example.com/logs.txt --model QuantFactory/Meta-Llama-3-8B-Instruct-GGUF --filename_suffix Q5_K_S.gguf
64
+ logdetective https://example.com/logs.txt --model QuantFactory/Meta-Llama-3-8B-Instruct-GGUF --filename-suffix Q5_K_S.gguf
63
65
  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
64
66
 
65
67
  Example of altered prompts:
@@ -71,7 +73,7 @@ Example of altered prompts:
71
73
 
72
74
  Note that streaming with some models (notably Meta-Llama-3) is broken and can be worked around by `no-stream` option:
73
75
 
74
- logdetective https://example.com/logs.txt --model QuantFactory/Meta-Llama-3-8B-Instruct-GGUF --filename_suffix Q5_K_M.gguf --no-stream
76
+ logdetective https://example.com/logs.txt --model QuantFactory/Meta-Llama-3-8B-Instruct-GGUF --filename-suffix Q5_K_M.gguf --no-stream
75
77
 
76
78
  Choice of LLM
77
79
  -------------
@@ -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
  )
@@ -0,0 +1,320 @@
1
+ import inspect
2
+ from collections import defaultdict
3
+ import datetime
4
+ from typing import Optional, Union, Dict
5
+ from functools import wraps
6
+
7
+ import aiohttp
8
+ import numpy
9
+ from starlette.responses import StreamingResponse
10
+
11
+ from logdetective.remote_log import RemoteLog
12
+ from logdetective.server.config import LOG
13
+ from logdetective.server.compressors import LLMResponseCompressor, RemoteLogCompressor
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
22
+ from logdetective.server.exceptions import LogDetectiveMetricsError
23
+
24
+
25
+ async def add_new_metrics(
26
+ api_name: EndpointType,
27
+ url: Optional[str] = None,
28
+ http_session: Optional[aiohttp.ClientSession] = None,
29
+ received_at: Optional[datetime.datetime] = None,
30
+ compressed_log_content: Optional[bytes] = None,
31
+ ) -> int:
32
+ """Add a new database entry for a received request.
33
+
34
+ This will store the time when this function is called,
35
+ the endpoint from where the request was received,
36
+ and the log (in a zip format) for which analysis is requested.
37
+ """
38
+ if not compressed_log_content:
39
+ if not (url and http_session):
40
+ raise LogDetectiveMetricsError(
41
+ f"""Remote log can not be retrieved without URL and http session.
42
+ URL: {url}, http session:{http_session}""")
43
+ remote_log = RemoteLog(url, http_session)
44
+ compressed_log_content = await RemoteLogCompressor(remote_log).zip_content()
45
+
46
+ return await AnalyzeRequestMetrics.create(
47
+ endpoint=EndpointType(api_name),
48
+ compressed_log=compressed_log_content,
49
+ request_received_at=received_at
50
+ if received_at
51
+ else datetime.datetime.now(datetime.timezone.utc),
52
+ )
53
+
54
+
55
+ async def update_metrics(
56
+ metrics_id: int,
57
+ response: Union[Response, StagedResponse, StreamingResponse],
58
+ sent_at: Optional[datetime.datetime] = None,
59
+ ) -> None:
60
+ """Update a database metric entry for a received request,
61
+ filling data for the given response.
62
+
63
+ This will add to the database entry the time when the response was sent,
64
+ the length of the created response and the certainty for it.
65
+ """
66
+ try:
67
+ compressed_response = LLMResponseCompressor(response).zip_response()
68
+ except AttributeError as e:
69
+ compressed_response = None
70
+ LOG.warning(
71
+ "Given response can not be serialized "
72
+ "and saved in db (probably a StreamingResponse): %s.",
73
+ e,
74
+ )
75
+
76
+ response_sent_at = (
77
+ sent_at if sent_at else datetime.datetime.now(datetime.timezone.utc)
78
+ )
79
+ response_length = None
80
+ if hasattr(response, "explanation") and isinstance(
81
+ response.explanation, Explanation
82
+ ):
83
+ response_length = len(response.explanation.text)
84
+ response_certainty = (
85
+ response.response_certainty if hasattr(response, "response_certainty") else None
86
+ )
87
+ await AnalyzeRequestMetrics.update(
88
+ id_=metrics_id,
89
+ response_sent_at=response_sent_at,
90
+ response_length=response_length,
91
+ response_certainty=response_certainty,
92
+ compressed_response=compressed_response,
93
+ )
94
+
95
+
96
+ def track_request(name=None):
97
+ """
98
+ Decorator to track requests/responses metrics
99
+
100
+ On entering the decorated function, it registers the time for the request
101
+ and saves the passed log content.
102
+ On exiting the decorated function, it registers the time for the response
103
+ and saves the generated response.
104
+
105
+ Use it to decorate server endpoints that generate a llm response
106
+ as in the following example:
107
+
108
+ >>> @app.post("/analyze", response_model=Response)
109
+ >>> @track_request()
110
+ >>> async def analyze_log(build_log)
111
+ >>> pass
112
+
113
+ Warning: the decorators' order is important!
114
+ The function returned by the *track_request* decorator is the
115
+ server API function we want to be called by FastAPI.
116
+ """
117
+
118
+ def decorator(f):
119
+ @wraps(f)
120
+ async def async_decorated_function(*args, **kwargs):
121
+ log_url = kwargs["build_log"].url
122
+ metrics_id = await add_new_metrics(
123
+ api_name=EndpointType(name if name else f.__name__),
124
+ url=log_url, http_session=kwargs["http_session"]
125
+ )
126
+ response = await f(*args, **kwargs)
127
+ await update_metrics(metrics_id, response)
128
+ return response
129
+
130
+ if inspect.iscoroutinefunction(f):
131
+ return async_decorated_function
132
+ raise NotImplementedError("An async coroutine is needed")
133
+
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]