Skip to content

Commit b53641e

Browse files
committed
Fix Grafana relative time parsing for Loki API - Convert relative time formats to Unix nanosecond timestamps
1 parent 1dba165 commit b53641e

File tree

4 files changed

+110
-139
lines changed

4 files changed

+110
-139
lines changed

grafana_loki_mcp/server.py

Lines changed: 60 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
"""
77

88
import argparse
9-
import datetime
109
import json
1110
import os
1211
import re
1312
import sys
14-
from typing import Annotated, Any, Dict, Optional, Union, cast
13+
from datetime import datetime, timedelta, timezone
14+
from typing import Annotated, Any, Dict, Optional, cast
1515

1616
# mypy: ignore-errors
1717
import requests
@@ -32,16 +32,6 @@
3232
DEFAULT_GRAFANA_API_KEY = os.environ.get("GRAFANA_API_KEY", "")
3333

3434

35-
def iso8601_to_unix_nano(ts: str) -> Optional[int]:
36-
"""Convert ISO8601 string to UNIX nanoseconds. Return None if not ISO8601."""
37-
try:
38-
# Accepts e.g. 2025-05-27T14:59:33.073316 or 2025-05-27T14:59:33.073316Z
39-
dt = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
40-
return int(dt.timestamp() * 1_000_000_000)
41-
except Exception:
42-
return None
43-
44-
4535
class GrafanaClient:
4636
"""Client for interacting with Grafana API."""
4737

@@ -127,23 +117,11 @@ def query_loki(
127117
"direction": direction,
128118
}
129119
if start is not None:
130-
# Accept ISO8601 or UNIX ns or Grafana relative time
131-
if start.startswith("now") or start.isdigit():
132-
# Keep Grafana relative time or Unix timestamp as is
133-
params["start"] = start
134-
else:
135-
# Convert ISO8601 to UNIX nanoseconds
136-
unix_start = iso8601_to_unix_nano(start)
137-
params["start"] = unix_start if unix_start is not None else start
120+
# Use parse_grafana_time to convert all time formats to Unix nanoseconds
121+
params["start"] = parse_grafana_time(start)
138122
if end is not None:
139-
# Accept ISO8601 or UNIX ns or Grafana relative time
140-
if end.startswith("now") or end.isdigit():
141-
# Keep Grafana relative time or Unix timestamp as is
142-
params["end"] = end
143-
else:
144-
# Convert ISO8601 to UNIX nanoseconds
145-
unix_end = iso8601_to_unix_nano(end)
146-
params["end"] = unix_end if unix_end is not None else end
123+
# Use parse_grafana_time to convert all time formats to Unix nanoseconds
124+
params["end"] = parse_grafana_time(end)
147125

148126
# Send request
149127
try:
@@ -194,33 +172,6 @@ def get_loki_labels(self) -> Dict[str, Any]:
194172
response.raise_for_status()
195173
return response.json()
196174

197-
# ... rest of the file unchanged ...
198-
199-
datasource_id = self._get_loki_datasource_uid()
200-
201-
# Set base URL for API request
202-
base_url = f"{self.base_url}/api/datasources/proxy/{datasource_id}"
203-
204-
url = f"{base_url}/loki/api/v1/labels"
205-
206-
try:
207-
response = requests.get(url, headers=self.headers)
208-
response.raise_for_status()
209-
return cast(Dict[str, Any], response.json())
210-
except requests.exceptions.RequestException as e:
211-
# Get more detailed error information
212-
error_detail = str(e)
213-
if hasattr(e, "response") and e.response is not None:
214-
try:
215-
error_json = e.response.json()
216-
error_detail = f"{error_detail} - Details: {json.dumps(error_json)}"
217-
except Exception:
218-
if e.response.text:
219-
error_detail = f"{error_detail} - Response: {e.response.text}"
220-
221-
# Raise a ValueError with the detailed error message
222-
raise ValueError(f"Error getting Loki labels: {error_detail}") from e
223-
224175
def get_loki_label_values(self, label: str) -> Dict[str, Any]:
225176
"""Get values for a specific label from Loki.
226177
@@ -405,7 +356,7 @@ def get_grafana_client() -> GrafanaClient:
405356
return GrafanaClient(args.grafana_url, args.grafana_api_key)
406357

407358

