diff --git a/.gitignore b/.gitignore index d431a86..1368eac 100644 --- a/.gitignore +++ b/.gitignore @@ -79,7 +79,7 @@ poetry.lock .claude .claude/ .gemini/ -AGENT.md +AGENTS.md CLAUDE.md RELEASE_NOTES_v*.md TASKS.md diff --git a/README.md b/README.md index 5c3af64..696eeee 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ * **Fast & Lightweight**: Tail files natively or stream huge data directly via pipes (`cat server.log | logscope`). * **Colored & Structured Logs**: Automatically identifies `INFO`, `WARNING`, `ERROR`, `CRITICAL`, and `DEBUG`, applying beautiful typography. -* **Universal Parser**: Reads typical bracket logs (`[INFO]`) **and** parses modern NDJSON / JSON logs out of the box (e.g., Kubernetes, Docker). +* **Universal Parser**: Reads typical bracket logs (`[INFO]`) **and** parses modern NDJSON / JSON logs out of the box (e.g., Kubernetes, Docker, OpenTelemetry). * **Auto-Highlighting**: Magically highlights `IPs`, `URLs`, `Dates/Timestamps`, `UUIDs`, and `E-Mails` with dynamic colors. * **Custom Keyword Highlighting**: Highlight specific keywords in log messages with `--highlight` and customize colors with `--highlight-color`. * **Live Dashboard**: Watch logs stream in real-time alongside a live statistics panel keeping track of Error vs Info counts (`--dashboard`). @@ -90,6 +90,10 @@ logscope app.log --no-color logscope archive/app.log.gz ``` +JSON logs can use common fields such as `level`, `severity`, `severity_text`, `message`, `msg`, +`body`, or Docker's `log`. LogScope also extracts observability context from fields such as +`service.name`, `resource.attributes.service.name`, `trace_id`, and `span_id`. + ### Piping from other commands (Stdin support) LogScope acts as a brilliant text reformatter for other tools! @@ -155,4 +159,4 @@ pytest tests/ ## License MIT License. -Made by [vinnytherobot](https://github.com/vinnytherobot) \ No newline at end of file +Made by [vinnytherobot](https://github.com/vinnytherobot) diff --git a/docs/api.md b/docs/api.md index 5637b70..12c9086 100644 --- a/docs/api.md +++ b/docs/api.md @@ -23,6 +23,10 @@ This page documents the public Python-facing surfaces currently used by LogScope - `logscope.parser.parse_line(line: str) -> LogEntry` - Parses bracket-style logs and JSON logs. - Normalizes severities (`WARNING` -> `WARN`, `ERR` -> `ERROR`, `EMERGENCY` -> `FATAL`). + - Recognizes common JSON severity/message keys including `level`, `severity`, `log.level`, + `severity_text`, `severityText`, `message`, `msg`, `text`, `body`, and Docker's `log`. + - Extracts observability metadata from top-level fields and OpenTelemetry + `resource.attributes.service.name`. ## Viewer API diff --git a/logscope/parser.py b/logscope/parser.py index 360bc27..5eb7f8f 100644 --- a/logscope/parser.py +++ b/logscope/parser.py @@ -51,6 +51,10 @@ class LogEntry: "EMERGENCY": "FATAL", "ERR": "ERROR", } +_JSON_LEVEL_KEYS = ("level", "severity", "log.level", "severity_text", "severityText") +_JSON_MESSAGE_KEYS = ("message", "msg", "text", "body", "log") +_JSON_TIMESTAMP_KEYS = ("timestamp", "time", "@timestamp") +_MISSING = object() def _normalize_level(level: str) -> str: @@ -58,6 +62,23 @@ def _normalize_level(level: str) -> str: return _NORMALIZE_LEVEL_MAP.get(level.upper(), level.upper()) +def _first_json_value(data: dict, keys: Tuple[str, ...]): + """Return the first present JSON value from a list of common log field names.""" + for key in keys: + if key in data: + return data[key] + return _MISSING + + +def _stringify_json_message(value, raw_line: str) -> str: + """Convert JSON message-like values to stable display text.""" + if value is _MISSING: + return raw_line + if isinstance(value, (dict, list)): + return json.dumps(value, sort_keys=True) + return str(value).rstrip("\r\n") + + def _extract_json_observability(data: dict) -> Tuple[Optional[str], Optional[str], Optional[str]]: """Pull service / trace / span from common JSON log shapes (K8s, OTel, Docker).""" k8s = data.get("kubernetes") @@ -66,10 +87,18 @@ def _extract_json_observability(data: dict) -> Tuple[Optional[str], Optional[str if not pod_name and isinstance(k8s_d.get("pod"), dict): pod_name = k8s_d["pod"].get("name") + resource = data.get("resource") + resource_d: dict = resource if isinstance(resource, dict) else {} + resource_attrs = resource_d.get("attributes") + resource_attrs_d: dict = resource_attrs if isinstance(resource_attrs, dict) else {} + service = ( data.get("service") or data.get("service.name") or data.get("service_name") + or data.get("resource.attributes.service.name") + or resource_attrs_d.get("service.name") + or resource_attrs_d.get("service_name") or pod_name or k8s_d.get("container_name") or data.get("container") @@ -102,15 +131,20 @@ def parse_line(line: str) -> LogEntry: if line.startswith('{') and line.endswith('}'): try: data = json.loads(line) - # Find level key - level = _normalize_level(data.get('level', data.get('severity', data.get('log.level', 'UNKNOWN')))) - # Find message key - message = str(data.get('message', data.get('msg', data.get('text', line)))) + level_value = _first_json_value(data, _JSON_LEVEL_KEYS) + message = _stringify_json_message(_first_json_value(data, _JSON_MESSAGE_KEYS), line) + if level_value is _MISSING: + inner_entry = parse_line(message) if message != line else None + level = inner_entry.level if inner_entry and inner_entry.level != "UNKNOWN" else "UNKNOWN" + if inner_entry and inner_entry.level != "UNKNOWN": + message = inner_entry.message + else: + level = _normalize_level(str(level_value)) # Find timestamp - timestamp_str = data.get('timestamp', data.get('time', data.get('@timestamp'))) + timestamp_str = _first_json_value(data, _JSON_TIMESTAMP_KEYS) timestamp = None - if timestamp_str: + if timestamp_str is not _MISSING and timestamp_str: try: # Basic ISO parsing timestamp = datetime.fromisoformat(str(timestamp_str).replace('Z', '+00:00')) diff --git a/tests/test_parser.py b/tests/test_parser.py index 8ff21fc..7b972dd 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -48,3 +48,26 @@ def test_parse_json_observability_fields(): assert entry.service == "checkout-api" assert entry.trace_id == "abcd1234efgh5678ijklmnop" assert entry.span_id == "span99" + + +def test_parse_docker_json_log_message_and_inner_level(): + log_line = '{"log":"[ERROR] payment failed\\n","stream":"stderr","time":"2026-03-14T15:30:00Z"}' + entry = parse_line(log_line) + assert entry.level == "ERROR" + assert entry.message == "payment failed" + assert entry.timestamp is not None + + +def test_parse_opentelemetry_json_fields(): + log_line = ( + '{"severity_text":"warn","body":"checkout latency high",' + '"resource":{"attributes":{"service.name":"checkout-api"}},' + '"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736",' + '"span_id":"00f067aa0ba902b7"}' + ) + entry = parse_line(log_line) + assert entry.level == "WARN" + assert entry.message == "checkout latency high" + assert entry.service == "checkout-api" + assert entry.trace_id == "4bf92f3577b34da6a3ce929d0e0e4736" + assert entry.span_id == "00f067aa0ba902b7"