Skip to content

feat: recognize OpenTelemetry flat JSON logs out of the box#1395

Open
merlimat wants to merge 5 commits into
pamburus:masterfrom
merlimat:feat/otel-flat-json
Open

feat: recognize OpenTelemetry flat JSON logs out of the box#1395
merlimat wants to merge 5 commits into
pamburus:masterfrom
merlimat:feat/otel-flat-json

Conversation

@merlimat

@merlimat merlimat commented Apr 16, 2026

Copy link
Copy Markdown

Summary

Adds an OpenTelemetry log preset (etc/defaults/config-otel.toml) alongside the existing config-ecs.toml / config-k8s.toml, so users can render OTLP-JSON logs in human-readable form via --config etc/defaults/config-otel.toml (or HL_CONFIG=…).

This is a scoped-down version of the original PR — see history below.

What's in the preset

Recognizes the canonical PascalCase OTel Logs Data Model field names (spec):

  • time: Timestamp, ObservedTimestamp
  • message: Body
  • logger: InstrumentationScope.Name
  • caller / caller-file / caller-line: code.function / code.filepath / code.lineno (OTel semconv)
  • Two level variants:
    • SeverityTextTRACE/DEBUG/INFO/WARN/ERROR/FATAL plus numbered forms INFO2, WARN3, FATAL4, … per § SeverityText. hl matches case-insensitively, so the uppercase spec forms are picked up automatically.
    • SeverityNumber — 1–24 numeric mapping per § SeverityNumber. FATAL* maps to error because hl has no fatal level.

Example

Input:

{"Timestamp":"2026-04-16T10:15:30.123Z","SeverityText":"INFO","SeverityNumber":9,"Body":"server started","service.name":"checkout-api","http.port":8080}
{"Timestamp":"2026-04-16T10:15:31.456Z","SeverityText":"WARN","SeverityNumber":13,"Body":"slow request","service.name":"checkout-api","duration_ms":1523}
{"Timestamp":"2026-04-16T10:15:32.000Z","SeverityText":"FATAL","SeverityNumber":21,"Body":"unrecoverable","service.name":"checkout-api"}
{"Timestamp":"2026-04-16T10:15:33.200Z","SeverityText":"INFO2","SeverityNumber":10,"Body":"heartbeat","service.name":"checkout-api"}

Rendered with --config etc/defaults/config-otel.toml:

2026-04-16 10:15:30.123 [INF] server started › SeverityNumber=9 service.name=checkout-api http.port=8080
2026-04-16 10:15:31.456 [WRN] slow request › SeverityNumber=13 service.name=checkout-api duration-ms=1523
2026-04-16 10:15:32.000 [ERR] unrecoverable › SeverityNumber=21 service.name=checkout-api
2026-04-16 10:15:33.200 [INF] heartbeat › SeverityNumber=10 service.name=checkout-api

When both SeverityText and SeverityNumber are present, SeverityText wins (matching the existing systemd PRIORITY behavior) and SeverityNumber falls through as a regular field. When only SeverityNumber is present, it becomes the level.

Scope changes from the original PR

The first commit on this branch also added the OTel field names to the default etc/defaults/config.toml so detection would be automatic. Per @pamburus's review, that was reverted to avoid collisions with non-OTel logs (e.g., Body is generic, body is commonly an HTTP payload field). The default config now stays untouched; only the preset is added.

A subsequent refactor also removed the snake_case alternates (severity_text, body, observed_timestamp, …) from the preset, keeping only the canonical OTLP-JSON PascalCase form. hl's case-insensitive level matching means severity_text consumers would need to switch to SeverityText (or roll their own preset).

Test plan

  • cargo testconfig::tests::test_load_otel validates the preset parses correctly and asserts the predefined field names and both level variants.
  • cargo clippy --all-targets — clean
  • cargo fmt --check — clean
  • Manual smoke test:
    echo '{"Timestamp":"2026-04-16T10:15:30Z","SeverityText":"INFO","Body":"hello","service.name":"api"}' \
      | hl -P --config etc/defaults/config-otel.toml
    # → 2026-04-16 10:15:30.000 [INF] hello › service.name=api

Add OTel field-name conventions to the default config so logs serialized
via the OpenTelemetry Logs data model (both PascalCase OTLP-JSON and
snake_case flattened form) render correctly without extra configuration:

- time: adds ObservedTimestamp / observed_timestamp
- message: adds Body / body
- level: new SeverityText (TRACE/DEBUG/INFO/WARN/ERROR/FATAL incl.
  numbered variants INFO2..FATAL4) and SeverityNumber (1..24) variants,
  with FATAL* mapped to `error` since hl has no fatal level

Also ships etc/defaults/config-otel.toml as a strict OTel preset for
users who want only OTel semantics (modeled after config-ecs.toml /
config-k8s.toml).