408-
def parse_grafana_time(time_str: str) -> Union[str, datetime.datetime]:
359+
def parse_grafana_time(time_str: str) -> str:
409360
"""Parse time string in various formats.
410361
411362
Args:
@@ -416,23 +367,59 @@ def parse_grafana_time(time_str: str) -> Union[str, datetime.datetime]:
416367
- RFC3339 format
417368
418369
Returns:
419-
Original string if it's a Grafana relative time or Unix timestamp,
420-
or datetime object for other formats
370+
Unix nanosecond timestamp string for all formats
421371
"""
422372
if not time_str:
423-
return "now"
373+
return str(int(datetime.now(timezone.utc).timestamp() * 1_000_000_000))
374+
375+
# Handle 'now'
376+
if time_str == "now":
377+
return str(int(datetime.now(timezone.utc).timestamp() * 1_000_000_000))
378+
379+
# Handle Grafana relative time format (now-1h, now-5m, etc.)
380+
if time_str.startswith("now-"):
381+
match = re.match(r"^now-(\d+)([smhdwMy])$", time_str)
382+
if match:
383+
amount = int(match.group(1))
384+
unit = match.group(2)
385+
386+
# Convert to timedelta
387+
if unit == "s":
388+
delta = timedelta(seconds=amount)
389+
elif unit == "m":
390+
delta = timedelta(minutes=amount)
391+
elif unit == "h":
392+
delta = timedelta(hours=amount)
393+
elif unit == "d":
394+
delta = timedelta(days=amount)
395+
elif unit == "w":
396+
delta = timedelta(weeks=amount)
397+
elif unit == "M":
398+
delta = timedelta(days=amount * 30) # Approximate month
399+
elif unit == "y":
400+
delta = timedelta(days=amount * 365) # Approximate year
401+
else:
402+
# Invalid unit, return current time
403+
return str(int(datetime.now(timezone.utc).timestamp() * 1_000_000_000))
424404

425-
# Grafana relative time format - return as-is for Loki API
426-
if time_str == "now" or re.match(r"^now-\d+[smhdwMy]$", time_str):
427-
return time_str
405+
# Calculate the time
406+
target_time = datetime.now(timezone.utc) - delta
407+
return str(int(target_time.timestamp() * 1_000_000_000))
428408

429-
# Unix timestamp (numeric string) - return as-is
409+
# Unix timestamp (numeric string) - convert to nanoseconds if needed
430410
if time_str.isdigit():
431-
return time_str
411+
# Assume it's in seconds if less than 13 digits, otherwise nanoseconds
412+
if len(time_str) <= 10:
413+
return str(int(time_str) * 1_000_000_000)
414+
else:
415+
return time_str
432416

433417
# Try to parse as ISO format
434418
try:
435-
return datetime.datetime.fromisoformat(time_str)
419+
dt = datetime.fromisoformat(time_str)
420+
if dt.tzinfo is None:
421+
dt = dt.replace(tzinfo=timezone.utc)
422+
return str(int(dt.timestamp() * 1_000_000_000))
436423
except ValueError:
437424
pass
438425

@@ -442,12 +429,13 @@ def parse_grafana_time(time_str: str) -> Union[str, datetime.datetime]:
442429
iso_str = (
443430
time_str.replace("Z", "+00:00") if time_str.endswith("Z") else time_str
444431
)
445-
return datetime.datetime.fromisoformat(iso_str)
432+
dt = datetime.fromisoformat(iso_str)
433+
return str(int(dt.timestamp() * 1_000_000_000))
446434
except ValueError:
447435
pass
448436

449-
# If all parsing fails, return as Grafana relative time
450-
return "now"
437+
# If all parsing fails, return current time
438+
return str(int(datetime.now(timezone.utc).timestamp() * 1_000_000_000))
451439

452440

