Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions skills/cloud-monitoring/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this skill will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.17] - 2026-04-20
### Changed
- Replaced 'gist' terminology with 'Sparkline' and 'ASCII Art'.
- Added pipe `|` wrappers around the generated sparklines to ensure empty spaces are visibly framed.
- Delegated standalone CSV usage to the new `csv_to_sparkline.py` script hosted in `monitoring-graphs`.

## [0.2.16] - 2026-04-16
### Added
- Rephrased gist the 'graphical' part.
Expand Down
4 changes: 2 additions & 2 deletions skills/cloud-monitoring/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: cloud-monitoring
description: 🐉 Skill for interacting with Google Cloud Monitoring (CM) via APIs to avoid large context bloat. Produces nice short synoptic "gists" of graphs
version: 0.2.16
version: 0.2.17
tools:
- mcp_google-monitoring_get_alert
- mcp_google-monitoring_get_alert_policy
Expand Down Expand Up @@ -36,5 +36,5 @@ This skill provides utilities for analyzing and extracting data from Google Clou
## Available Tools

* `scripts/export_timeseries_to_csv.py`: Fetches time-series data for specified metric(s) and time range, outputting a CSV file with metadata headers. Supports extracting two or more variables for direct correlation and comparison. This is an amazing synoptic you can surface to the user
* It's very effective particularly to share the "**gist**" / **Shape** part showing use in TEXT the graphical shape of the curve, eg "█▇▆▇ ▂▃ ▂ ▂ ▂ ▂" or "█▆▃▂▄▅▅_▄▄▄▂▄▄█▆". This is very useful to humans for visual feedback!
* It's very effective particularly to share the "**Sparkline**" / **ASCII Art** part showing in TEXT the graphical shape of the curve (e.g., `|█▇▆▇ ▂▃ ▂ ▂|`). This is very useful for humans for rapid visual feedback! Use `csv_to_sparkline.py` from `monitoring-graphs` to recreate it for arbitrary CSVs.
* `scripts/setup-frontend-slo.sh`: A bash script to automatically set up a 99.9% availability SLO using Log-based Metrics for a 'frontend' service. (See `scripts/README.md` for full manual).
14 changes: 8 additions & 6 deletions skills/cloud-monitoring/scripts/export_timeseries_to_csv.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,9 @@ def generate_sparkline(vals, num_bins=16):
return "▄" * len(binned)

normalized = np.round((binned - vmin) / (vmax - vmin) * 7).astype(int)
chars = ['_', '▂', '▃', '▄', '▅', '▆', '▇', '█']
return "".join([chars[i] for i in normalized])
chars = [' ', '▂', '▃', '▄', '▅', '▆', '▇', '█']
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 This change from _ to (space) for the lowest bucket improves the visual clarity of the sparkline when wrapped in | pipes, as requested.

shape_str = "".join([chars[i] for i in normalized])
return f"|{shape_str}|"

