mirror of
https://github.com/openai/skills.git
synced 2026-09-11 19:47:26 +03:00
Switch Sentry skill from bundled Python script to Sentry CLI (#336)
## Summary - Replace the custom `sentry_api.py` script with native Sentry CLI commands, which handle authentication, org/project auto-detection, pagination, and retries out of the box. - Update `SKILL.md` with CLI-based workflows covering issue inspection, trace exploration, log streaming, and a generic API fallback for uncovered endpoints. - Update the agent description in `openai.yaml` to reflect the CLI-based approach. ## Motivation The bundled Python script was a thin wrapper around Sentry's REST API that required manual auth token setup and explicit org/project configuration. The Sentry CLI (`sentry-cli`) handles all of this natively — including interactive auth, DSN-based project detection, and structured JSON output — making the script redundant and the skill simpler to use.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: "sentry"
|
name: "sentry"
|
||||||
description: "Use when the user asks to inspect Sentry issues or events, summarize recent production errors, or pull basic Sentry health data via the Sentry API; perform read-only queries with the bundled script and require `SENTRY_AUTH_TOKEN`."
|
description: "Use when the user asks to inspect Sentry issues or events, summarize recent production errors, or pull basic Sentry health data via the Sentry CLI; perform read-only queries using the `sentry` command."
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
@@ -8,102 +8,99 @@ description: "Use when the user asks to inspect Sentry issues or events, summari
|
|||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
- If not already authenticated, ask the user to provide a valid `SENTRY_AUTH_TOKEN` (read-only scopes such as `project:read`, `event:read`) or to log in and create one before running commands.
|
- If not already authenticated, ask the user to run `sentry auth login` or set `SENTRY_AUTH_TOKEN` as an env var.
|
||||||
- Set `SENTRY_AUTH_TOKEN` as an env var.
|
- The CLI auto-detects org/project from DSNs in `.env` files, source code, config defaults, and directory names. Only specify `<org>/<project>` if auto-detection fails or picks the wrong target.
|
||||||
- Optional defaults: `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_BASE_URL`.
|
- Defaults: time range `24h`, environment `production`, limit 20.
|
||||||
- Defaults: org/project `{your-org}`/`{your-project}`, time range `24h`, environment `prod`, limit 20 (max 50).
|
- Always use `--json` when processing output programmatically. Use `--json --fields` to select specific fields and reduce output size.
|
||||||
- Always call the Sentry API (no heuristics, no caching).
|
- Use `sentry schema <resource>` to discover API endpoints quickly.
|
||||||
|
|
||||||
If the token is missing, give the user these steps:
|
If the CLI is not installed, give the user these steps:
|
||||||
1. Create a Sentry auth token: https://sentry.io/settings/account/api/auth-tokens/
|
1. Install the Sentry CLI: `curl https://cli.sentry.dev/install -fsS | bash`
|
||||||
2. Create a token with read-only scopes such as `project:read`, `event:read`, and `org:read`.
|
2. Authenticate: `sentry auth login`
|
||||||
3. Set `SENTRY_AUTH_TOKEN` as an environment variable in their system.
|
3. Confirm authentication: `sentry auth status`
|
||||||
4. Offer to guide them through setting the environment variable for their OS/shell if needed.
|
|
||||||
- Never ask the user to paste the full token in chat. Ask them to set it locally and confirm when ready.
|
- Never ask the user to paste the full token in chat. Ask them to set it locally and confirm when ready.
|
||||||
|
|
||||||
## Core tasks (use bundled script)
|
## Core tasks (use Sentry CLI)
|
||||||
|
|
||||||
Use `scripts/sentry_api.py` for deterministic API calls. It handles pagination and retries once on transient errors.
|
Use the `sentry` CLI for all queries. It handles authentication, org/project detection, pagination, and retries automatically. Use `--json` for machine-readable output.
|
||||||
|
|
||||||
## Skill path (set once)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
|
|
||||||
export SENTRY_API="$CODEX_HOME/skills/sentry/scripts/sentry_api.py"
|
|
||||||
```
|
|
||||||
|
|
||||||
User-scoped skills install under `$CODEX_HOME/skills` (default: `~/.codex/skills`).
|
|
||||||
|
|
||||||
### 1) List issues (ordered by most recent)
|
### 1) List issues (ordered by most recent)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 "$SENTRY_API" \
|
sentry issue list \
|
||||||
list-issues \
|
--query "is:unresolved environment:production" \
|
||||||
--org {your-org} \
|
--period 24h \
|
||||||
--project {your-project} \
|
|
||||||
--environment prod \
|
|
||||||
--time-range 24h \
|
|
||||||
--limit 20 \
|
--limit 20 \
|
||||||
--query "is:unresolved"
|
--json --fields shortId,title,priority,level,status
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2) Resolve an issue short ID to issue ID
|
If auto-detection doesn't resolve org/project, pass them explicitly:
|
||||||
|
```bash
|
||||||
|
sentry issue list {your-org}/{your-project} \
|
||||||
|
--query "is:unresolved environment:production" \
|
||||||
|
--period 24h \
|
||||||
|
--limit 20 \
|
||||||
|
--json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2) Resolve an issue short ID to issue detail
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 "$SENTRY_API" \
|
sentry issue view {ABC-123} --json
|
||||||
list-issues \
|
|
||||||
--org {your-org} \
|
|
||||||
--project {your-project} \
|
|
||||||
--query "ABC-123" \
|
|
||||||
--limit 1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Use the returned `id` for issue detail or events.
|
Use the short ID format (e.g., `ABC-123`), not the numeric ID.
|
||||||
|
|
||||||
### 3) Issue detail
|
### 3) Issue detail
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 "$SENTRY_API" \
|
sentry issue view {ABC-123}
|
||||||
issue-detail \
|
|
||||||
1234567890
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4) Issue events
|
### 4) Issue events
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 "$SENTRY_API" \
|
sentry issue events {ABC-123} --limit 20 --json
|
||||||
issue-events \
|
|
||||||
1234567890 \
|
|
||||||
--limit 20
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5) Event detail (no stack traces by default)
|
### 5) Event detail
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 "$SENTRY_API" \
|
sentry event view {your-org}/{your-project}/{event_id} --json
|
||||||
event-detail \
|
|
||||||
--org {your-org} \
|
|
||||||
--project {your-project} \
|
|
||||||
abcdef1234567890
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## API requirements
|
### 6) AI-powered root cause analysis
|
||||||
|
|
||||||
Always use these endpoints (GET only):
|
```bash
|
||||||
|
sentry issue explain {ABC-123}
|
||||||
|
```
|
||||||
|
|
||||||
- List issues: `/api/0/projects/{org_slug}/{project_slug}/issues/`
|
### 7) AI-powered fix plan
|
||||||
- Issue detail: `/api/0/issues/{issue_id}/`
|
|
||||||
- Events for issue: `/api/0/issues/{issue_id}/events/`
|
```bash
|
||||||
- Event detail: `/api/0/projects/{org_slug}/{project_slug}/events/{event_id}/`
|
sentry issue plan {ABC-123}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fallback: arbitrary API access
|
||||||
|
|
||||||
|
For endpoints not covered by dedicated CLI commands, use `sentry api`:
|
||||||
|
```bash
|
||||||
|
sentry api /api/0/organizations/{your-org}/ --method GET
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `sentry schema` to discover available API endpoints:
|
||||||
|
```bash
|
||||||
|
sentry schema issues
|
||||||
|
```
|
||||||
|
|
||||||
## Inputs and defaults
|
## Inputs and defaults
|
||||||
|
|
||||||
- `org_slug`, `project_slug`: default to `{your-org}`/`{your-project}` (avoid non-prod orgs).
|
- `org_slug`, `project_slug`: auto-detected by the CLI from DSNs, env vars, and directory names. Override with positional `{your-org}/{your-project}` if auto-detection fails.
|
||||||
- `time_range`: default `24h` (pass as `statsPeriod`).
|
- `time_range`: default `24h` (pass as `--period 24h`).
|
||||||
- `environment`: default `prod`.
|
- `environment`: default `prod` (pass as part of `--query`, e.g., `environment:production`).
|
||||||
- `limit`: default 20, max 50 (paginate until limit reached).
|
- `limit`: default 20 (pass as `--limit`).
|
||||||
- `search_query`: optional `query` parameter.
|
- `search_query`: optional `--query` parameter, uses Sentry search syntax (e.g., `is:unresolved`, `assigned:me`).
|
||||||
- `issue_short_id`: resolve via list-issues query first.
|
- `issue_short_id`: use directly with `sentry issue view`.
|
||||||
|
|
||||||
## Output formatting rules
|
## Output formatting rules
|
||||||
|
|
||||||
@@ -119,5 +116,5 @@ Always use these endpoints (GET only):
|
|||||||
- Project: `{your-project}`
|
- Project: `{your-project}`
|
||||||
- Issue short ID: `{ABC-123}`
|
- Issue short ID: `{ABC-123}`
|
||||||
|
|
||||||
Example prompt: “List the top 10 open issues for prod in the last 24h.”
|
Example prompt: "List the top 10 open issues for prod in the last 24h."
|
||||||
Expected: ordered list with titles, short IDs, counts, last seen.
|
Expected: ordered list with titles, short IDs, counts, last seen.
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from urllib.error import HTTPError, URLError
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
from urllib.request import Request, urlopen
|
|
||||||
|
|
||||||
DEFAULT_BASE_URL = "https://sentry.io"
|
|
||||||
DEFAULT_ORG = "your-org"
|
|
||||||
DEFAULT_PROJECT = "your-project"
|
|
||||||
MAX_LIMIT = 50
|
|
||||||
|
|
||||||
EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
|
||||||
IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
|
|
||||||
|
|
||||||
|
|
||||||
def redact_string(value):
|
|
||||||
value = EMAIL_RE.sub("[REDACTED_EMAIL]", value)
|
|
||||||
value = IP_RE.sub("[REDACTED_IP]", value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def redact_data(value):
|
|
||||||
if isinstance(value, str):
|
|
||||||
return redact_string(value)
|
|
||||||
if isinstance(value, list):
|
|
||||||
return [redact_data(item) for item in value]
|
|
||||||
if isinstance(value, dict):
|
|
||||||
redacted = {}
|
|
||||||
for key, item in value.items():
|
|
||||||
if key.lower() in {"email", "ip", "ip_address"}:
|
|
||||||
redacted[key] = "[REDACTED]"
|
|
||||||
else:
|
|
||||||
redacted[key] = redact_data(item)
|
|
||||||
return redacted
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def next_cursor(link_header):
|
|
||||||
if not link_header:
|
|
||||||
return None
|
|
||||||
for part in link_header.split(","):
|
|
||||||
if 'rel="next"' in part and 'results="true"' in part:
|
|
||||||
match = re.search(r'cursor="([^"]+)"', part)
|
|
||||||
if match:
|
|
||||||
return match.group(1)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def request_json(url, token, retries=1):
|
|
||||||
req = Request(url)
|
|
||||||
req.add_header("Authorization", f"Bearer {token}")
|
|
||||||
req.add_header("Accept", "application/json")
|
|
||||||
|
|
||||||
attempt = 0
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
with urlopen(req) as resp:
|
|
||||||
body = resp.read().decode("utf-8")
|
|
||||||
data = json.loads(body) if body else None
|
|
||||||
return data, resp.headers
|
|
||||||
except HTTPError as err:
|
|
||||||
body = err.read().decode("utf-8", "ignore")
|
|
||||||
if attempt < retries and (err.code >= 500 or err.code == 429):
|
|
||||||
attempt += 1
|
|
||||||
time.sleep(1)
|
|
||||||
continue
|
|
||||||
raise RuntimeError(f"HTTP {err.code} for {url}: {body or 'request failed'}") from err
|
|
||||||
except URLError as err:
|
|
||||||
if attempt < retries:
|
|
||||||
attempt += 1
|
|
||||||
time.sleep(1)
|
|
||||||
continue
|
|
||||||
raise RuntimeError(f"Network error for {url}: {err.reason}") from err
|
|
||||||
|
|
||||||
|
|
||||||
def build_url(base_url, path, params=None):
|
|
||||||
base = base_url.rstrip("/")
|
|
||||||
url = f"{base}{path}"
|
|
||||||
if params:
|
|
||||||
url = f"{url}?{urlencode(params, doseq=True)}"
|
|
||||||
return url
|
|
||||||
|
|
||||||
|
|
||||||
def paged_get(base_url, path, params, token, limit):
|
|
||||||
results = []
|
|
||||||
cursor = None
|
|
||||||
while len(results) < limit:
|
|
||||||
page_params = dict(params)
|
|
||||||
page_params["per_page"] = min(MAX_LIMIT, limit - len(results))
|
|
||||||
if cursor:
|
|
||||||
page_params["cursor"] = cursor
|
|
||||||
url = build_url(base_url, path, page_params)
|
|
||||||
data, headers = request_json(url, token)
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
results.extend(data)
|
|
||||||
cursor = next_cursor(headers.get("Link"))
|
|
||||||
if not cursor:
|
|
||||||
break
|
|
||||||
return results[:limit]
|
|
||||||
|
|
||||||
|
|
||||||
def require_org_project(org, project):
|
|
||||||
if org == DEFAULT_ORG or project == DEFAULT_PROJECT:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Missing org/project. Set SENTRY_ORG and SENTRY_PROJECT or pass --org/--project."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_list_issues(args, token, base_url):
|
|
||||||
require_org_project(args.org, args.project)
|
|
||||||
limit = min(args.limit, MAX_LIMIT)
|
|
||||||
params = {
|
|
||||||
"statsPeriod": args.time_range,
|
|
||||||
"environment": args.environment,
|
|
||||||
}
|
|
||||||
if args.query:
|
|
||||||
params["query"] = args.query
|
|
||||||
|
|
||||||
path = f"/api/0/projects/{args.org}/{args.project}/issues/"
|
|
||||||
issues = paged_get(base_url, path, params, token, limit)
|
|
||||||
return issues
|
|
||||||
|
|
||||||
|
|
||||||
def handle_issue_detail(args, token, base_url):
|
|
||||||
path = f"/api/0/issues/{args.issue_id}/"
|
|
||||||
url = build_url(base_url, path)
|
|
||||||
data, _ = request_json(url, token)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def handle_issue_events(args, token, base_url):
|
|
||||||
limit = min(args.limit, MAX_LIMIT)
|
|
||||||
path = f"/api/0/issues/{args.issue_id}/events/"
|
|
||||||
events = paged_get(base_url, path, {}, token, limit)
|
|
||||||
return events
|
|
||||||
|
|
||||||
|
|
||||||
def handle_event_detail(args, token, base_url):
|
|
||||||
require_org_project(args.org, args.project)
|
|
||||||
path = f"/api/0/projects/{args.org}/{args.project}/events/{args.event_id}/"
|
|
||||||
url = build_url(base_url, path)
|
|
||||||
data, _ = request_json(url, token)
|
|
||||||
if data and not args.include_entries:
|
|
||||||
data = dict(data)
|
|
||||||
data.pop("entries", None)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def build_parser():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Read-only Sentry API helper for issues and events"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--base-url",
|
|
||||||
default=os.environ.get("SENTRY_BASE_URL", DEFAULT_BASE_URL),
|
|
||||||
help="Sentry base URL (default: https://sentry.io)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--org",
|
|
||||||
default=os.environ.get("SENTRY_ORG", DEFAULT_ORG),
|
|
||||||
help="Sentry org slug",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--project",
|
|
||||||
default=os.environ.get("SENTRY_PROJECT", DEFAULT_PROJECT),
|
|
||||||
help="Sentry project slug",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-redact",
|
|
||||||
action="store_true",
|
|
||||||
help="Do not redact PII in output",
|
|
||||||
)
|
|
||||||
|
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
list_issues = subparsers.add_parser("list-issues", help="List issues")
|
|
||||||
list_issues.add_argument("--time-range", default="24h")
|
|
||||||
list_issues.add_argument("--environment", default="prod")
|
|
||||||
list_issues.add_argument("--query", default="")
|
|
||||||
list_issues.add_argument("--limit", type=int, default=20)
|
|
||||||
|
|
||||||
issue_detail = subparsers.add_parser("issue-detail", help="Issue detail")
|
|
||||||
issue_detail.add_argument("issue_id")
|
|
||||||
|
|
||||||
issue_events = subparsers.add_parser("issue-events", help="Issue events")
|
|
||||||
issue_events.add_argument("issue_id")
|
|
||||||
issue_events.add_argument("--limit", type=int, default=20)
|
|
||||||
|
|
||||||
event_detail = subparsers.add_parser("event-detail", help="Event detail")
|
|
||||||
event_detail.add_argument("event_id")
|
|
||||||
event_detail.add_argument(
|
|
||||||
"--include-entries",
|
|
||||||
action="store_true",
|
|
||||||
help="Include event entries (may contain stack traces)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return parser
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = build_parser()
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
token = os.environ.get("SENTRY_AUTH_TOKEN")
|
|
||||||
if not token:
|
|
||||||
raise RuntimeError("Missing SENTRY_AUTH_TOKEN env var.")
|
|
||||||
|
|
||||||
base_url = args.base_url
|
|
||||||
|
|
||||||
if args.command == "list-issues":
|
|
||||||
data = handle_list_issues(args, token, base_url)
|
|
||||||
elif args.command == "issue-detail":
|
|
||||||
data = handle_issue_detail(args, token, base_url)
|
|
||||||
elif args.command == "issue-events":
|
|
||||||
data = handle_issue_events(args, token, base_url)
|
|
||||||
elif args.command == "event-detail":
|
|
||||||
data = handle_event_detail(args, token, base_url)
|
|
||||||
else:
|
|
||||||
raise RuntimeError(f"Unknown command: {args.command}")
|
|
||||||
|
|
||||||
if not args.no_redact:
|
|
||||||
data = redact_data(data)
|
|
||||||
|
|
||||||
print(json.dumps(data, indent=2, sort_keys=True))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
main()
|
|
||||||
except RuntimeError as exc:
|
|
||||||
print(f"Error: {exc}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
Reference in New Issue
Block a user