devlogs 2.2.0__tar.gz → 2.2.2__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 (74) hide show
  1. {devlogs-2.2.0/src/devlogs.egg-info → devlogs-2.2.2}/PKG-INFO +1 -1
  2. {devlogs-2.2.0 → devlogs-2.2.2}/pyproject.toml +1 -1
  3. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/cli.py +37 -8
  4. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/collector/schema.py +5 -0
  5. devlogs-2.2.2/src/devlogs/formatting.py +71 -0
  6. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/mcp/server.py +95 -2
  7. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/opensearch/queries.py +30 -11
  8. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/web/server.py +8 -2
  9. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/web/static/devlogs.js +3 -0
  10. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/web/static/index.html +4 -0
  11. {devlogs-2.2.0 → devlogs-2.2.2/src/devlogs.egg-info}/PKG-INFO +1 -1
  12. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_web.py +1 -1
  13. devlogs-2.2.0/src/devlogs/formatting.py +0 -34
  14. {devlogs-2.2.0 → devlogs-2.2.2}/LICENSE +0 -0
  15. {devlogs-2.2.0 → devlogs-2.2.2}/MANIFEST.in +0 -0
  16. {devlogs-2.2.0 → devlogs-2.2.2}/README.md +0 -0
  17. {devlogs-2.2.0 → devlogs-2.2.2}/setup.cfg +0 -0
  18. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/__init__.py +0 -0
  19. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/__main__.py +0 -0
  20. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/build_info.py +0 -0
  21. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/collector/__init__.py +0 -0
  22. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/collector/auth.py +0 -0
  23. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/collector/cli.py +0 -0
  24. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/collector/errors.py +0 -0
  25. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/collector/forwarder.py +0 -0
  26. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/collector/ingestor.py +0 -0
  27. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/collector/server.py +0 -0
  28. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/config.py +0 -0
  29. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/context.py +0 -0
  30. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/demo.py +0 -0
  31. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/devlogs_client.py +0 -0
  32. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/handler.py +0 -0
  33. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/jenkins/__init__.py +0 -0
  34. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/jenkins/cli.py +0 -0
  35. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/jenkins/core.py +0 -0
  36. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/levels.py +0 -0
  37. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/mcp/__init__.py +0 -0
  38. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/opensearch/__init__.py +0 -0
  39. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/opensearch/client.py +0 -0
  40. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/opensearch/indexing.py +0 -0
  41. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/opensearch/mappings.py +0 -0
  42. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/retention.py +0 -0
  43. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/scrub.py +0 -0
  44. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/time_utils.py +0 -0
  45. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/version.py +0 -0
  46. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/web/__init__.py +0 -0
  47. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/web/static/devlogs.css +0 -0
  48. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs/wrapper.py +0 -0
  49. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs.egg-info/SOURCES.txt +0 -0
  50. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs.egg-info/dependency_links.txt +0 -0
  51. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs.egg-info/entry_points.txt +0 -0
  52. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs.egg-info/requires.txt +0 -0
  53. {devlogs-2.2.0 → devlogs-2.2.2}/src/devlogs.egg-info/top_level.txt +0 -0
  54. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_build_info.py +0 -0
  55. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_cli.py +0 -0
  56. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_collector_auth.py +0 -0
  57. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_collector_config.py +0 -0
  58. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_collector_schema.py +0 -0
  59. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_collector_server.py +0 -0
  60. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_config.py +0 -0
  61. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_context.py +0 -0
  62. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_devlogs_client.py +0 -0
  63. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_formatting.py +0 -0
  64. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_handler.py +0 -0
  65. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_indexing.py +0 -0
  66. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_levels.py +0 -0
  67. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_mappings.py +0 -0
  68. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_mcp_server.py +0 -0
  69. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_opensearch_client.py +0 -0
  70. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_opensearch_queries.py +0 -0
  71. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_retention.py +0 -0
  72. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_scrub.py +0 -0
  73. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_time_utils.py +0 -0
  74. {devlogs-2.2.0 → devlogs-2.2.2}/tests/test_url_parsing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlogs
3
- Version: 2.2.0
3
+ Version: 2.2.2
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.2.0"
7
+ version = "2.2.2"
8
8
  description = "Developer-focused logging library for Python with OpenSearch integration."
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -11,7 +11,7 @@ import typer
11
11
  from pathlib import Path
12
12
 
13
13
  from .config import load_config, set_dotenv_path, set_url, URLParseError, _parse_opensearch_url, parse_url, CollectorURLConfig
