devlogs 2.2.8__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. {devlogs-2.2.8/src/devlogs.egg-info → devlogs-2.3.0}/PKG-INFO +8 -2
  2. {devlogs-2.2.8 → devlogs-2.3.0}/README.md +7 -1
  3. {devlogs-2.2.8 → devlogs-2.3.0}/pyproject.toml +1 -1
  4. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/_version_static.py +1 -1
  5. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/cli.py +112 -1
  6. devlogs-2.3.0/src/devlogs/collector/loki_plugin.py +182 -0
  7. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/collector/schema.py +11 -4
  8. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/collector/server.py +28 -1
  9. devlogs-2.3.0/src/devlogs/loki/__init__.py +1 -0
  10. devlogs-2.3.0/src/devlogs/loki/queries.py +332 -0
  11. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/mcp/server.py +142 -0
  12. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/opensearch/queries.py +42 -0
  13. {devlogs-2.2.8 → devlogs-2.3.0/src/devlogs.egg-info}/PKG-INFO +8 -2
  14. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs.egg-info/SOURCES.txt +3 -0
  15. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_build_info.py +20 -10
  16. {devlogs-2.2.8 → devlogs-2.3.0}/LICENSE +0 -0
  17. {devlogs-2.2.8 → devlogs-2.3.0}/MANIFEST.in +0 -0
  18. {devlogs-2.2.8 → devlogs-2.3.0}/setup.cfg +0 -0
  19. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/__init__.py +0 -0
  20. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/__main__.py +0 -0
  21. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/build_info.py +0 -0
  22. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/collector/__init__.py +0 -0
  23. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/collector/auth.py +0 -0
  24. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/collector/cli.py +0 -0
  25. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/collector/errors.py +0 -0
  26. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/collector/forwarder.py +0 -0
  27. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/collector/ingestor.py +0 -0
  28. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/collector/plugins.py +0 -0
  29. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/config.py +0 -0
  30. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/context.py +0 -0
  31. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/demo.py +0 -0
  32. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/devlogs_client.py +0 -0
  33. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/formatting.py +0 -0
  34. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/handler.py +0 -0
  35. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/jenkins/__init__.py +0 -0
  36. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/jenkins/cli.py +0 -0
  37. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/jenkins/core.py +0 -0
  38. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/levels.py +0 -0
  39. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/mcp/__init__.py +0 -0
  40. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/opensearch/__init__.py +0 -0
  41. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/opensearch/client.py +0 -0
  42. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/opensearch/indexing.py +0 -0
  43. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/opensearch/mappings.py +0 -0
  44. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/retention.py +0 -0
  45. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/scrub.py +0 -0
  46. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/time_utils.py +0 -0
  47. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/version.py +0 -0
  48. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/web/__init__.py +0 -0
  49. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/web/server.py +0 -0
  50. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/web/static/devlogs.css +0 -0
  51. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/web/static/devlogs.js +0 -0
  52. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/web/static/index.html +0 -0
  53. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/wrapper.py +0 -0
  54. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs.egg-info/dependency_links.txt +0 -0
  55. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs.egg-info/entry_points.txt +0 -0
  56. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs.egg-info/requires.txt +0 -0
  57. {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs.egg-info/top_level.txt +0 -0
  58. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_cli.py +0 -0
  59. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_collector_auth.py +0 -0
  60. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_collector_config.py +0 -0
  61. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_collector_plugins.py +0 -0
  62. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_collector_schema.py +0 -0
  63. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_collector_server.py +0 -0
  64. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_config.py +0 -0
  65. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_context.py +0 -0
  66. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_devlogs_client.py +0 -0
  67. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_formatting.py +0 -0
  68. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_handler.py +0 -0
  69. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_indexing.py +0 -0
  70. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_levels.py +0 -0
  71. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_mappings.py +0 -0
  72. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_mcp_server.py +0 -0
  73. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_opensearch_client.py +0 -0
  74. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_opensearch_queries.py +0 -0
  75. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_retention.py +0 -0
  76. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_scrub.py +0 -0
  77. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_time_utils.py +0 -0
  78. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_url_parsing.py +0 -0
  79. {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlogs
3
- Version: 2.2.8
3
+ Version: 2.3.0
4
4
  Summary: Developer-focused logging library for Python with OpenSearch integration.
5
5
  Author-email: Dan Driscoll <dan@thedandriscoll.org>
6
6
  License: MIT License
@@ -62,6 +62,10 @@ If you don't have OpenSearch running and you want to stand one up:
62
62
 
63
63
  ## Step 2: Copy/paste these instructions into your coding agent
64
64
 
65
+ Pick the block for your language and paste it into your coding agent. The agent will install devlogs as a dev dependency, create a connection config file, initialize the index, and add a small logging hook to your app entrypoint — guarded by an environment check so it only runs in development. No existing code is modified.
66
+
67
+ For language-specific details and quirks, see [AGENT_HOWTO_PYTHON.md](AGENT_HOWTO_PYTHON.md) or [AGENT_HOWTO_JAVASCRIPT.md](AGENT_HOWTO_JAVASCRIPT.md).
68
+
65
69
  ### Python
66
70
 
67
71
  > Please do the following in this project:
@@ -109,9 +113,9 @@ If you don't have OpenSearch running and you want to stand one up:
109
113
  > application: 'my-app', // Required: your app name
110
114
  > component: 'frontend', // Required: component name
111
115
  > });
