Skip to content

Commit 5bad536

Browse files
authored
[ROB-914] holmes graphs chatbot (#1773)
* passing holmes files * working graphs * add description * added chat graph param * generate graph * fix pr * bugfix * PR CHANGES * param updates + safer code
1 parent f9f7b06 commit 5bad536

5 files changed

Lines changed: 198 additions & 6 deletions

File tree

src/robusta/core/model/base_params.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,6 @@ class ResourceInfo(BaseModel):
8181

8282

8383
class HolmesParams(ActionParams):
84-
8584
holmes_url: Optional[str]
8685
model: Optional[str]
8786
@validator("holmes_url", allow_reuse=True)
@@ -190,6 +189,7 @@ class HolmesChatParams(HolmesParams):
190189

191190
ask: str
192191
conversation_history: Optional[list[dict]] = None
192+
render_graph_images: bool = False
193193

194194

195195
class HolmesIssueChatParams(HolmesChatParams):

src/robusta/core/playbooks/internal/ai_integration.py

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,26 @@
22
import logging
33

44
import requests
5+
from prometrix import PrometheusQueryResult
56

67
from robusta.core.model.base_params import (
78
AIInvestigateParams,
9+
ChartValuesFormat,
810
HolmesChatParams,
911
HolmesConversationParams,
1012
HolmesIssueChatParams,
13+
HolmesWorkloadHealthChatParams,
1114
HolmesWorkloadHealthParams,
1215
ResourceInfo,
13-
HolmesWorkloadHealthChatParams
1416
)
1517
from robusta.core.model.events import ExecutionBaseEvent
1618
from robusta.core.playbooks.actions_registry import action
19+
from robusta.core.playbooks.prometheus_enrichment_utils import build_chart_from_prometheus_result
1720
from robusta.core.reporting import Finding, FindingSubject
1821
from robusta.core.reporting.base import EnrichmentType
1922
from robusta.core.reporting.consts import FindingSubjectType, FindingType
2023
from robusta.core.reporting.holmes import (
24+
FileBlock,
2125
HolmesChatRequest,
2226
HolmesChatResult,
2327
HolmesChatResultsBlock,
@@ -29,6 +33,7 @@
2933
HolmesResultsBlock,
3034
HolmesWorkloadHealthRequest,
3135
)
36+
from robusta.core.reporting.utils import convert_svg_to_png
3237
from robusta.core.schedule.model import FixedDelayRepeat
3338
from robusta.integrations.kubernetes.autogenerated.events import KubernetesAnyChangeEvent
3439
from robusta.integrations.prometheus.utils import HolmesDiscovery
@@ -65,12 +70,18 @@ def ask_holmes(event: ExecutionBaseEvent, params: AIInvestigateParams):
6570
)
6671

6772
if params.stream:
68-
with requests.post(f"{holmes_url}/api/stream/investigate", data=holmes_req.json(), stream=True, headers={"Connection": "keep-alive"}) as resp:
73+
with requests.post(
74+
f"{holmes_url}/api/stream/investigate",
75+
data=holmes_req.json(),
76+
stream=True,
77+
headers={"Connection": "keep-alive"},
78+
) as resp:
6979
resp.raise_for_status()
70-
for line in resp.iter_content(chunk_size=None, decode_unicode=True): # Avoid streaming chunks from holmes. send them as they arrive.
80+
for line in resp.iter_content(
81+
chunk_size=None, decode_unicode=True
82+
): # Avoid streaming chunks from holmes. send them as they arrive.
7183
if line:
7284
event.ws(data=line)
73-
7485
return
7586

7687
else:
@@ -182,7 +193,9 @@ def build_conversation_title(params: HolmesConversationParams) -> str:
182193

183194

