Skip to content

Commit 55e6c3c

Browse files
added support for Valkey and Redis Cluster instances
1 parent 8e74ce3 commit 55e6c3c

3 files changed

Lines changed: 363 additions & 6 deletions

File tree

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,16 @@ Execute
4747

4848
```
4949
# To use the copied service-account:
50-
python msstats.py
50+
python memorystore.py --project YOUR_PROJECT_ID --credentials /path/to/sa.json --out /path/to/out.csv
5151
52-
# or, to use the `gcloud` user (then, you _need_ to give a precise project):
53-
python3 msstats.py --user-account --project-id my-gcp-project
5452
```
5553

56-
This generates a file named <your project>.xlsx. You need to get that file and send it to Redis.
54+
This generates a csv file. You need to get that file and send it to Redis.
5755
By default, it uses steps of 60 seconds and a period of 7 days (604800 seconds).
5856
You can set different values as follows:
5957

6058
````
61-
python msstats.py --duration 1800 --step 300
59+
python memorystore.py --project YOUR_PROJECT_ID --credentials /path/to/sa.json --out /path/to/out.csv --duration 1800 --step 300
6260
````
6361

6462
This can help solving issue like:
@@ -87,6 +85,8 @@ For example,
8785
./grant_sa_monitoring_viewer.sh gmflau-sa@gcp-dev-day-nyc.iam.gserviceaccount.com
8886
```
8987

88+
Edit the `batch_run_msstats.sh` file to set the right path to the credentials file.
89+
9090
Execute
9191

9292
```

batch_run_msstats.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
projects=$(gcloud projects list | awk 'NR>1 {print $1}')
44

55
echo "$projects" | while read -r project_id; do
6-
python msstats.py -p $project_id
6+
python memorystore.py --project $project_id --credentials /path/to/sa.json --out /path/to/$project_id.csv
77
done