116
+ > devlogs.installGlobalHandlers();
112
117
  > }
113
118
  > ```
114
- > After `init()`, all `console.log`, `console.warn`, `console.error`, and `console.debug` calls are automatically forwarded to OpenSearch. The original console output is preserved.
115
119
  > 3. Use `devlogs.setArea('dashboard')` and `devlogs.setOperationId('op-123')` to add context to logs. Pass a plain object as the last argument to attach custom fields:
116
120
  > ```javascript
117
121
  > console.log('User action', { userId: 123, action: 'clicked' });
@@ -459,6 +463,8 @@ See [docs/build-info.md](docs/build-info.md) for CI integration examples and ful
459
463
 
460
464
  ## See Also
461
465
 
466
+ - [AGENT_HOWTO_PYTHON.md](AGENT_HOWTO_PYTHON.md) - Agent setup instructions for Python
467
+ - [AGENT_HOWTO_JAVASCRIPT.md](AGENT_HOWTO_JAVASCRIPT.md) - Agent setup instructions for browser JS/TS
462
468
  - [MIGRATION-V2.md](MIGRATION-V2.md) - Migration guide from v1.x to v2.0
463
469
  - [HOWTO-COLLECTOR.md](HOWTO-COLLECTOR.md) - HTTP collector setup and deployment
464
470
  - [HOWTO-DEVLOGS-FORMAT.md](HOWTO-DEVLOGS-FORMAT.md) - Devlogs record format reference
@@ -19,6 +19,10 @@ If you don't have OpenSearch running and you want to stand one up:
19
19
 
20
20
  ## Step 2: Copy/paste these instructions into your coding agent
21
21
 
22
+ Pick the block for your language and paste it into your coding agent. The agent will install devlogs as a dev dependency, create a connection config file, initialize the index, and add a small logging hook to your app entrypoint — guarded by an environment check so it only runs in development. No existing code is modified.
23
+
24
+ For language-specific details and quirks, see [AGENT_HOWTO_PYTHON.md](AGENT_HOWTO_PYTHON.md) or [AGENT_HOWTO_JAVASCRIPT.md](AGENT_HOWTO_JAVASCRIPT.md).
25
+
22
26
  ### Python
23
27
 
24
28
  > Please do the following in this project:
@@ -66,9 +70,9 @@ If you don't have OpenSearch running and you want to stand one up:
66
70
  > application: 'my-app', // Required: your app name
67
71
  > component: 'frontend', // Required: component name
68
72
  > });
73
+ > devlogs.installGlobalHandlers();
69
74
  > }
70
75
  > ```
71
- > After `init()`, all `console.log`, `console.warn`, `console.error`, and `console.debug` calls are automatically forwarded to OpenSearch. The original console output is preserved.
72
76
  > 3. Use `devlogs.setArea('dashboard')` and `devlogs.setOperationId('op-123')` to add context to logs. Pass a plain object as the last argument to attach custom fields:
73
77
  > ```javascript
74
78
  > console.log('User action', { userId: 123, action: 'clicked' });