184195
def add_labels_to_ask(params: HolmesConversationParams) -> str:
185-
label_string = f"the alert has the following labels: {params.context.get('labels')}" if params.context.get("labels") else ""
196+
label_string = (
197+
f"the alert has the following labels: {params.context.get('labels')}" if params.context.get("labels") else ""
198+
)
186199
ask = f"{params.ask}, {label_string}" if label_string else params.ask
187200
logging.debug(f"holmes ask query: {ask}")
188201
return ask
@@ -342,6 +355,34 @@ def holmes_chat(event: ExecutionBaseEvent, params: HolmesChatParams):
342355
result = requests.post(f"{holmes_url}/api/chat", data=holmes_req.json())
343356
result.raise_for_status()
344357
holmes_result = HolmesChatResult(**json.loads(result.text))
358+
holmes_result.files = []
359+
if params.render_graph_images:
360+
try:
361+
for tool in holmes_result.tool_calls:
362+
if tool.tool_name != "execute_prometheus_range_query":
363+
continue
364+
365+
json_content = json.loads(tool.result)
366+
query_result = PrometheusQueryResult(data=json_content.get("data", {}))
367+
try:
368+
output_type_str = json_content.get("output_type", "Plain")
369+
output_type = ChartValuesFormat[output_type_str]
370+
except KeyError:
371+
output_type = ChartValuesFormat.Plain # fallback in case of an invalid string
372+
373+
chart = build_chart_from_prometheus_result(
374+
query_result, json_content.get("description", "graph"), values_format=output_type
375+
)
376+
contents = convert_svg_to_png(chart.render())
377+
name = json_content.get("description", "graph").replace(" ", "_")
378+
holmes_result.files.append(FileBlock(f"{name}.png", contents))
379+
380+
holmes_result.tool_calls = [
381+
tool for tool in holmes_result.tool_calls if tool.tool_name != "execute_prometheus_range_query"
382+
]
383+
384+
except Exception:
385+
logging.exception(f"Failed to convert tools to images")
345386

346387
finding = Finding(
347388
title="AI Ask Chat",
@@ -352,6 +393,7 @@ def holmes_chat(event: ExecutionBaseEvent, params: HolmesChatParams):
352393
finding_type=FindingType.AI_ANALYSIS,
353394
failure=False,
354395
)
396+
355397
finding.add_enrichment(
356398
[HolmesChatResultsBlock(holmes_result=holmes_result)], enrichment_type=EnrichmentType.ai_analysis
357399
)

src/robusta/core/playbooks/prometheus_enrichment_utils.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,141 @@ def create_chart_from_prometheus_query(
401401
)
402402

403403

