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.
@@ -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.
@@ -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