14
- from .formatting import format_timestamp
14
+ from .formatting import format_timestamp, all_entries_today
15
15
  from .opensearch.client import (
16
16
  get_opensearch_client,
17
17
  check_connection,
@@ -31,6 +31,7 @@ from .opensearch.mappings import (
31
31
  from .opensearch.queries import normalize_log_entries, search_logs, tail_logs, get_last_errors
32
32
  from .retention import cleanup_old_logs, get_retention_stats
33
33
  from .jenkins.cli import jenkins_app
34
+ from .version import __version__
34
35
 
35
36
  app = typer.Typer()
36
37
  app.add_typer(jenkins_app, name="jenkins")
@@ -50,10 +51,17 @@ def _apply_common_options(env: str = None, url: str = None):
50
51
  set_url(url)
51
52
 
52
53
 
54
+ def _version_callback(value: bool):
55
+ if value:
56
+ typer.echo(f"devlogs {__version__}")
57
+ raise typer.Exit()
58
+
59
+
53
60
  # Global callback to handle --env flag before any command runs (for backwards compatibility)
54
61
  @app.callback(invoke_without_command=True)
55
62
  def main_callback(
56
63
  ctx: typer.Context,
64
+ version: bool = typer.Option(False, "--version", callback=_version_callback, is_eager=True, help="Show version and exit"),
57
65
  env: str = ENV_OPTION,
58
66
  url: str = URL_OPTION,
59
67
  ):
@@ -650,8 +658,10 @@ def diagnose(
650
658
  def tail(
651
659
  operation_id: str = typer.Option(None, "--operation", "-o"),
652
660
  area: str = typer.Option(None, "--area"),
661
+ component: str = typer.Option(None, "--component", "-c", help="Filter by component name"),
653
662
  level: str = typer.Option(None, "--level"),
654
663
  since: str = typer.Option(None, "--since"),
664
+ application: str = typer.Option(None, "--application", "-a", help="Filter by application name"),
655
665
  limit: int = typer.Option(20, "--limit"),
656
666
  follow: bool = typer.Option(False, "--follow", "-f"),
657
667
  verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output"),
@@ -698,12 +708,18 @@ def tail(
698
708
  color=typer.colors.YELLOW,
699
709
  )
700
710
 
711
+ effective_application = application or cfg.application
712
+
701
713
  if verbose:
702
714
  parts = []
715
+ if effective_application:
716
+ parts.append(f"application={effective_application}")
703
717
  if operation_id:
704
718
  parts.append(f"operation={operation_id}")
705
719
  if area:
706
720
  parts.append(f"area={area}")
721
+ if component:
722
+ parts.append(f"component={component}")
707
723
  if level:
708
724
  parts.append(f"level={level}")
709
725
  if since:
@@ -728,7 +744,8 @@ def tail(
728
744
  since=since,
729
745
  limit=limit,
730
746
  search_after=search_after,
731
- application=cfg.application,
747
+ application=effective_application,
748
+ component=component,
732
749
  )
733
750
  _verbose_echo(f"Received {len(docs)} docs, next cursor={search_after}")
734
751
  if verbose and docs:
@@ -791,9 +808,10 @@ def tail(
791
808
  typer.echo(typer.style("No logs found.", dim=True), err=True)
792
809
  first_poll = False
793
810
 
811
+ omit_date = all_entries_today(entries, use_utc=utc)
794
812
  for entry_index, doc in enumerate(entries):
795
813
  try:
796
- timestamp = format_timestamp(doc.get("timestamp") or "", use_utc=utc)
814
+ timestamp = format_timestamp(doc.get("timestamp") or "", use_utc=utc, omit_date=omit_date)
797
815
  entry_level = doc.get("level") or ""
798
816
  entry_area = doc.get("area") or ""
799
817
  entry_operation = doc.get("operation_id") or ""
@@ -820,9 +838,11 @@ def tail(
820
838
  def search(
821
839
  q: str = typer.Option("", "--q", help="Search query"),
822
840
  area: str = typer.Option(None, "--area"),
841
+ component: str = typer.Option(None, "--component", "-c", help="Filter by component name"),
823
842
  level: str = typer.Option(None, "--level"),
824
843
  operation_id: str = typer.Option(None, "--operation", "-o"),
825
844
  since: str = typer.Option(None, "--since"),
845
+ application: str = typer.Option(None, "--application", "-a", help="Filter by application name"),
826
846
  limit: int = typer.Option(50, "--limit"),
827
847
  follow: bool = typer.Option(False, "--follow", "-f"),
828
848
  utc: bool = typer.Option(False, "--utc", help="Display timestamps in UTC instead of local time"),
@@ -834,6 +854,7 @@ def search(
834
854
 
835
855
  _apply_common_options(env, url)
836
856
  client, cfg = require_opensearch()
857
+ effective_application = application or cfg.application
837
858
  search_after = None
838
859
  consecutive_errors = 0
839
860
  max_errors = 3
@@ -852,7 +873,8 @@ def search(
852
873
  since=since,
853
874
  limit=limit,
854
875
  search_after=search_after,
855
- application=cfg.application,
876
+ application=effective_application,
877
+ component=component,
856
878
  )
857
879
  else:
858
880
  docs = search_logs(
@@ -864,7 +886,8 @@ def search(
864
886
  level=level,
865
887
  since=since,
866
888
  limit=limit,
867
- application=cfg.application,
889
+ application=effective_application,
890
+ component=component,
868
891
  )
869
892
  entries = normalize_log_entries(docs, limit=limit)
870
893
  consecutive_errors = 0
@@ -905,8 +928,9 @@ def search(
905
928
  typer.echo(typer.style("No logs found.", dim=True), err=True)
906
929
  first_poll = False
907
930
 
931
+ omit_date = all_entries_today(entries, use_utc=utc)
908
932
  for doc in entries:
909
- timestamp = format_timestamp(doc.get("timestamp") or "", use_utc=utc)
933
+ timestamp = format_timestamp(doc.get("timestamp") or "", use_utc=utc, omit_date=omit_date)
910
934
  entry_level = doc.get("level") or ""
911
935
  entry_area = doc.get("area") or ""
912
936
  entry_operation = doc.get("operation_id") or ""
@@ -926,9 +950,11 @@ def search(
926
950
  def last_error(
927
951
  q: str = typer.Option("", "--q", help="Search query"),
928
952
  area: str = typer.Option(None, "--area"),
953
+ component: str = typer.Option(None, "--component", "-c", help="Filter by component name"),
929
954
  operation_id: str = typer.Option(None, "--operation", "-o"),
930
955
  since: str = typer.Option(None, "--since"),
931
956
  until: str = typer.Option(None, "--until"),
957
+ application: str = typer.Option(None, "--application", "-a", help="Filter by application name"),
932
958
  limit: int = typer.Option(1, "--limit"),
933
959
  utc: bool = typer.Option(False, "--utc", help="Display timestamps in UTC instead of local time"),
934
960
  env: str = ENV_OPTION,
@@ -939,6 +965,7 @@ def last_error(
939
965
 
940
966
  _apply_common_options(env, url)
941
967
  client, cfg = require_opensearch()
968
+ effective_application = application or cfg.application
942
969
 
943
970
  try:
944
971
  docs = get_last_errors(
@@ -950,7 +977,8 @@ def last_error(
950
977
  since=since,
951
978
  until=until,
952
979
  limit=limit,
953
- application=cfg.application,
980
+ application=effective_application,
981
+ component=component,
954
982
  )
955
983
  entries = normalize_log_entries(docs, limit=limit)
956
984
  except (ConnectionFailedError, urllib.error.URLError) as e:
@@ -982,8 +1010,9 @@ def last_error(
982
1010
  typer.echo(typer.style("No errors found.", dim=True), err=True)
983
1011
  return
984
1012
 
1013
+ omit_date = all_entries_today(entries, use_utc=utc)
985
1014
  for doc in entries:
986
- timestamp = format_timestamp(doc.get("timestamp") or "", use_utc=utc)
1015
+ timestamp = format_timestamp(doc.get("timestamp") or "", use_utc=utc, omit_date=omit_date)
987
1016
  entry_level = doc.get("level") or ""
988
1017
  entry_area = doc.get("area") or ""
989
1018
  entry_operation = doc.get("operation_id") or ""
@@ -73,6 +73,7 @@ class DevlogsRecord:
73
73
  message: Optional[str] = None
74
74
  level: Optional[str] = None
75
75
  area: Optional[str] = None
76
+ operation_id: Optional[str] = None
76
77
  environment: Optional[str] = None
77
78
  version: Optional[str] = None
78
79
 
@@ -122,6 +123,8 @@ class DevlogsRecord:
122
123
  doc["level"] = self.level
123
124
  if self.area is not None:
124
125
  doc["area"] = self.area
126
+ if self.operation_id is not None:
127
+ doc["operation_id"] = self.operation_id
125
128
  if self.environment is not None:
126
129
  doc["environment"] = self.environment
127
130
  if self.version is not None:
@@ -246,6 +249,7 @@ def validate_record(data: Dict[str, Any]) -> DevlogsRecord:
246
249
  message = validate_string(data.get("message"), "message", required=False)
247
250
  level = validate_string(data.get("level"), "level", required=False)
248
251
  area = validate_string(data.get("area"), "area", required=False)
252
+ operation_id = validate_string(data.get("operation_id"), "operation_id", required=False)
249
253
  environment = validate_string(data.get("environment"), "environment", required=False)
250
254
  version = validate_string(data.get("version"), "version", required=False)
251
255
 
@@ -259,6 +263,7 @@ def validate_record(data: Dict[str, Any]) -> DevlogsRecord:
259
263
  message=message,
260
264
  level=level,
261
265
  area=area,
266
+ operation_id=operation_id,
262
267
  environment=environment,
263
268
  version=version,
264
269
  fields=fields,
@@ -0,0 +1,71 @@
1
+ # Formatting utilities for devlogs output
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Iterable, Dict, Any
5
+
6
+
7
+ def format_timestamp(timestamp_str: str | None, use_utc: bool = False, omit_date: bool = False) -> str:
8
+ """
9
+ Format a timestamp string for display.
10
+
11
+ Args:
12
+ timestamp_str: ISO 8601 timestamp string (typically UTC with Z suffix) or None
13
+ use_utc: If True, display in UTC; if False, display in local time
14
+ omit_date: If True, show only time portion (HH:MM:SS.mmm)
15
+
16
+ Returns:
17
+ Formatted timestamp string
18
+ """
19
+ if not timestamp_str:
20
+ return ""
21
+
22
+ try:
23
+ # Parse the ISO timestamp (handles Z suffix and +00:00 format)
24
+ dt = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
25
+
26
+ if use_utc:
27
+ dt_display = dt.astimezone(timezone.utc)
28
+ if omit_date:
29
+ return dt_display.strftime("%H:%M:%S.%f")[:-3] + "Z"
30
+ return dt_display.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
31
+ else:
32
+ dt_display = dt.astimezone()
33
+ if omit_date:
34
+ return dt_display.strftime("%H:%M:%S.%f")[:-3]
35
+ return dt_display.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
36
+ except (ValueError, AttributeError):
37
+ # If parsing fails, return original string
38
+ return timestamp_str
39
+
40
+
41
+ def all_entries_today(entries: Iterable[Dict[str, Any]], use_utc: bool = False) -> bool:
42
+ """Check if all entries have timestamps from today.
43
+
44
+ Args:
45
+ entries: Log entries with 'timestamp' keys
46
+ use_utc: If True, compare against UTC date; otherwise local date
47
+
48
+ Returns:
49
+ True if all entries are from today (or have no timestamp)
50
+ """
51
+ if use_utc:
52
+ today = datetime.now(timezone.utc).date()
53
+ else:
54
+ today = datetime.now().astimezone().date()
55
+ has_any = False
56
+ for entry in entries:
57
+ ts = entry.get("timestamp")
58
+ if not ts:
59
+ continue
60
+ has_any = True
61
+ try:
62
+ dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
63
+ if use_utc:
64
+ entry_date = dt.astimezone(timezone.utc).date()
65
+ else:
66
+ entry_date = dt.astimezone().date()
67
+ if entry_date != today:
68
+ return False
69
+ except (ValueError, AttributeError):
70
+ return False
71
+ return has_any
@@ -135,6 +135,10 @@ async def main():
135
135
  "type": "string",
136
136
  "description": "Filter by application area (e.g., 'api', 'database', 'auth')",
137
137
  },
138
+ "component": {
139
+ "type": "string",
140
+ "description": "Filter by component name (e.g., 'web', 'worker', 'jenkins')",
141
+ },
138
142
  "operation_id": {
139
143
  "type": "string",
140
144
  "description": "Filter by specific operation ID to see all logs for that operation",
@@ -161,6 +165,10 @@ async def main():
161
165
  "items": {"type": ["string", "number"]},
162
166
  "description": "Cursor from a previous response for pagination",
163
167
  },
168
+ "application": {
169
+ "type": "string",
170
+ "description": "Filter by application name",
171
+ },
164
172
  },
165
173
  },
166
174
  ),
@@ -182,6 +190,10 @@ async def main():
182
190
  "type": "string",
183
191
  "description": "Filter by application area",
184
192
  },
193
+ "component": {
194
+ "type": "string",
195
+ "description": "Filter by component name",
196
+ },
185
197
  "level": {
186
198
  "type": "string",
187
199
  "description": "Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
@@ -204,6 +216,10 @@ async def main():
204
216
  "items": {"type": ["string", "number"]},
205
217
  "description": "Cursor from a previous response for pagination",
206
218
  },
219
+ "application": {
220
+ "type": "string",
221
+ "description": "Filter by application name",
222
+ },
207
223
  },
208
224
  },
209
225
  ),
@@ -217,6 +233,14 @@ async def main():
217
233
  "type": "string",
218
234
  "description": "The operation ID to summarize",
219
235
  },
236
+ "component": {
237
+ "type": "string",
238
+ "description": "Filter by component name",
239
+ },
240
+ "application": {
241
+ "type": "string",
242
+ "description": "Filter by application name",
243
+ },
220
244
  },
221
245
  "required": ["operation_id"],
222
246
  },
@@ -257,6 +281,14 @@ async def main():
257
281
  "items": {"type": ["string", "number"]},
258
282
  "description": "Cursor from a previous response for pagination",
259
283
  },
284
+ "component": {
285
+ "type": "string",
286
+ "description": "Filter by component name",
287
+ },
288
+ "application": {
289
+ "type": "string",
290
+ "description": "Filter by application name",
291
+ },
260
292
  },
261
293
  "required": ["operation_id"],
262
294
  },
@@ -271,6 +303,10 @@ async def main():
271
303
  "type": "string",
272
304
  "description": "Filter by application area",
273
305
  },