404+
def build_chart_from_prometheus_result(
405+
prometheus_query_result: PrometheusQueryResult,
406+
chart_title: Optional[str] = "Prometheus Chart",
407+
values_format: Optional[ChartValuesFormat] = None,
408+
) -> pygal.Graph:
409+
if prometheus_query_result.result_type != "matrix":
410+
raise ValueError(f"Expected 'matrix' result_type, got '{prometheus_query_result.result_type}'")
411+
412+
HIGHEST_END = 32536799999
413+
LOWEST_START = 0
414+
415+
min_time = HIGHEST_END
416+
max_time = LOWEST_START
417+
max_y_value = 0
418+
419+
plot_data_list: List[PlotData] = []
420+
series_list_result = prometheus_query_result.series_list_result
421+
COLOR_PALETTE = [
422+
"#1f77b4",
423+
"#ff7f0e",
424+
"#2ca02c",
425+
"#d62728",
426+
"#9467bd",
427+
"#8c564b",
428+
"#e377c2",
429+
"#7f7f7f",
430+
"#bcbd22",
431+
"#17becf",
432+
"#393b79",
433+
"#637939",
434+
"#8c6d31",
435+
"#843c39",
436+
"#7b4173",
437+
"#5254a3",
438+
"#9c9ede",
439+
"#6b6ecf",
440+
"#b5cf6b",
441+
"#e7ba52",
442+
"#e7969c",
443+
"#de9ed6",
444+
"#9edae5",
445+
"#c7c7c7",
446+
"#c49c94",
447+
"#f7b6d2",
448+
"#dbdb8d",
449+
"#aec7e8",
450+
"#ffbb78",
451+
"#98df8a",
452+
]
453+
for i, series in enumerate(series_list_result):
454+
label = get_target_name(series)
455+
if not label:
456+
label = "\n".join([v for (key, v) in series["metric"].items() if key != "job"])
457+
458+
values = []
459+
for idx, timestamp in enumerate(series["timestamps"]):
460+
val = round(float(series["values"][idx]), FLOAT_PRECISION_LIMIT)
461+
values.append((timestamp, val))
462+
max_y_value = max(max_y_value, val)
463+
464+
min_time = min(min_time, min(series["timestamps"]))
465+
max_time = max(max_time, max(series["timestamps"]))
466+
467+
plot_data = PlotData(
468+
plot=(label, values),
469+
color=COLOR_PALETTE[i % len(COLOR_PALETTE)],
470+
show_dots=False,
471+
stroke_style={"width": 8, "dasharray": "8", "linecap": "round", "linejoin": "round"},
472+
)
473+
plot_data_list.append(plot_data)
474+
475+
if min_time == HIGHEST_END:
476+
raise ValueError("No valid data points found in time series.")
477+
478+
config = pygal.Config()
479+
custom_css = PlotCustomCSS().get_css_file_path()
480+
config.css.append(f"file://{custom_css}")
481+
482+
graph_colors = [plot_data.color for plot_data in plot_data_list]
483+
graph_colors.extend(["#1e0047", "#2a0065"])
484+
485+
chart = pygal.XY(
486+
config,
487+
show_dots=True,
488+
style=charts_style(graph_colors=tuple(graph_colors)),
489+
truncate_legend=15,
490+
include_x_axis=True,
491+
width=1280,
492+
height=500,
493+
show_legend=True,
494+
)
495+
496+
chart.range = (0, max_y_value + (max_y_value * 0.2))
497+
interval = chart.range[1] / 4
498+
if values_format == ChartValuesFormat.Percentage:
499+
chart.y_labels = [round(i * interval * 100) / 100 for i in range(5)]
500+
else:
501+
chart.y_labels = [round(i * interval) for i in range(5)]
502+
chart.y_labels_major = chart.y_labels
503+
504+
chart.show_x_guides = True
505+
chart.show_y_guides = True
506+
chart.spacing = 20
507+
chart.margin_top = 10
508+
chart.margin_bottom = 50
509+
chart.x_label_rotation = 35
510+
chart.truncate_label = -1
511+
chart.x_value_formatter = lambda timestamp: datetime.fromtimestamp(timestamp).strftime("%b %-d %H:%M")
512+
chart.legend_at_bottom = True
513+
chart.legend_at_bottom_columns = 5
514+
chart.legend_box_size = 8
515+
516+
value_formatters = {
517+
ChartValuesFormat.Plain: lambda val: str(val),
518+
ChartValuesFormat.Bytes: lambda val: humanize.naturalsize(val, binary=True),
519+
ChartValuesFormat.Percentage: lambda val: f"{(100 * val):.1f}%",
520+
ChartValuesFormat.CPUUsage: lambda val: f"{(1000 * val):.1f}m",
521+
}
522+
chart.value_formatter = value_formatters.get(values_format, lambda val: str(val))
523+
524+
chart.title = chart_title
525+
526+
for plot_data in plot_data_list:
527+
chart.add(
528+
plot_data.plot[0],
529+
plot_data.plot[1],
530+
stroke_style=plot_data.stroke_style,
531+
show_dots=plot_data.show_dots,
532+
dots_size=plot_data.dots_size,
533+
stroke=plot_data.stroke,
534+
)
535+
536+
return chart
537+
538+
404539
def run_prometheus_query(prometheus_params: PrometheusParams, query: str) -> PrometheusQueryResult:
405540
"""
406541
This function runs prometheus query and returns the result (usually a vector),

src/robusta/core/reporting/holmes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
ResourceInfo,
1010
)
1111
from robusta.core.reporting import BaseBlock
12+
from robusta.core.reporting.blocks import FileBlock
1213

1314

1415
class HolmesRequest(BaseModel):
@@ -68,6 +69,7 @@ class HolmesResultsBlock(BaseBlock):
6869

6970
class HolmesChatResult(BaseModel):
7071
analysis: Optional[str] = None
72+
files: Optional[List[FileBlock]] = None
7173
tool_calls: Optional[List[ToolCallResult]] = None
7274
conversation_history: Optional[List[dict]] = None
7375

src/robusta/core/sinks/robusta/dal/model_conversion.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,18 @@ def append_to_structured_data_tool_calls(tool_calls: List[ToolCallResult], struc
100100
data_obj["metadata"] = {"description": tool_call.description, "tool_name": tool_call.tool_name}
101101
structured_data.append(data_obj)
102102

103+
@staticmethod
104+
def append_to_structured_files(files: List[FileBlock], structured_data) -> None:
105+
if not files:
106+
return
107+
for file in files:
108+
file_name = file.filename # changes after zip
109+
file.zip()
110+
data_obj = ModelConversion.get_file_object(file)
111+
data_obj["metadata"] = {
112+
"file_name": file_name,
113+
}
114+
structured_data.append(data_obj)
103115

104116
@staticmethod
105117
def add_ai_chat_data(structured_data: List[Dict], block: HolmesChatResultsBlock):
@@ -110,6 +122,7 @@ def add_ai_chat_data(structured_data: List[Dict], block: HolmesChatResultsBlock)
110122
"data": Transformer.to_github_markdown(block.holmes_result.analysis),
111123
}
112124
)
125+
ModelConversion.append_to_structured_files(block.holmes_result.files, structured_data)
113126
ModelConversion.append_to_structured_data_tool_calls(block.holmes_result.tool_calls, structured_data)
114127

115128
conversation_history_block = FileBlock(

0 commit comments

Comments
 (0)