Refs:
  https://opentelemetry.io/docs/specs/otel/logs/data-model/
  https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitytext
  https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber
@codecov

codecov Bot commented Apr 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.05%. Comparing base (42f1434) to head (fcafae7).
⚠️ Report is 50 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #1395   +/-   ##
=======================================
  Coverage   90.05%   90.05%           
=======================================
  Files          72       72           
  Lines       12322    12322           
=======================================
  Hits        11096    11096           
  Misses       1226     1226           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@pamburus

Copy link
Copy Markdown
Owner

Thanks for the well-researched PR, @merlimat! I appreciate the effort and the thorough survey of peer tools.

I like the idea of having a preset configuration file for OTel (config-otel.toml) — that's a great addition alongside the existing config-ecs.toml / config-k8s.toml.

However, I'm not very keen on adding all the OTel fields to the default config out of the box. The main concern is field name collisions with existing formats. For example, body is commonly used for HTTP request/response body in many log formats, and treating it as the message field by default would misinterpret those logs. Similarly, Body with PascalCase is quite generic. The PR itself acknowledges this as a tradeoff, and I think it's a more significant one than it might seem.

Instead of expanding the default config to cover every format, I think a better direction would be:

  1. Configurable sub-format presets — making it easy to switch between sub-format presets (default, otel, ecs, k8s, etc.) without having to pass --config with a full path. Something like --sub-format otel or a config option.
  2. Per-project configuration — so that users can set the appropriate preset for each project (e.g., via a .hl.toml in the project directory) and not have to remember to pass flags.
  3. Sub-format auto-detection — I've been thinking about adding smart detection logic where the main format is JSON or logfmt, and then the tool auto-detects the "sub-format" (OTel, ECS, etc.) based on field names present in the log lines. This would give the best of both worlds — no configuration needed, no collisions — but it requires significantly more effort, so it's more of a longer-term goal.

Would you be interested in contributing the preset part (config-otel.toml) as a standalone change? That would be immediately useful for OTel users while we work toward a more robust solution for format detection.

Per review feedback on pamburus#1395, drop the additions to the default config
(OTel field names in `time.names`/`message.names`/level variants) and
keep only the new `etc/defaults/config-otel.toml` preset.

The broader default-config change risked collisions (e.g., `body` is
commonly used for HTTP payloads in many log formats) and is better
pursued later via a sub-format preset/auto-detection mechanism.

Tests that exercised the now-removed defaults change are dropped; the
preset is still validated by `config::tests::test_load_otel`.
@merlimat

Copy link
Copy Markdown
Author

@pamburus Thanks for the thoughtful feedback! I've force-reverted the default-config additions and this branch now contains only etc/defaults/config-otel.toml plus its load test, matching your "preset as a standalone change" suggestion.

@pamburus

Copy link
Copy Markdown
Owner

Thanks!

I have a question about the field name choices in config-otel.toml.

The preset comment says it covers "OTLP JSON (PascalCase) and snake_case conventions", but according to the OTLP spec, the actual OTLP/JSON wire format uses lowerCamelCase keys (per proto3 JSON mapping rules). So a compliant OTLP/JSON exporter would produce fields like observedTimestamp, severityText, severityNumber, etc. — not ObservedTimestamp or observed_timestamp.

Could you clarify what specific log sources or exporters you had in mind when adding the PascalCase and snake_case variants? For example:

  • Are these from a particular SDK or logging library that emits flat JSON with those exact field names?
  • Or is this perhaps aimed at a non-OTLP use case where logs are structured inspired by the OTel data model but not strictly following the wire format?

It would help to either document the supported sources more precisely in the config file comment, or align the field names with actual OTLP/JSON output (observedTimestamp, severityText, etc.) if strict OTLP compliance is the goal.

@merlimat

Copy link
Copy Markdown
Author

@pamburus

The spec you linked is the part regarding OTLP (the OTel collector) with protobuf & json format. It's used as a transport protocol.

For applications logs format, here are:

  1. The docs https://opentelemetry.io/docs/specs/otel/logs/data-model/
  2. The spec: https://github.com/open-telemetry/opentelemetry-specification/blob/main/oteps/logs/0097-log-data-model.md

@pamburus

Copy link
Copy Markdown
Owner

Thanks for the clarification — you're right that the log data model defines PascalCase field names (ObservedTimestamp, SeverityText, etc.), and those are distinct from the OTLP wire format.

However, I still don't see where the snake_case variants (observed_timestamp, severity_text, severity_number) come from — neither the data model spec nor the OTEP defines those. Are these from a specific SDK or exporter you've seen in practice?

Also, if we're covering multiple conventions, shouldn't we include the lowerCamelCase variants (observedTimestamp, severityText) from the OTLP JSON wire format as well?