def main():
epilog_text = """
Expand Down Expand Up @@ -255,7 +256,7 @@ def main():

stats_summary[metric_name] = {
"count": len(vals),
"shape": generate_sparkline(vals, num_bins=args.num_bins),
"sparkline": generate_sparkline(vals, num_bins=args.num_bins),
"min": vals[min_idx],
"min_ts": metric_points[min_idx]["timestamp"],
"min_pos": get_position_label(min_idx, len(vals)),
Expand Down Expand Up @@ -291,15 +292,16 @@ def main():
max_time = s['max_ts'][11:19]

print(f"\nMetric: {m}")
print(f" Shape: {s['shape']}")
print(f" Sparkline:{s['sparkline']}")
print(f" To gen: uv run ../monitoring-graphs/scripts/csv_to_sparkline.py --csv {args.output} --values-column-name value")
print(f" Count: {s['count']}")
print(f" Average: {s['avg']:.4f}")
print(f" Variance: {s['var']:.4f}")
print(f" Minimum: [{min_time}] {s['min']:.4f} ({s['min_pos']})")
print(f" Maximum: [{max_time}] {s['max']:.4f} ({s['max_pos']})")

# Write to CSV header
csvfile.write(f"# stats_{m}_shape: {s['shape']}\n")
csvfile.write(f"# stats_{m}_sparkline: {s['sparkline']}\n")
csvfile.write(f"# stats_{m}_avg: {s['avg']}\n")
csvfile.write(f"# stats_{m}_min: [{min_time}] {s['min']} ({s['min_pos']})\n")
csvfile.write(f"# stats_{m}_max: [{max_time}] {s['max']} ({s['max_pos']})\n")
Expand All @@ -310,7 +312,7 @@ def main():
print("SYNOPTIC COMPARISON")
print("="*40)
for m, s in stats_summary.items():
print(f"{s['shape']} {m}")
print(f"{s['sparkline']} {m}")
print("="*40)

csvfile.write(f"# metadata_point_count: {len(all_data_points)}\n")
Expand Down
4 changes: 4 additions & 0 deletions skills/monitoring-graphs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

All notable changes to the `monitoring-graphs` skill will be documented in this file.

## [0.0.6] - 2026-04-20
### Added
- Added `scripts/csv_to_sparkline.py` script extracted from `cloud-monitoring`. This generic script allows you to auto-generate an ASCII sparkline graphic wrapped in `|` for any given CSV numeric column. You can specify `--values-column-name` explicitly.

## [0.0.5] - 2026-04-07
### 📊 The "Data Integrity & Aesthetics" Update

Expand Down
2 changes: 1 addition & 1 deletion skills/monitoring-graphs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: 🐉 skill for generating high-quality, annotated incident graphs f
#verbose_description: Expert SRE skill for generating high-quality, annotated incident graphs for post-mortems using Python and Monitoring MCP on Google Cloud. Use this when the user needs to visualize an outage, show error rates, or correlate metrics with incident milestones (start, detection, mitigation, end).
metadata:
author: Riccardo Carlesso
version: 0.0.5
version: 0.0.6
---

# 📈 Monitoring & Incident Graphing Skill 📊
Expand Down
115 changes: 115 additions & 0 deletions skills/monitoring-graphs/scripts/csv_to_sparkline.py
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 The extraction of sparkline generation into a generic script is a great improvement for reusability across the extension.

However, consider adding a basic unit test for this new script (e.g., in a test/ directory) to ensure the ASCII generation remains consistent as you evolve it.

Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env -S uv run
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# /// script
# requires-python = ">=3.10"
# dependencies = [
# "pandas",
# "numpy",
# "python-dateutil"
# ]
# ///

import argparse
import sys
import numpy as np
import pandas as pd

def generate_sparkline(vals, num_bins=16, wrapper="|"):
"""
Generates an ASCII sparkline from a sequence of values.
Uses ' ' (space) for the lowest/empty bucket to ensure empty spots are visible when framed.
"""
if len(vals) == 0:
return f"{wrapper}{wrapper}"
if len(vals) < num_bins:
num_bins = len(vals)

splits = np.array_split(vals, num_bins)
binned = np.array([np.mean(s) for s in splits if len(s) > 0])

vmin, vmax = np.min(binned), np.max(binned)
if vmin == vmax:
shape_str = "▄" * len(binned)
return f"{wrapper}{shape_str}{wrapper}"

normalized = np.round((binned - vmin) / (vmax - vmin) * 7).astype(int)
# Replaced lowest char '_' with ' ' (space) as requested for better framing visibility
chars = [' ', '▂', '▃', '▄', '▅', '▆', '▇', '█']
shape_str = "".join([chars[i] for i in normalized])
return f"{wrapper}{shape_str}{wrapper}"

def main():
parser = argparse.ArgumentParser(description="Generates an ASCII sparkline from a CSV file.")
parser.add_argument("--csv", required=True, help="Path to the input CSV data")
parser.add_argument("--values-column-name", dest="values_column", required=True, help="Column name containing the numeric values.")
parser.add_argument("--time-column-name", dest="time_column", required=False, help="(Optional) Time column name. Will try to auto-infer if not provided.")
parser.add_argument("--bins", type=int, default=16, help="Number of bins (characters) in the sparkline.")
parser.add_argument("--wrapper", type=str, default="|", help="Wrapper character around the sparkline (default: |).")
args = parser.parse_args()

try:
df = pd.read_csv(args.csv)
try:
df = pd.read_csv(args.csv)
except Exception as e:
Comment thread
palladius marked this conversation as resolved.
print(f"Error reading CSV '{args.csv}': {e}", file=sys.stderr)
sys.exit(1)
print(f"Error reading CSV: {e}", file=sys.stderr)
sys.exit(1)

if args.values_column not in df.columns:
print(f"Error: Column '{args.values_column}' not found in CSV. Available columns: {', '.join(df.columns)}", file=sys.stderr)
sys.exit(1)

# Autoinfer time column if not provided
time_col = args.time_column
if not time_col:
# Check heuristics for time column
time_keywords = ['time', 'timestamp', 'date', 'datetime']
for col in df.columns:
if any(k in col.lower() for k in time_keywords):
time_col = col
break

# If still not found, try to see if the first column can be parsed as datetime, or just use the first column
if not time_col and len(df.columns) > 0:
potential_col = df.columns[0]
if potential_col != args.values_column:
try:
pd.to_datetime(df[potential_col][:5])
time_col = potential_col
except:
# just pick the first column if no time column is found, as long as it's not the value!
time_col = potential_col

if time_col and time_col in df.columns:
try:
# Sort by the inferred or explicit time column
df[time_col] = pd.to_datetime(df[time_col])
df = df.sort_values(by=time_col)
except Exception:
pass # ignore sorting if it's not actually a time parsing compatible column

values = df[args.values_column].dropna().values
if len(values) == 0:
print(f"Error: No valid numeric data found in column '{args.values_column}'.", file=sys.stderr)
sys.exit(1)

sparkline = generate_sparkline(values, num_bins=args.bins, wrapper=args.wrapper)
print(sparkline)

if __name__ == "__main__":
main()
Loading