@@ -416,6 +420,8 @@ See [docs/build-info.md](docs/build-info.md) for CI integration examples and ful
416
420
 
417
421
  ## See Also
418
422
 
423
+ - [AGENT_HOWTO_PYTHON.md](AGENT_HOWTO_PYTHON.md) - Agent setup instructions for Python
424
+ - [AGENT_HOWTO_JAVASCRIPT.md](AGENT_HOWTO_JAVASCRIPT.md) - Agent setup instructions for browser JS/TS
419
425
  - [MIGRATION-V2.md](MIGRATION-V2.md) - Migration guide from v1.x to v2.0
420
426
  - [HOWTO-COLLECTOR.md](HOWTO-COLLECTOR.md) - HTTP collector setup and deployment
421
427
  - [HOWTO-DEVLOGS-FORMAT.md](HOWTO-DEVLOGS-FORMAT.md) - Devlogs record format reference
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devlogs"
7
- version = "2.2.8"
7
+ version = "2.3.0"
8
8
  description = "Developer-focused logging library for Python with OpenSearch integration."
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -1,2 +1,2 @@
1
1
  # AUTO-GENERATED at build time — do not edit or commit
2
- __version__ = "2.2.8"
2
+ __version__ = "2.3.0"
@@ -28,7 +28,7 @@ from .opensearch.mappings import (
28
28
  build_reindex_script,
29
29
  SCHEMA_VERSION,
30
30
  )
31
- from .opensearch.queries import normalize_log_entries, search_logs, tail_logs, get_last_errors
31
+ from .opensearch.queries import normalize_log_entries, search_logs, tail_logs, get_last_errors, get_index_stats
32
32
  from .retention import cleanup_old_logs, get_retention_stats
33
33
  from .jenkins.cli import jenkins_app
34
34
  from .version import __version__