306
+ "component": {
307
+ "type": "string",
308
+ "description": "Filter by component name",
309
+ },
274
310
  "since": {
275
311
  "type": "string",
276
312
  "description": "ISO timestamp or relative duration like '1h' to filter operations after this time",
@@ -285,6 +321,10 @@ async def main():
285
321
  "description": "Only show operations that had errors",
286
322
  "default": False,
287
323
  },
324
+ "application": {
325
+ "type": "string",
326
+ "description": "Filter by application name",
327
+ },
288
328
  },
289
329
  },
290
330
  ),
@@ -298,6 +338,10 @@ async def main():
298
338
  "type": "string",
299
339
  "description": "Filter by application area",
300
340
  },
341
+ "component": {
342
+ "type": "string",
343
+ "description": "Filter by component name",
344
+ },
301
345
  "since": {
302
346
  "type": "string",
303
347
  "description": "ISO timestamp or relative duration like '1h' to filter operations after this time",
@@ -321,6 +365,10 @@ async def main():
321
365
  "description": "Only show operations that had errors",
322
366
  "default": False,
323
367
  },
368
+ "application": {
369
+ "type": "string",
370
+ "description": "Filter by application name",
371
+ },
324
372
  },
325
373
  },
326
374
  ),
@@ -339,6 +387,14 @@ async def main():
339
387
  "description": "Minimum number of operations an area must have to be included",
