devlogs 2.1.0__tar.gz → 2.2.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.
- {devlogs-2.1.0/src/devlogs.egg-info → devlogs-2.2.0}/PKG-INFO +1 -1
- {devlogs-2.1.0 → devlogs-2.2.0}/pyproject.toml +1 -1
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/cli.py +6 -2
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/collector/forwarder.py +2 -4
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/collector/server.py +1 -12
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/config.py +46 -16
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/demo.py +35 -1
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/devlogs_client.py +1 -2
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/handler.py +19 -7
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/mcp/server.py +64 -55
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/opensearch/queries.py +31 -12
- {devlogs-2.1.0 → devlogs-2.2.0/src/devlogs.egg-info}/PKG-INFO +1 -1
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_collector_server.py +24 -42
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_config.py +9 -7
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_devlogs_client.py +5 -5
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_handler.py +79 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_mcp_server.py +21 -4
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_opensearch_queries.py +111 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_url_parsing.py +43 -6
- {devlogs-2.1.0 → devlogs-2.2.0}/LICENSE +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/MANIFEST.in +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/README.md +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/setup.cfg +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/__init__.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/__main__.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/build_info.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/collector/__init__.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/collector/auth.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/collector/cli.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/collector/errors.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/collector/ingestor.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/collector/schema.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/context.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/formatting.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/jenkins/__init__.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/jenkins/cli.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/jenkins/core.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/levels.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/mcp/__init__.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/opensearch/__init__.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/opensearch/client.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/opensearch/indexing.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/opensearch/mappings.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/retention.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/scrub.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/time_utils.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/version.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/web/__init__.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/web/server.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/web/static/devlogs.css +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/web/static/devlogs.js +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/web/static/index.html +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/wrapper.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs.egg-info/SOURCES.txt +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs.egg-info/dependency_links.txt +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs.egg-info/entry_points.txt +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs.egg-info/requires.txt +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs.egg-info/top_level.txt +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_build_info.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_cli.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_collector_auth.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_collector_config.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_collector_schema.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_context.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_formatting.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_indexing.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_levels.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_mappings.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_opensearch_client.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_retention.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_scrub.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_time_utils.py +0 -0
- {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_web.py +0 -0
|
@@ -728,6 +728,7 @@ def tail(
|
|
|
728
728
|
since=since,
|
|
729
729
|
limit=limit,
|
|
730
730
|
search_after=search_after,
|
|
731
|
+
application=cfg.application,
|
|
731
732
|
)
|
|
732
733
|
_verbose_echo(f"Received {len(docs)} docs, next cursor={search_after}")
|
|
733
734
|
if verbose and docs:
|
|
@@ -851,6 +852,7 @@ def search(
|
|
|
851
852
|
since=since,
|
|
852
853
|
limit=limit,
|
|
853
854
|
search_after=search_after,
|
|
855
|
+
application=cfg.application,
|
|
854
856
|
)
|
|
855
857
|
else:
|
|
856
858
|
docs = search_logs(
|
|
@@ -862,6 +864,7 @@ def search(
|
|
|
862
864
|
level=level,
|
|
863
865
|
since=since,
|
|
864
866
|
limit=limit,
|
|
867
|
+
application=cfg.application,
|
|
865
868
|
)
|
|
866
869
|
entries = normalize_log_entries(docs, limit=limit)
|
|
867
870
|
consecutive_errors = 0
|
|
@@ -947,6 +950,7 @@ def last_error(
|
|
|
947
950
|
since=since,
|
|
948
951
|
until=until,
|
|
949
952
|
limit=limit,
|
|
953
|
+
application=cfg.application,
|
|
950
954
|
)
|
|
951
955
|
entries = normalize_log_entries(docs, limit=limit)
|
|
952
956
|
except (ConnectionFailedError, urllib.error.URLError) as e:
|
|
@@ -1244,7 +1248,7 @@ def mkurl():
|
|
|
1244
1248
|
the components (host, port, credentials, index) one by one.
|
|
1245
1249
|
|
|
1246
1250
|
The output shows three equivalent formats:
|
|
1247
|
-
- A bare URL (for --url flag or
|
|
1251
|
+
- A bare URL (for --url flag or DEVLOGS_URL)
|
|
1248
1252
|
- The URL as a single .env variable
|
|
1249
1253
|
- Individual .env variables for each component
|
|
1250
1254
|
"""
|
|
@@ -1314,7 +1318,7 @@ def mkurl():
|
|
|
1314
1318
|
typer.echo()
|
|
1315
1319
|
typer.echo(typer.style("2. Single .env variable:", fg=typer.colors.CYAN, bold=True))
|
|
1316
1320
|
typer.echo("-" * 50)
|
|
1317
|
-
typer.echo(f"
|
|
1321
|
+
typer.echo(f"DEVLOGS_URL={url}")
|
|
1318
1322
|
|
|
1319
1323
|
# Format 3: Individual .env variables
|
|
1320
1324
|
typer.echo()
|
|
@@ -24,7 +24,7 @@ def forward_request(
|
|
|
24
24
|
Forwards the request body as-is, preserving relevant headers.
|
|
25
25
|
|
|
26
26
|
Args:
|
|
27
|
-
forward_url: The upstream URL to forward to
|
|
27
|
+
forward_url: The upstream URL to forward to
|
|
28
28
|
body: The raw request body bytes
|
|
29
29
|
content_type: The Content-Type header value
|
|
30
30
|
auth_header: Optional Authorization header to forward
|
|
@@ -37,9 +37,7 @@ def forward_request(
|
|
|
37
37
|
Raises:
|
|
38
38
|
ForwardError: If the forward request fails
|
|
39
39
|
"""
|
|
40
|
-
|
|
41
|
-
if not forward_url.endswith("/v1/logs"):
|
|
42
|
-
forward_url = forward_url.rstrip("/") + "/v1/logs"
|
|
40
|
+
forward_url = forward_url.rstrip("/")
|
|
43
41
|
|
|
44
42
|
headers = {
|
|
45
43
|
"Content-Type": content_type,
|
|
@@ -139,19 +139,8 @@ async def collector_error_handler(request: Request, exc: CollectorError):
|
|
|
139
139
|
)
|
|
140
140
|
|
|
141
141
|
|
|
142
|
-
@app.get("/health")
|
|
143
|
-
async def health():
|
|
144
|
-
"""Health check endpoint."""
|
|
145
|
-
cfg = load_config()
|
|
146
|
-
mode = cfg.get_collector_mode()
|
|
147
|
-
return {
|
|
148
|
-
"status": "healthy",
|
|
149
|
-
"mode": mode,
|
|
150
|
-
"version": __version__,
|
|
151
|
-
}
|
|
152
|
-
|
|
153
142
|
|
|
154
|
-
@app.post("/
|
|
143
|
+
@app.post("/")
|
|
155
144
|
async def ingest_logs(request: Request):
|
|
156
145
|
"""Ingest log records.
|
|
157
146
|
|
|
@@ -311,7 +311,9 @@ def _parse_opensearch_url(url: str):
|
|
|
311
311
|
# URL-decode username and password since urlparse doesn't do this automatically
|
|
312
312
|
user = unquote(parsed.username) if parsed.username else None
|
|
313
313
|
password = unquote(parsed.password) if parsed.password else None
|
|
314
|
-
|
|
314
|
+
path = parsed.path.strip("/")
|
|
315
|
+
parts = path.split("/", 1) if path else []
|
|
316
|
+
index = parts[0] if parts else None
|
|
315
317
|
return (scheme, host, port, user, password, index)
|
|
316
318
|
|
|
317
319
|
|
|
@@ -327,18 +329,41 @@ class DevlogsConfig:
|
|
|
327
329
|
def __init__(self, enabled: bool = True):
|
|
328
330
|
self.enabled = enabled
|
|
329
331
|
|
|
330
|
-
# Collector URL (where apps send logs)
|
|
331
|
-
self.collector_url = _getenv("DEVLOGS_URL", "")
|
|
332
|
-
|
|
333
332
|
# Forward URL (if set, collector operates in forward mode)
|
|
334
333
|
self.forward_url = _getenv("DEVLOGS_FORWARD_URL", "")
|
|
335
334
|
|
|
336
|
-
#
|
|
337
|
-
#
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if
|
|
341
|
-
|
|
335
|
+
# DEVLOGS_URL is the standard env var. It auto-detects collector vs OpenSearch
|
|
336
|
+
# via parse_url(). DEVLOGS_OPENSEARCH_URL is a legacy alias for OpenSearch URLs.
|
|
337
|
+
self.collector_url = ""
|
|
338
|
+
self.application = None
|
|
339
|
+
opensearch_url_config = None # result from _parse_opensearch_url if found
|
|
340
|
+
|
|
341
|
+
devlogs_url = os.getenv("DEVLOGS_URL", "")
|
|
342
|
+
legacy_opensearch_url = os.getenv("DEVLOGS_OPENSEARCH_URL", "")
|
|
343
|
+
|
|
344
|
+
if devlogs_url:
|
|
345
|
+
try:
|
|
346
|
+
parsed = parse_url(devlogs_url)
|
|
347
|
+
if isinstance(parsed, CollectorURLConfig):
|
|
348
|
+
self.collector_url = devlogs_url
|
|
349
|
+
else:
|
|
350
|
+
# OpenSearch URL via DEVLOGS_URL
|
|
351
|
+
opensearch_url_config = _parse_opensearch_url(devlogs_url)
|
|
352
|
+
self.application = parsed.application
|
|
353
|
+
except URLParseError:
|
|
354
|
+
# Treat unparseable DEVLOGS_URL as legacy OpenSearch
|
|
355
|
+
opensearch_url_config = _parse_opensearch_url(devlogs_url)
|
|
356
|
+
|
|
357
|
+
# Legacy: DEVLOGS_OPENSEARCH_URL overrides OpenSearch settings from DEVLOGS_URL
|
|
358
|
+
if legacy_opensearch_url:
|
|
359
|
+
opensearch_url_config = _parse_opensearch_url(legacy_opensearch_url)
|
|
360
|
+
# Parse application filter from opensearch:// URL (second path segment)
|
|
361
|
+
if legacy_opensearch_url.startswith("opensearchs://") or legacy_opensearch_url.startswith("opensearch://"):
|
|
362
|
+
parsed_os = _parse_opensearch_scheme_url(legacy_opensearch_url)
|
|
363
|
+
self.application = parsed_os.application
|
|
364
|
+
|
|
365
|
+
if opensearch_url_config:
|
|
366
|
+
scheme, host, port, url_user, url_pass, url_index = opensearch_url_config
|
|
342
367
|
self.opensearch_scheme = scheme
|
|
343
368
|
self.opensearch_host = host
|
|
344
369
|
self.opensearch_port = port
|
|
@@ -406,6 +431,15 @@ class DevlogsConfig:
|
|
|
406
431
|
|
|
407
432
|
def has_opensearch_config(self) -> bool:
|
|
408
433
|
"""Check if OpenSearch admin connection is configured."""
|
|
434
|
+
# DEVLOGS_URL with an OpenSearch URL also counts
|
|
435
|
+
devlogs_url = os.getenv("DEVLOGS_URL", "")
|
|
436
|
+
if devlogs_url:
|
|
437
|
+
try:
|
|
438
|
+
parsed = parse_url(devlogs_url)
|
|
439
|
+
if isinstance(parsed, OpenSearchURLConfig):
|
|
440
|
+
return True
|
|
441
|
+
except URLParseError:
|
|
442
|
+
pass
|
|
409
443
|
return bool(
|
|
410
444
|
os.getenv("DEVLOGS_OPENSEARCH_URL") or
|
|
411
445
|
os.getenv("DEVLOGS_OPENSEARCH_HOST")
|
|
@@ -449,12 +483,8 @@ def set_url(url: str):
|
|
|
449
483
|
os.environ["DEVLOGS_OPENSEARCH_URL"] = url
|
|
450
484
|
return
|
|
451
485
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
os.environ["DEVLOGS_URL"] = url
|
|
455
|
-
else:
|
|
456
|
-
# OpenSearch URL - reconstruct with the correct scheme for _parse_opensearch_url
|
|
457
|
-
os.environ["DEVLOGS_OPENSEARCH_URL"] = url
|
|
486
|
+
# Always set DEVLOGS_URL as the standard env var
|
|
487
|
+
os.environ["DEVLOGS_URL"] = url
|
|
458
488
|
|
|
459
489
|
def load_config() -> DevlogsConfig:
|
|
460
490
|
"""Return a config object with all settings loaded."""
|
|
@@ -7,7 +7,7 @@ import typer
|
|
|
7
7
|
|
|
8
8
|
from .config import load_config
|
|
9
9
|
from .context import operation
|
|
10
|
-
from .handler import OpenSearchHandler
|
|
10
|
+
from .handler import OpenSearchHandler, DevlogsHandler
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def run_demo(
|
|
@@ -25,6 +25,23 @@ def run_demo(
|
|
|
25
25
|
typer.echo(f" Collector URL: {collector_url}")
|
|
26
26
|
typer.echo("")
|
|
27
27
|
|
|
28
|
+
# Validate first write before starting
|
|
29
|
+
from .devlogs_client import DevlogsClient
|
|
30
|
+
test_client = DevlogsClient(
|
|
31
|
+
collector_url=collector_url,
|
|
32
|
+
application="devlogs-demo",
|
|
33
|
+
component="connectivity-check",
|
|
34
|
+
timeout=5,
|
|
35
|
+
)
|
|
36
|
+
if not test_client.emit(message="Demo starting", level="info"):
|
|
37
|
+
typer.echo(typer.style(
|
|
38
|
+
f" Error: failed to write to collector at {test_client._get_endpoint()}",
|
|
39
|
+
fg=typer.colors.RED,
|
|
40
|
+
))
|
|
41
|
+
raise typer.Exit(1)
|
|
42
|
+
typer.echo(typer.style(" First write succeeded.", fg=typer.colors.GREEN))
|
|
43
|
+
typer.echo("")
|
|
44
|
+
|
|
28
45
|
handler = OpenSearchHandler(
|
|
29
46
|
level=logging.DEBUG,
|
|
30
47
|
collector_url=collector_url,
|
|
@@ -229,6 +246,9 @@ def run_demo(
|
|
|
229
246
|
|
|
230
247
|
generated += 1
|
|
231
248
|
|
|
249
|
+
# Reset counters before starting
|
|
250
|
+
DevlogsHandler.reset_counters()
|
|
251
|
+
|
|
232
252
|
# Main loop: emit logs and show countdown
|
|
233
253
|
while generated < count:
|
|
234
254
|
check_countdown()
|
|
@@ -238,4 +258,18 @@ def run_demo(
|
|
|
238
258
|
elapsed = time.time() - start_time
|
|
239
259
|
typer.echo(typer.style(f"\n--- Demo complete! ---", fg=typer.colors.GREEN))
|
|
240
260
|
typer.echo(f"Generated {generated} log entries in {elapsed:.1f} seconds.")
|
|
261
|
+
|
|
262
|
+
# Report send results
|
|
263
|
+
sent = DevlogsHandler._emit_count
|
|
264
|
+
errors = DevlogsHandler._emit_errors
|
|
265
|
+
skipped = DevlogsHandler._emit_skipped
|
|
266
|
+
succeeded = sent - errors - skipped
|
|
267
|
+
if errors or skipped:
|
|
268
|
+
typer.echo(typer.style(
|
|
269
|
+
f" Delivered: {succeeded}/{sent} (errors: {errors}, skipped: {skipped})",
|
|
270
|
+
fg=typer.colors.RED,
|
|
271
|
+
))
|
|
272
|
+
else:
|
|
273
|
+
typer.echo(typer.style(f" Delivered: {succeeded}/{sent}", fg=typer.colors.GREEN))
|
|
274
|
+
|
|
241
275
|
typer.echo(f"View logs with: devlogs tail --follow")
|
|
@@ -161,8 +161,7 @@ class DevlogsClient:
|
|
|
161
161
|
|
|
162
162
|
def _get_endpoint(self) -> str:
|
|
163
163
|
"""Get the collector endpoint URL."""
|
|
164
|
-
|
|
165
|
-
return f"{base}/v1/logs"
|
|
164
|
+
return self._clean_url.rstrip("/")
|
|
166
165
|
|
|
167
166
|
def _get_headers(self) -> Dict[str, str]:
|
|
168
167
|
"""Get request headers."""
|
|
@@ -76,6 +76,9 @@ class DevlogsHandler(logging.Handler):
|
|
|
76
76
|
_circuit_breaker_duration = 60.0 # seconds to wait before retrying
|
|
77
77
|
_last_error_printed = 0.0
|
|
78
78
|
_error_print_interval = 10.0 # only print errors every 10 seconds
|
|
79
|
+
_emit_count = 0
|
|
80
|
+
_emit_errors = 0
|
|
81
|
+
_emit_skipped = 0
|
|
79
82
|
|
|
80
83
|
def __init__(
|
|
81
84
|
self,
|
|
@@ -119,17 +122,22 @@ class DevlogsHandler(logging.Handler):
|
|
|
119
122
|
except Exception:
|
|
120
123
|
parsed = None
|
|
121
124
|
if isinstance(parsed, CollectorURLConfig):
|
|
122
|
-
|
|
123
|
-
self._collector_endpoint = f"{base}/v1/logs"
|
|
125
|
+
self._collector_endpoint = parsed.url.rstrip("/")
|
|
124
126
|
self._collector_headers = {"Content-Type": "application/json"}
|
|
125
127
|
if parsed.token:
|
|
126
128
|
self._collector_headers["Authorization"] = f"Bearer {parsed.token}"
|
|
127
129
|
else:
|
|
128
130
|
# Treat as plain collector URL without token extraction
|
|
129
|
-
|
|
130
|
-
self._collector_endpoint = f"{base}/v1/logs"
|
|
131
|
+
self._collector_endpoint = collector_url.rstrip("/")
|
|
131
132
|
self._collector_headers = {"Content-Type": "application/json"}
|
|
132
133
|
|
|
134
|
+
@classmethod
|
|
135
|
+
def reset_counters(cls):
|
|
136
|
+
"""Reset emit counters."""
|
|
137
|
+
cls._emit_count = 0
|
|
138
|
+
cls._emit_errors = 0
|
|
139
|
+
cls._emit_skipped = 0
|
|
140
|
+
|
|
133
141
|
def emit(self, record: logging.LogRecord) -> None:
|
|
134
142
|
"""Emit a log record to OpenSearch or a collector endpoint."""
|
|
135
143
|
# Build log document
|
|
@@ -137,13 +145,14 @@ class DevlogsHandler(logging.Handler):
|
|
|
137
145
|
|
|
138
146
|
# Circuit breaker: skip indexing if we know the target is unavailable
|
|
139
147
|
current_time = time.time()
|
|
148
|
+
DevlogsHandler._emit_count += 1
|
|
140
149
|
if DevlogsHandler._circuit_open and current_time < DevlogsHandler._circuit_open_until:
|
|
141
|
-
|
|
150
|
+
DevlogsHandler._emit_skipped += 1
|
|
142
151
|
return
|
|
143
152
|
|
|
144
153
|
try:
|
|
145
154
|
if self._collector_endpoint:
|
|
146
|
-
# Collector mode: POST to
|
|
155
|
+
# Collector mode: POST to collector endpoint
|
|
147
156
|
data = json.dumps(doc).encode("utf-8")
|
|
148
157
|
req = urllib.request.Request(
|
|
149
158
|
self._collector_endpoint,
|
|
@@ -164,6 +173,7 @@ class DevlogsHandler(logging.Handler):
|
|
|
164
173
|
DevlogsHandler._circuit_open = False
|
|
165
174
|
print(f"[devlogs] Connection restored, resuming indexing")
|
|
166
175
|
except Exception as e:
|
|
176
|
+
DevlogsHandler._emit_errors += 1
|
|
167
177
|
# Open circuit breaker to prevent further attempts
|
|
168
178
|
DevlogsHandler._circuit_open = True
|
|
169
179
|
DevlogsHandler._circuit_open_until = current_time + DevlogsHandler._circuit_breaker_duration
|
|
@@ -259,8 +269,9 @@ class DiagnosticsHandler(DevlogsHandler):
|
|
|
259
269
|
def emit(self, record: logging.LogRecord) -> None:
|
|
260
270
|
# Circuit breaker: skip indexing if we know the index is unavailable
|
|
261
271
|
current_time = time.time()
|
|
272
|
+
DevlogsHandler._emit_count += 1
|
|
262
273
|
if DevlogsHandler._circuit_open and current_time < DevlogsHandler._circuit_open_until:
|
|
263
|
-
|
|
274
|
+
DevlogsHandler._emit_skipped += 1
|
|
264
275
|
return
|
|
265
276
|
|
|
266
277
|
doc = self.format_record(record)
|
|
@@ -279,6 +290,7 @@ class DiagnosticsHandler(DevlogsHandler):
|
|
|
279
290
|
DevlogsHandler._circuit_open = False
|
|
280
291
|
print(f"[devlogs] Connection restored, resuming indexing")
|
|
281
292
|
except Exception as e:
|
|
293
|
+
DevlogsHandler._emit_errors += 1
|
|
282
294
|
# Open circuit breaker to prevent further attempts
|
|
283
295
|
DevlogsHandler._circuit_open = True
|
|
284
296
|
DevlogsHandler._circuit_open_until = current_time + DevlogsHandler._circuit_breaker_duration
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
"""MCP server for devlogs - allows AI assistants to search and analyze logs."""
|
|
2
|
-
|
|
1
|
+
"""MCP server for devlogs - allows AI assistants to search and analyze logs."""
|
|
2
|
+
|
|
3
3
|
import asyncio
|
|
4
4
|
import json
|
|
5
5
|
from typing import Any
|
|
@@ -33,11 +33,11 @@ from ..opensearch.queries import (
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def _create_client_and_index():
|
|
36
|
-
"""Create OpenSearch client and get index name from config."""
|
|
36
|
+
"""Create OpenSearch client and get index name and application filter from config."""
|
|
37
37
|
try:
|
|
38
38
|
client = get_opensearch_client()
|
|
39
39
|
cfg = load_config()
|
|
40
|
-
return client, cfg.index
|
|
40
|
+
return client, cfg.index, cfg.application
|
|
41
41
|
except DevlogsDisabledError as e:
|
|
42
42
|
raise RuntimeError(str(e))
|
|
43
43
|
except ConnectionFailedError as e:
|
|
@@ -116,7 +116,7 @@ def _error_response(message: str, error_type: str = "Error") -> list[types.TextC
|
|
|
116
116
|
async def main():
|
|
117
117
|
"""Run the MCP server."""
|
|
118
118
|
server = Server("devlogs")
|
|
119
|
-
|
|
119
|
+
|
|
120
120
|
@server.list_tools()
|
|
121
121
|
async def handle_list_tools() -> list[types.Tool]:
|
|
122
122
|
"""List available MCP tools."""
|
|
@@ -266,24 +266,24 @@ async def main():
|
|
|
266
266
|
description="List recent operations with summary stats. Use this to discover operations without knowing their IDs.",
|
|
267
267
|
inputSchema={
|
|
268
268
|
"type": "object",
|
|
269
|
-
"properties": {
|
|
270
|
-
"area": {
|
|
271
|
-
"type": "string",
|
|
272
|
-
"description": "Filter by application area",
|
|
273
|
-
},
|
|
269
|
+
"properties": {
|
|
270
|
+
"area": {
|
|
271
|
+
"type": "string",
|
|
272
|
+
"description": "Filter by application area",
|
|
273
|
+
},
|
|
274
274
|
"since": {
|
|
275
275
|
"type": "string",
|
|
276
276
|
"description": "ISO timestamp or relative duration like '1h' to filter operations after this time",
|
|
277
277
|
},
|
|
278
|
-
"limit": {
|
|
279
|
-
"type": "integer",
|
|
280
|
-
"description": "Maximum number of operations to return (default: 20)",
|
|
281
|
-
"default": 20,
|
|
282
|
-
},
|
|
283
|
-
"with_errors_only": {
|
|
284
|
-
"type": "boolean",
|
|
285
|
-
"description": "Only show operations that had errors",
|
|
286
|
-
"default": False,
|
|
278
|
+
"limit": {
|
|
279
|
+
"type": "integer",
|
|
280
|
+
"description": "Maximum number of operations to return (default: 20)",
|
|
281
|
+
"default": 20,
|
|
282
|
+
},
|
|
283
|
+
"with_errors_only": {
|
|
284
|
+
"type": "boolean",
|
|
285
|
+
"description": "Only show operations that had errors",
|
|
286
|
+
"default": False,
|
|
287
287
|
},
|
|
288
288
|
},
|
|
289
289
|
},
|
|
@@ -329,15 +329,15 @@ async def main():
|
|
|
329
329
|
description="List all application areas with activity counts. Use this to discover what subsystems exist in the application.",
|
|
330
330
|
inputSchema={
|
|
331
331
|
"type": "object",
|
|
332
|
-
"properties": {
|
|
332
|
+
"properties": {
|
|
333
333
|
"since": {
|
|
334
334
|
"type": "string",
|
|
335
335
|
"description": "ISO timestamp or relative duration like '1h' to filter activity after this time",
|
|
336
336
|
},
|
|
337
|
-
"min_operations": {
|
|
338
|
-
"type": "integer",
|
|
339
|
-
"description": "Minimum number of operations an area must have to be included",
|
|
340
|
-
"default": 1,
|
|
337
|
+
"min_operations": {
|
|
338
|
+
"type": "integer",
|
|
339
|
+
"description": "Minimum number of operations an area must have to be included",
|
|
340
|
+
"default": 1,
|
|
341
341
|
},
|
|
342
342
|
},
|
|
343
343
|
},
|
|
@@ -457,17 +457,17 @@ async def main():
|
|
|
457
457
|
},
|
|
458
458
|
),
|
|
459
459
|
]
|
|
460
|
-
|
|
461
|
-
@server.call_tool()
|
|
462
|
-
async def handle_call_tool(
|
|
463
|
-
name: str, arguments: dict | None
|
|
464
|
-
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
|
|
465
|
-
"""Handle tool calls."""
|
|
466
|
-
if arguments is None:
|
|
467
|
-
arguments = {}
|
|
468
|
-
|
|
460
|
+
|
|
461
|
+
@server.call_tool()
|
|
462
|
+
async def handle_call_tool(
|
|
463
|
+
name: str, arguments: dict | None
|
|
464
|
+
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
|
|
465
|
+
"""Handle tool calls."""
|
|
466
|
+
if arguments is None:
|
|
467
|
+
arguments = {}
|
|
468
|
+
|
|
469
469
|
try:
|
|
470
|
-
client, index = _create_client_and_index()
|
|
470
|
+
client, index, application = _create_client_and_index()
|
|
471
471
|
except RuntimeError as e:
|
|
472
472
|
return _error_response(str(e), "InitializationError")
|
|
473
473
|
|
|
@@ -494,6 +494,7 @@ async def main():
|
|
|
494
494
|
limit=limit,
|
|
495
495
|
cursor=cursor,
|
|
496
496
|
sort_order="desc",
|
|
497
|
+
application=application,
|
|
497
498
|
)
|
|
498
499
|
entries = _normalize_entries(docs, limit=limit)
|
|
499
500
|
|
|
@@ -531,6 +532,7 @@ async def main():
|
|
|
531
532
|
until=until,
|
|
532
533
|
limit=limit,
|
|
533
534
|
search_after=cursor,
|
|
535
|
+
application=application,
|
|
534
536
|
)
|
|
535
537
|
entries = _normalize_entries(docs, limit=limit)
|
|
536
538
|
|
|
@@ -552,7 +554,7 @@ async def main():
|
|
|
552
554
|
return _error_response("operation_id is required", "ValidationError")
|
|
553
555
|
|
|
554
556
|
try:
|
|
555
|
-
summary = get_operation_summary(client, index, operation_id)
|
|
557
|
+
summary = get_operation_summary(client, index, operation_id, application=application)
|
|
556
558
|
|
|
557
559
|
if not summary:
|
|
558
560
|
return _json_response(
|
|
@@ -591,6 +593,7 @@ async def main():
|
|
|
591
593
|
until=until,
|
|
592
594
|
limit=limit,
|
|
593
595
|
cursor=cursor,
|
|
596
|
+
application=application,
|
|
594
597
|
)
|
|
595
598
|
entries = _normalize_entries(docs, limit=limit)
|
|
596
599
|
|
|
@@ -614,11 +617,12 @@ async def main():
|
|
|
614
617
|
try:
|
|
615
618
|
operations = list_operations(
|
|
616
619
|
client=client,
|
|
617
|
-
index=index,
|
|
618
|
-
area=area,
|
|
619
|
-
since=since,
|
|
620
|
-
limit=limit,
|
|
620
|
+
index=index,
|
|
621
|
+
area=area,
|
|
622
|
+
since=since,
|
|
623
|
+
limit=limit,
|
|
621
624
|
with_errors_only=with_errors_only,
|
|
625
|
+
application=application,
|
|
622
626
|
)
|
|
623
627
|
|
|
624
628
|
return _json_response(
|
|
@@ -649,6 +653,7 @@ async def main():
|
|
|
649
653
|
limit=limit,
|
|
650
654
|
order_by=order_by,
|
|
651
655
|
with_errors_only=with_errors_only,
|
|
656
|
+
application=application,
|
|
652
657
|
)
|
|
653
658
|
|
|
654
659
|
return _json_response(
|
|
@@ -665,11 +670,12 @@ async def main():
|
|
|
665
670
|
min_operations = arguments.get("min_operations", 1)
|
|
666
671
|
|
|
667
672
|
try:
|
|
668
|
-
areas = list_areas(
|
|
669
|
-
client=client,
|
|
670
|
-
index=index,
|
|
671
|
-
since=since,
|
|
673
|
+
areas = list_areas(
|
|
674
|
+
client=client,
|
|
675
|
+
index=index,
|
|
676
|
+
since=since,
|
|
672
677
|
min_operations=min_operations,
|
|
678
|
+
application=application,
|
|
673
679
|
)
|
|
674
680
|
|
|
675
681
|
return _json_response(
|
|
@@ -702,6 +708,7 @@ async def main():
|
|
|
702
708
|
limit=limit,
|
|
703
709
|
min_count=min_count,
|
|
704
710
|
include_missing=include_missing,
|
|
711
|
+
application=application,
|
|
705
712
|
)
|
|
706
713
|
return _json_response(
|
|
707
714
|
data={"signatures": signatures},
|
|
@@ -730,6 +737,7 @@ async def main():
|
|
|
730
737
|
since=since,
|
|
731
738
|
until=until,
|
|
732
739
|
limit=limit,
|
|
740
|
+
application=application,
|
|
733
741
|
)
|
|
734
742
|
entries = _normalize_entries(docs, limit=limit)
|
|
735
743
|
return _json_response(
|
|
@@ -766,6 +774,7 @@ async def main():
|
|
|
766
774
|
level=level,
|
|
767
775
|
before=before,
|
|
768
776
|
after=after,
|
|
777
|
+
application=application,
|
|
769
778
|
)
|
|
770
779
|
entries = _normalize_entries(docs)
|
|
771
780
|
return _json_response(
|
|
@@ -781,15 +790,15 @@ async def main():
|
|
|
781
790
|
|
|
782
791
|
else:
|
|
783
792
|
raise ValueError(f"Unknown tool: {name}")
|
|
784
|
-
|
|
785
|
-
# Run the server using stdio transport
|
|
786
|
-
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
|
|
787
|
-
await server.run(
|
|
788
|
-
read_stream,
|
|
789
|
-
write_stream,
|
|
790
|
-
server.create_initialization_options()
|
|
791
|
-
)
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
if __name__ == "__main__":
|
|
795
|
-
asyncio.run(main())
|
|
793
|
+
|
|
794
|
+
# Run the server using stdio transport
|
|
795
|
+
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
|
|
796
|
+
await server.run(
|
|
797
|
+
read_stream,
|
|
798
|
+
write_stream,
|
|
799
|
+
server.create_initialization_options()
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
if __name__ == "__main__":
|
|
804
|
+
asyncio.run(main())
|