memorystore.py

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
#!/usr/bin/env python3
2+
"""
3+
memorystore.py
4+
5+
Collects Memorystore metrics for Redis, Valkey, and Redis Cluster using ONLY Cloud Monitoring.
6+
No direct connections are made to instances. Requires only roles/monitoring.viewer.
7+
8+
- Reuses command categorization from the attached msstats.py (imported).
9+
- Computes the same high-level command categories that msstats.py outputs via:
10+
processMetricPoint() + processNodeStats().
11+
12+
For each node (instance node or cluster node), the script outputs a CSV row containing:
13+
Source, Project ID, InstanceType, ClusterId, InstanceId, NodeId, NodeRole, Region, Zone, NodeType,
14+
BytesUsedForCache, MaxMemory, and the command-category columns from msstats.py.
15+
16+
Usage:
17+
python memorystore.py --project YOUR_PROJECT_ID --credentials /path/to/sa.json --out /path/to/out.csv
18+
Optional:
19+
--duration 604800 # lookback window in seconds (default 7 days)
20+
--step 60 # alignment step in seconds for rate metrics (default 60)
21+
22+
"""
23+
import argparse
24+
import csv
25+
import os
26+
import sys
27+
import time
28+
from typing import Dict, List, Optional, Any
29+
from collections import defaultdict
30+
31+
from google.oauth2 import service_account
32+
from google.cloud import monitoring_v3
33+
34+
# Import the categorization logic from the provided msstats.py
35+
# We rely on these to compute the exact same metrics/categories.
36+
try:
37+
import msstats as ms
38+
except ImportError as ex:
39+
print(f"Error: msstats.py not found in PYTHONPATH. Place msstats.py next to this script. {ex}")
40+
sys.exit(1)
41+
42+
# ---------- Metric maps per product ----------
43+
# Redis (non-cluster)
44+
REDIS_METRICS = {
45+
"commands": "redis.googleapis.com/commands/calls",
46+
"memory_usage": "redis.googleapis.com/stats/memory/usage",
47+
"max_memory": "redis.googleapis.com/stats/memory/maxmemory",
48+
}
49+
# Valkey (Memorystore for Valkey) - use node-level for commands & usage; instance-level for size.
50+
VALKEY_METRICS = {
51+
"commands": "memorystore.googleapis.com/instance/node/commandstats/calls_count",
52+
"memory_usage": "memorystore.googleapis.com/instance/node/memory/usage",
53+
"max_memory": "memorystore.googleapis.com/instance/memory/size",
54+
}
55+
# Redis Cluster - use node-level for commands & usage; cluster-level for size.
56+
CLUSTER_METRICS = {
57+
"commands": "redis.googleapis.com/cluster/node/commandstats/calls_count",
58+
"memory_usage": "redis.googleapis.com/cluster/node/memory/usage",
59+
"max_memory": "redis.googleapis.com/cluster/memory/size",
60+
}
61+
62+
# Helper label candidates
63+
REGION_LABELS = ("region", "location")
64+
ZONE_LABELS = ("zone",)
65+
NODETYPE_LABELS = ("node_type", "cluster_node_type", "tier", "service_tier", "instance_type")
66+
67+
def _pick(labels: Dict[str, str], keys) -> Optional[str]:
68+
for k in keys:
69+
v = labels.get(k)
70+
if v:
71+
return v
72+
return None
73+
74+
def _time_interval(duration_sec: int) -> monitoring_v3.TimeInterval:
75+
now = time.time()
76+
seconds = int(now)
77+
nanos = int((now - seconds) * 10**9)
78+
return monitoring_v3.TimeInterval(
79+
{
80+
"end_time": {"seconds": seconds, "nanos": nanos},
81+
"start_time": {"seconds": (seconds - duration_sec), "nanos": nanos},
82+
}
83+
)
84+
85+
def _make_rate_aggregation(step_sec: int) -> monitoring_v3.Aggregation:
86+
return monitoring_v3.Aggregation(
87+
{
88+
"alignment_period": {"seconds": step_sec},
89+
"per_series_aligner": monitoring_v3.Aggregation.Aligner.ALIGN_RATE,
90+
"cross_series_reducer": monitoring_v3.Aggregation.Reducer.REDUCE_NONE,
91+
}
92+
)
93+
94+
def _list_ts(client: monitoring_v3.MetricServiceClient, project_name: str, metric_type: str,
95+
interval: monitoring_v3.TimeInterval, view=monitoring_v3.ListTimeSeriesRequest.TimeSeriesView.FULL,
96+
aggregation: Optional[monitoring_v3.Aggregation] = None):
97+
req = {
98+
"name": project_name,
99+
"filter": f'metric.type = "{metric_type}"',
100+
"interval": interval,
101+
"view": view,
102+
}
103+
if aggregation is not None:
104+
req["aggregation"] = aggregation
105+
return list(client.list_time_series(request=req))
106+
107+
def _ensure_node_entry(table: Dict[str, Dict[str, Dict[str, Any]]], inst_key: str, node_id: str) -> Dict[str, Any]:
108+
if inst_key not in table:
109+
table[inst_key] = {}
110+
if node_id not in table[inst_key]:
111+
table[inst_key][node_id] = {
112+
"Source": "MS",
113+
"ClusterId": inst_key, # for Redis this is the instance name; for Cluster this is the cluster name
114+
"NodeId": node_id,
115+
"NodeRole": "",
116+
"NodeType": "",
117+
"Region": "",
118+
"Zone": "",
119+
"Project ID": "", # filled later
120+
"InstanceId": "", # full resource name if available
121+
"InstanceType": "", # Redis | Valkey | Redis Cluster
122+
"points": {}, # timestamp -> {cmd: rate}
123+
}
124+
return table[inst_key][node_id]
125+
126+
def _accumulate_commands(results, table, product_name: str, project_id: str):
127+
"""
128+
Accumulate per-node command rates into table[instance][node]["points"][timestamp][cmd] = rate
129+
"""
130+
for ts in results:
131+
rlabels = dict(ts.resource.labels)
132+
mlabels = dict(ts.metric.labels)
133+
134+
# Identify instance/cluster id & node
135+
inst_key = rlabels.get("instance_id") or rlabels.get("cluster_id") or rlabels.get("resource_name") or "unknown"
136+
node_id = rlabels.get("node_id") or rlabels.get("shard_id") or "unknown"
137+
entry = _ensure_node_entry(table, inst_key, node_id)
138+
139+
# Fill common attributes
140+
entry["Project ID"] = project_id
141+
entry["InstanceId"] = rlabels.get("instance_id") or rlabels.get("cluster_id") or ""
142+
entry["Region"] = _pick(rlabels, REGION_LABELS) or entry["Region"]
143+
entry["Zone"] = _pick(rlabels, ZONE_LABELS) or entry["Zone"]
144+
entry["NodeType"] = _pick(rlabels, NODETYPE_LABELS) or entry["NodeType"]
145+
146+
# Node role if provided (e.g., 'primary'/'replica')
147+
role = mlabels.get("role") or rlabels.get("role") or ""
148+
if role:
149+
entry["NodeRole"] = "Master" if role == "primary" else ("Replica" if role == "replica" else role)
150+
151+
# Instance type label
152+
entry["InstanceType"] = product_name
153+
154+
# Command name label is typically 'cmd'
155+
cmd = mlabels.get("cmd")
156+
if not cmd:
157+
# best effort alternative label naming
158+
for alt in ("command", "command_name"):
159+
if alt in mlabels:
160+
cmd = mlabels[alt]
161+
break
162+
if not cmd:
163+
continue
164+
165+
# Collect points as rates
166+
for point in ts.points:
167+
t = point.interval.start_time.timestamp()
168+
if t not in entry["points"]:
169+
entry["points"][t] = {}
170+
# Support both int/double values
171+
pv = 0.0
172+
try:
173+
pv = point.value.double_value
174+
except Exception:
175+
try:
176+
pv = float(point.value.int64_value)
177+
except Exception:
178+
pv = 0.0
179+
entry["points"][t][cmd] = pv
180+
181+
def _apply_processed_categories(table):
182+
"""
183+
For each node entry that has points, compute processed per-timestamp categories using
184+
ms.processMetricPoint(), then reduce with ms.processNodeStats() (max across window).
185+
Store the dict under entry["commandstats"] and remove 'points'.
186+
"""
187+
for inst in list(table.keys()):
188+
for node in list(table[inst].keys()):
189+
entry = table[inst][node]
190+
processed = {}
191+
for ts, cmdmap in entry.get("points", {}).items():
192+
processed[ts] = ms.processMetricPoint(cmdmap)
193+
entry["commandstats"] = ms.processNodeStats(processed) if processed else {}
194+
if "points" in entry:
195+
del entry["points"]
196+
197+
def _attach_memory_usage(results, table, key_name="BytesUsedForCache"):
198+
for ts in results:
199+
rlabels = dict(ts.resource.labels)
200+
inst_key = rlabels.get("instance_id") or rlabels.get("cluster_id") or rlabels.get("resource_name") or "unknown"
201+
node_id = rlabels.get("node_id") or rlabels.get("shard_id") or "unknown"
202+
if inst_key not in table or node_id not in table[inst_key]:
203+
_ensure_node_entry(table, inst_key, node_id)
204+
entry = table[inst_key][node_id]
205+
# take the max usage observed
206+
maxv = 0
207+
for point in ts.points:
208+
try:
209+
v = int(point.value.int64_value)
210+
except Exception:
211+
try:
212+
v = int(point.value.double_value)
213+
except Exception:
214+
v = 0
215+
if v > maxv:
216+
maxv = v
217+
prev = entry.get(key_name, 0)
218+
entry[key_name] = max(prev, maxv)
219+
220+
def _attach_capacity_scalar(results, table, key_name="MaxMemory"):
221+
"""Attach a capacity scalar (e.g., memory size); applies to all nodes within the instance/cluster."""
222+
cap_by_inst = defaultdict(int)
223+
for ts in results:
224+
rlabels = dict(ts.resource.labels)
225+
inst_key = rlabels.get("instance_id") or rlabels.get("cluster_id") or rlabels.get("resource_name") or "unknown"
226+
v_max = 0
227+
for point in ts.points:
228+
try:
229+
v = int(point.value.int64_value)
230+
except Exception:
231+
try:
232+
v = int(point.value.double_value)
233+
except Exception:
234+
v = 0
235+
if v > v_max:
236+
v_max = v
237+
if v_max > cap_by_inst[inst_key]:
238+
cap_by_inst[inst_key] = v_max
239+
240+
for inst_key, nodes in table.items():
241+
if inst_key in cap_by_inst:
242+
for node_id in nodes:
243+
nodes[node_id][key_name] = cap_by_inst[inst_key]
244+
245+
def _flatten_rows(table, project_id: str, instance_type: str) -> List[Dict[str, Any]]:
246+
rows = []
247+
for inst_key, nodes in table.items():
248+
for node_id, entry in nodes.items():
249+
entry["Project ID"] = project_id or entry.get("Project ID", "")
250+
entry["InstanceType"] = instance_type or entry.get("InstanceType", "")
251+
row = {**entry}
252+
row.update(entry.get("commandstats", {}))
253+
row.pop("commandstats", None)
254+
rows.append(row)
255+
return rows
256+
257+
def collect_for_product(client, project_id: str, duration: int, step: int,
258+
metric_map: Dict[str, str], instance_type_label: str) -> List[Dict[str, Any]]:
259+
project_name = f"projects/{project_id}"
260+
interval = _time_interval(duration)
261+
agg = _make_rate_aggregation(step)
262+
263+
table: Dict[str, Dict[str, Dict[str, Any]]] = {}
264+
265+
# Commands (primary discovery)
266+
try:
267+
cmd_results = _list_ts(client, project_name, metric_map["commands"], interval,
268+
view=monitoring_v3.ListTimeSeriesRequest.TimeSeriesView.FULL,
269+
aggregation=agg)
270+
except Exception:
271+
cmd_results = []
272+
_accumulate_commands(cmd_results, table, instance_type_label, project_id)
273+
274+
# If nothing found, discover via memory usage series (so we still emit rows)
275+
if not table:
276+
try:
277+
mem_results = _list_ts(client, project_name, metric_map["memory_usage"], interval)
278+
except Exception:
279+
mem_results = []
280+
_attach_memory_usage(mem_results, table)
281+
for inst_key, nodes in table.items():
282+
for node_id, entry in nodes.items():
283+
entry["InstanceType"] = instance_type_label
284+
entry["Project ID"] = project_id
285+
286+
# Memory usage (BytesUsedForCache)
287+
try:
288+
mem_results = _list_ts(client, project_name, metric_map["memory_usage"], interval)
289+
_attach_memory_usage(mem_results, table)
290+
except Exception:
291+
pass
292+
293+
# Capacity (MaxMemory) - instance/cluster level
294+
try:
295+
cap_results = _list_ts(client, project_name, metric_map["max_memory"], interval)
296+
_attach_capacity_scalar(cap_results, table, key_name="MaxMemory")
297+
except Exception:
298+
pass
299+
300+
# Compute command categories
301+
_apply_processed_categories(table)
302+
303+
# Flatten to rows
304+
return _flatten_rows(table, project_id, instance_type_label)
305+
306+
def main():
307+
parser = argparse.ArgumentParser(description="Export Memorystore metrics for Redis, Valkey and Redis Cluster to CSV (using only Cloud Monitoring).")
308+
parser.add_argument("--project", required=True, help="GCP Project ID")
309+
parser.add_argument("--credentials", required=True, help="Path to service account JSON with monitoring.viewer")
310+
parser.add_argument("--out", required=True, help="Output CSV file path")
311+
parser.add_argument("--duration", type=int, default=604800, help="Lookback window in seconds (default 7 days)")
312+
parser.add_argument("--step", type=int, default=60, help="Alignment step in seconds for rate metrics (default 60)")
313+
args = parser.parse_args()
314+
315+
# Auth
316+
creds = service_account.Credentials.from_service_account_file(args.credentials)
317+
client = monitoring_v3.MetricServiceClient(credentials=creds)
318+
319+
all_rows: List[Dict[str, Any]] = []
320+
321+
# Collect for each product
322+
for metric_map, label in (
323+
(REDIS_METRICS, "Redis"),
324+
(VALKEY_METRICS, "Valkey"),
325+
(CLUSTER_METRICS, "Redis Cluster"),
326+
):
327+
rows = collect_for_product(client, args.project, args.duration, args.step, metric_map, label)
328+
all_rows.extend(rows)
329+
330+
if not all_rows:
331+
print("Warning: No metrics found; CSV will be created with no rows.")
332+
333+
# Build header: union of keys across rows, with useful columns first
334+
base_order = [
335+
"Source", "Project ID", "InstanceType",
336+
"ClusterId", "InstanceId", "NodeId", "NodeRole", "NodeType", "Region", "Zone",
337+
"BytesUsedForCache", "MaxMemory",
338+
]
339+
category_keys = []
340+
for row in all_rows:
341+
for k in row.keys():
342+
if k not in base_order and k not in category_keys:
343+
category_keys.append(k)
344+
header = base_order + category_keys
345+
346+
# Write CSV
347+
os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True)
348+
with open(args.out, "w", newline="") as f:
349+
writer = csv.DictWriter(f, fieldnames=header)
350+
writer.writeheader()
351+
for row in all_rows:
352+
writer.writerow(row)
353+
354+
print(f"Wrote {len(all_rows)} rows to {args.out}")
355+
356+
if __name__ == "__main__":
357+
sys.exit(main())

0 commit comments

Comments
 (0)