340
388
  "default": 1,
341
389
  },
390
+ "component": {
391
+ "type": "string",
392
+ "description": "Filter by component name",
393
+ },
394
+ "application": {
395
+ "type": "string",
396
+ "description": "Filter by application name",
397
+ },
342
398
  },
343
399
  },
344
400
  ),
@@ -356,6 +412,10 @@ async def main():
356
412
  "type": "string",
357
413
  "description": "Filter by application area",
358
414
  },
415
+ "component": {
416
+ "type": "string",
417
+ "description": "Filter by component name",
418
+ },
359
419
  "since": {
360
420
  "type": "string",
361
421
  "description": "ISO timestamp or relative duration like '1h' to filter logs after this time",
@@ -379,6 +439,10 @@ async def main():
379
439
  "description": "Include logs missing the signature field",
380
440
  "default": False,
381
441
  },
442
+ "application": {
443
+ "type": "string",
444
+ "description": "Filter by application name",
445
+ },
382
446
  },
383
447
  },
384
448
  ),
@@ -396,6 +460,10 @@ async def main():
396
460
  "type": "string",
397
461
  "description": "Filter by application area",
398
462
  },
463
+ "component": {
464
+ "type": "string",
465
+ "description": "Filter by component name",
466
+ },
399
467
  "operation_id": {
400
468
  "type": "string",
401
469
  "description": "Filter by specific operation ID",
@@ -413,6 +481,10 @@ async def main():
413
481
  "description": "Maximum number of error entries to return (default: 1, max: 100)",
414
482
  "default": 1,
415
483
  },
