devlogs 2.0.0__tar.gz → 2.0.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 (72) hide show
  1. {devlogs-2.0.0/src/devlogs.egg-info → devlogs-2.0.2}/PKG-INFO +1 -1
  2. {devlogs-2.0.0 → devlogs-2.0.2}/pyproject.toml +1 -1
  3. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/cli.py +147 -7
  4. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/devlogs_client.py +113 -10
  5. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/handler.py +9 -13
  6. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/opensearch/client.py +8 -0
  7. devlogs-2.0.2/src/devlogs/opensearch/mappings.py +199 -0
  8. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/opensearch/queries.py +8 -8
  9. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/web/static/devlogs.js +3 -3
  10. {devlogs-2.0.0 → devlogs-2.0.2/src/devlogs.egg-info}/PKG-INFO +1 -1
  11. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs.egg-info/SOURCES.txt +1 -0
  12. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_devlogs_client.py +99 -1
  13. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_handler.py +9 -9
  14. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_indexing.py +3 -3
  15. devlogs-2.0.2/tests/test_mappings.py +204 -0
  16. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_mcp_server.py +272 -272
  17. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_web.py +3 -3
  18. devlogs-2.0.0/src/devlogs/opensearch/mappings.py +0 -47
  19. {devlogs-2.0.0 → devlogs-2.0.2}/LICENSE +0 -0
  20. {devlogs-2.0.0 → devlogs-2.0.2}/MANIFEST.in +0 -0
  21. {devlogs-2.0.0 → devlogs-2.0.2}/README.md +0 -0
  22. {devlogs-2.0.0 → devlogs-2.0.2}/setup.cfg +0 -0
  23. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/__init__.py +0 -0
  24. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/__main__.py +0 -0
  25. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/build_info.py +0 -0
  26. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/collector/__init__.py +0 -0
  27. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/collector/auth.py +0 -0
  28. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/collector/cli.py +0 -0
  29. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/collector/errors.py +0 -0
  30. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/collector/forwarder.py +0 -0
  31. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/collector/ingestor.py +0 -0
  32. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/collector/schema.py +0 -0
  33. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/collector/server.py +0 -0
  34. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/config.py +0 -0
  35. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/context.py +0 -0
  36. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/demo.py +0 -0
  37. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/formatting.py +0 -0
  38. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/jenkins/__init__.py +0 -0
  39. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/jenkins/cli.py +0 -0
  40. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/jenkins/core.py +0 -0
  41. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/levels.py +0 -0
  42. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/mcp/__init__.py +0 -0
  43. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/mcp/server.py +0 -0
  44. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/opensearch/__init__.py +0 -0
  45. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/opensearch/indexing.py +0 -0
  46. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/retention.py +0 -0
  47. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/scrub.py +0 -0
  48. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/time_utils.py +0 -0
  49. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/web/__init__.py +0 -0
  50. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/web/server.py +0 -0
  51. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/web/static/devlogs.css +0 -0
  52. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/web/static/index.html +0 -0
  53. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/wrapper.py +0 -0
  54. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs.egg-info/dependency_links.txt +0 -0
  55. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs.egg-info/entry_points.txt +0 -0
  56. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs.egg-info/requires.txt +0 -0
  57. {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs.egg-info/top_level.txt +0 -0
  58. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_build_info.py +0 -0
  59. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_cli.py +0 -0
  60. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_collector_auth.py +0 -0
  61. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_collector_config.py +0 -0
  62. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_collector_schema.py +0 -0
  63. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_collector_server.py +0 -0
  64. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_config.py +0 -0
  65. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_context.py +0 -0
  66. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_formatting.py +0 -0
  67. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_levels.py +0 -0
  68. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_opensearch_client.py +0 -0
  69. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_opensearch_queries.py +0 -0
  70. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_retention.py +0 -0
  71. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_scrub.py +0 -0
  72. {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_time_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlogs
3
- Version: 2.0.0
3
+ Version: 2.0.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.0.0"
7
+ version = "2.0.2"
8
8
  description = "Developer-focused logging library for Python with OpenSearch integration."
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -20,7 +20,14 @@ from .opensearch.client import (
20
20
  ConnectionFailedError,
21
21
  DevlogsDisabledError,
22
22
  )
23
- from .opensearch.mappings import build_log_index_template, get_template_names
23
+ from .opensearch.mappings import (
24
+ build_log_index_template,
25
+ get_template_names,
26
+ detect_schema_version,
27
+ get_schema_issues,
28
+ build_reindex_script,
29
+ SCHEMA_VERSION,
30
+ )
24
31
  from .opensearch.queries import normalize_log_entries, search_logs, tail_logs, get_last_errors
25
32
  from .retention import cleanup_old_logs, get_retention_stats
26
33
  from .jenkins.cli import jenkins_app
@@ -184,17 +191,148 @@ def _write_codex_config(path: Path, python_path: str) -> str:
184
191
  return "written"
185
192
 
186
193
 
194
+ def _check_schema_compatibility(client, index: str) -> tuple[int | None, list[str]]:
195
+ """Check index schema compatibility and return (version, issues)."""
196
+ try:
197
+ mapping = client.indices.get_mapping(index=index)
198
+ version = detect_schema_version(mapping)
199
+ issues = get_schema_issues(mapping) if version != SCHEMA_VERSION else []
200
+ return version, issues
201
+ except Exception:
202
+ return None, []
203
+
204
+
205
+ def _perform_upgrade(client, cfg, source_index: str) -> bool:
206
+ """Upgrade index to v2 schema by reindexing.
207
+
208
+ Returns True on success, False on failure.
209
+ """
210
+ import uuid
211
+
212
+ target_index = f"{source_index}-v2-{uuid.uuid4().hex[:8]}"
213
+ template_body = build_log_index_template(cfg.index)
214
+
215
+ typer.echo(f"Creating new index '{target_index}' with v2 schema...")
216
+ try:
217
+ client.indices.create(index=target_index, body=template_body["template"])
218
+ except Exception as e:
219
+ typer.echo(typer.style(f"Error creating target index: {e}", fg=typer.colors.RED), err=True)
220
+ return False
221
+
222
+ typer.echo(f"Reindexing from '{source_index}' to '{target_index}'...")
223
+ typer.echo(typer.style("This may take a while for large indices...", dim=True))
224
+
225
+ reindex_body = {
226
+ "source": {"index": source_index},
227
+ "dest": {"index": target_index},
228
+ "script": {
229
+ "source": build_reindex_script(),
230
+ "lang": "painless",
231
+ },
232
+ }
233
+
234
+ try:
235
+ result = client.indices.reindex(body=reindex_body)
236
+ total = result.get("total", 0)
237
+ created = result.get("created", 0)
238
+ updated = result.get("updated", 0)
239
+ failures = result.get("failures", [])
240
+
241
+ if failures:
242
+ typer.echo(typer.style(f"Warning: {len(failures)} documents failed to reindex", fg=typer.colors.YELLOW))
243
+ for failure in failures[:3]:
244
+ typer.echo(f" - {failure}", err=True)
245
+ if len(failures) > 3:
246
+ typer.echo(f" ... and {len(failures) - 3} more", err=True)
247
+
248
+ typer.echo(f"Reindexed {total} documents ({created} created, {updated} updated)")
249
+ except Exception as e:
250
+ typer.echo(typer.style(f"Error during reindex: {e}", fg=typer.colors.RED), err=True)
251
+ typer.echo(f"The partial index '{target_index}' may need to be cleaned up manually.")
252
+ return False
253
+
254
+ # Delete old index and rename new one
255
+ typer.echo(f"Removing old index '{source_index}'...")
256
+ try:
257
+ client.indices.delete(index=source_index)
258
+ except Exception as e:
259
+ typer.echo(typer.style(f"Error deleting old index: {e}", fg=typer.colors.RED), err=True)
260
+ typer.echo(f"New index is available at '{target_index}'. Manual cleanup may be needed.")
261
+ return False
262
+
263
+ # Create alias or new index with original name pointing to data
264
+ typer.echo(f"Creating new index '{source_index}' with v2 schema...")
265
+ try:
266
+ client.indices.create(index=source_index, body=template_body["template"])
267
+ except Exception as e:
268
+ typer.echo(typer.style(f"Error creating new index: {e}", fg=typer.colors.RED), err=True)
269
+ typer.echo(f"Data is available at '{target_index}'.")
270
+ return False
271
+
272
+ # Reindex from temp to final
273
+ typer.echo(f"Moving data to '{source_index}'...")
274
+ reindex_final = {
275
+ "source": {"index": target_index},
276
+ "dest": {"index": source_index},
277
+ }
278
+ try:
279
+ client.indices.reindex(body=reindex_final)
280
+ client.indices.delete(index=target_index)
281
+ except Exception as e:
282
+ typer.echo(typer.style(f"Error finalizing: {e}", fg=typer.colors.RED), err=True)
283
+ typer.echo(f"Data may be split between '{source_index}' and '{target_index}'.")
284
+ return False
285
+
286
+ typer.echo(typer.style(f"Successfully upgraded '{source_index}' to v2 schema!", fg=typer.colors.GREEN))
287
+ return True
288
+
289
+
187
290
  @app.command()
188
291
  def init(
292
+ upgrade: bool = typer.Option(False, "--upgrade", help="Upgrade existing index to v2 schema if needed"),
189
293
  env: str = ENV_OPTION,
190
294
  url: str = URL_OPTION,
191
295
  ):
192
- """Initialize OpenSearch indices and templates (idempotent)."""
296
+ """Initialize OpenSearch indices and templates (idempotent).
297
+
298
+ Checks existing index for v2 schema compatibility. Use --upgrade to
299
+ automatically migrate data from v1 to v2 schema.
300
+ """
193
301
  _apply_common_options(env, url)
194
302
  client, cfg = require_opensearch(check_idx=False)
303
+
304
+ # Check existing index schema
305
+ index_exists = client.indices.exists(index=cfg.index)
306
+ if index_exists:
307
+ version, issues = _check_schema_compatibility(client, cfg.index)
308
+ if version is not None:
309
+ typer.echo(f"Index '{cfg.index}' exists with schema v{version}")
310
+ if version == SCHEMA_VERSION:
311
+ typer.echo(typer.style("Schema is v2-compatible.", fg=typer.colors.GREEN))
312
+ else:
313
+ typer.echo(typer.style(f"Schema needs upgrade to v{SCHEMA_VERSION}.", fg=typer.colors.YELLOW))
314
+ if issues:
315
+ typer.echo("Issues found:")
316
+ for issue in issues:
317
+ typer.echo(f" - {issue}")
318
+
319
+ if upgrade:
320
+ typer.echo("")
321
+ if not _perform_upgrade(client, cfg, cfg.index):
322
+ raise typer.Exit(1)
323
+ else:
324
+ typer.echo("")
325
+ typer.echo("Run with --upgrade to migrate data to v2 schema.")
326
+ typer.echo(typer.style(
327
+ "Warning: Upgrade will reindex all data. Back up your index first.",
328
+ fg=typer.colors.YELLOW,
329
+ ))
330
+ raise typer.Exit(1)
331
+
195
332
  # Create or update index templates
196
333
  template_body = build_log_index_template(cfg.index)
197
334
  template_name, legacy_template_name = get_template_names(cfg.index)
335
+
198
336
  # Remove any conflicting templates before creating a new one
199
337
  names_to_remove = {template_name, legacy_template_name}
200
338
  names_to_remove.update(OLD_TEMPLATE_NAMES)
@@ -210,10 +348,12 @@ def init(
210
348
  err=True,
211
349
  )
212
350
  client.indices.put_index_template(name=template_name, body=template_body)
351
+
213
352
  # Create initial index with explicit mappings if it doesn't exist
214
- if not client.indices.exists(index=cfg.index):
353
+ if not index_exists:
215
354
  client.indices.create(index=cfg.index, body=template_body["template"])
216
- typer.echo(f"Created index '{cfg.index}'.")
355
+ typer.echo(f"Created index '{cfg.index}' with v{SCHEMA_VERSION} schema.")
356
+
217
357
  typer.echo("OpenSearch indices and templates initialized.")
218
358
 
219
359
 
@@ -651,7 +791,7 @@ def tail(
651
791
  entry_area = doc.get("area") or ""
652
792
  entry_operation = doc.get("operation_id") or ""
653
793
  message = doc.get("message") or ""
654
- features = _format_features(doc.get("features"))
794
+ features = _format_features(doc.get("fields"))
655
795
  if features:
656
796
  typer.echo(f"{timestamp} {entry_level} {entry_area} {entry_operation} {features} {message}")
657
797
  else:
@@ -762,7 +902,7 @@ def search(
762
902
  entry_area = doc.get("area") or ""
763
903
  entry_operation = doc.get("operation_id") or ""
764
904
  message = doc.get("message") or ""
765
- features = _format_features(doc.get("features"))
905
+ features = _format_features(doc.get("fields"))
766
906
  if features:
767
907
  typer.echo(f"{timestamp} {entry_level} {entry_area} {entry_operation} {features} {message}")
768
908
  else:
@@ -838,7 +978,7 @@ def last_error(
838
978
  entry_area = doc.get("area") or ""
839
979
  entry_operation = doc.get("operation_id") or ""
840
980
  message = doc.get("message") or ""
841
- features = _format_features(doc.get("features"))
981
+ features = _format_features(doc.get("fields"))
842
982
  if features:
843
983
  typer.echo(f"{timestamp} {entry_level} {entry_area} {entry_operation} {features} {message}")
844
984
  else:
@@ -8,18 +8,104 @@ import urllib.request
8
8
  import urllib.error
9
9
  from dataclasses import dataclass, field
10
10
  from datetime import datetime, timezone
11
- from typing import Any, Dict, List, Optional
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+ from urllib.parse import urlparse, unquote, urlunparse
13
+
14
+
15
+ def _parse_collector_url(url: str) -> Tuple[str, Optional[str]]:
16
+ """Parse a URL and extract auth token if it's a collector URL.
17
+
18
+ Distinguishes between OpenSearch URLs and collector URLs:
19
+ - OpenSearch URL: has BOTH username AND password - keep credentials in URL
20
+ - Collector URL: has only token in username position - extract for Bearer auth
21
+
22
+ Collector URL format: http://token@host:port
23
+ OpenSearch URL format: http://user:password@host:port
24
+
25
+ Args:
26
+ url: The URL, optionally with credentials in userinfo
27
+
28
+ Returns:
29
+ Tuple of (url, token):
30
+ - For OpenSearch URLs (user:pass): returns original URL, None
31
+ - For collector URLs (token only): returns clean URL without userinfo, token
32
+ - For plain URLs: returns original URL, None
33
+ """
34
+ if not url:
35
+ return url, None
36
+
37
+ parsed = urlparse(url)
38
+
39
+ # If no userinfo, return as-is
40
+ if not parsed.username and not parsed.password:
41
+ return url, None
42
+
43
+ # OpenSearch URL: has BOTH username AND password
44
+ # Keep the URL as-is with credentials, no Bearer token
45
+ if parsed.username and parsed.password:
46
+ return url, None
47
+
48
+ # Collector URL: token in username position only (no password)
49
+ # Extract token and strip userinfo from URL
50
+ token = unquote(parsed.username) if parsed.username else None
51
+
52
+ # Rebuild URL without userinfo
53
+ # netloc without userinfo is just host:port
54
+ if parsed.port:
55
+ netloc = f"{parsed.hostname}:{parsed.port}"
56
+ else:
57
+ netloc = parsed.hostname or ""
58
+
59
+ clean_url = urlunparse((
60
+ parsed.scheme,
61
+ netloc,
62
+ parsed.path,
63
+ parsed.params,
64
+ parsed.query,
65
+ parsed.fragment,
66
+ ))
67
+
68
+ return clean_url, token
12
69
 
13
70
 
14
71
  @dataclass
15
72
  class DevlogsClient:
16
- """Client for sending logs to a devlogs collector.
73
+ """Client for sending logs to a devlogs collector or OpenSearch.
74
+
75
+ URL Types:
76
+ This client distinguishes between collector URLs and OpenSearch URLs:
77
+
78
+ Collector URL (token in username position):
79
+ http://dl1_myapp_secret@localhost:8080
80
+ - Token is extracted and sent as Bearer auth header
81
+ - Userinfo is stripped from the request URL
82
+
83
+ OpenSearch URL (both username AND password):
84
+ https://admin:password@opensearch.example.com:9200
85
+ - Credentials remain in the URL for HTTP Basic auth
86
+ - No Bearer token is used
17
87
 
18
88
  Usage:
89
+ # Collector URL with token:
90
+ client = DevlogsClient(
91
+ collector_url="http://dl1_myapp_secret@localhost:8080",
92
+ application="my-app",
93
+ component="api-server",
94
+ )
95
+
96
+ # OpenSearch URL with credentials:
97
+ client = DevlogsClient(
98
+ collector_url="https://admin:password@opensearch.example.com:9200",
99
+ application="my-app",
100
+ component="api-server",
101
+ )
102
+
103
+ # Or with explicit auth_token parameter:
19
104
  client = DevlogsClient(
20
105
  collector_url="http://localhost:8080",
21
106
  application="my-app",
22
107
  component="api-server",
108
+ auth_token="dl1_myapp_secret",
23
109
  )
24
110
 
25
111
  # Send a single log
@@ -34,6 +120,8 @@ class DevlogsClient:
34
120
  {"message": "Event 1", "level": "info"},
35
121
  {"message": "Event 2", "level": "warning"},
36
122
  ])
123
+
124
+ If both URL token and auth_token parameter are provided, auth_token takes precedence.
37
125
  """
38
126
 
39
127
  collector_url: str
@@ -44,16 +132,27 @@ class DevlogsClient:
44
132
  auth_token: Optional[str] = None
45
133
  timeout: int = 30
46
134
 
135
+ # Internal fields set by __post_init__
136
+ _clean_url: str = field(default="", init=False, repr=False)
137
+ _resolved_token: Optional[str] = field(default=None, init=False, repr=False)
138
+
139
+ def __post_init__(self):
140
+ """Parse collector URL and extract token if present."""
141
+ clean_url, url_token = _parse_collector_url(self.collector_url)
142
+ self._clean_url = clean_url
143
+ # Explicit auth_token parameter takes precedence over URL token
144
+ self._resolved_token = self.auth_token if self.auth_token else url_token
145
+
47
146
  def _get_endpoint(self) -> str:
48
147
  """Get the collector endpoint URL."""
49
- base = self.collector_url.rstrip("/")
148
+ base = self._clean_url.rstrip("/")
50
149
  return f"{base}/v1/logs"
51
150
 
52
151
  def _get_headers(self) -> Dict[str, str]:
53
152
  """Get request headers."""
54
153
  headers = {"Content-Type": "application/json"}
55
- if self.auth_token:
56
- headers["Authorization"] = f"Bearer {self.auth_token}"
154
+ if self._resolved_token:
155
+ headers["Authorization"] = f"Bearer {self._resolved_token}"
57
156
  return headers
58
157
 
59
158
  def _now(self) -> str:
@@ -209,15 +308,17 @@ def create_client(
209
308
  version: Optional[str] = None,
210
309
  auth_token: Optional[str] = None,
211
310
  ) -> DevlogsClient:
212
- """Create an Devlogs client.
311
+ """Create a Devlogs client.
213
312
 
214
313
  Args:
215
- collector_url: The collector endpoint URL (DEVLOGS_URL)
314
+ collector_url: The endpoint URL. URL type is auto-detected:
315
+ - Collector URL: http://token@host:port (token becomes Bearer auth)
316
+ - OpenSearch URL: http://user:pass@host:port (credentials kept in URL)
216
317
  application: Application name
217
318
  component: Component name within the application
218
319
  environment: Deployment environment (optional)
219
320
  version: Application version (optional)
220
- auth_token: Bearer token for authentication (optional)
321
+ auth_token: Bearer token for authentication (optional, overrides URL token)
221
322
 
222
323
  Returns:
223
324
  Configured DevlogsClient instance
@@ -248,7 +349,9 @@ def emit_log(
248
349
  For repeated logging, use create_client() instead.
249
350
 
250
351
  Args:
251
- collector_url: The collector endpoint URL
352
+ collector_url: The endpoint URL. URL type is auto-detected:
353
+ - Collector URL: http://token@host:port (token becomes Bearer auth)
354
+ - OpenSearch URL: http://user:pass@host:port (credentials kept in URL)
252
355
  application: Application name
253
356
  component: Component name
254
357
  message: Log message
@@ -256,7 +359,7 @@ def emit_log(
256
359
  fields: Custom fields
257
360
  environment: Deployment environment
258
361
  version: Application version
259
- auth_token: Bearer token
362
+ auth_token: Bearer token (optional, overrides URL token)
260
363
 
261
364
  Returns:
262
365
  True if accepted, False on error
@@ -169,24 +169,20 @@ class DevlogsHandler(logging.Handler):
169
169
  if operation_id:
170
170
  doc["operation_id"] = operation_id
171
171
 
172
- # Custom fields (renamed from 'features')
172
+ # Custom fields
173
173
  fields = _extract_features(record)
174
174
  if fields:
175
175
  doc["fields"] = fields
176
176
 
177
- # Source location info (useful for debugging)
178
- doc["source"] = {
179
- "logger": record.name,
180
- "pathname": record.pathname,
181
- "lineno": record.lineno,
182
- "funcName": record.funcName,
183
- }
177
+ # Source location info (flat schema to match mappings)
178
+ doc["logger"] = record.name
179
+ doc["pathname"] = record.pathname
180
+ doc["lineno"] = record.lineno
181
+ doc["funcname"] = record.funcName
184
182
 
185
- # Process/thread info
186
- doc["process"] = {
187
- "id": record.process,
188
- "thread": record.thread,
189
- }
183
+ # Process/thread info (flat schema to match mappings)
184
+ doc["process"] = record.process
185
+ doc["thread"] = record.thread
190
186
 
191
187
  # Exception info if present
192
188
  exc_text = getattr(record, "exc_text", None)
@@ -263,6 +263,14 @@ class _IndicesClient:
263
263
  """Refresh an index to make recent changes searchable."""
264
264
  return self._client._request("POST", f"/{index}/_refresh")
265
265
 
266
+ def get_mapping(self, index):
267
+ """Get index mapping."""
268
+ return self._client._request("GET", f"/{index}/_mapping")
269
+
270
+ def reindex(self, body):
271
+ """Reindex documents from one index to another."""
272
+ return self._client._request("POST", "/_reindex", body)
273
+
266
274
 
267
275
  def get_opensearch_client():
268
276
  cfg = load_config()
@@ -0,0 +1,199 @@
1
+ # OpenSearch index templates and mappings
2
+
3
+ from typing import Optional
4
+
5
+
6
+ # Current schema version
7
+ SCHEMA_VERSION = 2
8
+
9
+ # V2 required fields (flat schema)
10
+ V2_REQUIRED_FIELDS = {"logger", "funcname", "fields"}
11
+
12
+ # V1 fields that indicate old schema
13
+ V1_INDICATOR_FIELDS = {"logger_name", "features", "funcName"}
14
+
15
+
16
+ def detect_schema_version(mapping: dict) -> Optional[int]:
17
+ """Detect schema version from index mapping.
18
+
19
+ Returns:
20
+ 2 if v2-compatible (flat schema with logger, funcname, fields)
21
+ 1 if v1 schema (nested source/process, logger_name, features)
22
+ None if unknown/empty
23
+ """
24
+ if not mapping:
25
+ return None
26
+
27
+ # Extract properties from mapping (handle different response formats)
28
+ properties = mapping.get("properties", {})
29
+ if not properties:
30
+ # Try nested format from get_mapping response
31
+ for index_data in mapping.values():
32
+ if isinstance(index_data, dict):
33
+ properties = index_data.get("mappings", {}).get("properties", {})
34
+ if properties:
35
+ break
36
+
37
+ if not properties:
38
+ return None
39
+
40
+ field_names = set(properties.keys())
41
+
42
+ # Check for v2 indicators
43
+ has_v2_fields = V2_REQUIRED_FIELDS.issubset(field_names)
44
+
45
+ # Check for v1 indicators
46
+ has_v1_fields = bool(V1_INDICATOR_FIELDS & field_names)
47
+
48
+ # Check if process is an object (v1) vs integer (v2)
49
+ process_mapping = properties.get("process", {})
50
+ process_is_object = process_mapping.get("type") == "object" or "properties" in process_mapping
51
+
52
+ if has_v2_fields and not has_v1_fields and not process_is_object:
53
+ return 2
54
+ elif has_v1_fields or process_is_object:
55
+ return 1
56
+
57
+ # If we have some standard fields but can't determine version, assume v1
58
+ if field_names & {"timestamp", "level", "message"}:
59
+ return 1
60
+
61
+ return None
62
+
63
+
64
+ def get_schema_issues(mapping: dict) -> list[str]:
65
+ """Get list of schema compatibility issues.
66
+
67
+ Returns list of human-readable issues that need to be fixed for v2 compatibility.
68
+ """
69
+ issues = []
70
+
71
+ properties = mapping.get("properties", {})
72
+ if not properties:
73
+ for index_data in mapping.values():
74
+ if isinstance(index_data, dict):
75
+ properties = index_data.get("mappings", {}).get("properties", {})
76
+ if properties:
77
+ break
78
+
79
+ if not properties:
80
+ return ["No mapping properties found"]
81
+
82
+ field_names = set(properties.keys())
83
+
84
+ # Check for old field names
85
+ if "logger_name" in field_names:
86
+ issues.append("Has 'logger_name' field (v2 uses 'logger')")
87
+ if "funcName" in field_names:
88
+ issues.append("Has 'funcName' field (v2 uses 'funcname')")
89
+ if "features" in field_names:
90
+ issues.append("Has 'features' field (v2 uses 'fields')")
91
+
92
+ # Check process type
93
+ process_mapping = properties.get("process", {})
94
+ if process_mapping.get("type") == "object" or "properties" in process_mapping:
95
+ issues.append("'process' is an object (v2 expects integer)")
96
+
97
+ # Check for nested source object
98
+ if "source" in field_names:
99
+ source_mapping = properties.get("source", {})
100
+ if source_mapping.get("type") == "object" or "properties" in source_mapping:
101
+ issues.append("Has nested 'source' object (v2 uses flat fields)")
102
+
103
+ # Check for missing v2 fields
104
+ for field in V2_REQUIRED_FIELDS:
105
+ if field not in field_names:
106
+ issues.append(f"Missing '{field}' field")
107
+
108
+ return issues
109
+
110
+
111
+ def build_reindex_script() -> str:
112
+ """Build Painless script to transform v1 documents to v2 schema."""
113
+ return """
114
+ // Transform logger_name to logger
115
+ if (ctx._source.containsKey('logger_name')) {
116
+ ctx._source.logger = ctx._source.remove('logger_name');
117
+ }
118
+ // Transform source.logger to logger (if nested)
119
+ if (ctx._source.containsKey('source') && ctx._source.source instanceof Map) {
120
+ if (ctx._source.source.containsKey('logger')) {
121
+ ctx._source.logger = ctx._source.source.logger;
122
+ }
123
+ if (ctx._source.source.containsKey('pathname')) {
124
+ ctx._source.pathname = ctx._source.source.pathname;
125
+ }
126
+ if (ctx._source.source.containsKey('lineno')) {
127
+ ctx._source.lineno = ctx._source.source.lineno;
128
+ }
129
+ if (ctx._source.source.containsKey('funcName')) {
130
+ ctx._source.funcname = ctx._source.source.funcName;
131
+ }
132
+ ctx._source.remove('source');
133
+ }
134
+ // Transform funcName to funcname
135
+ if (ctx._source.containsKey('funcName')) {
136
+ ctx._source.funcname = ctx._source.remove('funcName');
137
+ }
138
+ // Transform features to fields
139
+ if (ctx._source.containsKey('features')) {
140
+ ctx._source.fields = ctx._source.remove('features');
141
+ }
142
+ // Transform nested process object to flat fields
143
+ if (ctx._source.containsKey('process') && ctx._source.process instanceof Map) {
144
+ def proc = ctx._source.process;
145
+ if (proc.containsKey('id')) {
146
+ ctx._source.process = proc.id;
147
+ }
148
+ if (proc.containsKey('thread')) {
149
+ ctx._source.thread = proc.thread;
150
+ }
151
+ }
152
+ """.strip()
153
+
154
+
155
+ def build_log_index_template(index_name: str) -> dict:
156
+ """Return the composable index template for the exact index name."""
157
+ base_template = {
158
+ "index_patterns": [index_name],
159
+ "priority": 100,
160
+ "template": {
161
+ "settings": {"number_of_shards": 1, "number_of_replicas": 0},
162
+ "mappings": {
163
+ "properties": {
164
+ # Core log entry fields (flat schema)
165
+ "doc_type": {"type": "keyword"}, # Always "log_entry"
166
+ "timestamp": {"type": "date"},
167
+ "level": {"type": "keyword"},
168
+ "levelno": {"type": "integer"},
169
+ "logger": {"type": "keyword"},
170
+ "message": {"type": "text"},
171
+ "area": {"type": "keyword"},
172
+ "operation_id": {"type": "keyword"},
173
+ "pathname": {"type": "keyword"},
174
+ "lineno": {"type": "integer"},
175
+ "funcname": {"type": "keyword"},
176
+ "thread": {"type": "long"},
177
+ "process": {"type": "integer"},
178
+ "exception": {"type": "text"},
179
+ "fields": {"type": "object", "dynamic": True},
180
+ }
181
+ }
182
+ }
183
+ }
184
+ return base_template
185
+
186
+
187
+ def build_legacy_log_template(index_name: str) -> dict:
188
+ """Return the legacy template payload for clusters without composable templates."""
189
+ template = build_log_index_template(index_name)
190
+ return {
191
+ "index_patterns": template["index_patterns"],
192
+ "settings": template["template"]["settings"],
193
+ "mappings": template["template"]["mappings"],
194
+ }
195
+
196
+
197
+ def get_template_names(index_name: str) -> tuple[str, str]:
198
+ """Return deterministic template names based on the index name."""
199
+ return (f"{index_name}-template", f"{index_name}-legacy-template")