feat(core): introduce OtlpLogRecord with AnyValue body - #27
Merged
Conversation
Cashes in the §6.1/§6.2 amendments by giving Ourios its first
faithful in-memory OTLP record type. Until this PR no code in
the workspace knew what an OTLP record was — the spec said
`MinerCluster::ingest` should take one, but the type didn't exist.
## Type design
`crates/ourios-core/src/otlp.rs` defines:
- `OtlpLogRecord` — the receiver-flattened wire shape per
RFC 0001 §6.1 + RFC 0003 §6.6. Field-for-field with the spec.
Carries `tenant_id` as its own field (per RFC 0003 §6.3, the
`otel.*` namespace is reserved and Resource attributes
describe the *observed entity*, not Ourios's ingest routing —
putting `tenant_id` there would violate spec intent).
- `Body { String(String), Structured(AnyValue) }` — the §6.2
step-0 fork. `Structured` carries the decoded
`opentelemetry-proto` `AnyValue` directly, not its OTLP-
canonical JSON encoding (see §6.4 amendment below).
- `BodyKind { String, Structured }` — derived from `body` via
`OtlpLogRecord::body_kind() -> Option<BodyKind>`. Never
stored, since the discriminator is the only thing the §6.2
step-0 fork needs.
The decision to use `opentelemetry-proto`'s `AnyValue` /
`KeyValue` directly (rather than rolling our own minimal types
in `ourios-core`) is the spec-faithfulness call: the OTel
spec defines non-trivial `AnyValue` conversion rules (integer
ranges, enum handling, non-UTF-8 → `bytes_value`, ...) and any
hand-rolled re-implementation carries real drift risk. The
usual ACL argument doesn't apply here — `opentelemetry-proto`
isn't a *foreign* vocabulary to Ourios, it IS the ingest
contract; insulating from it would be insulating from our own
canonical wire format. The wrapping `OtlpLogRecord` struct
exists to attach `tenant_id` and to flatten the
`ResourceLogs → ScopeLogs → LogRecord` nesting, not to
duplicate types.
`opentelemetry-proto` enters the workspace with
`default-features = false, features = ["gen-tonic-messages",
"logs"]` — minimum needed for the `tonic::logs::v1` and
`tonic::common::v1` types. RFC 0003 §4.1 already pins this
crate as the wire-stack default, so this is not a new
architectural commitment.
## RFC amendments
The above design contradicts RFC 0003's original §6.4 stance
that the *receiver* canonicalises structured bodies eagerly
into `Bytes`. Both RFCs amended atomically with this PR so
spec and code agree at every commit:
- RFC 0003 §6.4 — canonicalisation is now deferred to the
storage layer (Parquet writer, when it lands) so the
in-memory record carries `Body::Structured(AnyValue)`
verbatim. Preserves optionality for the future "mine inner
field" mode (RFC 0001 §6.1) gated on corpus evidence.
- RFC 0003 §6.5 — step (2) reworded to drop the
canonicalises-here claim.
- RFC 0003 §6.6 — `OtlpLogRecord` struct sketch updated to
match the as-built type: `body: Option<Body>` with
`Body::Structured(AnyValue)`, `body_kind()` is a derivation,
not a stored column. Authoritative definition is now
`ourios-core::otlp` (not `ourios-ingester`, which doesn't
exist yet).
- RFC 0001 §5 RFC0001.9 — body-column wording updated to
reflect that the canonical JSON encoding is produced at
Parquet-write time, not at the receiver.
## Tests
7 unit tests in `crates/ourios-core/src/otlp.rs`:
- `Body::from_any_value` String / non-String / empty-value
handling — locks the §6.2 step-0 fork at the type-construction
boundary.
- `OtlpLogRecord::body_kind()` correctly classifies absent /
String / Structured bodies.
- `OtlpLogRecord::default()` pins the important defaults
(severity_number = 0 = UNSPECIFIED — distinct key bucket per
§6.1; all OTLP fields zeroed; body absent).
## What this *doesn't* do
- `MinerCluster::ingest` signature unchanged (still `&str`).
That's the next PR (PR B per the discussion). This PR is
pure type plumbing.
- Doesn't fill any of the new H1.4/H1.5/RFC0001.9–11 stubs;
they reference `OtlpLogRecord` but the miner crate doesn't
consume it yet.
- Doesn't touch the receiver or WAL crates (neither exists).
## Verified locally
- `cargo fmt --all --check`
- `cargo clippy --all-targets --all-features -- -D warnings`
- `cargo test --all-features` (7 new ourios-core tests pass;
full workspace 57 passed / 25 ignored / 0 failed)
- `mdbook build`
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Introduces the core in-memory OTLP log record shape that future receiver/miner integration will consume, while updating RFC text to defer structured body canonicalisation to the storage layer.
Changes:
- Adds
ourios-core::otlpwithOtlpLogRecord,Body, andBodyKind. - Exposes the OTLP module from
ourios-coreand addsopentelemetry-proto. - Updates RFC 0001/0003 wording around structured
AnyValuehandling.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
crates/ourios-core/src/otlp.rs |
Adds OTLP record/body types and unit tests. |
crates/ourios-core/src/lib.rs |
Exposes the new otlp module. |
crates/ourios-core/Cargo.toml |
Adds the OTLP proto dependency. |
docs/rfcs/0001-template-miner.md |
Updates structured-body scenario wording. |
docs/rfcs/0003-otlp-receiver.md |
Amends canonicalisation boundary and record-shape documentation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Three substantive review comments, each addressed:
1. **`Body::from_any_value` no longer deep-clones the AnyValue tree.**
The structured branch now moves the inner `oneof` out and
reconstructs `AnyValue { value: Some(other) }` rather than
cloning the whole tree before classification. Arrays /
kvlists / bytes pay no copy cost on the miner-facing path.
Aligns with the §6.4 amendment's "no allocation in the
structured branch" claim. Doc comment updated to state the
move semantics explicitly.
2. **RFC 0003 now has a single authoritative canonicalisation
boundary.** The §6.4 amendment was the bottom-line shift, but
§1 summary, §2.2 third bullet, §5 RFC0003.6 / RFC0003.7,
§6.1 step (3), §6.2 HTTP/JSON dispatch, §7.5
(alternative-rejected), and §8 testing-strategy bullet all
still spoke as if the receiver canonicalised. Each updated
to reflect the storage-layer-canonicalisation commitment;
§9's "Where does canonicalisation cost land?" open question
marked resolved.
3. **`Cargo.lock` committed.** Adding `opentelemetry-proto`
changed dependency resolution; `.gitignore`:19-20 documents
the lockfile as part of the reproducible-build contract for
this binary project. PR A omitted the lockfile change; this
commit fixes that.
Verified locally: cargo fmt --check, clippy -D warnings, 7
ourios-core tests still pass, full workspace 57 passed / 25
ignored / 0 failed, mdbook builds.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
5 tasks
jensholdgaard
added a commit
that referenced
this pull request
May 14, 2026
* feat(miner): consume OtlpLogRecord with body_kind fork PR B in the OtlpLogRecord rollout (PR #27 was PR A). With the type now defined, this PR makes the miner actually consume it. ## API change `MinerCluster::ingest`: before: pub fn ingest(&mut self, tenant_id: &TenantId, raw: &str) -> u64 after: pub fn ingest(&mut self, record: &OtlpLogRecord) -> u64 The `tenant_id` now lives on the record per RFC 0001 §6.1 (and per RFC 0003 §6.3, the receiver derives it once per `ResourceLogs.resource`). The body comes from `record.body`. ## Body fork (RFC 0001 §6.2 step 0) - `Body::String(s)` — existing tokenize → mask → descend pipeline. The leaf-list lookup now also compares the leaf's `(severity_number, scope_name)` against the record's, so two records with the same masked tokens but different severity or scope get distinct `template_id`s. Locks the implementation side of H1.4 / H1.5. - `Body::Structured(_)` — short-circuit. The `AnyValue` tree is not walked; a per-tenant `(severity_number, scope_name) -> template_id` map keyed on the §6.1 *Template-key composition* tuple is the entire lookup. Same tuple reuses the same id (RFC0001.9-shape behaviour, modulo the not-yet-emitted Parquet columns). - `None` body — returns `NO_TEMPLATE` (no allocation). §6.1 doesn't pin a behaviour for the absent case; the conservative sentinel-return never silently coalesces into the structured bucket. Documented inline as a forward-pointer for a future amendment if the case turns out to need its own template. ## Type / impl details - `Leaf` gains `severity_number: u8` and `scope_name: Option<String>` fields (the §6.1 template-key tuple's discriminator). `matches_exactly` extended to compare them. - `TenantState` gains a `structured_templates: HashMap<(u8, Option<String>), u64>` for the §6.2 step-0 short-circuit. The cache invariant on `template_count` now covers both stores: every fresh allocation (tree leaf or structured-map insert) increments the cache by exactly one. - `ourios-core::otlp` now re-exports `AnyValue`, `KeyValue`, and `any_value` from `opentelemetry-proto`. Downstream crates (the miner today, the receiver and Parquet writer later) get a single import path through `ourios_core::otlp::*` and don't take a direct `opentelemetry-proto` dep. ## Test discipline Existing tests adapted, not weakened, per CLAUDE.md §6.2 "Tests are specifications, not friction": - The 7 cluster.rs unit tests (repeat-shape, distinct-shape, unseen-tenant, lazy-allocation, near-match-separate-leaves, empty-input sentinel, cache-invariant) all carry the same assertions. Only their call sites changed: `cluster.ingest(&t, "text")` becomes `cluster.ingest(&string_record(&t, "text"))` via a small in-test helper. With default severity (UNSPECIFIED / 0) and scope (None) on every record, the new template-key discriminator is constant, so the previously-asserted behaviour is preserved. - The two crate-level integration tests in `tests/invariants.rs` (§3.7.1 cross-tenant pollination, §3.7.2 distinct ids per tenant) get the same migration. Same defaults, same assertions. - 7 new cluster.rs unit tests cover the new code paths: * `ingest_returns_no_template_for_absent_body` * `structured_body_short_circuit_allocates_one_template_per_severity_scope_tuple` * `structured_body_distinguishes_severity_within_one_scope` * `structured_body_distinguishes_scope_within_one_severity` * `structured_body_with_scope_none_is_its_own_bucket` * `structured_and_string_share_no_template_ids_at_same_severity_scope` * `string_body_distinguishes_severity_within_one_scope` - 7 tree.rs Leaf-construction sites updated for the new fields (severity_number=0, scope_name=None — what those tests assert is independent of the template-key discriminator). The H1.4/H1.5/§3.7.3/RFC0001.9-11 ignored test stubs (crates/ourios-miner/tests/{hazards,invariants,rfc_internal}.rs) remain ignored. Their assertions are about the *emitted record* (`confidence`, `lossy_flag`, Parquet columns), which need the Parquet writer crate to land before they become fillable. This PR's behavioural changes are pinned by the new cluster unit tests above; the §5 acceptance-criteria stubs get implemented as the dependent crates land. ## Doc tweak (team feedback follow-up) `ourios-core::otlp` crate-level docstring extended with one paragraph explaining `tenant_id` is deliberately a sibling field rather than synthesised into `resource_attributes` (the OTel `Resource` spec restricts what belongs there, and `otel.*` is reserved). Captures the rationale already in PR #27's commit message but in the place a future reader is most likely to look. ## Verified locally - `cargo fmt --all --check` - `cargo clippy --all-targets --all-features -- -D warnings` - `cargo test --all-features` — workspace 64 passed / 25 ignored / 0 failed (49 miner unit, 7 ourios-core, 5 invariants integration, 3 rfc_internal integration, hazards.rs all-ignored). - `mdbook build` Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: address PR #28 review feedback Three substantive review comments, each addressed: 1. **Structured-store cross-tenant isolation regression test.** The §3.7.2 scenario in `tests/invariants.rs` exercises the String branch only (its line is "user 42 logged in from 10.0.0.1"). The §3.7 invariant — *"every template tree is scoped per tenant"* — applies equally to the new structured-templates store, but PR #28 had no test pinning that. Added `structured_body_isolates_template_ids_across_tenants` in `cluster.rs` covering the structured branch: identical structured records from two tenants produce distinct template_ids, and each tenant's `template_count` is exactly 1 (no cross-pollination). 2. **`MinerCluster::ingest` doc no longer claims the Parquet writer "reads `confidence`/`lossy_flag` off the record".** `OtlpLogRecord` has no such fields; those values are derived *by the miner* (sentinel-constants for the structured branch). Doc reworded to make the derivation explicit and to note that the values aren't on the cluster's return surface yet because the Parquet writer crate consuming them doesn't exist. 3. **`ourios-core::otlp` docstring no longer over-specifies tenant derivation.** Removed the "(or auth context)" parenthetical from the `Resource`-attribute derivation description — auth-context-driven binding is currently a RFC 0003 §9 open question, not part of the accepted contract. The docstring now flags it as a possible future extension that joins the same derivation rule rather than displacing it. Verified locally: cargo fmt --check, clippy -D warnings, full workspace 65 passed / 25 ignored / 0 failed (50 miner unit tests now, +1 from the new structured-tenant-isolation case), mdbook builds. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Cashes in the §6.1/§6.2 amendments by giving Ourios its first faithful in-memory OTLP record type. Until this PR no code in the workspace knew what an OTLP record was — the spec said
MinerCluster::ingestshould take one, but the type didn't exist.This is PR A of the two-PR plan (discussion in PR #25 / #26 / earlier conversation). PR B (the
MinerCluster::ingestsignature change + body-fork implementation) follows.Design
crates/ourios-core/src/otlp.rs:OtlpLogRecord— receiver-flattened wire shape per RFC 0001 §6.1 + RFC 0003 §6.6, field-for-field with the spec. Carriestenant_idas its own field (otel.*is reserved andResourceattributes describe the observed entity, not Ourios's ingest routing — puttingtenant_idthere would violate spec intent per RFC 0003 §6.3).Body { String(String), Structured(AnyValue) }— the §6.2 step-0 fork.Structuredcarries the decodedopentelemetry-protoAnyValuedirectly, not its OTLP-canonical JSON encoding (see RFC 0003 §6.4 amendment below).BodyKind { String, Structured }— derived viaOtlpLogRecord::body_kind() -> Option<BodyKind>. Never stored, since the discriminator is the only thing the §6.2 step-0 fork needs.Why
opentelemetry-protodirectly, not a hand-rolled re-implementationThis is the spec-faithfulness call. The OTel spec defines non-trivial
AnyValueconversion rules (integer ranges, enum handling, non-UTF-8 →bytes_value, …) and any hand-rolled re-implementation carries real drift risk. The usual ACL argument doesn't apply:opentelemetry-protoisn't foreign to Ourios — it IS the ingest contract; insulating from it would be insulating from our own canonical wire format. The wrappingOtlpLogRecordstruct exists to attachtenant_idand to flattenResourceLogs → ScopeLogs → LogRecordnesting, not to duplicate types.opentelemetry-protoenters withdefault-features = false, features = ["gen-tonic-messages", "logs"]— minimum for thetonic::logs::v1andtonic::common::v1types. RFC 0003 §4.1 already pinned this crate as the wire-stack default, so this isn't a new architectural commitment.RFC amendments (atomic with this PR so spec ↔ code agree at every commit)
The decision to carry
AnyValuedirectly contradicts RFC 0003's original §6.4 stance that the receiver canonicalises eagerly intoBytes. Both RFCs amended in the same commit:OtlpLogRecordstruct sketch updated to match:body: Option<Body>withBody::Structured(AnyValue);body_kind()is a derivation, not a stored column. Authoritative definition is nowourios-core::otlp.Invariant / hazard touch
[§3.7](multi-tenancy not bolted on):tenant_idis a top-level field on every record from day one — no code path constructs anOtlpLogRecordwithout one.severity_number = 0 = UNSPECIFIEDdistinct-bucket rule is locked in by theDefaulttest (zero-value default, not "missing").No invariant in CLAUDE.md §3 is touched destructively; this PR adds a new type and amends two RFC subsections to match it.
Tests
7 unit tests in
crates/ourios-core/src/otlp.rs, all passing:Body::from_any_value— String / non-String / empty-value handling locks the §6.2 step-0 fork at the type-construction boundary.OtlpLogRecord::body_kind()— correctly classifies absent / String / Structured bodies (one test per case, strict AAA).OtlpLogRecord::default()— pins the important defaults (severity = 0 = UNSPECIFIED distinct bucket, all OTLP fields zeroed, body absent).Out of scope
MinerCluster::ingestsignature unchanged. PR B handles that.OtlpLogRecordbut the miner crate doesn't consume it yet.Test plan
cargo fmt --all --checkcargo clippy --all-targets --all-features -- -D warningscargo test --all-features— 7 new ourios-core tests pass; workspace 57 passed / 25 ignored / 0 failedmdbook build🤖 Generated with Claude Code