484
+ "application": {
485
+ "type": "string",
486
+ "description": "Filter by application name",
487
+ },
416
488
  },
417
489
  },
418
490
  ),
@@ -434,6 +506,10 @@ async def main():
434
506
  "type": "string",
435
507
  "description": "Filter by application area",
436
508
  },
509
+ "component": {
510
+ "type": "string",
511
+ "description": "Filter by component name",
512
+ },
437
513
  "query": {
438
514
  "type": "string",
439
515
  "description": "Text search query to match against log messages, logger names, and features",
@@ -452,6 +528,10 @@ async def main():
452
528
  "description": "Number of entries after the anchor (default: 20)",
453
529
  "default": 20,
454
530
  },
531
+ "application": {
532
+ "type": "string",
533
+ "description": "Filter by application name",
534
+ },
455
535
  },
456
536
  "required": ["anchor_timestamp"],
457
537
  },
@@ -467,10 +547,14 @@ async def main():
467
547
  arguments = {}
468
548
 
469
549
  try:
470
- client, index, application = _create_client_and_index()
550
+ client, index, config_application = _create_client_and_index()
471
551
  except RuntimeError as e:
472
552
  return _error_response(str(e), "InitializationError")
473
553
 
554
+ application = arguments.get("application") or config_application
555
+
556
+ component = arguments.get("component")
557
+
474
558
  if name == "search_logs":
475
559
  query = arguments.get("query")
476
560
  area = arguments.get("area")
@@ -495,6 +579,7 @@ async def main():
495
579
  cursor=cursor,
496
580
  sort_order="desc",
497
581
  application=application,
582
+ component=component,
498
583
  )
499
584
  entries = _normalize_entries(docs, limit=limit)
500
585
 
@@ -533,6 +618,7 @@ async def main():
533
618
  limit=limit,
534
619
  search_after=cursor,
535
620
  application=application,
621
+ component=component,
536
622
  )
537
623
  entries = _normalize_entries(docs, limit=limit)
538
624
 
@@ -554,7 +640,7 @@ async def main():
554
640
  return _error_response("operation_id is required", "ValidationError")
555
641
 
556
642
  try:
557
- summary = get_operation_summary(client, index, operation_id, application=application)
643
+ summary = get_operation_summary(client, index, operation_id, application=application, component=component)
558
644
 
559
645
  if not summary:
560
646
  return _json_response(
@@ -594,6 +680,7 @@ async def main():
594
680
  limit=limit,
595
681
  cursor=cursor,
596
682
  application=application,
683
+ component=component,
597
684
  )
598
685
  entries = _normalize_entries(docs, limit=limit)
599
686
 
@@ -623,6 +710,7 @@ async def main():
623
710
  limit=limit,
624
711
  with_errors_only=with_errors_only,
625
712
  application=application,
713
+ component=component,
626
714
  )
627
715
 
628
716
  return _json_response(
@@ -654,6 +742,7 @@ async def main():
654
742
  order_by=order_by,
655
743
  with_errors_only=with_errors_only,
656
744
  application=application,
745
+ component=component,
657
746
  )
658
747
 
659
748
  return _json_response(
@@ -676,6 +765,7 @@ async def main():
676
765
  since=since,
677
766
  min_operations=min_operations,
678
767
  application=application,
768
+ component=component,
679
769
  )
680
770
 
681
771
  return _json_response(
@@ -709,6 +799,7 @@ async def main():
709
799
  min_count=min_count,
710
800
  include_missing=include_missing,
711
801
  application=application,
802
+ component=component,
712
803
  )
713
804
  return _json_response(
714
805
  data={"signatures": signatures},
@@ -738,6 +829,7 @@ async def main():
738
829
  until=until,
739
830
  limit=limit,
740
831
  application=application,
832
+ component=component,
741
833
  )
742
834
  entries = _normalize_entries(docs, limit=limit)
743
835
  return _json_response(
@@ -775,6 +867,7 @@ async def main():
775
867
  before=before,
776
868
  after=after,
777
869
  application=application,
870
+ component=component,
778
871
  )
779
872
  entries = _normalize_entries(docs)