453441
def get_custom_query_loki_description() -> str:
@@ -577,25 +565,13 @@ def query_loki(
577565
if start is not None and end is None:
578566
end = "now" # Default end to current time when start is specified
579567

580-
# Parse start time
568+
# Parse start time - parse_grafana_time now always returns string
581569
if start:
582-
parsed_start = parse_grafana_time(start)
583-
if isinstance(parsed_start, str):
584-
# Grafana relative time or Unix timestamp - use as-is
585-
start = parsed_start
586-
else:
587-
# Convert datetime to Unix nanoseconds
588-
start = str(int(parsed_start.timestamp() * 1_000_000_000))
570+
start = parse_grafana_time(start)
589571

590-
# Parse end time
572+
# Parse end time - parse_grafana_time now always returns string
591573
if end:
592-
parsed_end = parse_grafana_time(end)
593-
if isinstance(parsed_end, str):
594-
# Grafana relative time or Unix timestamp - use as-is
595-
end = parsed_end
596-
else:
597-
# Convert datetime to Unix nanoseconds
598-
end = str(int(parsed_end.timestamp() * 1_000_000_000))
574+
end = parse_grafana_time(end)
599575

600576
client = get_grafana_client()
601577
return client.query_loki(query, start, end, limit, direction, max_per_line)

tests/test_parse_grafana_time.py

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
Tests for the parse_grafana_time function in the Grafana-Loki MCP Server.
33
"""
44

5-
import datetime
65
import os
76
import sys
87

@@ -14,51 +13,57 @@
1413
def test_parse_grafana_time_empty() -> None:
1514
"""Test parse_grafana_time with empty string input."""
1615
result = parse_grafana_time("")
17-
assert result == "now"
16+
# Should return current time as Unix nanoseconds string
17+
assert result.isdigit()
18+
assert len(result) >= 18 # Unix nanoseconds should be 19 digits
1819

1920

2021
def test_parse_grafana_time_now() -> None:
2122
"""Test parse_grafana_time with 'now' input."""
2223
result = parse_grafana_time("now")
23-
assert result == "now"
24+
# Should return current time as Unix nanoseconds string
25+
assert result.isdigit()
26+
assert len(result) >= 18 # Unix nanoseconds should be 19 digits
2427

2528

2629
def test_parse_grafana_time_relative() -> None:
2730
"""Test parse_grafana_time with relative time formats."""
2831
formats = ["now-1s", "now-5m", "now-2h", "now-1d", "now-1w", "now-1M", "now-1y"]
2932
for fmt in formats:
3033
result = parse_grafana_time(fmt)
31-
assert result == fmt
34+
# Should return Unix nanoseconds string
35+
assert result.isdigit()
36+
assert len(result) >= 18 # Unix nanoseconds should be 19 digits
3237

3338

3439
def test_parse_grafana_time_unix_timestamp() -> None:
3540
"""Test parse_grafana_time with Unix timestamp."""
3641
result = parse_grafana_time("1609459200")
37-
assert result == "1609459200"
42+
# Should convert to nanoseconds
43+
assert result == "1609459200000000000"
3844

3945

4046
def test_parse_grafana_time_iso_format() -> None:
4147
"""Test parse_grafana_time with ISO format."""
4248
result = parse_grafana_time("2021-01-01T00:00:00")
43-
assert isinstance(result, datetime.datetime)
44-
assert result.year == 2021
45-
assert result.month == 1
46-
assert result.day == 1
49+
# Should return Unix nanoseconds string
50+
assert result.isdigit()
51+
# For 2021-01-01T00:00:00 UTC, this should be 1609459200000000000
52+
assert result == "1609459200000000000"
4753

4854

4955
def test_parse_grafana_time_rfc3339() -> None:
5056
"""Test parse_grafana_time with RFC3339 format."""
5157
result = parse_grafana_time("2021-01-01T00:00:00Z")
52-
assert isinstance(result, datetime.datetime)
53-
assert result.year == 2021
54-
assert result.month == 1
55-
assert result.day == 1
56-
assert result.hour == 0
57-
assert result.minute == 0
58-
assert result.second == 0
58+
# Should return Unix nanoseconds string
59+
assert result.isdigit()
60+
# For 2021-01-01T00:00:00Z UTC, this should be 1609459200000000000
61+
assert result == "1609459200000000000"
5962

6063

6164
def test_parse_grafana_time_invalid() -> None:
6265
"""Test parse_grafana_time with invalid format."""
6366
result = parse_grafana_time("invalid-format")
64-
assert result == "now"
67+
# Should return current time as Unix nanoseconds string
68+
assert result.isdigit()
69+
assert len(result) >= 18 # Unix nanoseconds should be 19 digits

tests/test_parse_grafana_time_invalid_unit.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,6 @@
1313
def test_parse_grafana_time_invalid_unit() -> None:
1414
"""Test parse_grafana_time with invalid unit."""
1515
result = parse_grafana_time("now-1z") # Invalid unit 'z'
16-
assert result == "now"
16+
# Should return current time as Unix nanoseconds string
17+
assert result.isdigit()
18+
assert len(result) >= 18 # Unix nanoseconds should be 19 digits

0 commit comments

Comments
 (0)