Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ poetry.lock
.claude
.claude/
.gemini/
AGENT.md
AGENTS.md
CLAUDE.md
RELEASE_NOTES_v*.md
TASKS.md
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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!

Expand Down Expand Up @@ -155,4 +159,4 @@ pytest tests/
## License
MIT License.

Made by [vinnytherobot](https://github.com/vinnytherobot)
Made by [vinnytherobot](https://github.com/vinnytherobot)
4 changes: 4 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 40 additions & 6 deletions logscope/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,34 @@ 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:
"""Normalize log level aliases to canonical forms."""
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")
Expand All @@ -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")
Expand Down Expand Up @@ -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'))
Expand Down
23 changes: 23 additions & 0 deletions tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading