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.
- {devlogs-2.0.0/src/devlogs.egg-info → devlogs-2.0.2}/PKG-INFO +1 -1
- {devlogs-2.0.0 → devlogs-2.0.2}/pyproject.toml +1 -1
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/cli.py +147 -7
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/devlogs_client.py +113 -10
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/handler.py +9 -13
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/opensearch/client.py +8 -0
- devlogs-2.0.2/src/devlogs/opensearch/mappings.py +199 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/opensearch/queries.py +8 -8
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/web/static/devlogs.js +3 -3
- {devlogs-2.0.0 → devlogs-2.0.2/src/devlogs.egg-info}/PKG-INFO +1 -1
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs.egg-info/SOURCES.txt +1 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_devlogs_client.py +99 -1
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_handler.py +9 -9
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_indexing.py +3 -3
- devlogs-2.0.2/tests/test_mappings.py +204 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_mcp_server.py +272 -272
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_web.py +3 -3
- devlogs-2.0.0/src/devlogs/opensearch/mappings.py +0 -47
- {devlogs-2.0.0 → devlogs-2.0.2}/LICENSE +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/MANIFEST.in +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/README.md +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/setup.cfg +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/__init__.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/__main__.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/build_info.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/collector/__init__.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/collector/auth.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/collector/cli.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/collector/errors.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/collector/forwarder.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/collector/ingestor.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/collector/schema.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/collector/server.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/config.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/context.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/demo.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/formatting.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/jenkins/__init__.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/jenkins/cli.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/jenkins/core.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/levels.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/mcp/__init__.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/mcp/server.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/opensearch/__init__.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/opensearch/indexing.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/retention.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/scrub.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/time_utils.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/web/__init__.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/web/server.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/web/static/devlogs.css +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/web/static/index.html +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs/wrapper.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs.egg-info/dependency_links.txt +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs.egg-info/entry_points.txt +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs.egg-info/requires.txt +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/src/devlogs.egg-info/top_level.txt +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_build_info.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_cli.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_collector_auth.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_collector_config.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_collector_schema.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_collector_server.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_config.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_context.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_formatting.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_levels.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_opensearch_client.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_opensearch_queries.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_retention.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_scrub.py +0 -0
- {devlogs-2.0.0 → devlogs-2.0.2}/tests/test_time_utils.py +0 -0
|
@@ -20,7 +20,14 @@ from .opensearch.client import (
|
|
|
20
20
|
ConnectionFailedError,
|
|
21
21
|
DevlogsDisabledError,
|
|
22
22
|
)
|
|
23
|
-
from .opensearch.mappings import
|
|
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
|
|
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("
|
|
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("
|
|
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("
|
|
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.
|
|
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.
|
|
56
|
-
headers["Authorization"] = f"Bearer {self.
|
|
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
|
|
311
|
+
"""Create a Devlogs client.
|
|
213
312
|
|
|
214
313
|
Args:
|
|
215
|
-
collector_url: The
|
|
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
|
|
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
|
|
172
|
+
# Custom fields
|
|
173
173
|
fields = _extract_features(record)
|
|
174
174
|
if fields:
|
|
175
175
|
doc["fields"] = fields
|
|
176
176
|
|
|
177
|
-
# Source location info (
|
|
178
|
-
doc["
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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")
|