780
873
  return _json_response(
@@ -32,7 +32,7 @@ def _build_time_range(since: Optional[str], until: Optional[str], since_inclusiv
32
32
  return {"range": {"timestamp": range_query}}
33
33
 
34
34
 
35
- def _build_log_query(query=None, area=None, operation_id=None, level=None, since=None, until=None, since_inclusive: bool = True, until_inclusive: bool = True, application=None):
35
+ def _build_log_query(query=None, area=None, operation_id=None, level=None, since=None, until=None, since_inclusive: bool = True, until_inclusive: bool = True, application=None, component=None):
36
36
  filters = [
37
37
  {
38
38
  "bool": {
@@ -48,6 +48,8 @@ def _build_log_query(query=None, area=None, operation_id=None, level=None, since
48
48
  filters.append({"term": {"application": application}})
49
49
  if area:
50
50
  filters.append({"term": {"area": area}})
51
+ if component:
52
+ filters.append({"term": {"component": component}})
51
53
  if operation_id:
52
54
  filters.append({"term": {"operation_id": operation_id}})
53
55
  level_terms = _normalize_level_terms(level)
@@ -109,6 +111,7 @@ def _normalize_entry(doc: Dict[str, Any]) -> Dict[str, Any]:
109
111
  "level": normalize_level(doc.get("level")),
110
112
  "message": doc.get("message"),
111
113
  "logger": doc.get("logger"),
114
+ "component": doc.get("component"),
112
115
  "area": doc.get("area"),
113
116
  "operation_id": doc.get("operation_id"),
114
117
  "pathname": doc.get("pathname"),
@@ -128,7 +131,7 @@ def normalize_log_entries(docs: Iterable[Dict[str, Any]], limit: Optional[int] =
128
131
  return entries
129
132
 
130
133
 
131
- def search_logs(client, index, query=None, area=None, operation_id=None, level=None, since=None, until=None, limit=50, application=None):
134
+ def search_logs(client, index, query=None, area=None, operation_id=None, level=None, since=None, until=None, limit=50, application=None, component=None):
132
135
  """Search log entries with filters."""
133
136
  body = {
134
137
  "query": _build_log_query(
@@ -139,6 +142,7 @@ def search_logs(client, index, query=None, area=None, operation_id=None, level=N
139
142
  since=since,
140
143
  until=until,
141
144
  application=application,
145
+ component=component,
142
146
  ),
143
147
  "sort": [{"timestamp": "desc"}, {"_id": "desc"}],
144
148
  "size": limit,
@@ -148,7 +152,7 @@ def search_logs(client, index, query=None, area=None, operation_id=None, level=N
148
152
  return _hits_to_docs(hits)
149
153
 
150
154
 
151
- def get_last_errors(client, index, query=None, area=None, operation_id=None, since=None, until=None, limit=1, application=None):
155
+ def get_last_errors(client, index, query=None, area=None, operation_id=None, since=None, until=None, limit=1, application=None, component=None):
152
156
  """Get the most recent error/critical log entries."""
153
157
  base_query = _build_log_query(
154
158
  query=query,
@@ -157,6 +161,7 @@ def get_last_errors(client, index, query=None, area=None, operation_id=None, sin
157
161
  since=since,
158
162
  until=until,
159
163
  application=application,
164
+ component=component,
160
165
  )
161
166
  base_query.get("bool", {}).get("filter", []).append(
162
167
  {"terms": {"level": ["error", "critical"]}}
@@ -191,6 +196,7 @@ def search_logs_page(
191
196
  since_inclusive: bool = True,
192
197
  until_inclusive: bool = True,
193
198
  application=None,
199
+ component=None,
194
200
  ):
195
201
  """Search log entries with pagination support."""
196
202
  body = {
@@ -204,6 +210,7 @@ def search_logs_page(
204
210
  since_inclusive=since_inclusive,
205
211
  until_inclusive=until_inclusive,
206
212
  application=application,
213
+ component=component,
207
214
  ),
208
215
  "sort": _build_sort(sort_order),
209
216
  "size": limit,
@@ -217,7 +224,7 @@ def search_logs_page(
217
224
  return docs, next_cursor
218
225
 
219
226
 
220
- def get_operation_logs(client, index, operation_id, query=None, level=None, since=None, until=None, limit=100, cursor=None, application=None):
227
+ def get_operation_logs(client, index, operation_id, query=None, level=None, since=None, until=None, limit=100, cursor=None, application=None, component=None):
221
228
  """Get logs for an operation in chronological order."""
222
229
  return search_logs_page(
223
230
  client=client,
@@ -231,10 +238,11 @@ def get_operation_logs(client, index, operation_id, query=None, level=None, sinc
231
238
  cursor=cursor,
232
239
  sort_order="asc",
233
240
  application=application,
241
+ component=component,
234
242
  )
235
243
 
236
244
 
237
- def tail_logs(client, index, query=None, operation_id=None, area=None, level=None, since=None, until=None, limit=20, search_after=None, application=None):
245
+ def tail_logs(client, index, query=None, operation_id=None, area=None, level=None, since=None, until=None, limit=20, search_after=None, application=None, component=None):
238
246
  """Tail log entries for an operation.
239
247
 
240
248
  First call returns the most recent entries (newest first) and reverses for chronological display.
@@ -249,6 +257,7 @@ def tail_logs(client, index, query=None, operation_id=None, area=None, level=Non
249
257
  since=since,
250
258
  until=until,
251
259
  application=application,
260
+ component=component,
252
261
  ),
253
262
  "size": limit,
254
263
  }
@@ -281,9 +290,11 @@ def tail_logs(client, index, query=None, operation_id=None, area=None, level=Non
281
290
  return docs, next_search_after
282
291
 
283
292
 
284
- def get_operation_summary(client, index, operation_id, application=None):
293
+ def get_operation_summary(client, index, operation_id, application=None, component=None):
285
294
  """Get summary for an operation using aggregations."""
286
295
  op_query_filters = [{"term": {"operation_id": operation_id}}]
296
+ if component:
297
+ op_query_filters.append({"term": {"component": component}})
287
298
  if application:
288
299
  op_query_filters.append({"term": {"application": application}})
289
300
  body = {
@@ -358,11 +369,13 @@ def get_operation_summary(client, index, operation_id, application=None):
358
369
  }
359
370
 
360
371
 
361
- def list_operations(client, index, area=None, since=None, limit=20, with_errors_only=False, application=None):
372
+ def list_operations(client, index, area=None, since=None, limit=20, with_errors_only=False, application=None, component=None):
362
373
  """List recent operations with summary stats."""
363
374
  query_filters = []
364
375
  if application:
365
376
  query_filters.append({"term": {"application": application}})
377
+ if component:
378
+ query_filters.append({"term": {"component": component}})
366
379
  if area:
367
380
  query_filters.append({"term": {"area": area}})
368
381
  if since:
@@ -434,9 +447,9 @@ def list_operations(client, index, area=None, since=None, limit=20, with_errors_
434
447
  return operations
435
448
 
436
449
 
437
- def list_recent_operations(client, index, area=None, since=None, until=None, limit=20, order_by: str = "last_activity", with_errors_only: bool = False, application=None):
450
+ def list_recent_operations(client, index, area=None, since=None, until=None, limit=20, order_by: str = "last_activity", with_errors_only: bool = False, application=None, component=None):
438
451
  """List recent operations ordered by last activity or error count."""
439
- base_query = _build_log_query(area=area, since=since, until=until, application=application)
452
+ base_query = _build_log_query(area=area, since=since, until=until, application=application, component=component)
440
453
  if order_by not in ("last_activity", "error_count"):
441
454
  order_by = "last_activity"
442
455
 
@@ -551,13 +564,14 @@ def list_error_signatures(
551
564
  min_count: int = 1,
552
565
  include_missing: bool = False,
553
566
  application=None,
567
+ component=None,
554
568
  ):
555
569
  """Aggregate error signatures by exception/message."""
556
570
  if not field:
557
571
  field = "exception"
558
572
  field_name = field if field.endswith(".keyword") else f"{field}.keyword"
559
573
 
560
- base_query = _build_log_query(area=area, since=since, until=until, application=application)
574
+ base_query = _build_log_query(area=area, since=since, until=until, application=application, component=component)
561
575
  base_filters = base_query.get("bool", {}).get("filter", [])
562
576
  base_filters.append({"terms": {"level": ["error", "critical"]}})
563
577
  if not include_missing:
@@ -623,6 +637,7 @@ def get_error_context(
623
637
  before: int = 20,
624
638
  after: int = 20,
625
639
  application=None,
640
+ component=None,
626
641
  ):
627
642
  """Fetch logs around an anchor timestamp."""
628
643
  before_count = max(int(before or 0), 0)
@@ -641,6 +656,7 @@ def get_error_context(
641
656
  sort_order="desc",
642
657
  until_inclusive=True,
643
658
  application=application,
659
+ component=component,
644
660
  )
645
661
  after_docs, _ = search_logs_page(
646
662
  client=client,
@@ -654,17 +670,20 @@ def get_error_context(
654
670
  sort_order="asc",
655
671
  since_inclusive=False,
656
672
  application=application,
673
+ component=component,
657
674
  )
658
675
  before_docs = list(reversed(before_docs))
659
676
 
660
677
  return before_docs + after_docs
661
678
 
662
679
 
663
- def list_areas(client, index, since=None, min_operations=1, application=None):
680
+ def list_areas(client, index, since=None, min_operations=1, application=None, component=None):
664
681
  """List all application areas with activity counts."""
665
682
  query_filters = []
666
683
  if application:
667
684
  query_filters.append({"term": {"application": application}})
685
+ if component:
686
+ query_filters.append({"term": {"component": component}})
668
687
  if since:
669
688
  normalized_since = resolve_relative_time(since)
670
689
  query_filters.append({"range": {"timestamp": {"gte": normalized_since}}})
@@ -34,11 +34,12 @@ def _try_client() -> Tuple[Optional[object], Optional[str]]:
34
34
 
35
35
 
36
36
  @app.get("/api/search")
37
- def search(q: Optional[str] = None, area: Optional[str] = None, level: Optional[str] = None, operation_id: Optional[str] = None, since: Optional[str] = None, limit: int = 50):
37
+ def search(q: Optional[str] = None, area: Optional[str] = None, component: Optional[str] = None, level: Optional[str] = None, operation_id: Optional[str] = None, since: Optional[str] = None, application: Optional[str] = None, limit: int = 50):
38
38
  client, error = _try_client()
39
39
  if not client:
40
40
  return {"results": [], "error": error}
41
41
  cfg = load_config()
42
+ effective_application = application or cfg.application
42
43
  docs = search_logs(
43
44
  client,
44
45
  cfg.index,
@@ -48,16 +49,19 @@ def search(q: Optional[str] = None, area: Optional[str] = None, level: Optional[
48
49
  level=level,
49
50
  since=since,
50
51
  limit=limit,
52
+ application=effective_application,
53
+ component=component,
51
54
  )
52
55
  results = normalize_log_entries(docs, limit=limit)
53
56
  return {"results": results}
54
57
 
55
58
  @app.get("/api/tail")
56
- def tail(operation_id: Optional[str] = None, area: Optional[str] = None, level: Optional[str] = None, since: Optional[str] = None, limit: int = 20):
59
+ def tail(operation_id: Optional[str] = None, area: Optional[str] = None, component: Optional[str] = None, level: Optional[str] = None, since: Optional[str] = None, application: Optional[str] = None, limit: int = 20):
57
60
  client, error = _try_client()
58
61
  if not client:
59
62
  return {"results": [], "error": error}
60
63
  cfg = load_config()
64
+ effective_application = application or cfg.application
61
65
  docs, cursor = tail_logs(
62
66
  client,
63
67
  cfg.index,
@@ -66,6 +70,8 @@ def tail(operation_id: Optional[str] = None, area: Optional[str] = None, level:
66
70
  level=level,
67
71
  since=since,
68
72
  limit=limit,
73
+ application=effective_application,
74
+ component=component,
69
75
  )
70
76
  results = normalize_log_entries(docs, limit=limit)
71
77
  return {"results": results, "cursor": cursor}
@@ -2,6 +2,7 @@
2
2
  const elements = {
3
3
  search: document.getElementById('search'),
4
4
  area: document.getElementById('area'),
5
+ component: document.getElementById('component'),
5
6
  operation: document.getElementById('operation'),
6
7
  level: document.getElementById('level'),
7
8
  limit: document.getElementById('limit'),
@@ -240,6 +241,7 @@ async function fetchLogs({ append = false } = {}) {
240
241
  const query = elements.search.value.trim();
241
242
  if (query) params.set('q', query);
242
243
  if (elements.area.value.trim()) params.set('area', elements.area.value.trim());
244
+ if (elements.component.value.trim()) params.set('component', elements.component.value.trim());
243
245
  if (elements.operation.value.trim()) params.set('operation_id', elements.operation.value.trim());
244
246
  if (elements.level.value) params.set('level', elements.level.value);
245
247
  if (elements.limit.value) params.set('limit', elements.limit.value);
@@ -322,6 +324,7 @@ function setFollow(enabled) {
322
324
 
323
325
  elements.search.addEventListener('input', () => fetchLogs());
324
326
  elements.area.addEventListener('input', () => fetchLogs());
327
+ elements.component.addEventListener('input', () => fetchLogs());
325
328
  elements.operation.addEventListener('input', () => fetchLogs());
326
329
  elements.level.addEventListener('change', () => fetchLogs());
327
330
  elements.limit.addEventListener('change', () => fetchLogs());
@@ -23,6 +23,10 @@
23
23
  <label for="area">Area</label>
24
24
  <input id="area" placeholder="web, jobs" />
25
25
  </div>
26
+ <div class="field">
27
+ <label for="component">Component</label>
28
+ <input id="component" placeholder="worker, api" />
29
+ </div>
26
30
  <div class="field">
27
31
  <label for="operation">Operation</label>
28
32
  <input id="operation" placeholder="operation id" />
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlogs
3
- Version: 2.2.0
3
+ Version: 2.2.2
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
@@ -9,7 +9,7 @@ from devlogs.web import server
9
9
 
10
10
  def _set_client_ready(monkeypatch, index_name="devlogs-test"):
11
11
  monkeypatch.setattr(server, "_try_client", lambda: (object(), None))
12
- monkeypatch.setattr(server, "load_config", lambda: SimpleNamespace(index=index_name))
12
+ monkeypatch.setattr(server, "load_config", lambda: SimpleNamespace(index=index_name, application=None))
13
13
 
14
14
  def test_search_endpoint(monkeypatch):
15
15
  _set_client_ready(monkeypatch)
@@ -1,34 +0,0 @@
1
- # Formatting utilities for devlogs output
2
-
3
- from datetime import datetime, timezone
4
-
5
-
6
- def format_timestamp(timestamp_str: str | None, use_utc: bool = False) -> str:
7
- """
8
- Format a timestamp string for display.
9
-
10
- Args:
11
- timestamp_str: ISO 8601 timestamp string (typically UTC with Z suffix) or None
12
- use_utc: If True, display in UTC; if False, display in local time
13
-
14
- Returns:
15
- Formatted timestamp string
16
- """
17
- if not timestamp_str:
18
- return ""
19
-
20
- try:
21
- # Parse the ISO timestamp (handles Z suffix and +00:00 format)
22
- dt = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
23
-
24
- if use_utc:
25
- # Keep in UTC, format as ISO-like string
26
- dt_utc = dt.astimezone(timezone.utc)
27
- return dt_utc.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
28
- else:
29
- # Convert to local time
30
- dt_local = dt.astimezone()
31
- return dt_local.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
32
- except (ValueError, AttributeError):
33
- # If parsing fails, return original string
34
- return timestamp_str
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes