Skip to content

Commit 22cb11d

Browse files
authored
Filter runtime guard logs by ready revision (#277)
1 parent ec2e271 commit 22cb11d

3 files changed

Lines changed: 208 additions & 2 deletions

File tree

.github/workflows/runtime-guard.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ jobs:
6565
CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }}
6666
CLOUD_RUN_SERVICES: ${{ vars.CLOUD_RUN_SERVICES }}
6767
CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON }}
68+
CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }}
6869
GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }}
6970
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
7071
TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }}

scripts/cloud_run_runtime_guard.py

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,19 @@ def _run_gcloud(args: list[str]) -> subprocess.CompletedProcess[str]:
102102
return subprocess.run(args, text=True, capture_output=True, check=False)
103103

104104

105+
def _run_gcloud_json(args: list[str], context: str) -> Any:
106+
result = _run_gcloud(args)
107+
if result.returncode != 0:
108+
detail = (result.stderr or result.stdout or "").strip()
109+
raise RuntimeError(detail or f"gcloud {context} failed")
110+
if not result.stdout.strip():
111+
return None
112+
try:
113+
return json.loads(result.stdout)
114+
except json.JSONDecodeError as exc:
115+
raise RuntimeError(f"gcloud {context} returned invalid JSON: {exc}") from exc
116+
117+
105118
def _run_gcloud_logging(project: str, log_filter: str, limit: int) -> list[dict[str, Any]]:
106119
command = [
107120
"gcloud",
@@ -126,6 +139,144 @@ def _run_gcloud_logging(project: str, log_filter: str, limit: int) -> list[dict[
126139
return payload if isinstance(payload, list) else []
127140

128141

142+
def _parse_timestamp(value: Any) -> dt.datetime | None:
143+
if not value:
144+
return None
145+
text = str(value).strip()
146+
if not text:
147+
return None
148+
if text.endswith("Z"):
149+
text = f"{text[:-1]}+00:00"
150+
try:
151+
parsed = dt.datetime.fromisoformat(text)
152+
except ValueError:
153+
return None
154+
if parsed.tzinfo is None:
155+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
156+
return parsed.astimezone(dt.timezone.utc)
157+
158+
159+
def _format_timestamp(value: dt.datetime) -> str:
160+
return value.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
161+
162+
163+
def _target_payloads() -> list[dict[str, Any]]:
164+
raw_targets = (os.environ.get("CLOUD_RUN_SERVICE_TARGETS_JSON") or "").strip()
165+
if not raw_targets:
166+
return []
167+
try:
168+
payload = json.loads(raw_targets)
169+
except json.JSONDecodeError:
170+
return []
171+
targets = payload.get("targets") if isinstance(payload, dict) else payload
172+
if not isinstance(targets, list):
173+
return []
174+
return [target for target in targets if isinstance(target, dict)]
175+
176+
177+
def _runtime_target(target: dict[str, Any]) -> dict[str, Any]:
178+
runtime_target = target.get("runtime_target") or target.get("runtime_target_json")
179+
if isinstance(runtime_target, str):
180+
try:
181+
runtime_target = json.loads(runtime_target)
182+
except json.JSONDecodeError:
183+
runtime_target = {}
184+
return runtime_target if isinstance(runtime_target, dict) else {}
185+
186+
187+
def _target_service_names(target: dict[str, Any]) -> list[str]:
188+
runtime_target = _runtime_target(target)
189+
for key in ("service", "service_name", "cloud_run_service"):
190+
value = target.get(key) or runtime_target.get(key)
191+
if value:
192+
return _split_values(str(value))
193+
return []
194+
195+
196+
def _region_for_service(service: str) -> str:
197+
for target in _target_payloads():
198+
if service not in _target_service_names(target):
199+
continue
200+
runtime_target = _runtime_target(target)
201+
for key in ("region", "cloud_run_region", "location"):
202+
value = target.get(key) or runtime_target.get(key)
203+
if value:
204+
return str(value).strip()
205+
return (
206+
os.environ.get("RUNTIME_GUARD_CLOUD_RUN_REGION")
207+
or os.environ.get("CLOUD_RUN_REGION")
208+
or os.environ.get("CLOUD_RUN_LOCATION")
209+
or os.environ.get("GOOGLE_CLOUD_REGION")
210+
or ""
211+
).strip()
212+
213+
214+
def _latest_ready_revision_started_at(project: str, service: str) -> dt.datetime | None:
215+
region = _region_for_service(service)
216+
if not region:
217+
return None
218+
219+
service_payload = _run_gcloud_json(
220+
[
221+
"gcloud",
222+
"run",
223+
"services",
224+
"describe",
225+
service,
226+
"--project",
227+
project,
228+
"--region",
229+
region,
230+
"--format=json",
231+
],
232+
f"run services describe {service}",
233+
)
234+
if not isinstance(service_payload, dict):
235+
return None
236+
status = service_payload.get("status") or {}
237+
if not isinstance(status, dict):
238+
return None
239+
revision = str(status.get("latestReadyRevisionName") or "").strip()
240+
if not revision:
241+
return None
242+
243+
revision_payload = _run_gcloud_json(
244+
[
245+
"gcloud",
246+
"run",
247+
"revisions",
248+
"describe",
249+
revision,
250+
"--project",
251+
project,
252+
"--region",
253+
region,
254+
"--format=json",
255+
],
256+
f"run revisions describe {revision}",
257+
)
258+
if not isinstance(revision_payload, dict):
259+
return None
260+
metadata = revision_payload.get("metadata") or {}
261+
if not isinstance(metadata, dict):
262+
return None
263+
return _parse_timestamp(metadata.get("creationTimestamp"))
264+
265+
266+
def _cloud_run_log_since(project: str, service: str, fallback: dt.datetime) -> dt.datetime:
267+
try:
268+
revision_start = _latest_ready_revision_started_at(project, service)
269+
except RuntimeError as exc:
270+
print(
271+
f"Unable to resolve latest ready revision for {service}; using lookback window: {exc}",
272+
file=sys.stderr,
273+
)
274+
return fallback
275+
if revision_start and revision_start > fallback:
276+
return revision_start
277+
return fallback
278+
279+
129280
def _status(entry: dict[str, Any]) -> int | None:
130281
value = (entry.get("httpRequest") or {}).get("status")
131282
try:
@@ -267,6 +418,7 @@ def main() -> int:
267418
require_success = _env_bool("RUNTIME_GUARD_REQUIRE_SUCCESS", False)
268419
fail_workflow = _env_bool("RUNTIME_GUARD_FAIL_WORKFLOW_ON_ALERT", True)
269420
check_scheduler = _env_bool("RUNTIME_GUARD_CHECK_SCHEDULER", True)
421+
ignore_pre_ready_logs = _env_bool("RUNTIME_GUARD_IGNORE_PRE_READY_REVISION_LOGS", True)
270422

271423
since = (
272424
dt.datetime.now(dt.timezone.utc) - dt.timedelta(minutes=lookback_minutes)
@@ -288,10 +440,12 @@ def main() -> int:
288440
)
289441

290442
for service in services:
443+
service_since = _cloud_run_log_since(project, service, since) if ignore_pre_ready_logs else since
444+
service_since_text = _format_timestamp(service_since)
291445
log_filter = (
292446
'resource.type="cloud_run_revision" '
293447
f'AND resource.labels.service_name="{service}" '
294-
f'AND timestamp >= "{since_text}"'
448+
f'AND timestamp >= "{service_since_text}"'
295449
)
296450
try:
297451
entries = _run_gcloud_logging(project, log_filter, limit)

tests/test_cloud_run_runtime_guard.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from __future__ import annotations
22

3-
import subprocess
3+
import datetime as dt
4+
import json
45
import re
6+
import subprocess
57

68
from scripts import cloud_run_runtime_guard as guard
79

@@ -13,6 +15,7 @@ def test_scheduler_job_pattern_includes_service_alias():
1315
assert re.search(pattern, "longbridge-quant-hk-scheduler")
1416
assert not re.search(pattern, "longbridge-quant-sg-scheduler")
1517

18+
1619
def test_telegram_token_falls_back_to_secret_manager(monkeypatch):
1720
monkeypatch.delenv("TELEGRAM_TOKEN", raising=False)
1821
monkeypatch.delenv("TG_TOKEN", raising=False)
@@ -39,3 +42,51 @@ def fake_run_gcloud(command):
3942
"longbridgequant",
4043
]
4144

45+
46+
def test_cloud_run_log_since_uses_latest_ready_revision(monkeypatch):
47+
monkeypatch.setenv("CLOUD_RUN_REGION", "us-central1")
48+
observed = []
49+
50+
def fake_run_gcloud(command):
51+
observed.append(command)
52+
if command[1:4] == ["run", "services", "describe"]:
53+
payload = {"status": {"latestReadyRevisionName": "longbridge-quant-hk-service-00002"}}
54+
else:
55+
payload = {"metadata": {"creationTimestamp": "2026-07-01T06:50:04.123Z"}}
56+
return subprocess.CompletedProcess(command, 0, stdout=json.dumps(payload), stderr="")
57+
58+
monkeypatch.setattr(guard, "_run_gcloud", fake_run_gcloud)
59+
60+
fallback = dt.datetime(2026, 7, 1, 6, 0, tzinfo=dt.timezone.utc)
61+
result = guard._cloud_run_log_since("longbridgequant", "longbridge-quant-hk-service", fallback)
62+
63+
assert result == dt.datetime(2026, 7, 1, 6, 50, 4, 123000, tzinfo=dt.timezone.utc)
64+
assert observed[0] == [
65+
"gcloud",
66+
"run",
67+
"services",
68+
"describe",
69+
"longbridge-quant-hk-service",
70+
"--project",
71+
"longbridgequant",
72+
"--region",
73+
"us-central1",
74+
"--format=json",
75+
]
76+
assert observed[1][1:5] == ["run", "revisions", "describe", "longbridge-quant-hk-service-00002"]
77+
78+
79+
def test_region_for_service_prefers_target_region(monkeypatch):
80+
monkeypatch.setenv("CLOUD_RUN_REGION", "us-central1")
81+
monkeypatch.setenv(
82+
"CLOUD_RUN_SERVICE_TARGETS_JSON",
83+
json.dumps(
84+
{
85+
"targets": [
86+
{"service": "longbridge-quant-hk-service", "region": "asia-east1"},
87+
]
88+
}
89+
),
90+
)
91+
92+
assert guard._region_for_service("longbridge-quant-hk-service") == "asia-east1"

0 commit comments

Comments
 (0)