@@ -462,6 +462,117 @@ def refresh(
462
462
  raise typer.Exit(1)
463
463
 
464
464
 
465
+ def _check_collector_liveness(collector_url: str) -> tuple[bool, str]:
466
+ """Probe a collector URL for liveness. Returns (reachable, detail)."""
467
+ import urllib.request
468
+ import urllib.error
469
+ from .devlogs_client import _parse_collector_url
470
+
471
+ clean_url, token = _parse_collector_url(collector_url)
472
+ endpoint = clean_url.rstrip("/")
473
+ headers = {}
474
+ if token:
475
+ headers["Authorization"] = f"Bearer {token}"
476
+ req = urllib.request.Request(endpoint, method="HEAD", headers=headers)
477
+ try:
478
+ with urllib.request.urlopen(req, timeout=5):
479
+ pass
480
+ return True, "reachable"
481
+ except urllib.error.HTTPError as e:
482
+ # Any HTTP response means the server is up
483
+ return True, f"reachable (HTTP {e.code})"
484
+ except urllib.error.URLError as e:
485
+ return False, str(e.reason)
486
+ except Exception as e:
487
+ return False, f"{type(e).__name__}: {e}"
488
+
489
+
490
+ def _print_index_stats(stats: dict):
491
+ """Print OpenSearch index statistics."""
492
+ typer.echo(f"Total records: {stats['total']:,}")
493
+ typer.echo(f"First entry: {stats['first_entry'] or '(none)'}")
494
+ typer.echo(f"Last entry: {stats['last_entry'] or '(none)'}")
495
+
496
+ counts = stats["counts_by_level"]
497
+ if counts:
498
+ typer.echo()
499
+ typer.echo("Records by level:")
500
+ for level_name in ("debug", "info", "warning", "error", "critical"):
501
+ if level_name in counts:
502
+ typer.echo(f" {level_name + ':':12s} {counts[level_name]:>8,}")
503
+ for level_name in sorted(counts):
504
+ if level_name not in ("debug", "info", "warning", "error", "critical"):
505
+ typer.echo(f" {level_name + ':':12s} {counts[level_name]:>8,}")
506
+
507
+
508
+ @app.command()
509
+ def status(
510
+ env: str = ENV_OPTION,
511
+ url: str = URL_OPTION,
512
+ ):
513
+ """Show connection status and index statistics.
514
+
515
+ Detects the configured mode automatically:
516
+
517
+ OpenSearch mode — queries the index for record counts, timestamps,
518
+ and level breakdowns.
519
+
520
+ Collector mode — probes the write-only HTTP endpoint for liveness.
521
+ Collectors accept logs but cannot be queried, so
522
+ index statistics are not available.
523
+ """
524
+ _apply_common_options(env, url)
525
+ cfg = load_config()
526
+
527
+ if cfg.url_mode == "collector":
528
+ typer.echo(f"Mode: collector (write-only)")
529
+ typer.echo(f"Collector URL: {cfg.collector_url}")
530
+
531
+ # Check for plugin
532
+ from .collector.plugins import get_plugin_for_url
533
+ plugin = get_plugin_for_url(cfg.collector_url, cfg)
534
+ if plugin:
535
+ typer.echo(f"Plugin: {plugin.name}")
536
+ try:
537
+ plugin_status = plugin.check()
538
+ typer.echo(typer.style(f"Status: {plugin_status}", fg=typer.colors.GREEN))
539
+ except Exception as e:
540
+ typer.echo(typer.style(f"Status: unreachable ({e})", fg=typer.colors.RED))
541
+ raise typer.Exit(1)
542
+ else:
543
+ reachable, detail = _check_collector_liveness(cfg.collector_url)
544
+ if reachable:
545
+ typer.echo(typer.style(f"Status: {detail}", fg=typer.colors.GREEN))
546
+ else:
547
+ typer.echo(typer.style(f"Status: unreachable ({detail})", fg=typer.colors.RED))
548
+ raise typer.Exit(1)
549
+
550
+ typer.echo()
551
+ typer.echo(typer.style(
552
+ "Index statistics are not available in collector mode.\n"
553
+ "The collector is a write-only HTTP endpoint that accepts logs\n"
554
+ "but cannot be queried. Use an OpenSearch URL to see index stats.",
555
+ dim=True,
556
+ ))
557
+ return
558
+
559
+ if cfg.url_mode == "none":
560
+ typer.echo(typer.style(
561
+ "Error: No OpenSearch or collector URL configured.\n"
562
+ "Set DEVLOGS_URL or DEVLOGS_OPENSEARCH_HOST.",
563
+ fg=typer.colors.RED,
564
+ ), err=True)
565
+ raise typer.Exit(1)
566
+
567
+ # OpenSearch mode
568
+ client, cfg = require_opensearch()
569
+ typer.echo(f"Mode: opensearch (read/write)")
570
+ typer.echo(f"Index: {cfg.index}")
571
+
572
+ stats = get_index_stats(client, cfg.index)
573
+ _print_index_stats(stats)
574
+
575
+
465
576
  @app.command()
466
577
  def diagnose(
467
578
  env: str = ENV_OPTION,
@@ -0,0 +1,182 @@
1
+ # Loki output plugin for the devlogs collector
2
+ #
3
+ # Activated by setting DEVLOGS_FORWARD_URL=loki://host:3100 (or lokis:// for TLS).
4
+ # Converts validated DevlogsRecord objects into Loki stream push payloads.
5
+ #
6
+ # Label strategy (low-cardinality only):
7
+ # application, component, level, area, environment
8
+ #
9
+ # Everything else (message, operation_id, fields, etc.) is stored as a JSON
10
+ # log line and accessible via Loki's | json pipeline.
11
+
12
+ import json
13
+ import time
14
+ import urllib.error
15
+ import urllib.request
16
+ from datetime import datetime, timezone
17
+ from typing import Any, Dict, List
18
+ from urllib.parse import urlparse
19
+
20
+ from .errors import PluginError
21
+ from .plugins import OutputPlugin, register_plugin
22
+ from .schema import DevlogsRecord
23
+
24
+ # Labels promoted to Loki stream labels (kept low-cardinality)
25
+ _LOKI_LABEL_FIELDS = ("application", "component", "level", "area", "environment")
26
+
27
+
28
+ def _record_to_ns(record: DevlogsRecord) -> int:
29
+ """Convert record timestamp to Unix nanoseconds for Loki."""
30
+ ts_str = record.timestamp or record.collected_ts
31
+ if not ts_str:
32
+ return int(time.time() * 1e9)
33
+ try:
34
+ # fromisoformat() doesn't accept 'Z' suffix in Python < 3.11
35
+ ts_clean = ts_str
36
+ if ts_clean.endswith("Z"):
37
+ ts_clean = ts_clean[:-1] + "+00:00"
38
+ dt = datetime.fromisoformat(ts_clean)
39
+ if dt.tzinfo is None:
40
+ dt = dt.replace(tzinfo=timezone.utc)
41
+ return int(dt.timestamp() * 1e9)
42
+ except Exception:
43
+ return int(time.time() * 1e9)
44
+
45
+
46
+ def _build_log_line(record: DevlogsRecord) -> str:
47
+ """Serialize the log record as a JSON log line for Loki storage."""
48
+ payload: Dict[str, Any] = {}
49
+ if record.message is not None:
50
+ payload["message"] = record.message
51
+ if record.operation_id is not None:
52
+ payload["operation_id"] = record.operation_id
53
+ payload["timestamp"] = record.timestamp
54
+ if record.collected_ts is not None:
55
+ payload["collected_ts"] = record.collected_ts
56
+ if record.version is not None:
57
+ payload["version"] = record.version
58
+ if record.fields:
59
+ payload["fields"] = record.fields
60
+ return json.dumps(payload, ensure_ascii=False)
61
+
62
+
63
+ def _stream_key(record: DevlogsRecord) -> tuple:
64
+ """Return a hashable key representing this record's Loki stream labels."""
65
+ labels = []
66
+ if record.application:
67
+ labels.append(("application", record.application))
68
+ if record.component:
69
+ labels.append(("component", record.component))
70
+ if record.level:
71
+ labels.append(("level", record.level.lower()))
72
+ if record.area:
73
+ labels.append(("area", record.area))
74
+ if record.environment:
75
+ labels.append(("environment", record.environment))
76
+ return tuple(labels)
77
+
78
+
79
+ class LokiOutputPlugin(OutputPlugin):
80
+ """Pushes log records to Grafana Loki via the HTTP push API.
81
+
82
+ URL schemes:
83
+ loki://host:port — plain HTTP
84
+ lokis://host:port — HTTPS
85
+ """
86
+
87
+ name = "loki"
88
+ schemes = ["loki", "lokis"]
89
+
90
+ def __init__(self, url: str, cfg: Any):
91
+ parsed = urlparse(url)
92
+ scheme = "https" if parsed.scheme == "lokis" else "http"
93
+ host = parsed.hostname or "localhost"
94
+ port = parsed.port or 3100
95
+ base = f"{scheme}://{host}:{port}"
96
+ self._push_url = f"{base}/loki/api/v1/push"
97
+ self._ready_url = f"{base}/ready"
98
+ self._display_url = url # original url for display_info
99
+
100
+ def send(self, records: List[DevlogsRecord]) -> Dict[str, Any]:
101
+ """Push records to Loki, grouped into streams by label combination."""
102
+ streams: Dict[tuple, list] = {}
103
+ for record in records:
104
+ key = _stream_key(record)
105
+ if key not in streams:
106
+ streams[key] = []
107
+ ns = _record_to_ns(record)
108
+ line = _build_log_line(record)
109
+ streams[key].append([str(ns), line])
110
+
111
+ payload = {
112
+ "streams": [
113
+ {"stream": dict(key), "values": values}
114
+ for key, values in streams.items()
115
+ ]
116
+ }
117
+
118
+ body = json.dumps(payload).encode("utf-8")
119
+ req = urllib.request.Request(
120
+ self._push_url,
121
+ data=body,
122
+ headers={"Content-Type": "application/json"},
123
+ method="POST",
124
+ )
125
+
126
+ for attempt in range(3):
127
+ try:
128
+ with urllib.request.urlopen(req, timeout=10) as resp:
129
+ # Loki returns 204 No Content on success
130
+ if resp.status in (200, 204):
131
+ return {"ingested": len(records)}
132
+ raise PluginError(
133
+ "UNEXPECTED_STATUS",
134
+ f"Loki push returned unexpected status {resp.status}",
135
+ )
136
+ except urllib.error.HTTPError as e:
137
+ if e.code in (429, 500, 502, 503, 504) and attempt < 2:
138
+ time.sleep(0.5 * (2 ** attempt))
139
+ continue
140
+ try:
141
+ detail = e.read().decode("utf-8", errors="replace")[:300]
142
+ except Exception:
143
+ detail = ""
144
+ raise PluginError(
145
+ "HTTP_ERROR",
146
+ f"Loki push failed with HTTP {e.code}: {detail}",
147
+ )
148
+ except urllib.error.URLError as e:
149
+ if attempt < 2:
150
+ time.sleep(0.5 * (2 ** attempt))
151
+ continue
152
+ raise PluginError(
153
+ "SEND_FAILED",
154
+ f"Failed to connect to Loki: {e.reason}",
155
+ )
156
+ except PluginError:
157
+ raise
158
+ except Exception as e:
159
+ if attempt < 2:
160
+ time.sleep(0.5 * (2 ** attempt))
161
+ continue
162
+ raise PluginError("SEND_FAILED", f"Loki send failed: {e}")
163
+
164
+ raise PluginError("SEND_FAILED", "Failed to push to Loki after retries")
165
+
166
+ def check(self) -> str:
167
+ """Verify Loki is reachable via its /ready endpoint."""
168
+ try:
169
+ req = urllib.request.Request(self._ready_url, method="GET")
170
+ with urllib.request.urlopen(req, timeout=5) as resp:
171
+ if resp.status == 200:
172
+ return f"Loki: OK ({self._push_url})"
173
+ return f"Loki: /ready returned {resp.status}"
174
+ except Exception as e:
175
+ raise Exception(f"Loki not reachable at {self._ready_url}: {e}")
176
+
177
+ def display_info(self) -> str:
178
+ return f"Loki: {self._display_url}"
179
+
180
+
181
+ # Self-register when this module is imported
182
+ register_plugin(LokiOutputPlugin)
@@ -14,9 +14,12 @@
14
14
  # - environment (string): Deployment environment (e.g., "development", "staging", "production")
15
15
  # - version (string): Application version
16
16
  #
17
- # Collector-set fields (added during ingestion):
17
+ # Collector-set fields (added during ingestion, always overwritten by collector):
18
18
  # - collected_ts (string): ISO 8601 UTC timestamp when collector received the record
19
- # - client_ip (string): IP address of the submitting client
19
+ # - client_ip (string): IP address of the submitting client as a bare IPv4 or IPv6
20
+ # address string (e.g. "192.168.1.5", "::1"). No port, no brackets, no CIDR suffix.
21
+ # Always set by the collector from the network connection; any value in the payload
22
+ # is discarded.
20
23
  # - identity (object): Identity information resolved from auth token
21
24
  # - mode: "anonymous" | "verified" | "passthrough"
22
25
  # - For verified: id, name (optional), type (optional), tags (optional)
@@ -60,7 +63,8 @@ class DevlogsRecord:
60
63
  version: Application version (optional)
61
64
  fields: Arbitrary nested JSON for custom data (optional)
62
65
  collected_ts: Timestamp when collector received (set by collector)
63
- client_ip: Submitting client IP address (set by collector)
66
+ client_ip: Submitting client IP address as a bare IPv4 or IPv6
67
+ string (set by collector, never accepted from payload)
64
68
  identity: Identity information resolved from auth (set by collector)
65
69
  """
66
70
 
@@ -329,9 +333,12 @@ def enrich_record(
329
333
  ) -> DevlogsRecord:
330
334
  """Enrich a record with collector metadata.
331
335
 
336
+ All three collector-set fields are unconditionally overwritten here;
337
+ any values from the payload are discarded.
338
+
332
339
  Args:
333
340
  record: The validated record
334
- client_ip: IP address of the submitting client
341
+ client_ip: Bare IPv4/IPv6 address string from get_client_ip()
335
342
  identity: Identity object or dict from auth resolution
336
343
 
337
344
  Returns:
@@ -5,10 +5,13 @@
5
5
  # - Ingest mode: write directly to OpenSearch
6
6
 
7
7
  import json
8
+ import logging
8
9
  import platform
9
10
  from contextlib import asynccontextmanager
10
11
  from typing import Optional
11
12
 
13
+ logger = logging.getLogger("devlogs.collector")
14
+
12
15
  from fastapi import FastAPI, Request, Response, HTTPException
13
16
  from fastapi.responses import JSONResponse
14
17
  from starlette.middleware.cors import CORSMiddleware
@@ -41,6 +44,13 @@ from .ingestor import ingest_records
41
44
  from .plugins import get_plugin_for_url
42
45
  from ..version import __version__
43
46
 
47
+ # Register built-in output plugins by importing them (side-effect: register_plugin is called)
48
+ try:
49
+ from . import loki_plugin as _loki_plugin # noqa: F401
50
+ except ImportError:
51
+ pass
52
+
53
+
44
54
  @asynccontextmanager
45
55
  async def lifespan(app: FastAPI):
46
56
  """Emit a startup trace to the index so operators can see when the collector started."""
@@ -120,6 +130,9 @@ app.add_middleware(
120
130
  def get_client_ip(request: Request) -> str:
121
131
  """Extract client IP from request.
122
132
 
133
+ Returns a bare IPv4 or IPv6 address string (e.g. "192.168.1.5", "::1").
134
+ No port, no brackets, no CIDR suffix.
135
+
123
136
  Checks X-Forwarded-For header first (for proxied requests),
124
137
  then falls back to direct client connection.
125
138
  """
@@ -144,6 +157,8 @@ def get_client_ip(request: Request) -> str:
144
157
  @app.exception_handler(CollectorError)
145
158
  async def collector_error_handler(request: Request, exc: CollectorError):
146
159
  """Handle CollectorError exceptions with structured response."""
160
+ client_ip = get_client_ip(request)
161
+ logger.warning("%s %d %s: %s", client_ip, exc.status_code, exc.subcode, exc.message)
147
162
  return JSONResponse(
148
163
  status_code=exc.status_code,
149
164
  content=exc.to_dict(),
@@ -187,6 +202,8 @@ async def ingest_logs(request: Request):
187
202
 
188
203
  # Determine operating mode
189
204
  mode = cfg.get_collector_mode()
205
+ client_ip = get_client_ip(request)
206
+ logger.info("%s POST / (%d bytes)", client_ip, len(body))
190
207
 
191
208
  if mode == "forward":
192
209
  try:
@@ -223,8 +240,11 @@ async def _handle_forward_mode(request: Request, cfg, body: bytes) -> Response:
223
240
  timeout=cfg.opensearch_timeout,
224
241
  )
225
242
 
243
+ client_ip = get_client_ip(request)
244
+
226
245
  # If upstream returned 2xx, return 202
227
246
  if 200 <= status < 300:
247
+ logger.info("%s 202 forwarded", client_ip)
228
248
  return Response(
229
249
  status_code=202,
230
250
  content=json.dumps({"status": "accepted", "forwarded": True}),
@@ -232,6 +252,7 @@ async def _handle_forward_mode(request: Request, cfg, body: bytes) -> Response:
232
252
  )
233
253
  else:
234
254
  # This shouldn't happen - HTTPError should have been raised
255
+ logger.warning("%s %d forward failed", client_ip, status)
235
256
  return JSONResponse(
236
257
  status_code=status,
237
258
  content=response_body,
@@ -321,6 +342,8 @@ async def _handle_ingest_mode(request: Request, cfg, body: bytes) -> Response:
321
342
  index_map = parse_forward_index_map_kv(cfg.forward_index_map_kv)
322
343
 
323
344
  result = ingest_records(client, cfg.index, enriched_records, index_map)
345
+ client_ip = get_client_ip(request)
346
+ logger.info("%s 202 ingested %d record(s)", client_ip, result["ingested"])
324
347
 
325
348
  return Response(
326
349
  status_code=202,
@@ -353,11 +376,15 @@ async def _handle_plugin_mode(request: Request, cfg, body: bytes, plugin) -> Res
353
376
  if not isinstance(result, dict):
354
377
  result = {}
355
378
 
379
+ ingested = result.get("ingested", len(enriched_records))
380
+ client_ip = get_client_ip(request)
381
+ logger.info("%s 202 plugin '%s' ingested %d record(s)", client_ip, plugin.name, ingested)
382
+
356
383
  return Response(
357
384
  status_code=202,
358
385
  content=json.dumps({
359
386
  "status": "accepted",
360
- "ingested": result.get("ingested", len(enriched_records)),
387
+ "ingested": ingested,
361
388
  }),
362
389
  media_type="application/json",
363
390
  )
@@ -0,0 +1 @@
1
+ # Loki query module for devlogs