-
Notifications
You must be signed in to change notification settings - Fork 34
Fix Graphite Tag integration #131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,7 +24,7 @@ | |
| from typing import Dict, Iterable, List, Optional | ||
|
|
||
| from otava.data_selector import DataSelector | ||
| from otava.util import parse_datetime | ||
| from otava.util import clean_str, parse_datetime | ||
|
|
||
|
|
||
| @dataclass | ||
|
|
@@ -57,7 +57,6 @@ class TimeSeries: | |
|
|
||
|
|
||
| def decode_graphite_datapoints(series: Dict[str, List[List[float]]]) -> List[DataPoint]: | ||
|
|
||
| points = series["datapoints"] | ||
| return [DataPoint(int(p[1]), p[0]) for p in points if p[0] is not None] | ||
|
|
||
|
|
@@ -80,49 +79,32 @@ class GraphiteError(IOError): | |
|
|
||
| @dataclass | ||
| class GraphiteEvent: | ||
| test_owner: str | ||
| test_name: str | ||
| run_id: str | ||
| status: str | ||
| start_time: datetime | ||
| pub_time: datetime | ||
| end_time: datetime | ||
| version: Optional[str] | ||
| branch: Optional[str] | ||
| commit: Optional[str] | ||
| test_owner: Optional[str] = None | ||
| test_name: Optional[str] = None | ||
| run_id: Optional[str] = None | ||
| status: Optional[str] = None | ||
| start_time: Optional[datetime] = None | ||
| end_time: Optional[datetime] = None | ||
| version: Optional[str] = None | ||
| branch: Optional[str] = None | ||
| commit: Optional[str] = None | ||
|
|
||
| def __init__( | ||
| self, | ||
| pub_time: int, | ||
| test_owner: str, | ||
| test_name: str, | ||
| run_id: str, | ||
| status: str, | ||
| start_time: int, | ||
| end_time: int, | ||
| version: Optional[str], | ||
| branch: Optional[str], | ||
| commit: Optional[str], | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. idea (maybe outside of scope of this PR): today, if we have other event tags like
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yea I wonder if we should even define fields here as "supported tags" and rather just store them in a map and print them all at the end as columns
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like this idea! Again, it is not required in this PR, but is worth doing in a follow-up. |
||
| ): | ||
| self.test_owner = test_owner | ||
| self.test_name = test_name | ||
| self.run_id = run_id | ||
| self.status = status | ||
| self.start_time = parse_datetime(str(start_time)) | ||
| self.pub_time = parse_datetime(str(pub_time)) | ||
| self.end_time = parse_datetime(str(end_time)) | ||
| if len(version) == 0 or version == "null": | ||
| self.version = None | ||
| else: | ||
| self.version = version | ||
| if len(branch) == 0 or branch == "null": | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This logic was lost in this PR. If a string text is empty or equal to "null", we still want to treat it as empty, unless there is a reason not to.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since I switched all actual "optional" fields to be optional, I will clean all of them (except pub_time) |
||
| self.branch = None | ||
| else: | ||
| self.branch = branch | ||
| if len(commit) == 0 or commit == "null": | ||
| self.commit = None | ||
| else: | ||
| self.commit = commit | ||
|
|
||
| def __post_init__(self): | ||
| if self.pub_time is None: | ||
| raise ValueError("pub_time is required and cannot be None") | ||
| # Ensure pub_time is always a datetime | ||
| self.pub_time = parse_datetime(str(self.pub_time)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should do def test_graphite_event_parses_start_and_end_time():
"""start_time and end_time are documented as supported tags and annotated
Optional[datetime]; if Graphite delivers them as Unix timestamps (int) or
ISO strings in the event data, they must be normalized to tz-aware
datetimes, the same way pub_time is."""
event = GraphiteEvent(
pub_time=1700000000,
start_time=1700000000,
end_time="2024-01-01 10:00:00",
)
assert isinstance(event.start_time, datetime)
assert event.start_time.tzinfo is not None
assert isinstance(event.end_time, datetime)
assert event.end_time.tzinfo is not None |
||
|
|
||
|
|
||
| self.version = clean_str(self.version) | ||
| self.branch = clean_str(self.branch) | ||
| self.commit = clean_str(self.commit) | ||
| self.test_owner = clean_str(self.test_owner) | ||
| self.test_name = clean_str(self.test_name) | ||
| self.run_id = clean_str(self.run_id) | ||
| self.status = clean_str(self.status) | ||
|
|
||
|
|
||
| def compress_target_paths(paths: List[str]) -> List[str]: | ||
|
|
@@ -190,7 +172,7 @@ def fetch_events( | |
| data_str = urllib.request.urlopen(url).read() | ||
| data_as_json = json.loads(data_str) | ||
| return [ | ||
| GraphiteEvent(event.get("when"), **ast.literal_eval(event.get("data"))) | ||
| GraphiteEvent(pub_time=event.get("when"), **ast.literal_eval(event.get("data"))) | ||
| for event in data_as_json | ||
| if event.get("what") == "Performance Test" | ||
| ] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,10 +20,10 @@ | |
| import sys | ||
| from collections import OrderedDict, deque | ||
| from dataclasses import dataclass | ||
| from datetime import datetime | ||
| from datetime import datetime, timezone | ||
| from functools import reduce | ||
| from itertools import islice | ||
| from typing import Dict, List, Optional, Set, TypeVar | ||
| from typing import Dict, List, Optional, Set, TypeVar, Union | ||
|
|
||
| import dateparser | ||
| from pytz import UTC | ||
|
|
@@ -134,18 +134,47 @@ class DateFormatError(ValueError): | |
| message: str | ||
|
|
||
|
|
||
| def parse_datetime(date: Optional[str]) -> Optional[datetime]: | ||
| def parse_datetime(date: Optional[Union[str, int, float, datetime]]) -> Optional[datetime]: | ||
| """ | ||
| Converts a human-readable string into a datetime object. | ||
| Accepts many formats and many languages, see dateparser package. | ||
| Raises DataFormatError if the input string format hasn't been recognized. | ||
| Normalize various datetime inputs into a timezone-aware datetime. | ||
|
|
||
| Supports: | ||
| - datetime (returned as-is) | ||
| - int/float (treated as Unix timestamp) | ||
| - str (parsed via dateparser) | ||
| - None (returns None) | ||
| """ | ||
| if date is None: | ||
| return None | ||
| parsed: datetime = dateparser.parse(date, settings={"RETURN_AS_TIMEZONE_AWARE": True}) | ||
| if parsed is None: | ||
| raise DateFormatError(f"Invalid datetime value: {date}") | ||
| return parsed | ||
|
|
||
| if isinstance(date, datetime): | ||
| return date | ||
|
|
||
| if isinstance(date, (int, float)): | ||
| return datetime.fromtimestamp(date, tz=timezone.utc) | ||
|
|
||
| if isinstance(date, str): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This method supports strings, ints, floats, datetime now. However, all its callers are using While I am not asking to refactor callers of this method across the entire codebase, do you think it would make sense to use it in Graphite importer? If not... then what's the point of this change? |
||
| parsed = dateparser.parse(date, settings={"RETURN_AS_TIMEZONE_AWARE": True}) | ||
| if parsed is None: | ||
| raise DateFormatError(f"Invalid datetime value: {date}") | ||
| return parsed | ||
|
|
||
| raise TypeError(f"Unsupported type for datetime parsing: {type(date)}") | ||
|
|
||
|
|
||
| def clean_str(value: Optional[str]) -> Optional[str]: | ||
| if value is None: | ||
| return None | ||
|
|
||
| if not isinstance(value, str): | ||
| return value # or raise TypeError if you want strictness | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This return violates the type signature. I'd throw a type error. |
||
|
|
||
| value = value.strip() | ||
|
|
||
| if value == "" or value.lower() == "null": | ||
| return None | ||
|
|
||
| return value | ||
|
|
||
|
|
||
| def sliding_window(iterable, size): | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.