Drop the snake_case / lowercase alternates from the OTel preset, keeping
only the canonical OTLP-JSON field names. hl matches severity text
values case-insensitively, so `info`/`info2` etc. continue to recognize
the spec-mandated `INFO`/`INFO2`/... forms automatically.

Removed:
- time.names: `timestamp`, `observed_timestamp`
- logger.names: `scope.name`
- message.names: `body`
- SeverityText variant: `severity_text`
- SeverityNumber variant: `severity_number`

Kept (these are canonical OTel semconv attribute names, not snake_case
alternates): `code.function`, `code.filepath`, `code.lineno`.
@merlimat

Copy link
Copy Markdown
Author

Good point. I've removed anything other than the PascalCase field.

time.names = ["Timestamp", "ObservedTimestamp"]
logger.names = ["InstrumentationScope.Name"]
message.names = ["Body"]
caller.names = ["code.function"]

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like otel does not provide any standard for the caller information.
So, using code.* field names in this configuration seems odd.

Wouldn't it be better to use TraceId in caller.names instead?

@pamburus

Copy link
Copy Markdown
Owner

Could you please provide an example of a source of such logs?

These examples do not match the configuration file, so I am confused.

@merlimat

Copy link
Copy Markdown
Author

Could you please provide an example of a source of such logs?

These examples do not match the configuration file, so I am confused.

There are examples in the spec:
https://github.com/open-telemetry/opentelemetry-specification/blob/v1.56.0/oteps/logs/0097-log-data-model.md#example-log-records

@pamburus

Copy link
Copy Markdown
Owner

Thanks for the pointer to OTEP 0097. I want to be upfront about a concern before I can move forward with this preset.

The example records in OTEP 0097 § Example Log Records carry an explicit disclaimer immediately above them:

Below are examples that show one possible representation of log records in JSON. These are just examples to help understand the data model. Don't treat the examples as the way to represent this data model in JSON.

This document does not define the actual encoding and format of the log record representation. Format definitions will be done in separate OTEPs (e.g. the log records may be represented as msgpack, JSON, Protocol Buffer messages, etc).

So that JSON shape is illustrative — it's documenting the data model, not a wire format. The actual encodings are defined elsewhere, and as far as I can tell none of them produce the flat PascalCase shape this preset targets:

  1. OTLP/JSON (the real wire format) — per the File Exporter spec, fields are lowerCamelCase (timeUnixNano, severityNumber, severityText, body) and nested under resourceLogs[].scopeLogs[].logRecords[]. Not flat, not PascalCase. This is what the official SDKs and the Collector file exporter emit.
  2. OTel-compliant Java logs to file — the official 2024 guide also uses the nested OTLP/JSON form, not flat PascalCase.
  3. ClickHouse exporter — does use Body / SeverityText / Timestamp as PascalCase identifiers, but those are SQL column names in a DB schema, not log lines on stdout/file that hl would consume.

Given that, I'd like to ask for one concrete thing before merging: a real producer that emits logs in the exact shape this preset matches — an SDK, logging library, exporter configuration, or framework you've actually seen output flat JSON with Timestamp / Body / SeverityText / SeverityNumber at the top level. Even a single repo link or a captured log line from a real system would work.

The reason I'm pressing on this: hl presets aren't just configuration, they're an implicit promise that "if you point hl at logs from $source, this preset will render them well." Without a real source, I can't review whether the field set, level mapping, and caller/code.* choices actually fit practical logs — I'd be reviewing against an illustrative example with a disclaimer telling me not to.

If the use case turns out to be "logs inspired by the OTel data model but not strictly OTLP" (e.g., an in-house logger someone wrote following the data model doc), that's fine — but then the preset's comment and filename should reflect that rather than implying OTLP/OTel-standard compatibility, so users aren't surprised when their actual OTLP/JSON logs don't match.

Stepping back from the spec details for a moment — I'd like to understand the motivation behind this PR more concretely before going further.

Could you share:

  1. What led you to open this PR? What problem were you trying to solve when you reached for hl and found it didn't render your OTel logs well?
  2. What is the actual source of the logs you're working with? Specifically:
    • Which application, service, or system is producing them?
    • Which logging library, SDK, exporter, or framework formats them this way?
    • Is this output you control (your own service) or output from a third-party tool you're consuming?
  3. Could you paste a few raw log lines (sanitized as needed) from that real source — exactly as they arrive at hl's stdin or as they appear in the file you're tailing? Even 2–3 unmodified lines would be enormously helpful.
  4. How did you arrive at this specific field set? Is the preset reverse-engineered from logs you observed in practice, or assembled from reading the data-model spec?

@pamburus pamburus added the enhancement New feature or request label Jun 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants