From 9f1da7d93418ba7f0bc9150c171efad592eb7812 Mon Sep 17 00:00:00 2001 From: sharpca Date: Wed, 21 Oct 2026 17:57:17 +0800 Subject: [PATCH] fix(security-tracker-stats): retry 5xx and default cache to temp root Why: `fetch_issues.py` ran `gh` once and turned any non-zero exit into a `SystemExit`, so a single transient `HTTP 502` aborted the whole dashboard run. Because this fetch is the first of six steps, the operator had to restart the pipeline by hand. `run_gh` now retries the failures that can clear on their own (5xx, gateway timeouts, connection resets) with exponential backoff, and still fails fast on the ones that cannot: an auth error, a bad repo slug, or an unknown flag never fixes itself. The default cache also moves off a hardcoded `/tmp`. A sandboxed run is granted its own scratch directory and cannot write `/tmp` at all, so the first fetch step failed before it ever reached the API. The default is now `/tracker-stats-cache`, which honours `$TMPDIR` on POSIX and `%TEMP%` on Windows; an explicit `TRACKER_STATS_CACHE` still wins. The default output path moves with it, for the same reason. Closes #1744. Generated-by: WorkBuddy AI (DeepSeek-V4.1-Flash) --- .../skills/tracker-stats-dashboard/SKILL.md | 3 +- .../templates/security-tracker-stats.md | 5 +- .../README.md | 4 +- .../fetch_bodies.py | 5 +- .../fetch_events.py | 5 +- .../fetch_issues.py | 140 +++++++++++----- .../fetch_prs.py | 5 +- .../fetch_roster.py | 5 +- .../render.py | 12 +- tools/security-tracker-stats-dashboard/run.sh | 6 +- .../tests/test_fetch_retry.py | 150 ++++++++++++++++++ .../fixtures/system-prompt.md | 2 +- 12 files changed, 287 insertions(+), 55 deletions(-) create mode 100644 tools/security-tracker-stats-dashboard/tests/test_fetch_retry.py diff --git a/plugins/magpie-security/skills/tracker-stats-dashboard/SKILL.md b/plugins/magpie-security/skills/tracker-stats-dashboard/SKILL.md index 1fef821d2..f3860c753 100644 --- a/plugins/magpie-security/skills/tracker-stats-dashboard/SKILL.md +++ b/plugins/magpie-security/skills/tracker-stats-dashboard/SKILL.md @@ -207,7 +207,8 @@ If the adopter passes nothing, surface the resolved output path and cache state 2. **Check cache freshness.** Inspect `/issues.json` mtime, where `` is the `tracker_stats_cache` value from the step 1 config (else - `${TRACKER_STATS_CACHE:-/tmp/tracker-stats-cache}`, the fetch scripts' default). + `${TRACKER_STATS_CACHE:-/tracker-stats-cache}`, the + fetch scripts' default — `$TMPDIR` on POSIX, `%TEMP%` on Windows). Step 3 passes the same `` so the check and the fetch agree. If older than 24 h, propose a fresh fetch; if missing or the user passed `clear-cache`, do a fresh fetch unconditionally. diff --git a/plugins/magpie-setup/templates/security-tracker-stats.md b/plugins/magpie-setup/templates/security-tracker-stats.md index 9e3984a8c..1fe997ff4 100644 --- a/plugins/magpie-setup/templates/security-tracker-stats.md +++ b/plugins/magpie-setup/templates/security-tracker-stats.md @@ -60,7 +60,10 @@ review). ## Cache directory ```yaml -tracker_stats_cache: /tmp/tracker-stats-cache +# Left unset, the fetch scripts default to +# `/tracker-stats-cache` ($TMPDIR on POSIX, %TEMP% on +# Windows) — the one location a sandboxed run can always write. +# tracker_stats_cache: /path/to/cache ``` Where the fetch scripts persist their cache. Safe to delete (forces a diff --git a/tools/security-tracker-stats-dashboard/README.md b/tools/security-tracker-stats-dashboard/README.md index 7d80dce6d..7a5a93474 100644 --- a/tools/security-tracker-stats-dashboard/README.md +++ b/tools/security-tracker-stats-dashboard/README.md @@ -76,8 +76,8 @@ Env knobs (all optional): | Var | Default | Notes | |---|---|---| | `TRACKER_STATS_REPO` | *(e.g. `airflow-s/airflow-s`)* | `` repo slug | -| `TRACKER_STATS_OUT` | `/tmp/airflow_s_monthly.html` | output HTML path | -| `TRACKER_STATS_CACHE` | `/tmp/tracker-stats-cache` | fetch cache dir | +| `TRACKER_STATS_OUT` | `$TMPDIR/airflow_s_monthly.html` | output HTML path | +| `TRACKER_STATS_CACHE` | `$TMPDIR/tracker-stats-cache` | fetch cache dir | | `TRACKER_STATS_CONFIG` | *(unset)* | path to a YAML overlay file | | `TRACKER_STATS_BUCKETS` | *(from config: `monthly`)* | `monthly` or `quarterly` | | `TRACKER_STATS_START` | *(from config: `null`)* | `YYYY-MM` or `YYYY-Qn` | diff --git a/tools/security-tracker-stats-dashboard/fetch_bodies.py b/tools/security-tracker-stats-dashboard/fetch_bodies.py index 488a0c576..c47c9c87d 100644 --- a/tools/security-tracker-stats-dashboard/fetch_bodies.py +++ b/tools/security-tracker-stats-dashboard/fetch_bodies.py @@ -30,6 +30,7 @@ import json import os import subprocess +import tempfile from concurrent.futures import ThreadPoolExecutor, as_completed EXTRA_FIELDS = ("body", "closedByPullRequestsReferences") @@ -85,7 +86,9 @@ def fetch(repo, n): def main(): - root = os.environ.get("TRACKER_STATS_CACHE", "/tmp/tracker-stats-cache") + # Default under the platform temp root, not a hardcoded `/tmp`: a sandboxed run + # is granted its own scratch directory and cannot write `/tmp`. See fetch_issues.py. + root = os.environ.get("TRACKER_STATS_CACHE") or os.path.join(tempfile.gettempdir(), "tracker-stats-cache") repo = os.environ.get("TRACKER_STATS_REPO", "airflow-s/airflow-s") out = f"{root}/issue_extra.json" diff --git a/tools/security-tracker-stats-dashboard/fetch_events.py b/tools/security-tracker-stats-dashboard/fetch_events.py index 9bd9f673d..ab8aef1e3 100644 --- a/tools/security-tracker-stats-dashboard/fetch_events.py +++ b/tools/security-tracker-stats-dashboard/fetch_events.py @@ -24,8 +24,11 @@ import json import os import subprocess +import tempfile -ROOT = os.environ.get("TRACKER_STATS_CACHE", "/tmp/tracker-stats-cache") +# Default under the platform temp root, not a hardcoded `/tmp`: a sandboxed run is +# granted its own scratch directory and cannot write `/tmp`. See fetch_issues.py. +ROOT = os.environ.get("TRACKER_STATS_CACHE") or os.path.join(tempfile.gettempdir(), "tracker-stats-cache") REPO = os.environ.get("TRACKER_STATS_REPO", "airflow-s/airflow-s") EVENTS_DIR = f"{ROOT}/events" diff --git a/tools/security-tracker-stats-dashboard/fetch_issues.py b/tools/security-tracker-stats-dashboard/fetch_issues.py index b7496d7ae..c0c94d370 100644 --- a/tools/security-tracker-stats-dashboard/fetch_issues.py +++ b/tools/security-tracker-stats-dashboard/fetch_issues.py @@ -21,49 +21,109 @@ import json import os +import re import subprocess +import sys +import tempfile +import time -ROOT = os.environ.get("TRACKER_STATS_CACHE", "/tmp/tracker-stats-cache") +# The cache defaults under the platform temp root rather than a hardcoded `/tmp`: +# a sandboxed run is typically granted its own scratch directory and cannot write +# `/tmp`, so `/tmp/tracker-stats-cache` made the first fetch step fail outright. +# `tempfile.gettempdir()` honours `$TMPDIR` (POSIX) and `%TEMP%` (Windows). +ROOT = os.environ.get("TRACKER_STATS_CACHE") or os.path.join(tempfile.gettempdir(), "tracker-stats-cache") REPO = os.environ.get("TRACKER_STATS_REPO", "airflow-s/airflow-s") -os.makedirs(ROOT, exist_ok=True) - -print(f"Fetching issue list from {REPO} (state=all, limit 1000) ...") -r = subprocess.run( - [ - "gh", - "issue", - "list", - "--repo", - REPO, - "--state", - "all", - "--limit", - "1000", - "--json", - # `updatedAt` is consumed by fetch_events.py, which refuses to trust a cached - # events file older than the issue's last update. Dropping it here silently - # disables that guard, because the check falls back to "no timestamp -> trust - # the cache" and every relabelled issue keeps its stale event history. - # `body` and `closedByPullRequestsReferences` ride on this one paginated call - # so fetch_bodies.py can build issue_extra.json without a `gh issue view` - # per issue. - "number,title,state,stateReason,createdAt,closedAt,updatedAt,labels,comments,body,closedByPullRequestsReferences", - ], - capture_output=True, - text=True, - timeout=300, +# `gh` reports a server-side hiccup as a non-zero exit with the status in stderr. +# A single transient `HTTP 502` on the first call used to abort the entire +# dashboard run, so retry the failures that are worth retrying and fail fast on +# everything else (auth errors, a bad repo slug, an unknown flag). +_TRANSIENT_FAILURE = re.compile( + r"HTTP\s+5\d\d" + r"|Bad Gateway" + r"|Service Unavailable" + r"|Gateway Time-?out" + r"|Internal Server Error" + r"|connection reset" + r"|timed out", + re.IGNORECASE, ) -if r.returncode != 0: - raise SystemExit(f"gh failed: {r.stderr}") - -issues = json.loads(r.stdout) -with open(f"{ROOT}/issues.json", "w") as f: - json.dump(issues, f) - -print(f"Wrote {len(issues)} issues to {ROOT}/issues.json") -if len(issues) >= 1000: - print( - "WARNING: the issue list hit the 1000-issue cap; older issues are missing " - "and every count in the dashboard is a floor. Raise --limit in fetch_issues.py." + +ATTEMPTS = 3 +BASE_DELAY_SECONDS = 2.0 + + +def is_transient_failure(stderr: str) -> bool: + """True when a failed `gh` call looks like a retryable server-side error.""" + return bool(_TRANSIENT_FAILURE.search(stderr or "")) + + +def run_gh( + args: list[str], + *, + attempts: int = ATTEMPTS, + base_delay: float = BASE_DELAY_SECONDS, + timeout: int = 300, +) -> subprocess.CompletedProcess: + """Run `gh`, retrying transient failures with exponential backoff. + + Raises `SystemExit` with the last stderr once the attempts are exhausted, or + immediately for a failure that retrying cannot fix. + """ + for attempt in range(1, attempts + 1): + r = subprocess.run(args, capture_output=True, text=True, timeout=timeout) + if r.returncode == 0: + return r + if attempt == attempts or not is_transient_failure(r.stderr): + raise SystemExit(f"gh failed: {r.stderr}") + delay = base_delay * 2 ** (attempt - 1) + print( + f"gh failed (attempt {attempt}/{attempts}), retrying in {delay:g}s: " + f"{(r.stderr or '').strip()[:200]}", + file=sys.stderr, + ) + time.sleep(delay) + raise AssertionError("unreachable: run_gh returns or raises on every attempt") + + +def main() -> None: + os.makedirs(ROOT, exist_ok=True) + + print(f"Fetching issue list from {REPO} (state=all, limit 1000) ...") + r = run_gh( + [ + "gh", + "issue", + "list", + "--repo", + REPO, + "--state", + "all", + "--limit", + "1000", + "--json", + # `updatedAt` is consumed by fetch_events.py, which refuses to trust a cached + # events file older than the issue's last update. Dropping it here silently + # disables that guard, because the check falls back to "no timestamp -> trust + # the cache" and every relabelled issue keeps its stale event history. + # `body` and `closedByPullRequestsReferences` ride on this one paginated call + # so fetch_bodies.py can build issue_extra.json without a `gh issue view` + # per issue. + "number,title,state,stateReason,createdAt,closedAt,updatedAt,labels,comments,body,closedByPullRequestsReferences", + ] ) + + issues = json.loads(r.stdout) + with open(f"{ROOT}/issues.json", "w") as f: + json.dump(issues, f) + + print(f"Wrote {len(issues)} issues to {ROOT}/issues.json") + if len(issues) >= 1000: + print( + "WARNING: the issue list hit the 1000-issue cap; older issues are missing " + "and every count in the dashboard is a floor. Raise --limit in fetch_issues.py." + ) + + +if __name__ == "__main__": + main() diff --git a/tools/security-tracker-stats-dashboard/fetch_prs.py b/tools/security-tracker-stats-dashboard/fetch_prs.py index a371cb874..d8f6e0f35 100644 --- a/tools/security-tracker-stats-dashboard/fetch_prs.py +++ b/tools/security-tracker-stats-dashboard/fetch_prs.py @@ -28,9 +28,12 @@ import os import re import subprocess +import tempfile from concurrent.futures import ThreadPoolExecutor, as_completed -ROOT = os.environ.get("TRACKER_STATS_CACHE", "/tmp/tracker-stats-cache") +# Default under the platform temp root, not a hardcoded `/tmp`: a sandboxed run is +# granted its own scratch directory and cannot write `/tmp`. See fetch_issues.py. +ROOT = os.environ.get("TRACKER_STATS_CACHE") or os.path.join(tempfile.gettempdir(), "tracker-stats-cache") UPSTREAM = os.environ.get("TRACKER_STATS_UPSTREAM_REPO", "apache/airflow") if UPSTREAM in ("", "none", "null"): print("TRACKER_STATS_UPSTREAM_REPO is empty/none - skipping PR fetch.") diff --git a/tools/security-tracker-stats-dashboard/fetch_roster.py b/tools/security-tracker-stats-dashboard/fetch_roster.py index 7375cc609..fecf17a91 100644 --- a/tools/security-tracker-stats-dashboard/fetch_roster.py +++ b/tools/security-tracker-stats-dashboard/fetch_roster.py @@ -21,8 +21,11 @@ import os import subprocess +import tempfile -ROOT = os.environ.get("TRACKER_STATS_CACHE", "/tmp/tracker-stats-cache") +# Default under the platform temp root, not a hardcoded `/tmp`: a sandboxed run is +# granted its own scratch directory and cannot write `/tmp`. See fetch_issues.py. +ROOT = os.environ.get("TRACKER_STATS_CACHE") or os.path.join(tempfile.gettempdir(), "tracker-stats-cache") REPO = os.environ.get("TRACKER_STATS_REPO", "airflow-s/airflow-s") os.makedirs(ROOT, exist_ok=True) diff --git a/tools/security-tracker-stats-dashboard/render.py b/tools/security-tracker-stats-dashboard/render.py index 574375949..38d8d81fe 100644 --- a/tools/security-tracker-stats-dashboard/render.py +++ b/tools/security-tracker-stats-dashboard/render.py @@ -19,7 +19,7 @@ """ Regenerate a tracker-stats dashboard. Reads cached issues+events+PR data -from `$TRACKER_STATS_CACHE` (default `/tmp/tracker-stats-cache`) and writes +from `$TRACKER_STATS_CACHE` (default `/tracker-stats-cache`) and writes a self-contained HTML page to `$TRACKER_STATS_OUT`. Configuration is loaded from `scripts/default-config.yaml`, optionally @@ -52,6 +52,7 @@ import re import statistics import sys +import tempfile from collections import defaultdict # Make the src/ package importable when render.py is run as a standalone script. @@ -104,8 +105,13 @@ def yaml_load(text): # --- Config loading ------------------------------------------------- -ROOT = os.environ.get("TRACKER_STATS_CACHE", "/tmp/tracker-stats-cache") -OUT_PATH = os.environ.get("TRACKER_STATS_OUT", "/tmp/airflow_s_monthly.html") +# Both defaults sit under the platform temp root, not a hardcoded `/tmp`: a +# sandboxed run is granted its own scratch directory and can write neither. +# See fetch_issues.py. +ROOT = os.environ.get("TRACKER_STATS_CACHE") or os.path.join(tempfile.gettempdir(), "tracker-stats-cache") +OUT_PATH = os.environ.get("TRACKER_STATS_OUT") or os.path.join( + tempfile.gettempdir(), "airflow_s_monthly.html" +) HERE = os.path.dirname(os.path.abspath(__file__)) DEFAULT_CONFIG_PATH = os.path.join(HERE, "default-config.yaml") diff --git a/tools/security-tracker-stats-dashboard/run.sh b/tools/security-tracker-stats-dashboard/run.sh index 9e7e01a8d..261345301 100755 --- a/tools/security-tracker-stats-dashboard/run.sh +++ b/tools/security-tracker-stats-dashboard/run.sh @@ -4,8 +4,8 @@ # Usage: run.sh [output-path] # # Env overrides: -# TRACKER_STATS_CACHE (default: /tmp/tracker-stats-cache) -# TRACKER_STATS_OUT (default: /tmp/airflow_s_monthly.html - or arg $1) +# TRACKER_STATS_CACHE (default: $TMPDIR/tracker-stats-cache) +# TRACKER_STATS_OUT (default: $TMPDIR/airflow_s_monthly.html - or arg $1) # TRACKER_STATS_REPO tracker repo (default: airflow-s/airflow-s) # TRACKER_STATS_BUCKETS monthly | quarterly (overlay) # TRACKER_STATS_START "YYYY-MM" / "YYYY-Qn" (overlay) @@ -55,4 +55,4 @@ echo "-> fetch_prs" echo "-> render" "${PY_CMD[@]}" "$HERE/render.py" -echo "done: ${TRACKER_STATS_OUT:-/tmp/airflow_s_monthly.html}" +echo "done: ${TRACKER_STATS_OUT:-${TMPDIR:-/tmp}/airflow_s_monthly.html}" diff --git a/tools/security-tracker-stats-dashboard/tests/test_fetch_retry.py b/tools/security-tracker-stats-dashboard/tests/test_fetch_retry.py new file mode 100644 index 000000000..8e854247b --- /dev/null +++ b/tools/security-tracker-stats-dashboard/tests/test_fetch_retry.py @@ -0,0 +1,150 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +"""Guards the retry behaviour and the cache default of the issue-list fetch. + +`fetch_issues.py` used to run `gh` exactly once and turn any non-zero exit into a +`SystemExit`. A single transient `HTTP 502` from the API therefore aborted the +entire dashboard run, and because this fetch is the first of six steps the +operator had to restart the pipeline by hand. `run_gh` now retries the failures +that can clear on their own, while still failing fast on the ones that cannot: +an auth error or a bad repo slug never fixes itself, and retrying it only delays +the error the operator needs to see. + +The default cache location is guarded here too. It sits under the platform temp +root rather than a hardcoded `/tmp`, because a sandboxed run is granted its own +scratch directory and cannot write `/tmp` at all. +""" + +from __future__ import annotations + +import importlib.util +import pathlib +import tempfile +import types + +import pytest + +TOOL_DIR = pathlib.Path(__file__).resolve().parents[1] + + +def _load_fetch_issues(): + """Load fetch_issues.py as a module; importing it has no side effects.""" + spec = importlib.util.spec_from_file_location("fetch_issues", TOOL_DIR / "fetch_issues.py") + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _result(returncode: int, stderr: str = "", stdout: str = "") -> types.SimpleNamespace: + """Stand-in for `subprocess.CompletedProcess`, which is all `run_gh` reads.""" + return types.SimpleNamespace(returncode=returncode, stderr=stderr, stdout=stdout) + + +def _patch_gh(monkeypatch, module, responses: list[types.SimpleNamespace]): + """Replace `gh` and `sleep`, returning the recorded calls and delays.""" + calls: list[list[str]] = [] + sleeps: list[float] = [] + + def fake_run(args, **kwargs): + calls.append(list(args)) + return responses.pop(0) + + def fake_sleep(seconds): + sleeps.append(seconds) + + monkeypatch.setattr(module.subprocess, "run", fake_run) + monkeypatch.setattr(module.time, "sleep", fake_sleep) + return calls, sleeps + + +def test_transient_failures_are_recognised(): + """The statuses `gh` surfaces for a server-side hiccup are all retryable.""" + fi = _load_fetch_issues() + assert fi.is_transient_failure("HTTP 502: Bad Gateway (https://api.github.com/graphql)") + assert fi.is_transient_failure("HTTP 503: Service Unavailable") + assert fi.is_transient_failure("HTTP 504: Gateway Time-out") + assert fi.is_transient_failure("error: request timed out") + + +def test_non_transient_failures_are_not_retried(): + """Auth errors, a bad repo slug, and a missing CLI never clear on their own.""" + fi = _load_fetch_issues() + assert not fi.is_transient_failure("gh: Not Found (HTTP 404)") + assert not fi.is_transient_failure("gh: Bad credentials (HTTP 401)") + assert not fi.is_transient_failure("unknown flag: --state") + assert not fi.is_transient_failure("") + + +def test_retries_transient_failure_then_succeeds(monkeypatch): + """One 502 no longer aborts the run, and the backoff doubles between attempts.""" + fi = _load_fetch_issues() + calls, sleeps = _patch_gh( + monkeypatch, + fi, + [ + _result(1, stderr="HTTP 502: Bad Gateway"), + _result(1, stderr="HTTP 502: Bad Gateway"), + _result(0, stdout="[]"), + ], + ) + + assert fi.run_gh(["gh", "issue", "list"]).returncode == 0 + assert len(calls) == 3 + assert sleeps == [2.0, 4.0] + + +def test_gives_up_after_the_attempt_limit(monkeypatch): + """A sustained outage still fails, so the operator learns the fetch is dead.""" + fi = _load_fetch_issues() + calls, sleeps = _patch_gh( + monkeypatch, + fi, + [_result(1, stderr="HTTP 503: Service Unavailable") for _ in range(fi.ATTEMPTS)], + ) + + with pytest.raises(SystemExit) as exc: + fi.run_gh(["gh", "issue", "list"]) + assert "503" in str(exc.value) + assert len(calls) == fi.ATTEMPTS + assert len(sleeps) == fi.ATTEMPTS - 1 + + +def test_non_transient_failure_raises_on_the_first_attempt(monkeypatch): + """No backoff is spent on an error that a retry cannot change.""" + fi = _load_fetch_issues() + calls, sleeps = _patch_gh(monkeypatch, fi, [_result(1, stderr="gh: Bad credentials (HTTP 401)")]) + + with pytest.raises(SystemExit): + fi.run_gh(["gh", "issue", "list"]) + assert len(calls) == 1 + assert sleeps == [] + + +def test_default_cache_root_is_under_the_temp_root(monkeypatch): + """The unset default has to be a directory a sandboxed run can actually write.""" + monkeypatch.delenv("TRACKER_STATS_CACHE", raising=False) + fi = _load_fetch_issues() + assert pathlib.Path(fi.ROOT) == pathlib.Path(tempfile.gettempdir()) / "tracker-stats-cache" + + +def test_cache_root_honours_the_env_override(monkeypatch, tmp_path): + """An explicit TRACKER_STATS_CACHE still wins, so adopters keep control.""" + monkeypatch.setenv("TRACKER_STATS_CACHE", str(tmp_path)) + fi = _load_fetch_issues() + assert pathlib.Path(fi.ROOT) == tmp_path diff --git a/tools/skill-evals/evals/security-tracker-stats-dashboard/step-2-cache-freshness/fixtures/system-prompt.md b/tools/skill-evals/evals/security-tracker-stats-dashboard/step-2-cache-freshness/fixtures/system-prompt.md index 66ad998a5..4a0246688 100644 --- a/tools/skill-evals/evals/security-tracker-stats-dashboard/step-2-cache-freshness/fixtures/system-prompt.md +++ b/tools/skill-evals/evals/security-tracker-stats-dashboard/step-2-cache-freshness/fixtures/system-prompt.md @@ -6,7 +6,7 @@ security-tracker-stats-dashboard skill from the Apache Magpie framework. ## Cache freshness rules -Cache directory: `${TRACKER_STATS_CACHE:-/tmp/tracker-stats-cache}/` +Cache directory: `${TRACKER_STATS_CACHE:-/tracker-stats-cache}/` Key cache file: `issues.json` Evaluate the following decision rules in priority order: