lgtm-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lgtm_cli-0.1.0/PKG-INFO +156 -0
- lgtm_cli-0.1.0/README.md +144 -0
- lgtm_cli-0.1.0/pyproject.toml +21 -0
- lgtm_cli-0.1.0/src/lgtm_cli/__init__.py +13 -0
- lgtm_cli-0.1.0/src/lgtm_cli/cli.py +668 -0
- lgtm_cli-0.1.0/src/lgtm_cli/client.py +239 -0
- lgtm_cli-0.1.0/src/lgtm_cli/config.py +136 -0
- lgtm_cli-0.1.0/src/lgtm_cli/py.typed +0 -0
lgtm_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: lgtm-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight CLI for querying Loki, Prometheus/Mimir, and Tempo
|
|
5
|
+
Author: Aiman Ismail
|
|
6
|
+
Author-email: Aiman Ismail <aiman@primeintellect.ai>
|
|
7
|
+
Requires-Dist: click>=8.3.1
|
|
8
|
+
Requires-Dist: httpx>=0.28.1
|
|
9
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# LGTM CLI
|
|
14
|
+
|
|
15
|
+
Lightweight CLI for querying Loki, Prometheus/Mimir, and Tempo.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
**Requires Python 3.12+**
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Install globally
|
|
23
|
+
uv tool install git+https://github.com/pokgak/lgtm-cli
|
|
24
|
+
|
|
25
|
+
# Or run directly without installing
|
|
26
|
+
uvx --from git+https://github.com/pokgak/lgtm-cli lgtm --help
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# List configured instances
|
|
33
|
+
lgtm instances
|
|
34
|
+
|
|
35
|
+
# Query Loki logs (defaults: last 15 min, limit 50)
|
|
36
|
+
lgtm loki query '{app="myapp"} |= "error"'
|
|
37
|
+
|
|
38
|
+
# Query Prometheus metrics
|
|
39
|
+
lgtm prom query 'rate(http_requests_total[5m])'
|
|
40
|
+
|
|
41
|
+
# Search Tempo traces (defaults: last 15 min, limit 20)
|
|
42
|
+
lgtm tempo search -q '{resource.service.name="api"}'
|
|
43
|
+
|
|
44
|
+
# Use specific instance
|
|
45
|
+
lgtm -i production loki labels
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
Create config at `~/.config/lgtm/config.yaml`:
|
|
51
|
+
|
|
52
|
+
```yaml
|
|
53
|
+
version: "1"
|
|
54
|
+
default_instance: "local"
|
|
55
|
+
|
|
56
|
+
instances:
|
|
57
|
+
local:
|
|
58
|
+
loki:
|
|
59
|
+
url: "http://localhost:3100"
|
|
60
|
+
prometheus:
|
|
61
|
+
url: "http://localhost:9090"
|
|
62
|
+
tempo:
|
|
63
|
+
url: "http://localhost:3200"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Authentication
|
|
67
|
+
|
|
68
|
+
| Config Fields | Auth Type | Description |
|
|
69
|
+
|---------------|-----------|-------------|
|
|
70
|
+
| `token` only | Bearer | `Authorization: Bearer <token>` header |
|
|
71
|
+
| `username` + `token` | Basic | HTTP Basic auth |
|
|
72
|
+
| `headers` | Custom | Custom headers (e.g., `X-Scope-OrgID` for multi-tenant) |
|
|
73
|
+
|
|
74
|
+
Example with authentication:
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
version: "1"
|
|
78
|
+
default_instance: "production"
|
|
79
|
+
|
|
80
|
+
instances:
|
|
81
|
+
production:
|
|
82
|
+
loki:
|
|
83
|
+
url: "https://loki.example.com"
|
|
84
|
+
token: "${LOKI_TOKEN}" # Bearer auth
|
|
85
|
+
prometheus:
|
|
86
|
+
url: "https://mimir.example.com"
|
|
87
|
+
username: "${MIMIR_USER}" # Basic auth
|
|
88
|
+
token: "${MIMIR_TOKEN}"
|
|
89
|
+
tempo:
|
|
90
|
+
url: "https://tempo.example.com"
|
|
91
|
+
token: "${TEMPO_TOKEN}"
|
|
92
|
+
headers:
|
|
93
|
+
X-Scope-OrgID: "my-tenant"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Environment variables (`${VAR_NAME}`) are expanded at runtime.
|
|
97
|
+
|
|
98
|
+
## Built-in Best Practices
|
|
99
|
+
|
|
100
|
+
- **Default time range:** 15 minutes (not hours/days)
|
|
101
|
+
- **Default limits:** 50 for logs, 20 for traces
|
|
102
|
+
- **Discovery commands:** Explore labels/metrics/tags first
|
|
103
|
+
|
|
104
|
+
### Recommended Workflow
|
|
105
|
+
|
|
106
|
+
1. **Discover** what's available:
|
|
107
|
+
```bash
|
|
108
|
+
lgtm loki labels
|
|
109
|
+
lgtm loki label-values app
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
2. **Aggregate** to get overview:
|
|
113
|
+
```bash
|
|
114
|
+
lgtm loki instant 'sum by (app) (count_over_time({namespace="prod"} |= "error" [15m]))'
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
3. **Drill down** to specifics:
|
|
118
|
+
```bash
|
|
119
|
+
lgtm loki query '{namespace="prod", app="checkout"} |= "error"' --limit 20
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Commands
|
|
123
|
+
|
|
124
|
+
### Loki
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
lgtm loki labels # List available labels
|
|
128
|
+
lgtm loki label-values <label> # List values for a label
|
|
129
|
+
lgtm loki query <logql> # Query logs
|
|
130
|
+
lgtm loki instant <logql> # Instant query (for aggregations)
|
|
131
|
+
lgtm loki series <selector>... # List series
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Prometheus
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
lgtm prom labels # List available labels
|
|
138
|
+
lgtm prom label-values <label> # List values for a label
|
|
139
|
+
lgtm prom query <promql> # Instant query
|
|
140
|
+
lgtm prom range <promql> # Range query
|
|
141
|
+
lgtm prom series <selector>... # List series
|
|
142
|
+
lgtm prom metadata # Get metric metadata
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Tempo
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
lgtm tempo tags # List available tags
|
|
149
|
+
lgtm tempo tag-values <tag> # List values for a tag
|
|
150
|
+
lgtm tempo search # Search traces
|
|
151
|
+
lgtm tempo trace <trace_id> # Get trace by ID
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Compatibility
|
|
155
|
+
|
|
156
|
+
Config format is compatible with [lgtm-mcp](https://github.com/pokgak/lgtm-mcp) for easy migration.
|
lgtm_cli-0.1.0/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# LGTM CLI
|
|
2
|
+
|
|
3
|
+
Lightweight CLI for querying Loki, Prometheus/Mimir, and Tempo.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
**Requires Python 3.12+**
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Install globally
|
|
11
|
+
uv tool install git+https://github.com/pokgak/lgtm-cli
|
|
12
|
+
|
|
13
|
+
# Or run directly without installing
|
|
14
|
+
uvx --from git+https://github.com/pokgak/lgtm-cli lgtm --help
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# List configured instances
|
|
21
|
+
lgtm instances
|
|
22
|
+
|
|
23
|
+
# Query Loki logs (defaults: last 15 min, limit 50)
|
|
24
|
+
lgtm loki query '{app="myapp"} |= "error"'
|
|
25
|
+
|
|
26
|
+
# Query Prometheus metrics
|
|
27
|
+
lgtm prom query 'rate(http_requests_total[5m])'
|
|
28
|
+
|
|
29
|
+
# Search Tempo traces (defaults: last 15 min, limit 20)
|
|
30
|
+
lgtm tempo search -q '{resource.service.name="api"}'
|
|
31
|
+
|
|
32
|
+
# Use specific instance
|
|
33
|
+
lgtm -i production loki labels
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
Create config at `~/.config/lgtm/config.yaml`:
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
version: "1"
|
|
42
|
+
default_instance: "local"
|
|
43
|
+
|
|
44
|
+
instances:
|
|
45
|
+
local:
|
|
46
|
+
loki:
|
|
47
|
+
url: "http://localhost:3100"
|
|
48
|
+
prometheus:
|
|
49
|
+
url: "http://localhost:9090"
|
|
50
|
+
tempo:
|
|
51
|
+
url: "http://localhost:3200"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Authentication
|
|
55
|
+
|
|
56
|
+
| Config Fields | Auth Type | Description |
|
|
57
|
+
|---------------|-----------|-------------|
|
|
58
|
+
| `token` only | Bearer | `Authorization: Bearer <token>` header |
|
|
59
|
+
| `username` + `token` | Basic | HTTP Basic auth |
|
|
60
|
+
| `headers` | Custom | Custom headers (e.g., `X-Scope-OrgID` for multi-tenant) |
|
|
61
|
+
|
|
62
|
+
Example with authentication:
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
version: "1"
|
|
66
|
+
default_instance: "production"
|
|
67
|
+
|
|
68
|
+
instances:
|
|
69
|
+
production:
|
|
70
|
+
loki:
|
|
71
|
+
url: "https://loki.example.com"
|
|
72
|
+
token: "${LOKI_TOKEN}" # Bearer auth
|
|
73
|
+
prometheus:
|
|
74
|
+
url: "https://mimir.example.com"
|
|
75
|
+
username: "${MIMIR_USER}" # Basic auth
|
|
76
|
+
token: "${MIMIR_TOKEN}"
|
|
77
|
+
tempo:
|
|
78
|
+
url: "https://tempo.example.com"
|
|
79
|
+
token: "${TEMPO_TOKEN}"
|
|
80
|
+
headers:
|
|
81
|
+
X-Scope-OrgID: "my-tenant"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Environment variables (`${VAR_NAME}`) are expanded at runtime.
|
|
85
|
+
|
|
86
|
+
## Built-in Best Practices
|
|
87
|
+
|
|
88
|
+
- **Default time range:** 15 minutes (not hours/days)
|
|
89
|
+
- **Default limits:** 50 for logs, 20 for traces
|
|
90
|
+
- **Discovery commands:** Explore labels/metrics/tags first
|
|
91
|
+
|
|
92
|
+
### Recommended Workflow
|
|
93
|
+
|
|
94
|
+
1. **Discover** what's available:
|
|
95
|
+
```bash
|
|
96
|
+
lgtm loki labels
|
|
97
|
+
lgtm loki label-values app
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
2. **Aggregate** to get overview:
|
|
101
|
+
```bash
|
|
102
|
+
lgtm loki instant 'sum by (app) (count_over_time({namespace="prod"} |= "error" [15m]))'
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
3. **Drill down** to specifics:
|
|
106
|
+
```bash
|
|
107
|
+
lgtm loki query '{namespace="prod", app="checkout"} |= "error"' --limit 20
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Commands
|
|
111
|
+
|
|
112
|
+
### Loki
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
lgtm loki labels # List available labels
|
|
116
|
+
lgtm loki label-values <label> # List values for a label
|
|
117
|
+
lgtm loki query <logql> # Query logs
|
|
118
|
+
lgtm loki instant <logql> # Instant query (for aggregations)
|
|
119
|
+
lgtm loki series <selector>... # List series
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Prometheus
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
lgtm prom labels # List available labels
|
|
126
|
+
lgtm prom label-values <label> # List values for a label
|
|
127
|
+
lgtm prom query <promql> # Instant query
|
|
128
|
+
lgtm prom range <promql> # Range query
|
|
129
|
+
lgtm prom series <selector>... # List series
|
|
130
|
+
lgtm prom metadata # Get metric metadata
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Tempo
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
lgtm tempo tags # List available tags
|
|
137
|
+
lgtm tempo tag-values <tag> # List values for a tag
|
|
138
|
+
lgtm tempo search # Search traces
|
|
139
|
+
lgtm tempo trace <trace_id> # Get trace by ID
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Compatibility
|
|
143
|
+
|
|
144
|
+
Config format is compatible with [lgtm-mcp](https://github.com/pokgak/lgtm-mcp) for easy migration.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "lgtm-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Lightweight CLI for querying Loki, Prometheus/Mimir, and Tempo"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Aiman Ismail", email = "aiman@primeintellect.ai" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"click>=8.3.1",
|
|
12
|
+
"httpx>=0.28.1",
|
|
13
|
+
"pyyaml>=6.0.3",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
lgtm = "lgtm_cli.cli:main"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["uv_build>=0.8.22,<0.9.0"]
|
|
21
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .config import load_config, Config, InstanceConfig, ServiceConfig, DEFAULT_CONFIG_PATH
|
|
2
|
+
from .client import LokiClient, PrometheusClient, TempoClient
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"load_config",
|
|
6
|
+
"Config",
|
|
7
|
+
"InstanceConfig",
|
|
8
|
+
"ServiceConfig",
|
|
9
|
+
"DEFAULT_CONFIG_PATH",
|
|
10
|
+
"LokiClient",
|
|
11
|
+
"PrometheusClient",
|
|
12
|
+
"TempoClient",
|
|
13
|
+
]
|
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from .config import load_config, DEFAULT_CONFIG_PATH
|
|
9
|
+
from .client import LokiClient, PrometheusClient, TempoClient, AlertingClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Best practice defaults
|
|
13
|
+
DEFAULT_TIME_RANGE_MINUTES = 15 # Start with narrow time range
|
|
14
|
+
DEFAULT_LOKI_LIMIT = 50 # Reasonable limit for logs
|
|
15
|
+
DEFAULT_TEMPO_LIMIT = 20 # Reasonable limit for traces
|
|
16
|
+
DEFAULT_PROM_STEP = "60s" # 1 minute resolution
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_default_times(minutes: int = DEFAULT_TIME_RANGE_MINUTES) -> tuple[str, str]:
|
|
20
|
+
"""Get default start/end times (RFC3339) for the last N minutes."""
|
|
21
|
+
now = datetime.now(timezone.utc)
|
|
22
|
+
start = now - timedelta(minutes=minutes)
|
|
23
|
+
return start.strftime("%Y-%m-%dT%H:%M:%SZ"), now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_default_times_unix(minutes: int = DEFAULT_TIME_RANGE_MINUTES) -> tuple[str, str]:
|
|
27
|
+
"""Get default start/end times (Unix seconds) for the last N minutes."""
|
|
28
|
+
now = datetime.now(timezone.utc)
|
|
29
|
+
start = now - timedelta(minutes=minutes)
|
|
30
|
+
return str(int(start.timestamp())), str(int(now.timestamp()))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def output_json(data: dict):
|
|
34
|
+
"""Output JSON data, pretty-printed."""
|
|
35
|
+
click.echo(json.dumps(data, indent=2))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def output_error(msg: str):
|
|
39
|
+
"""Output error message to stderr."""
|
|
40
|
+
click.echo(f"Error: {msg}", err=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@click.group()
|
|
44
|
+
@click.option("--config", "-c", type=click.Path(exists=True, path_type=Path), help="Config file path")
|
|
45
|
+
@click.option("--instance", "-i", help="Instance name from config")
|
|
46
|
+
@click.pass_context
|
|
47
|
+
def main(ctx, config: Path | None, instance: str | None):
|
|
48
|
+
"""LGTM CLI - Query Loki, Prometheus, and Tempo.
|
|
49
|
+
|
|
50
|
+
Best practices are built-in:
|
|
51
|
+
- Default time range: 15 minutes (use --start/--end to override)
|
|
52
|
+
- Default limits: 50 for logs, 20 for traces
|
|
53
|
+
- Always filter by labels when possible
|
|
54
|
+
"""
|
|
55
|
+
ctx.ensure_object(dict)
|
|
56
|
+
try:
|
|
57
|
+
ctx.obj["config"] = load_config(config)
|
|
58
|
+
ctx.obj["instance_name"] = instance
|
|
59
|
+
except FileNotFoundError as e:
|
|
60
|
+
output_error(str(e))
|
|
61
|
+
output_error(f"Create a config file at {DEFAULT_CONFIG_PATH}")
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# === LOKI COMMANDS ===
|
|
66
|
+
|
|
67
|
+
@main.group()
|
|
68
|
+
@click.pass_context
|
|
69
|
+
def loki(ctx):
|
|
70
|
+
"""Query Loki logs."""
|
|
71
|
+
instance = ctx.obj["config"].get_instance(ctx.obj["instance_name"])
|
|
72
|
+
if not instance.loki:
|
|
73
|
+
output_error(f"Loki not configured for instance '{instance.name}'")
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
ctx.obj["client"] = LokiClient(instance.loki)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@loki.command()
|
|
79
|
+
@click.argument("query")
|
|
80
|
+
@click.option("--start", "-s", help="Start time (RFC3339). Default: 15 minutes ago")
|
|
81
|
+
@click.option("--end", "-e", help="End time (RFC3339). Default: now")
|
|
82
|
+
@click.option("--limit", "-l", default=DEFAULT_LOKI_LIMIT, help=f"Max entries (default: {DEFAULT_LOKI_LIMIT})")
|
|
83
|
+
@click.option("--direction", "-d", type=click.Choice(["backward", "forward"]), default="backward")
|
|
84
|
+
@click.pass_context
|
|
85
|
+
def query(ctx, query: str, start: str | None, end: str | None, limit: int, direction: str):
|
|
86
|
+
"""Query logs with LogQL.
|
|
87
|
+
|
|
88
|
+
Examples:
|
|
89
|
+
|
|
90
|
+
lgtm loki query '{app="myapp"}'
|
|
91
|
+
|
|
92
|
+
lgtm loki query '{app="myapp"} |= "error"' --limit 100
|
|
93
|
+
|
|
94
|
+
lgtm loki query '{app="myapp"}' --start 2024-01-15T10:00:00Z --end 2024-01-15T11:00:00Z
|
|
95
|
+
"""
|
|
96
|
+
default_start, default_end = get_default_times()
|
|
97
|
+
try:
|
|
98
|
+
result = ctx.obj["client"].query(
|
|
99
|
+
query=query,
|
|
100
|
+
start=start or default_start,
|
|
101
|
+
end=end or default_end,
|
|
102
|
+
limit=limit,
|
|
103
|
+
direction=direction,
|
|
104
|
+
)
|
|
105
|
+
output_json(result)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
output_error(str(e))
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@loki.command()
|
|
112
|
+
@click.argument("query")
|
|
113
|
+
@click.option("--time", "-t", help="Evaluation time (RFC3339). Default: now")
|
|
114
|
+
@click.pass_context
|
|
115
|
+
def instant(ctx, query: str, time: str | None):
|
|
116
|
+
"""Run instant query (for metric queries like count_over_time).
|
|
117
|
+
|
|
118
|
+
Examples:
|
|
119
|
+
|
|
120
|
+
lgtm loki instant 'count_over_time({app="myapp"}[5m])'
|
|
121
|
+
|
|
122
|
+
lgtm loki instant 'sum by (level) (count_over_time({app="myapp"} | json [5m]))'
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
result = ctx.obj["client"].query_instant(query, time)
|
|
126
|
+
output_json(result)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
output_error(str(e))
|
|
129
|
+
sys.exit(1)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@loki.command()
|
|
133
|
+
@click.option("--start", "-s", help="Start time filter")
|
|
134
|
+
@click.option("--end", "-e", help="End time filter")
|
|
135
|
+
@click.pass_context
|
|
136
|
+
def labels(ctx, start: str | None, end: str | None):
|
|
137
|
+
"""List available labels.
|
|
138
|
+
|
|
139
|
+
Use this first to discover what labels are available before querying.
|
|
140
|
+
"""
|
|
141
|
+
try:
|
|
142
|
+
result = ctx.obj["client"].labels(start, end)
|
|
143
|
+
output_json(result)
|
|
144
|
+
except Exception as e:
|
|
145
|
+
output_error(str(e))
|
|
146
|
+
sys.exit(1)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@loki.command("label-values")
|
|
150
|
+
@click.argument("label")
|
|
151
|
+
@click.option("--start", "-s", help="Start time filter")
|
|
152
|
+
@click.option("--end", "-e", help="End time filter")
|
|
153
|
+
@click.pass_context
|
|
154
|
+
def label_values(ctx, label: str, start: str | None, end: str | None):
|
|
155
|
+
"""List values for a label.
|
|
156
|
+
|
|
157
|
+
Examples:
|
|
158
|
+
|
|
159
|
+
lgtm loki label-values app
|
|
160
|
+
|
|
161
|
+
lgtm loki label-values namespace
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
result = ctx.obj["client"].label_values(label, start, end)
|
|
165
|
+
output_json(result)
|
|
166
|
+
except Exception as e:
|
|
167
|
+
output_error(str(e))
|
|
168
|
+
sys.exit(1)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@loki.command()
|
|
172
|
+
@click.argument("match", nargs=-1, required=True)
|
|
173
|
+
@click.option("--start", "-s", help="Start time filter")
|
|
174
|
+
@click.option("--end", "-e", help="End time filter")
|
|
175
|
+
@click.pass_context
|
|
176
|
+
def series(ctx, match: tuple[str, ...], start: str | None, end: str | None):
|
|
177
|
+
"""List series matching selectors.
|
|
178
|
+
|
|
179
|
+
Examples:
|
|
180
|
+
|
|
181
|
+
lgtm loki series '{app="myapp"}'
|
|
182
|
+
|
|
183
|
+
lgtm loki series '{namespace="prod"}' '{namespace="staging"}'
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
result = ctx.obj["client"].series(list(match), start, end)
|
|
187
|
+
output_json(result)
|
|
188
|
+
except Exception as e:
|
|
189
|
+
output_error(str(e))
|
|
190
|
+
sys.exit(1)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# === PROMETHEUS COMMANDS ===
|
|
194
|
+
|
|
195
|
+
@main.group()
|
|
196
|
+
@click.pass_context
|
|
197
|
+
def prom(ctx):
|
|
198
|
+
"""Query Prometheus/Mimir metrics."""
|
|
199
|
+
instance = ctx.obj["config"].get_instance(ctx.obj["instance_name"])
|
|
200
|
+
if not instance.prometheus:
|
|
201
|
+
output_error(f"Prometheus not configured for instance '{instance.name}'")
|
|
202
|
+
sys.exit(1)
|
|
203
|
+
ctx.obj["client"] = PrometheusClient(instance.prometheus)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@prom.command()
|
|
207
|
+
@click.argument("query")
|
|
208
|
+
@click.option("--time", "-t", help="Evaluation time (RFC3339). Default: now")
|
|
209
|
+
@click.pass_context
|
|
210
|
+
def query(ctx, query: str, time: str | None):
|
|
211
|
+
"""Run instant query.
|
|
212
|
+
|
|
213
|
+
Examples:
|
|
214
|
+
|
|
215
|
+
lgtm prom query 'up{job="prometheus"}'
|
|
216
|
+
|
|
217
|
+
lgtm prom query 'rate(http_requests_total[5m])'
|
|
218
|
+
"""
|
|
219
|
+
try:
|
|
220
|
+
result = ctx.obj["client"].query(query, time)
|
|
221
|
+
output_json(result)
|
|
222
|
+
except Exception as e:
|
|
223
|
+
output_error(str(e))
|
|
224
|
+
sys.exit(1)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@prom.command()
|
|
228
|
+
@click.argument("query")
|
|
229
|
+
@click.option("--start", "-s", help="Start time (RFC3339). Default: 15 minutes ago")
|
|
230
|
+
@click.option("--end", "-e", help="End time (RFC3339). Default: now")
|
|
231
|
+
@click.option("--step", default=DEFAULT_PROM_STEP, help=f"Resolution step (default: {DEFAULT_PROM_STEP})")
|
|
232
|
+
@click.pass_context
|
|
233
|
+
def range(ctx, query: str, start: str | None, end: str | None, step: str):
|
|
234
|
+
"""Run range query.
|
|
235
|
+
|
|
236
|
+
Examples:
|
|
237
|
+
|
|
238
|
+
lgtm prom range 'rate(http_requests_total[5m])'
|
|
239
|
+
|
|
240
|
+
lgtm prom range 'up' --step 5m --start 2024-01-15T10:00:00Z
|
|
241
|
+
"""
|
|
242
|
+
default_start, default_end = get_default_times()
|
|
243
|
+
try:
|
|
244
|
+
result = ctx.obj["client"].query_range(
|
|
245
|
+
query=query,
|
|
246
|
+
start=start or default_start,
|
|
247
|
+
end=end or default_end,
|
|
248
|
+
step=step,
|
|
249
|
+
)
|
|
250
|
+
output_json(result)
|
|
251
|
+
except Exception as e:
|
|
252
|
+
output_error(str(e))
|
|
253
|
+
sys.exit(1)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@prom.command()
|
|
257
|
+
@click.option("--start", "-s", help="Start time filter")
|
|
258
|
+
@click.option("--end", "-e", help="End time filter")
|
|
259
|
+
@click.pass_context
|
|
260
|
+
def labels(ctx, start: str | None, end: str | None):
|
|
261
|
+
"""List available labels.
|
|
262
|
+
|
|
263
|
+
Use this first to discover what labels are available.
|
|
264
|
+
"""
|
|
265
|
+
try:
|
|
266
|
+
result = ctx.obj["client"].labels(start, end)
|
|
267
|
+
output_json(result)
|
|
268
|
+
except Exception as e:
|
|
269
|
+
output_error(str(e))
|
|
270
|
+
sys.exit(1)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@prom.command("label-values")
|
|
274
|
+
@click.argument("label")
|
|
275
|
+
@click.option("--start", "-s", help="Start time filter")
|
|
276
|
+
@click.option("--end", "-e", help="End time filter")
|
|
277
|
+
@click.pass_context
|
|
278
|
+
def prom_label_values(ctx, label: str, start: str | None, end: str | None):
|
|
279
|
+
"""List values for a label.
|
|
280
|
+
|
|
281
|
+
Examples:
|
|
282
|
+
|
|
283
|
+
lgtm prom label-values job
|
|
284
|
+
|
|
285
|
+
lgtm prom label-values __name__ # List all metric names
|
|
286
|
+
"""
|
|
287
|
+
try:
|
|
288
|
+
result = ctx.obj["client"].label_values(label, start, end)
|
|
289
|
+
output_json(result)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
output_error(str(e))
|
|
292
|
+
sys.exit(1)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@prom.command()
|
|
296
|
+
@click.argument("match", nargs=-1, required=True)
|
|
297
|
+
@click.option("--start", "-s", help="Start time filter")
|
|
298
|
+
@click.option("--end", "-e", help="End time filter")
|
|
299
|
+
@click.pass_context
|
|
300
|
+
def series(ctx, match: tuple[str, ...], start: str | None, end: str | None):
|
|
301
|
+
"""List series matching selectors.
|
|
302
|
+
|
|
303
|
+
Examples:
|
|
304
|
+
|
|
305
|
+
lgtm prom series 'up'
|
|
306
|
+
|
|
307
|
+
lgtm prom series 'http_requests_total{job="api"}'
|
|
308
|
+
"""
|
|
309
|
+
try:
|
|
310
|
+
result = ctx.obj["client"].series(list(match), start, end)
|
|
311
|
+
output_json(result)
|
|
312
|
+
except Exception as e:
|
|
313
|
+
output_error(str(e))
|
|
314
|
+
sys.exit(1)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@prom.command()
|
|
318
|
+
@click.option("--metric", "-m", help="Filter by metric name")
|
|
319
|
+
@click.pass_context
|
|
320
|
+
def metadata(ctx, metric: str | None):
|
|
321
|
+
"""Get metric metadata.
|
|
322
|
+
|
|
323
|
+
Examples:
|
|
324
|
+
|
|
325
|
+
lgtm prom metadata
|
|
326
|
+
|
|
327
|
+
lgtm prom metadata --metric http_requests_total
|
|
328
|
+
"""
|
|
329
|
+
try:
|
|
330
|
+
result = ctx.obj["client"].metadata(metric)
|
|
331
|
+
output_json(result)
|
|
332
|
+
except Exception as e:
|
|
333
|
+
output_error(str(e))
|
|
334
|
+
sys.exit(1)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# === TEMPO COMMANDS ===
|
|
338
|
+
|
|
339
|
+
@main.group()
|
|
340
|
+
@click.pass_context
|
|
341
|
+
def tempo(ctx):
|
|
342
|
+
"""Query Tempo traces."""
|
|
343
|
+
instance = ctx.obj["config"].get_instance(ctx.obj["instance_name"])
|
|
344
|
+
if not instance.tempo:
|
|
345
|
+
output_error(f"Tempo not configured for instance '{instance.name}'")
|
|
346
|
+
sys.exit(1)
|
|
347
|
+
ctx.obj["client"] = TempoClient(instance.tempo)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@tempo.command()
|
|
351
|
+
@click.argument("trace_id")
|
|
352
|
+
@click.pass_context
|
|
353
|
+
def trace(ctx, trace_id: str):
|
|
354
|
+
"""Get trace by ID.
|
|
355
|
+
|
|
356
|
+
Use this when you have a specific trace ID to investigate.
|
|
357
|
+
|
|
358
|
+
Examples:
|
|
359
|
+
|
|
360
|
+
lgtm tempo trace abc123def456
|
|
361
|
+
"""
|
|
362
|
+
try:
|
|
363
|
+
result = ctx.obj["client"].trace(trace_id)
|
|
364
|
+
output_json(result)
|
|
365
|
+
except Exception as e:
|
|
366
|
+
output_error(str(e))
|
|
367
|
+
sys.exit(1)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@tempo.command()
|
|
371
|
+
@click.option("--query", "-q", help="TraceQL query")
|
|
372
|
+
@click.option("--start", "-s", help="Start time (Unix seconds). Default: 15 minutes ago")
|
|
373
|
+
@click.option("--end", "-e", help="End time (Unix seconds). Default: now")
|
|
374
|
+
@click.option("--min-duration", help="Minimum duration (e.g., 100ms, 1s)")
|
|
375
|
+
@click.option("--max-duration", help="Maximum duration")
|
|
376
|
+
@click.option("--limit", "-l", default=DEFAULT_TEMPO_LIMIT, help=f"Max traces (default: {DEFAULT_TEMPO_LIMIT})")
|
|
377
|
+
@click.pass_context
|
|
378
|
+
def search(ctx, query: str | None, start: str | None, end: str | None,
|
|
379
|
+
min_duration: str | None, max_duration: str | None, limit: int):
|
|
380
|
+
"""Search traces with TraceQL.
|
|
381
|
+
|
|
382
|
+
Examples:
|
|
383
|
+
|
|
384
|
+
lgtm tempo search -q '{resource.service.name="api"}'
|
|
385
|
+
|
|
386
|
+
lgtm tempo search -q '{status=error}' --min-duration 1s
|
|
387
|
+
|
|
388
|
+
lgtm tempo search --min-duration 500ms --limit 50
|
|
389
|
+
"""
|
|
390
|
+
default_start, default_end = get_default_times_unix()
|
|
391
|
+
try:
|
|
392
|
+
result = ctx.obj["client"].search(
|
|
393
|
+
query=query,
|
|
394
|
+
start=start or default_start,
|
|
395
|
+
end=end or default_end,
|
|
396
|
+
min_duration=min_duration,
|
|
397
|
+
max_duration=max_duration,
|
|
398
|
+
limit=limit,
|
|
399
|
+
)
|
|
400
|
+
output_json(result)
|
|
401
|
+
except Exception as e:
|
|
402
|
+
output_error(str(e))
|
|
403
|
+
sys.exit(1)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@tempo.command()
|
|
407
|
+
@click.pass_context
|
|
408
|
+
def tags(ctx):
|
|
409
|
+
"""List available tags.
|
|
410
|
+
|
|
411
|
+
Use this first to discover what tags/attributes are available.
|
|
412
|
+
"""
|
|
413
|
+
try:
|
|
414
|
+
result = ctx.obj["client"].tags()
|
|
415
|
+
output_json(result)
|
|
416
|
+
except Exception as e:
|
|
417
|
+
output_error(str(e))
|
|
418
|
+
sys.exit(1)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@tempo.command("tag-values")
|
|
422
|
+
@click.argument("tag")
|
|
423
|
+
@click.pass_context
|
|
424
|
+
def tag_values(ctx, tag: str):
|
|
425
|
+
"""List values for a tag.
|
|
426
|
+
|
|
427
|
+
Examples:
|
|
428
|
+
|
|
429
|
+
lgtm tempo tag-values service.name
|
|
430
|
+
|
|
431
|
+
lgtm tempo tag-values http.status_code
|
|
432
|
+
"""
|
|
433
|
+
try:
|
|
434
|
+
result = ctx.obj["client"].tag_values(tag)
|
|
435
|
+
output_json(result)
|
|
436
|
+
except Exception as e:
|
|
437
|
+
output_error(str(e))
|
|
438
|
+
sys.exit(1)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# === ALERTS COMMANDS ===
|
|
442
|
+
|
|
443
|
+
DEFAULT_SILENCE_DURATION_HOURS = 2
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def parse_duration(duration: str) -> timedelta:
|
|
447
|
+
"""Parse duration string like '2h', '30m', '1d' to timedelta."""
|
|
448
|
+
import re
|
|
449
|
+
match = re.match(r'^(\d+)([smhd])$', duration.lower())
|
|
450
|
+
if not match:
|
|
451
|
+
raise click.BadParameter(f"Invalid duration format: {duration}. Use format like '2h', '30m', '1d'")
|
|
452
|
+
value = int(match.group(1))
|
|
453
|
+
unit = match.group(2)
|
|
454
|
+
if unit == 's':
|
|
455
|
+
return timedelta(seconds=value)
|
|
456
|
+
elif unit == 'm':
|
|
457
|
+
return timedelta(minutes=value)
|
|
458
|
+
elif unit == 'h':
|
|
459
|
+
return timedelta(hours=value)
|
|
460
|
+
elif unit == 'd':
|
|
461
|
+
return timedelta(days=value)
|
|
462
|
+
raise click.BadParameter(f"Unknown time unit: {unit}")
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def parse_matcher(matcher: str) -> dict:
|
|
466
|
+
"""Parse matcher string like 'alertname=HighCPU' or 'severity=~warning|critical'."""
|
|
467
|
+
import re
|
|
468
|
+
match = re.match(r'^([^=!~]+)(=~|!~|!=|=)(.*)$', matcher)
|
|
469
|
+
if not match:
|
|
470
|
+
raise click.BadParameter(f"Invalid matcher format: {matcher}. Use format like 'label=value' or 'label=~regex'")
|
|
471
|
+
name = match.group(1)
|
|
472
|
+
op = match.group(2)
|
|
473
|
+
value = match.group(3)
|
|
474
|
+
return {
|
|
475
|
+
"name": name,
|
|
476
|
+
"value": value,
|
|
477
|
+
"isRegex": op in ("=~", "!~"),
|
|
478
|
+
"isEqual": op in ("=", "=~"),
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@main.group()
|
|
483
|
+
@click.pass_context
|
|
484
|
+
def alerts(ctx):
|
|
485
|
+
"""Query Grafana Alerting/Alertmanager."""
|
|
486
|
+
instance = ctx.obj["config"].get_instance(ctx.obj["instance_name"])
|
|
487
|
+
if not instance.alerting:
|
|
488
|
+
output_error(f"Alerting not configured for instance '{instance.name}'")
|
|
489
|
+
sys.exit(1)
|
|
490
|
+
ctx.obj["client"] = AlertingClient(instance.alerting)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@alerts.command("list")
|
|
494
|
+
@click.option("--filter", "-f", "filters", multiple=True, help="Filter alerts by label (e.g., 'alertname=HighCPU')")
|
|
495
|
+
@click.option("--receiver", "-r", help="Filter by receiver")
|
|
496
|
+
@click.option("--silenced/--no-silenced", default=True, help="Include silenced alerts")
|
|
497
|
+
@click.option("--inhibited/--no-inhibited", default=True, help="Include inhibited alerts")
|
|
498
|
+
@click.option("--active/--no-active", default=True, help="Include active alerts")
|
|
499
|
+
@click.pass_context
|
|
500
|
+
def alerts_list(ctx, filters: tuple[str, ...], receiver: str | None, silenced: bool, inhibited: bool, active: bool):
|
|
501
|
+
"""List firing alerts.
|
|
502
|
+
|
|
503
|
+
Examples:
|
|
504
|
+
|
|
505
|
+
lgtm alerts list
|
|
506
|
+
|
|
507
|
+
lgtm alerts list --filter 'alertname=HighCPU'
|
|
508
|
+
|
|
509
|
+
lgtm alerts list --no-silenced --active
|
|
510
|
+
"""
|
|
511
|
+
try:
|
|
512
|
+
result = ctx.obj["client"].list_alerts(
|
|
513
|
+
filter=list(filters) if filters else None,
|
|
514
|
+
receiver=receiver,
|
|
515
|
+
silenced=silenced,
|
|
516
|
+
inhibited=inhibited,
|
|
517
|
+
active=active,
|
|
518
|
+
)
|
|
519
|
+
output_json(result)
|
|
520
|
+
except Exception as e:
|
|
521
|
+
output_error(str(e))
|
|
522
|
+
sys.exit(1)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
@alerts.command("groups")
|
|
526
|
+
@click.option("--filter", "-f", "filters", multiple=True, help="Filter alerts by label")
|
|
527
|
+
@click.option("--receiver", "-r", help="Filter by receiver")
|
|
528
|
+
@click.pass_context
|
|
529
|
+
def alerts_groups(ctx, filters: tuple[str, ...], receiver: str | None):
|
|
530
|
+
"""List alerts grouped by receiver/labels.
|
|
531
|
+
|
|
532
|
+
Examples:
|
|
533
|
+
|
|
534
|
+
lgtm alerts groups
|
|
535
|
+
|
|
536
|
+
lgtm alerts groups --filter 'severity=critical'
|
|
537
|
+
"""
|
|
538
|
+
try:
|
|
539
|
+
result = ctx.obj["client"].list_alert_groups(
|
|
540
|
+
filter=list(filters) if filters else None,
|
|
541
|
+
receiver=receiver,
|
|
542
|
+
)
|
|
543
|
+
output_json(result)
|
|
544
|
+
except Exception as e:
|
|
545
|
+
output_error(str(e))
|
|
546
|
+
sys.exit(1)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
@alerts.command("silences")
|
|
550
|
+
@click.option("--filter", "-f", "filters", multiple=True, help="Filter silences by label")
|
|
551
|
+
@click.pass_context
|
|
552
|
+
def alerts_silences(ctx, filters: tuple[str, ...]):
|
|
553
|
+
"""List all silences.
|
|
554
|
+
|
|
555
|
+
Examples:
|
|
556
|
+
|
|
557
|
+
lgtm alerts silences
|
|
558
|
+
|
|
559
|
+
lgtm alerts silences --filter 'alertname=HighCPU'
|
|
560
|
+
"""
|
|
561
|
+
try:
|
|
562
|
+
result = ctx.obj["client"].list_silences(
|
|
563
|
+
filter=list(filters) if filters else None,
|
|
564
|
+
)
|
|
565
|
+
output_json(result)
|
|
566
|
+
except Exception as e:
|
|
567
|
+
output_error(str(e))
|
|
568
|
+
sys.exit(1)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
@alerts.command("silence-get")
|
|
572
|
+
@click.argument("silence_id")
|
|
573
|
+
@click.pass_context
|
|
574
|
+
def alerts_silence_get(ctx, silence_id: str):
|
|
575
|
+
"""Get a specific silence by ID.
|
|
576
|
+
|
|
577
|
+
Examples:
|
|
578
|
+
|
|
579
|
+
lgtm alerts silence-get abc123-def456
|
|
580
|
+
"""
|
|
581
|
+
try:
|
|
582
|
+
result = ctx.obj["client"].get_silence(silence_id)
|
|
583
|
+
output_json(result)
|
|
584
|
+
except Exception as e:
|
|
585
|
+
output_error(str(e))
|
|
586
|
+
sys.exit(1)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
@alerts.command("silence-create")
|
|
590
|
+
@click.option("--matcher", "-m", "matchers", multiple=True, required=True,
|
|
591
|
+
help="Matcher in format 'label=value' or 'label=~regex'. Can be specified multiple times.")
|
|
592
|
+
@click.option("--duration", "-d", default="2h", help="Silence duration (e.g., '2h', '30m', '1d'). Default: 2h")
|
|
593
|
+
@click.option("--comment", "-c", required=True, help="Comment explaining the silence")
|
|
594
|
+
@click.option("--created-by", required=True, help="Creator identifier (e.g., email)")
|
|
595
|
+
@click.pass_context
|
|
596
|
+
def alerts_silence_create(ctx, matchers: tuple[str, ...], duration: str, comment: str, created_by: str):
|
|
597
|
+
"""Create a new silence.
|
|
598
|
+
|
|
599
|
+
Examples:
|
|
600
|
+
|
|
601
|
+
lgtm alerts silence-create --matcher 'alertname=HighCPU' --duration 2h --comment "Maintenance" --created-by "user@example.com"
|
|
602
|
+
|
|
603
|
+
lgtm alerts silence-create -m 'alertname=HighCPU' -m 'severity=warning' -d 1h -c "Investigating" --created-by "ops"
|
|
604
|
+
"""
|
|
605
|
+
try:
|
|
606
|
+
parsed_matchers = [parse_matcher(m) for m in matchers]
|
|
607
|
+
delta = parse_duration(duration)
|
|
608
|
+
now = datetime.now(timezone.utc)
|
|
609
|
+
starts_at = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
610
|
+
ends_at = (now + delta).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
611
|
+
|
|
612
|
+
result = ctx.obj["client"].create_silence(
|
|
613
|
+
matchers=parsed_matchers,
|
|
614
|
+
starts_at=starts_at,
|
|
615
|
+
ends_at=ends_at,
|
|
616
|
+
created_by=created_by,
|
|
617
|
+
comment=comment,
|
|
618
|
+
)
|
|
619
|
+
output_json(result)
|
|
620
|
+
except click.BadParameter as e:
|
|
621
|
+
output_error(str(e))
|
|
622
|
+
sys.exit(1)
|
|
623
|
+
except Exception as e:
|
|
624
|
+
output_error(str(e))
|
|
625
|
+
sys.exit(1)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
@alerts.command("silence-delete")
|
|
629
|
+
@click.argument("silence_id")
|
|
630
|
+
@click.pass_context
|
|
631
|
+
def alerts_silence_delete(ctx, silence_id: str):
|
|
632
|
+
"""Delete/expire a silence by ID.
|
|
633
|
+
|
|
634
|
+
Examples:
|
|
635
|
+
|
|
636
|
+
lgtm alerts silence-delete abc123-def456
|
|
637
|
+
"""
|
|
638
|
+
try:
|
|
639
|
+
ctx.obj["client"].delete_silence(silence_id)
|
|
640
|
+
click.echo(f"Silence {silence_id} deleted successfully")
|
|
641
|
+
except Exception as e:
|
|
642
|
+
output_error(str(e))
|
|
643
|
+
sys.exit(1)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
# === CONFIG COMMANDS ===
|
|
647
|
+
|
|
648
|
+
@main.command()
|
|
649
|
+
@click.pass_context
|
|
650
|
+
def instances(ctx):
|
|
651
|
+
"""List configured instances."""
|
|
652
|
+
config = ctx.obj["config"]
|
|
653
|
+
result = {
|
|
654
|
+
"default": config.default_instance,
|
|
655
|
+
"instances": {}
|
|
656
|
+
}
|
|
657
|
+
for name, instance in config.instances.items():
|
|
658
|
+
result["instances"][name] = {
|
|
659
|
+
"loki": instance.loki.url if instance.loki else None,
|
|
660
|
+
"prometheus": instance.prometheus.url if instance.prometheus else None,
|
|
661
|
+
"tempo": instance.tempo.url if instance.tempo else None,
|
|
662
|
+
"alerting": instance.alerting.url if instance.alerting else None,
|
|
663
|
+
}
|
|
664
|
+
output_json(result)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
if __name__ == "__main__":
|
|
668
|
+
main()
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from urllib.parse import urlencode
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from .config import ServiceConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LGTMClient:
|
|
10
|
+
def __init__(self, config: ServiceConfig, timeout: float = 30.0):
|
|
11
|
+
self.config = config
|
|
12
|
+
self.base_url = config.url.rstrip("/")
|
|
13
|
+
self.timeout = timeout
|
|
14
|
+
|
|
15
|
+
def _get_headers(self) -> dict[str, str]:
|
|
16
|
+
headers = {"Accept": "application/json"}
|
|
17
|
+
if self.config.username and self.config.token:
|
|
18
|
+
credentials = f"{self.config.username}:{self.config.token}"
|
|
19
|
+
encoded = base64.b64encode(credentials.encode()).decode()
|
|
20
|
+
headers["Authorization"] = f"Basic {encoded}"
|
|
21
|
+
elif self.config.token:
|
|
22
|
+
headers["Authorization"] = f"Bearer {self.config.token}"
|
|
23
|
+
if self.config.headers:
|
|
24
|
+
headers.update(self.config.headers)
|
|
25
|
+
return headers
|
|
26
|
+
|
|
27
|
+
def get(self, path: str, params: dict | None = None) -> dict:
|
|
28
|
+
url = f"{self.base_url}{path}"
|
|
29
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
30
|
+
response = client.get(url, params=params, headers=self._get_headers())
|
|
31
|
+
response.raise_for_status()
|
|
32
|
+
return response.json()
|
|
33
|
+
|
|
34
|
+
def post(self, path: str, data: dict | None = None, params: dict | None = None) -> dict:
|
|
35
|
+
url = f"{self.base_url}{path}"
|
|
36
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
37
|
+
response = client.post(url, data=data, params=params, headers=self._get_headers())
|
|
38
|
+
response.raise_for_status()
|
|
39
|
+
return response.json()
|
|
40
|
+
|
|
41
|
+
def post_json(self, path: str, json_data: dict | None = None, params: dict | None = None) -> dict:
|
|
42
|
+
url = f"{self.base_url}{path}"
|
|
43
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
44
|
+
response = client.post(url, json=json_data, params=params, headers=self._get_headers())
|
|
45
|
+
response.raise_for_status()
|
|
46
|
+
return response.json()
|
|
47
|
+
|
|
48
|
+
def delete(self, path: str, params: dict | None = None) -> dict:
|
|
49
|
+
url = f"{self.base_url}{path}"
|
|
50
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
51
|
+
response = client.delete(url, params=params, headers=self._get_headers())
|
|
52
|
+
response.raise_for_status()
|
|
53
|
+
if response.text:
|
|
54
|
+
return response.json()
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class LokiClient(LGTMClient):
|
|
59
|
+
def query(self, query: str, start: str, end: str, limit: int = 100, direction: str = "backward") -> dict:
|
|
60
|
+
return self.get("/loki/api/v1/query_range", {
|
|
61
|
+
"query": query,
|
|
62
|
+
"start": start,
|
|
63
|
+
"end": end,
|
|
64
|
+
"limit": limit,
|
|
65
|
+
"direction": direction,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
def query_instant(self, query: str, time: str | None = None) -> dict:
|
|
69
|
+
params = {"query": query}
|
|
70
|
+
if time:
|
|
71
|
+
params["time"] = time
|
|
72
|
+
return self.get("/loki/api/v1/query", params)
|
|
73
|
+
|
|
74
|
+
def labels(self, start: str | None = None, end: str | None = None) -> dict:
|
|
75
|
+
params = {}
|
|
76
|
+
if start:
|
|
77
|
+
params["start"] = start
|
|
78
|
+
if end:
|
|
79
|
+
params["end"] = end
|
|
80
|
+
return self.get("/loki/api/v1/labels", params or None)
|
|
81
|
+
|
|
82
|
+
def label_values(self, label: str, start: str | None = None, end: str | None = None) -> dict:
|
|
83
|
+
params = {}
|
|
84
|
+
if start:
|
|
85
|
+
params["start"] = start
|
|
86
|
+
if end:
|
|
87
|
+
params["end"] = end
|
|
88
|
+
return self.get(f"/loki/api/v1/label/{label}/values", params or None)
|
|
89
|
+
|
|
90
|
+
def series(self, match: list[str], start: str | None = None, end: str | None = None) -> dict:
|
|
91
|
+
params = {"match[]": match}
|
|
92
|
+
if start:
|
|
93
|
+
params["start"] = start
|
|
94
|
+
if end:
|
|
95
|
+
params["end"] = end
|
|
96
|
+
return self.get("/loki/api/v1/series", params)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class PrometheusClient(LGTMClient):
|
|
100
|
+
def query(self, query: str, time: str | None = None) -> dict:
|
|
101
|
+
params = {"query": query}
|
|
102
|
+
if time:
|
|
103
|
+
params["time"] = time
|
|
104
|
+
return self.get("/api/v1/query", params)
|
|
105
|
+
|
|
106
|
+
def query_range(self, query: str, start: str, end: str, step: str = "60s") -> dict:
|
|
107
|
+
return self.get("/api/v1/query_range", {
|
|
108
|
+
"query": query,
|
|
109
|
+
"start": start,
|
|
110
|
+
"end": end,
|
|
111
|
+
"step": step,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
def labels(self, start: str | None = None, end: str | None = None) -> dict:
|
|
115
|
+
params = {}
|
|
116
|
+
if start:
|
|
117
|
+
params["start"] = start
|
|
118
|
+
if end:
|
|
119
|
+
params["end"] = end
|
|
120
|
+
return self.get("/api/v1/labels", params or None)
|
|
121
|
+
|
|
122
|
+
def label_values(self, label: str, start: str | None = None, end: str | None = None) -> dict:
|
|
123
|
+
params = {}
|
|
124
|
+
if start:
|
|
125
|
+
params["start"] = start
|
|
126
|
+
if end:
|
|
127
|
+
params["end"] = end
|
|
128
|
+
return self.get(f"/api/v1/label/{label}/values", params or None)
|
|
129
|
+
|
|
130
|
+
def series(self, match: list[str], start: str | None = None, end: str | None = None) -> dict:
|
|
131
|
+
params = {"match[]": match}
|
|
132
|
+
if start:
|
|
133
|
+
params["start"] = start
|
|
134
|
+
if end:
|
|
135
|
+
params["end"] = end
|
|
136
|
+
return self.get("/api/v1/series", params)
|
|
137
|
+
|
|
138
|
+
def metadata(self, metric: str | None = None) -> dict:
|
|
139
|
+
params = {}
|
|
140
|
+
if metric:
|
|
141
|
+
params["metric"] = metric
|
|
142
|
+
return self.get("/api/v1/metadata", params or None)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class TempoClient(LGTMClient):
|
|
146
|
+
def trace(self, trace_id: str) -> dict:
|
|
147
|
+
return self.get(f"/api/traces/{trace_id}")
|
|
148
|
+
|
|
149
|
+
def search(
|
|
150
|
+
self,
|
|
151
|
+
query: str | None = None,
|
|
152
|
+
start: str | None = None,
|
|
153
|
+
end: str | None = None,
|
|
154
|
+
min_duration: str | None = None,
|
|
155
|
+
max_duration: str | None = None,
|
|
156
|
+
limit: int = 20,
|
|
157
|
+
) -> dict:
|
|
158
|
+
params = {"limit": limit}
|
|
159
|
+
if query:
|
|
160
|
+
params["q"] = query
|
|
161
|
+
if start:
|
|
162
|
+
params["start"] = start
|
|
163
|
+
if end:
|
|
164
|
+
params["end"] = end
|
|
165
|
+
if min_duration:
|
|
166
|
+
params["minDuration"] = min_duration
|
|
167
|
+
if max_duration:
|
|
168
|
+
params["maxDuration"] = max_duration
|
|
169
|
+
return self.get("/api/search", params)
|
|
170
|
+
|
|
171
|
+
def tags(self) -> dict:
|
|
172
|
+
return self.get("/api/search/tags")
|
|
173
|
+
|
|
174
|
+
def tag_values(self, tag: str) -> dict:
|
|
175
|
+
return self.get(f"/api/search/tag/{tag}/values")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class AlertingClient(LGTMClient):
|
|
179
|
+
BASE_PATH = "/api/alertmanager/grafana/api/v2"
|
|
180
|
+
|
|
181
|
+
def list_alerts(
|
|
182
|
+
self,
|
|
183
|
+
filter: list[str] | None = None,
|
|
184
|
+
receiver: str | None = None,
|
|
185
|
+
silenced: bool = True,
|
|
186
|
+
inhibited: bool = True,
|
|
187
|
+
active: bool = True,
|
|
188
|
+
) -> list:
|
|
189
|
+
params = {
|
|
190
|
+
"silenced": str(silenced).lower(),
|
|
191
|
+
"inhibited": str(inhibited).lower(),
|
|
192
|
+
"active": str(active).lower(),
|
|
193
|
+
}
|
|
194
|
+
if filter:
|
|
195
|
+
params["filter"] = filter
|
|
196
|
+
if receiver:
|
|
197
|
+
params["receiver"] = receiver
|
|
198
|
+
return self.get(f"{self.BASE_PATH}/alerts", params)
|
|
199
|
+
|
|
200
|
+
def list_alert_groups(
|
|
201
|
+
self,
|
|
202
|
+
filter: list[str] | None = None,
|
|
203
|
+
receiver: str | None = None,
|
|
204
|
+
) -> list:
|
|
205
|
+
params = {}
|
|
206
|
+
if filter:
|
|
207
|
+
params["filter"] = filter
|
|
208
|
+
if receiver:
|
|
209
|
+
params["receiver"] = receiver
|
|
210
|
+
return self.get(f"{self.BASE_PATH}/alerts/groups", params or None)
|
|
211
|
+
|
|
212
|
+
def list_silences(self, filter: list[str] | None = None) -> list:
|
|
213
|
+
params = {}
|
|
214
|
+
if filter:
|
|
215
|
+
params["filter"] = filter
|
|
216
|
+
return self.get(f"{self.BASE_PATH}/silences", params or None)
|
|
217
|
+
|
|
218
|
+
def get_silence(self, silence_id: str) -> dict:
|
|
219
|
+
return self.get(f"{self.BASE_PATH}/silence/{silence_id}")
|
|
220
|
+
|
|
221
|
+
def create_silence(
|
|
222
|
+
self,
|
|
223
|
+
matchers: list[dict],
|
|
224
|
+
starts_at: str,
|
|
225
|
+
ends_at: str,
|
|
226
|
+
created_by: str,
|
|
227
|
+
comment: str,
|
|
228
|
+
) -> dict:
|
|
229
|
+
payload = {
|
|
230
|
+
"matchers": matchers,
|
|
231
|
+
"startsAt": starts_at,
|
|
232
|
+
"endsAt": ends_at,
|
|
233
|
+
"createdBy": created_by,
|
|
234
|
+
"comment": comment,
|
|
235
|
+
}
|
|
236
|
+
return self.post_json(f"{self.BASE_PATH}/silences", payload)
|
|
237
|
+
|
|
238
|
+
def delete_silence(self, silence_id: str) -> dict:
|
|
239
|
+
return self.delete(f"{self.BASE_PATH}/silence/{silence_id}")
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".config" / "lgtm" / "config.yaml"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ServiceConfig:
|
|
15
|
+
url: str
|
|
16
|
+
token: str | None = None
|
|
17
|
+
username: str | None = None
|
|
18
|
+
headers: dict[str, str] | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class InstanceConfig:
|
|
23
|
+
name: str
|
|
24
|
+
loki: ServiceConfig | None = None
|
|
25
|
+
prometheus: ServiceConfig | None = None
|
|
26
|
+
tempo: ServiceConfig | None = None
|
|
27
|
+
alerting: ServiceConfig | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Config:
|
|
32
|
+
version: str
|
|
33
|
+
default_instance: str | None
|
|
34
|
+
instances: dict[str, InstanceConfig]
|
|
35
|
+
|
|
36
|
+
def get_instance(self, name: str | None = None) -> InstanceConfig:
|
|
37
|
+
if name:
|
|
38
|
+
if name not in self.instances:
|
|
39
|
+
raise ValueError(f"Instance '{name}' not found in config")
|
|
40
|
+
return self.instances[name]
|
|
41
|
+
if self.default_instance:
|
|
42
|
+
return self.instances[self.default_instance]
|
|
43
|
+
return next(iter(self.instances.values()))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_1password_ref(ref: str) -> str:
|
|
47
|
+
"""Resolve a 1Password reference using the op CLI.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
ref: 1Password reference in format 'op://vault/item/field'
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The secret value from 1Password
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
RuntimeError: If op CLI fails or is not available
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
result = subprocess.run(
|
|
60
|
+
["op", "read", ref],
|
|
61
|
+
capture_output=True,
|
|
62
|
+
text=True,
|
|
63
|
+
check=True,
|
|
64
|
+
)
|
|
65
|
+
return result.stdout.strip()
|
|
66
|
+
except FileNotFoundError:
|
|
67
|
+
raise RuntimeError("1Password CLI (op) not found. Install it from https://1password.com/downloads/command-line/")
|
|
68
|
+
except subprocess.CalledProcessError as e:
|
|
69
|
+
raise RuntimeError(f"Failed to read from 1Password: {e.stderr.strip()}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve_secret(value: str) -> str:
|
|
73
|
+
"""Resolve secrets from environment variables or 1Password.
|
|
74
|
+
|
|
75
|
+
Supports:
|
|
76
|
+
- Environment variables: ${VAR_NAME}
|
|
77
|
+
- 1Password references: op://vault/item/field
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
value: The value to resolve
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The resolved value with secrets substituted
|
|
84
|
+
"""
|
|
85
|
+
# Check if entire value is a 1Password reference
|
|
86
|
+
if value.startswith("op://"):
|
|
87
|
+
return resolve_1password_ref(value)
|
|
88
|
+
|
|
89
|
+
# Handle ${op://...} pattern for 1Password within strings
|
|
90
|
+
op_pattern = r'\$\{(op://[^}]+)\}'
|
|
91
|
+
def replace_op(match):
|
|
92
|
+
return resolve_1password_ref(match.group(1))
|
|
93
|
+
value = re.sub(op_pattern, replace_op, value)
|
|
94
|
+
|
|
95
|
+
# Handle ${VAR_NAME} pattern for environment variables
|
|
96
|
+
env_pattern = r'\$\{([^}]+)\}'
|
|
97
|
+
def replace_env(match):
|
|
98
|
+
var_name = match.group(1)
|
|
99
|
+
return os.environ.get(var_name, "")
|
|
100
|
+
return re.sub(env_pattern, replace_env, value)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def parse_service_config(data: dict | None) -> ServiceConfig | None:
|
|
104
|
+
if not data:
|
|
105
|
+
return None
|
|
106
|
+
return ServiceConfig(
|
|
107
|
+
url=resolve_secret(data.get("url", "")),
|
|
108
|
+
token=resolve_secret(data["token"]) if data.get("token") else None,
|
|
109
|
+
username=resolve_secret(data["username"]) if data.get("username") else None,
|
|
110
|
+
headers={k: resolve_secret(v) for k, v in data.get("headers", {}).items()} or None,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_config(path: Path | None = None) -> Config:
|
|
115
|
+
config_path = path or DEFAULT_CONFIG_PATH
|
|
116
|
+
if not config_path.exists():
|
|
117
|
+
raise FileNotFoundError(f"Config file not found: {config_path}")
|
|
118
|
+
|
|
119
|
+
with open(config_path) as f:
|
|
120
|
+
data = yaml.safe_load(f)
|
|
121
|
+
|
|
122
|
+
instances = {}
|
|
123
|
+
for name, instance_data in data.get("instances", {}).items():
|
|
124
|
+
instances[name] = InstanceConfig(
|
|
125
|
+
name=name,
|
|
126
|
+
loki=parse_service_config(instance_data.get("loki")),
|
|
127
|
+
prometheus=parse_service_config(instance_data.get("prometheus")),
|
|
128
|
+
tempo=parse_service_config(instance_data.get("tempo")),
|
|
129
|
+
alerting=parse_service_config(instance_data.get("alerting")),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return Config(
|
|
133
|
+
version=data.get("version", "1"),
|
|
134
|
+
default_instance=data.get("default_instance"),
|
|
135
|
+
instances=instances,
|
|
136
|
+
)
|
|
File without changes
|