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.
Files changed (73) hide show
  1. {devlogs-2.1.0/src/devlogs.egg-info → devlogs-2.2.0}/PKG-INFO +1 -1
  2. {devlogs-2.1.0 → devlogs-2.2.0}/pyproject.toml +1 -1
  3. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/cli.py +6 -2
  4. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/collector/forwarder.py +2 -4
  5. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/collector/server.py +1 -12
  6. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/config.py +46 -16
  7. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/demo.py +35 -1
  8. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/devlogs_client.py +1 -2
  9. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/handler.py +19 -7
  10. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/mcp/server.py +64 -55
  11. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/opensearch/queries.py +31 -12
  12. {devlogs-2.1.0 → devlogs-2.2.0/src/devlogs.egg-info}/PKG-INFO +1 -1
  13. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_collector_server.py +24 -42
  14. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_config.py +9 -7
  15. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_devlogs_client.py +5 -5
  16. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_handler.py +79 -0
  17. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_mcp_server.py +21 -4
  18. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_opensearch_queries.py +111 -0
  19. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_url_parsing.py +43 -6
  20. {devlogs-2.1.0 → devlogs-2.2.0}/LICENSE +0 -0
  21. {devlogs-2.1.0 → devlogs-2.2.0}/MANIFEST.in +0 -0
  22. {devlogs-2.1.0 → devlogs-2.2.0}/README.md +0 -0
  23. {devlogs-2.1.0 → devlogs-2.2.0}/setup.cfg +0 -0
  24. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/__init__.py +0 -0
  25. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/__main__.py +0 -0
  26. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/build_info.py +0 -0
  27. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/collector/__init__.py +0 -0
  28. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/collector/auth.py +0 -0
  29. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/collector/cli.py +0 -0
  30. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/collector/errors.py +0 -0
  31. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/collector/ingestor.py +0 -0
  32. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/collector/schema.py +0 -0
  33. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/context.py +0 -0
  34. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/formatting.py +0 -0
  35. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/jenkins/__init__.py +0 -0
  36. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/jenkins/cli.py +0 -0
  37. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/jenkins/core.py +0 -0
  38. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/levels.py +0 -0
  39. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/mcp/__init__.py +0 -0
  40. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/opensearch/__init__.py +0 -0
  41. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/opensearch/client.py +0 -0
  42. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/opensearch/indexing.py +0 -0
  43. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/opensearch/mappings.py +0 -0
  44. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/retention.py +0 -0
  45. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/scrub.py +0 -0
  46. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/time_utils.py +0 -0
  47. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/version.py +0 -0
  48. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/web/__init__.py +0 -0
  49. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/web/server.py +0 -0
  50. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/web/static/devlogs.css +0 -0
  51. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/web/static/devlogs.js +0 -0
  52. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/web/static/index.html +0 -0
  53. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs/wrapper.py +0 -0
  54. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs.egg-info/SOURCES.txt +0 -0
  55. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs.egg-info/dependency_links.txt +0 -0
  56. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs.egg-info/entry_points.txt +0 -0
  57. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs.egg-info/requires.txt +0 -0
  58. {devlogs-2.1.0 → devlogs-2.2.0}/src/devlogs.egg-info/top_level.txt +0 -0
  59. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_build_info.py +0 -0
  60. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_cli.py +0 -0
  61. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_collector_auth.py +0 -0
  62. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_collector_config.py +0 -0
  63. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_collector_schema.py +0 -0
  64. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_context.py +0 -0
  65. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_formatting.py +0 -0
  66. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_indexing.py +0 -0
  67. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_levels.py +0 -0
  68. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_mappings.py +0 -0
  69. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_opensearch_client.py +0 -0
  70. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_retention.py +0 -0
  71. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_scrub.py +0 -0
  72. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_time_utils.py +0 -0
  73. {devlogs-2.1.0 → devlogs-2.2.0}/tests/test_web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlogs
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Developer-focused logging library for Python with OpenSearch integration.
5
5
  Author-email: Dan Driscoll <dan@thedandriscoll.org>
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devlogs"
7
- version = "2.1.0"
7
+ version = "2.2.0"
8
8
  description = "Developer-focused logging library for Python with OpenSearch integration."
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -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 DEVLOGS_OPENSEARCH_URL)
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"DEVLOGS_OPENSEARCH_URL={url}")
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 (should end with /v1/logs)
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
- # Ensure URL ends with /v1/logs
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("/v1/logs")
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
- index = parsed.path.strip("/") or None
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
- # Check for URL shortcut first - it overrides individual settings
337
- # This is the admin OpenSearch connection used for search/tail/UI/CLI and ingest mode
338
- url_config = _parse_opensearch_url(os.getenv("DEVLOGS_OPENSEARCH_URL", ""))
339
-
340
- if url_config:
341
- scheme, host, port, url_user, url_pass, url_index = url_config
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
- if isinstance(parsed, CollectorURLConfig):
453
- # Reconstruct the full collector URL for DEVLOGS_URL
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
- base = self._clean_url.rstrip("/")
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
- base = parsed.url.rstrip("/")
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
- base = collector_url.rstrip("/")
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
- # Silently fail - circuit is open
150
+ DevlogsHandler._emit_skipped += 1
142
151
  return
143
152
 
144
153
  try:
145
154
  if self._collector_endpoint:
146
- # Collector mode: POST to /v1/logs
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
- # Silently fail - circuit is open
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())