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.
- {devlogs-2.2.8/src/devlogs.egg-info → devlogs-2.3.0}/PKG-INFO +8 -2
- {devlogs-2.2.8 → devlogs-2.3.0}/README.md +7 -1
- {devlogs-2.2.8 → devlogs-2.3.0}/pyproject.toml +1 -1
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/_version_static.py +1 -1
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/cli.py +112 -1
- devlogs-2.3.0/src/devlogs/collector/loki_plugin.py +182 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/collector/schema.py +11 -4
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/collector/server.py +28 -1
- devlogs-2.3.0/src/devlogs/loki/__init__.py +1 -0
- devlogs-2.3.0/src/devlogs/loki/queries.py +332 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/mcp/server.py +142 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/opensearch/queries.py +42 -0
- {devlogs-2.2.8 → devlogs-2.3.0/src/devlogs.egg-info}/PKG-INFO +8 -2
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs.egg-info/SOURCES.txt +3 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_build_info.py +20 -10
- {devlogs-2.2.8 → devlogs-2.3.0}/LICENSE +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/MANIFEST.in +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/setup.cfg +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/__init__.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/__main__.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/build_info.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/collector/__init__.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/collector/auth.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/collector/cli.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/collector/errors.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/collector/forwarder.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/collector/ingestor.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/collector/plugins.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/config.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/context.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/demo.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/devlogs_client.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/formatting.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/handler.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/jenkins/__init__.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/jenkins/cli.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/jenkins/core.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/levels.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/mcp/__init__.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/opensearch/__init__.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/opensearch/client.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/opensearch/indexing.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/opensearch/mappings.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/retention.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/scrub.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/time_utils.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/version.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/web/__init__.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/web/server.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/web/static/devlogs.css +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/web/static/devlogs.js +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/web/static/index.html +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs/wrapper.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs.egg-info/dependency_links.txt +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs.egg-info/entry_points.txt +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs.egg-info/requires.txt +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/src/devlogs.egg-info/top_level.txt +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_cli.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_collector_auth.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_collector_config.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_collector_plugins.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_collector_schema.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_collector_server.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_config.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_context.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_devlogs_client.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_formatting.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_handler.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_indexing.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_levels.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_mappings.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_mcp_server.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_opensearch_client.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_opensearch_queries.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_retention.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_scrub.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_time_utils.py +0 -0
- {devlogs-2.2.8 → devlogs-2.3.0}/tests/test_url_parsing.py +0 -0
- {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.
|
|
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
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# AUTO-GENERATED at build time — do not edit or commit
|
|
2
|
-
__version__ = "2.
|
|
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
|
|
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:
|
|
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":
|
|
387
|
+
"ingested": ingested,
|
|
361
388
|
}),
|
|
362
389
|
media_type="application/json",
|
|
363
390
|
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Loki query module for devlogs
|