diff --git a/docs/otlp-protobuf-design.md b/docs/otlp-protobuf-design.md index 2f9cedb..4d2afb7 100644 --- a/docs/otlp-protobuf-design.md +++ b/docs/otlp-protobuf-design.md @@ -43,6 +43,14 @@ Generated code uses `@protobuf.Sized`, `@protobuf.Read`, `@protobuf.Write`, and `moonbitlang/async` at `0.16.6`, while moontrace currently uses `moonbitlang/async@0.19.0`. +For `moontrace-cg9.7.4`, `moon add moonbitlang/protobuf` resolved +`moonbitlang/protobuf@0.1.1`; the local registry index contained `0.1.0` and +`0.1.1`, making `0.1.1` the latest available runtime at implementation time. +A spike that wrote and read length-delimited bytes/string, varint, fixed64, +and fixed32 values through the official runtime passed +`moon build --target native --release` and `moon test --target native` against +moontrace's current `moonbitlang/async@0.19.0`. + That makes the protobuf ecosystem promising but still immature for direct adoption here. There is a real runtime package and generated bindings can emit OTLP collector request messages, but the generator is custom, the generated @@ -125,25 +133,40 @@ Cons and risks: ## Recommendation -Use option 1, but do it as a follow-up implementation bead rather than inside -this design bead. - -The recommended path is to introduce an official generated-binding layer from -OpenTelemetry's `.proto` definitions, backed by `moonbitlang/protobuf`, and -keep moontrace's own hand-written code limited to mapping existing -`@otlp.OtlpSpan`, `@otlp.OtlpLogRecord`, `Resource`, and -`InstrumentationScope` values into the generated request messages. - -This keeps moontrace aligned with OTLP, avoids local wire-format ownership, and -preserves the current JSON path. The risk is dependency maturity, so the next -bead should first prove the current `moonbitlang/protobuf` package and generator -against moontrace's current toolchain before committing to generated code in -the main package. - -Hand-rolling is tempting because moontrace currently emits a small subset of -OTLP, but it creates the exact long-term maintenance trap protobuf codegen is -supposed to avoid. Vendoring moonbit_otel is useful as a reference, but its -older-stack pinning and broad generated surface make it a risky default. +The long-term preference remains an official generated-binding layer from +OpenTelemetry's `.proto` definitions, backed by `moonbitlang/protobuf`, once +the MoonBit protobuf generator and generated package surface are mature enough +for moontrace to own confidently. + +For `moontrace-cg9.7.4`, the chosen implemented path is option 2 with an +important constraint: moontrace hand-rolls only the minimal OTLP trace and log +messages it emits, but delegates all primitive protobuf writing to the official +`moonbitlang/protobuf@0.1.1` runtime. It does not vendor or depend on +`ryota0624/opentelemetry_proto_mbt`, and it does not introduce a codegen step. + +The implementation writes: + +- Traces: + `ExportTraceServiceRequest.resource_spans`, + `ResourceSpans.resource`, `ResourceSpans.scope_spans`, + `ScopeSpans.scope`, `ScopeSpans.spans`, `Span`, `Span.Link`, and `Status`. +- Logs: + `ExportLogsServiceRequest.resource_logs`, `ResourceLogs.resource`, + `ResourceLogs.scope_logs`, `ScopeLogs.scope`, `ScopeLogs.log_records`, and + `LogRecord`. +- Supporting messages: + `Resource`, `InstrumentationScope`, `KeyValue`, and `AnyValue` for the + current moontrace value variants: string, bool, int64, and double. + +Field numbers and wire types are copied from the read-only generated +moonbit_otel bindings. Trace IDs and span IDs are decoded from hex strings to +protobuf bytes. Span timestamps and log timestamps are fixed64. Span flags are +field 16 with fixed32 wire type. Enum-valued Moontrace integers are encoded as +varints after rejecting negative values. Nested messages are written into a +temporary buffer, then length-prefixed into their parent. + +The JSON encoder path remains unchanged and still delegates to +`@otlp.wrap_spans` and `@otlp.wrap_log_records`. ## Integration Points @@ -153,10 +176,13 @@ This bead adds the non-invasive slot: - `OtlpEncoder` chooses JSON or protobuf. - JSON encoding delegates to `@otlp.wrap_spans` and `@otlp.wrap_log_records`, then UTF-8 encodes the stringified JSON into bytes. -- Protobuf encoding is intentionally stubbed with - `EncodingError::ProtobufNotImplemented`. +- Protobuf encoding now returns `application/x-protobuf` with binary + `ExportTraceServiceRequest` or `ExportLogsServiceRequest` bytes. +- Protobuf encoding can return `EncodingError::ProtobufEncodeFailed` if an ID + is not valid hex, an enum-like integer is negative, or the runtime writer + raises an error. -The follow-up protobuf bead should wire these pieces into transport without +Future transport work can wire these pieces into the send path without rewriting today's JSON code: - Add a bytes-capable HTTP client method or body variant, for example a @@ -169,38 +195,35 @@ rewriting today's JSON code: - Keep JSON envelope builders as the source of truth for JSON; do not route JSON through generated protobuf JSON helpers. -Span flags need a dedicated mapping check. moontrace currently stores -`OtlpSpan.flags` as an `Int` and JSON emits it as `flags`. OTLP protobuf's -`Span.flags` is field 16 and is written as fixed32 in the generated -moonbit_otel bindings. The follow-up must define the conversion from -moontrace's span trace flags to the OTLP fixed32 value and test sampled versus -unsampled spans explicitly. +Span flags now have the dedicated mapping check this design called out. +moontrace stores `OtlpSpan.flags` as an `Int`; protobuf rejects negative values +and writes non-zero flags as `Span.flags` field 16 with fixed32 wire type. -## Proposed Follow-up Bead +## Implemented Bead -`moontrace-cg9.7.4`: implement OTLP protobuf export behind the encoder seam. +`moontrace-cg9.7.4` implements OTLP protobuf export behind the encoder seam. Scope: -- Evaluate the current `moonbitlang/protobuf` release with moontrace's toolchain - and `moonbitlang/async@0.19.0`. -- Generate or import only the OTLP packages needed for traces and logs: - collector trace/logs, trace/logs, resource, and common. -- Add generated bindings in a contained package or dependency; do not place - generated code inside `src/otlp/transport`. -- Implement protobuf `encode_spans` and `encode_logs` by mapping moontrace OTLP - structs into generated `ExportTraceServiceRequest` and - `ExportLogsServiceRequest` messages. -- Add a bytes-capable transport send path with `application/x-protobuf`, while - keeping the existing string JSON client path intact. -- Verify against golden protobuf bytes generated by a known-good implementation - or by decoding the produced bytes through generated `@protobuf.Read`. -- Include collector-level integration smoke tests if a lightweight local - collector fixture is practical. +- Evaluated `moonbitlang/protobuf@0.1.1` with moontrace's toolchain and + `moonbitlang/async@0.19.0`. +- Added `moonbitlang/protobuf@0.1.1` as a dependency. +- Implemented protobuf `encode_spans` and `encode_logs` by hand-writing the + minimal OTLP messages moontrace emits with official runtime writer + primitives. +- Kept the existing string JSON client path intact. +- Added round-trip decoder tests that inspect key trace, log, resource, scope, + status, attributes, timestamps, IDs, and flags. +- Added a golden-byte test for one representative `ExportTraceServiceRequest`. + The golden fixture was produced from a Python `google.protobuf` 7.34.1 dynamic + descriptor for the same OTLP subset. This checks field numbers, wire types, + nested length prefixes, and canonical field ordering for the representative + trace request. Dependencies and exit criteria: -- No protobuf dependency is added until codegen compiles and native tests pass. +- No dependency is taken on `ryota0624/opentelemetry_proto_mbt`, and no + `protoc-gen-mbt` or `buf` codegen toolchain is wired into moontrace. - `moon info && moon fmt` must leave generated interfaces clean. - `moon test --target native` and `moon build --target native --release` must pass. diff --git a/moon.mod b/moon.mod index 5e75230..b78f0ef 100644 --- a/moon.mod +++ b/moon.mod @@ -4,6 +4,7 @@ version = "0.12.0" import { "moonbitlang/async@0.19.0", + "moonbitlang/protobuf@0.1.1", } readme = "README.mbt.md" diff --git a/src/otlp/transport/encoding.mbt b/src/otlp/transport/encoding.mbt index 7a54d8f..30eab6d 100644 --- a/src/otlp/transport/encoding.mbt +++ b/src/otlp/transport/encoding.mbt @@ -21,6 +21,7 @@ pub fn OtlpSignal::to_string(self : OtlpSignal) -> String { ///| pub(all) enum EncodingError { ProtobufNotImplemented(OtlpSignal) + ProtobufEncodeFailed(OtlpSignal, String) } derive(Eq, Compare, Debug) ///| @@ -28,6 +29,8 @@ pub fn EncodingError::to_string(self : EncodingError) -> String { match self { ProtobufNotImplemented(signal) => "protobuf OTLP " + signal.to_string() + " encoding is not implemented" + ProtobufEncodeFailed(signal, message) => + "protobuf OTLP " + signal.to_string() + " encoding failed: " + message } } @@ -78,6 +81,402 @@ fn json_payload(json : Json) -> OtlpPayload { } } +///| +priv suberror ProtobufWireError { + InvalidHexId(String, String) + InvalidUnsignedField(String, Int) +} + +///| +fn encode_protobuf_payload( + signal : OtlpSignal, + write_body : (&@protobuf.Writer) -> Unit raise, +) -> Result[OtlpPayload, EncodingError] { + let body = Buffer() + let writer : &@protobuf.Writer = body + try { + write_body(writer) + Ok({ content_type: otlp_protobuf_content_type(), body: body.to_bytes() }) + } catch { + InvalidHexId(field, value) => + Err( + ProtobufEncodeFailed( + signal, + "invalid hex bytes for " + field + ": " + value, + ), + ) + InvalidUnsignedField(field, value) => + Err( + ProtobufEncodeFailed( + signal, + "negative value for unsigned " + field + ": " + value.to_string(), + ), + ) + err => Err(ProtobufEncodeFailed(signal, "\{err}")) + } +} + +///| +fn protobuf_message_field( + writer : &@protobuf.Writer, + field_number : UInt, + write_body : (&@protobuf.Writer) -> Unit raise, +) -> Unit raise { + let body = Buffer() + let nested_writer : &@protobuf.Writer = body + write_body(nested_writer) + writer |> @protobuf.write_tag((field_number, 2)) + writer |> @protobuf.write_uint32(body.length().reinterpret_as_uint()) + writer.write(body.to_bytes()) +} + +///| +fn protobuf_string_field( + writer : &@protobuf.Writer, + field_number : UInt, + value : String, +) -> Unit raise { + if !value.is_empty() { + writer |> @protobuf.write_tag((field_number, 2)) + writer |> @protobuf.write_string(value) + } +} + +///| +fn protobuf_bytes_field( + writer : &@protobuf.Writer, + field_number : UInt, + value : Bytes, +) -> Unit raise { + if value.length() > 0 { + writer |> @protobuf.write_tag((field_number, 2)) + writer |> @protobuf.write_bytes(value) + } +} + +///| +fn protobuf_uint32_field( + writer : &@protobuf.Writer, + field_number : UInt, + value : UInt, +) -> Unit raise { + if value != 0 { + writer |> @protobuf.write_tag((field_number, 0)) + writer |> @protobuf.write_uint32(value) + } +} + +///| +fn protobuf_fixed32_field( + writer : &@protobuf.Writer, + field_number : UInt, + value : UInt, +) -> Unit raise { + if value != 0 { + writer |> @protobuf.write_tag((field_number, 5)) + writer |> @protobuf.write_fixed32(value) + } +} + +///| +fn protobuf_fixed64_field( + writer : &@protobuf.Writer, + field_number : UInt, + value : UInt64, +) -> Unit raise { + if value != 0UL { + writer |> @protobuf.write_tag((field_number, 1)) + writer |> @protobuf.write_fixed64(value) + } +} + +///| +fn protobuf_bool_field( + writer : &@protobuf.Writer, + field_number : UInt, + value : Bool, +) -> Unit raise { + writer |> @protobuf.write_tag((field_number, 0)) + writer |> @protobuf.write_bool(value) +} + +///| +fn protobuf_int64_field( + writer : &@protobuf.Writer, + field_number : UInt, + value : Int64, +) -> Unit raise { + writer |> @protobuf.write_tag((field_number, 0)) + writer |> @protobuf.write_int64(value) +} + +///| +fn protobuf_double_field( + writer : &@protobuf.Writer, + field_number : UInt, + value : Double, +) -> Unit raise { + writer |> @protobuf.write_tag((field_number, 1)) + writer |> @protobuf.write_double(value) +} + +///| +fn unsigned_int(field : String, value : Int) -> UInt raise { + if value < 0 { + raise InvalidUnsignedField(field, value) + } + value.reinterpret_as_uint() +} + +///| +fn hex_id_bytes( + field : String, + value : String, + expected_bytes : Int, +) -> Bytes raise { + if value.length() != expected_bytes * 2 { + raise InvalidHexId(field, value) + } + let bytes = Buffer(size_hint=expected_bytes) + for index = 0; index < value.length(); { + let pair = value[index:index + 2].to_owned() + let byte = @string.parse_int(pair, base=16) catch { + _ => raise InvalidHexId(field, value) + } + if byte < 0 || byte > 255 { + raise InvalidHexId(field, value) + } + bytes.write_byte(byte.to_byte()) + continue index + 2 + } + bytes.to_bytes() +} + +///| +fn optional_hex_id_bytes( + field : String, + value : String, + expected_bytes : Int, +) -> Bytes raise { + if value.is_empty() { + Bytes::default() + } else { + hex_id_bytes(field, value, expected_bytes) + } +} + +///| +fn write_any_value( + writer : &@protobuf.Writer, + value : @otlp.OtlpValue, +) -> Unit raise { + match value { + StringVal(value) => { + writer |> @protobuf.write_tag((1, 2)) + writer |> @protobuf.write_string(value) + } + BoolVal(value) => protobuf_bool_field(writer, 2, value) + IntVal(value) => protobuf_int64_field(writer, 3, value) + DoubleVal(value) => protobuf_double_field(writer, 4, value) + } +} + +///| +fn write_key_value( + writer : &@protobuf.Writer, + attribute : @otlp.OtlpAttribute, +) -> Unit raise { + protobuf_string_field(writer, 1, attribute.key) + protobuf_message_field(writer, 2, writer => { + write_any_value(writer, attribute.value) + }) +} + +///| +fn write_key_values( + writer : &@protobuf.Writer, + field_number : UInt, + attributes : Array[@otlp.OtlpAttribute], +) -> Unit raise { + for attribute in attributes { + protobuf_message_field(writer, field_number, writer => { + write_key_value(writer, attribute) + }) + } +} + +///| +fn write_resource( + writer : &@protobuf.Writer, + resource : @otlp.Resource, +) -> Unit raise { + write_key_values(writer, 1, resource.attributes) +} + +///| +fn write_instrumentation_scope( + writer : &@protobuf.Writer, + scope : @otlp.InstrumentationScope, +) -> Unit raise { + protobuf_string_field(writer, 1, scope.name) + protobuf_string_field(writer, 2, scope.version) + write_key_values(writer, 3, scope.attributes) +} + +///| +fn write_status( + writer : &@protobuf.Writer, + status_code : Int, + status_message : String, +) -> Unit raise { + protobuf_string_field(writer, 2, status_message) + protobuf_uint32_field( + writer, + 3, + unsigned_int("span.status.code", status_code), + ) +} + +///| +fn write_span_link( + writer : &@protobuf.Writer, + link : @otlp.OtlpLink, +) -> Unit raise { + protobuf_bytes_field( + writer, + 1, + hex_id_bytes("link.trace_id", link.trace_id, 16), + ) + protobuf_bytes_field(writer, 2, hex_id_bytes("link.span_id", link.span_id, 8)) + protobuf_string_field(writer, 3, link.trace_state) + write_key_values(writer, 4, link.attributes) +} + +///| +fn write_span(writer : &@protobuf.Writer, span : @otlp.OtlpSpan) -> Unit raise { + protobuf_bytes_field( + writer, + 1, + hex_id_bytes("span.trace_id", span.trace_id, 16), + ) + protobuf_bytes_field(writer, 2, hex_id_bytes("span.span_id", span.span_id, 8)) + protobuf_bytes_field( + writer, + 4, + optional_hex_id_bytes("span.parent_span_id", span.parent_span_id, 8), + ) + protobuf_string_field(writer, 5, span.name) + protobuf_uint32_field(writer, 6, unsigned_int("span.kind", span.kind)) + protobuf_fixed64_field(writer, 7, span.start_time_unix_nano) + protobuf_fixed64_field(writer, 8, span.end_time_unix_nano) + write_key_values(writer, 9, span.attributes) + for link in span.links { + protobuf_message_field(writer, 13, writer => write_span_link(writer, link)) + } + if span.status_code != 0 || !span.status_message.is_empty() { + protobuf_message_field(writer, 15, writer => { + write_status(writer, span.status_code, span.status_message) + }) + } + protobuf_fixed32_field(writer, 16, unsigned_int("span.flags", span.flags)) +} + +///| +fn write_scope_spans( + writer : &@protobuf.Writer, + spans : Array[@otlp.OtlpSpan], + scope : @otlp.InstrumentationScope, +) -> Unit raise { + protobuf_message_field(writer, 1, writer => { + write_instrumentation_scope(writer, scope) + }) + for span in spans { + protobuf_message_field(writer, 2, writer => write_span(writer, span)) + } +} + +///| +fn write_resource_spans( + writer : &@protobuf.Writer, + spans : Array[@otlp.OtlpSpan], + resource : @otlp.Resource, + scope : @otlp.InstrumentationScope, +) -> Unit raise { + protobuf_message_field(writer, 1, writer => write_resource(writer, resource)) + protobuf_message_field(writer, 2, writer => { + write_scope_spans(writer, spans, scope) + }) +} + +///| +fn write_export_trace_service_request( + writer : &@protobuf.Writer, + spans : Array[@otlp.OtlpSpan], + resource : @otlp.Resource, + scope : @otlp.InstrumentationScope, +) -> Unit raise { + protobuf_message_field(writer, 1, writer => { + write_resource_spans(writer, spans, resource, scope) + }) +} + +///| +fn write_log_record( + writer : &@protobuf.Writer, + record : @otlp.OtlpLogRecord, +) -> Unit raise { + protobuf_fixed64_field(writer, 1, record.time_unix_nano) + protobuf_uint32_field( + writer, + 2, + unsigned_int("log_record.severity_number", record.severity_number), + ) + protobuf_string_field(writer, 3, record.severity_text) + protobuf_message_field(writer, 5, writer => { + write_any_value(writer, StringVal(record.body)) + }) + write_key_values(writer, 6, record.attributes) +} + +///| +fn write_scope_logs( + writer : &@protobuf.Writer, + records : Array[@otlp.OtlpLogRecord], + scope : @otlp.InstrumentationScope, +) -> Unit raise { + protobuf_message_field(writer, 1, writer => { + write_instrumentation_scope(writer, scope) + }) + for record in records { + protobuf_message_field(writer, 2, writer => write_log_record(writer, record)) + } +} + +///| +fn write_resource_logs( + writer : &@protobuf.Writer, + records : Array[@otlp.OtlpLogRecord], + resource : @otlp.Resource, + scope : @otlp.InstrumentationScope, +) -> Unit raise { + protobuf_message_field(writer, 1, writer => write_resource(writer, resource)) + protobuf_message_field(writer, 2, writer => { + write_scope_logs(writer, records, scope) + }) +} + +///| +fn write_export_logs_service_request( + writer : &@protobuf.Writer, + records : Array[@otlp.OtlpLogRecord], + resource : @otlp.Resource, + scope : @otlp.InstrumentationScope, +) -> Unit raise { + protobuf_message_field(writer, 1, writer => { + write_resource_logs(writer, records, resource, scope) + }) +} + ///| pub fn OtlpEncoder::encode_spans( self : OtlpEncoder, @@ -87,7 +486,10 @@ pub fn OtlpEncoder::encode_spans( ) -> Result[OtlpPayload, EncodingError] { match self { JsonEncoder => Ok(json_payload(@otlp.wrap_spans(spans, resource~, scope~))) - ProtobufEncoder => Err(ProtobufNotImplemented(Traces)) + ProtobufEncoder => + encode_protobuf_payload(Traces, writer => { + write_export_trace_service_request(writer, spans, resource, scope) + }) } } @@ -101,6 +503,9 @@ pub fn OtlpEncoder::encode_logs( match self { JsonEncoder => Ok(json_payload(@otlp.wrap_log_records(records, resource~, scope~))) - ProtobufEncoder => Err(ProtobufNotImplemented(Logs)) + ProtobufEncoder => + encode_protobuf_payload(Logs, writer => { + write_export_logs_service_request(writer, records, resource, scope) + }) } } diff --git a/src/otlp/transport/encoding_test.mbt b/src/otlp/transport/encoding_test.mbt index bf425c5..5ca51b2 100644 --- a/src/otlp/transport/encoding_test.mbt +++ b/src/otlp/transport/encoding_test.mbt @@ -49,6 +49,858 @@ fn assert_json_eq(actual : Json, expected : Json) -> Unit { } } +///| +priv struct DecodedAnyValue { + string_value : String? + bool_value : Bool? + int_value : Int64? + double_value : Double? +} + +///| +priv struct DecodedKeyValue { + key : String + value : DecodedAnyValue +} + +///| +priv struct DecodedResource { + attributes : Array[DecodedKeyValue] +} + +///| +priv struct DecodedScope { + name : String + version : String + attributes : Array[DecodedKeyValue] +} + +///| +priv struct DecodedStatus { + message : String + code : UInt +} + +///| +priv struct DecodedSpan { + trace_id : Bytes + span_id : Bytes + parent_span_id : Bytes + flags : UInt + name : String + kind : UInt + start_time_unix_nano : UInt64 + end_time_unix_nano : UInt64 + attributes : Array[DecodedKeyValue] + status : DecodedStatus +} + +///| +priv struct DecodedScopeSpans { + scope : DecodedScope + spans : Array[DecodedSpan] +} + +///| +priv struct DecodedResourceSpans { + resource : DecodedResource + scope_spans : Array[DecodedScopeSpans] +} + +///| +priv struct DecodedTraceRequest { + resource_spans : Array[DecodedResourceSpans] +} + +///| +priv struct DecodedLogRecord { + time_unix_nano : UInt64 + severity_number : UInt + severity_text : String + body : DecodedAnyValue + attributes : Array[DecodedKeyValue] +} + +///| +priv struct DecodedScopeLogs { + scope : DecodedScope + log_records : Array[DecodedLogRecord] +} + +///| +priv struct DecodedResourceLogs { + resource : DecodedResource + scope_logs : Array[DecodedScopeLogs] +} + +///| +priv struct DecodedLogsRequest { + resource_logs : Array[DecodedResourceLogs] +} + +///| +fn decoded_any_default() -> DecodedAnyValue { + { string_value: None, bool_value: None, int_value: None, double_value: None } +} + +///| +fn decoded_status_default() -> DecodedStatus { + { message: "", code: 0 } +} + +///| +fn decoded_resource_default() -> DecodedResource { + { attributes: [] } +} + +///| +fn decoded_scope_default() -> DecodedScope { + { name: "", version: "", attributes: [] } +} + +///| +fn assert_wire_type(actual : UInt, expected : UInt, context : String) -> Unit { + if actual != expected { + abort( + "unexpected protobuf wire type for " + + context + + ": expected " + + expected.to_string() + + ", got " + + actual.to_string(), + ) + } +} + +///| +fn decode_fail(context : String, message : String) -> Unit { + abort("protobuf decode failed for " + context + ": " + message) +} + +///| +fn read_bytes_field(reader : @protobuf.BytesReader, context : String) -> Bytes { + (reader |> @protobuf.read_bytes()) catch { + err => { + decode_fail(context, "\{err}") + Bytes::default() + } + } +} + +///| +fn read_string_field( + reader : @protobuf.BytesReader, + context : String, +) -> String { + (reader |> @protobuf.read_string()) catch { + err => { + decode_fail(context, "\{err}") + "" + } + } +} + +///| +fn read_uint32_field(reader : @protobuf.BytesReader, context : String) -> UInt { + (reader |> @protobuf.read_uint32()) catch { + err => { + decode_fail(context, "\{err}") + 0 + } + } +} + +///| +fn read_fixed32_field(reader : @protobuf.BytesReader, context : String) -> UInt { + (reader |> @protobuf.read_fixed32()) catch { + err => { + decode_fail(context, "\{err}") + 0 + } + } +} + +///| +fn read_fixed64_field( + reader : @protobuf.BytesReader, + context : String, +) -> UInt64 { + (reader |> @protobuf.read_fixed64()) catch { + err => { + decode_fail(context, "\{err}") + 0UL + } + } +} + +///| +fn read_bool_field(reader : @protobuf.BytesReader, context : String) -> Bool { + (reader |> @protobuf.read_bool()) catch { + err => { + decode_fail(context, "\{err}") + false + } + } +} + +///| +fn read_int64_field(reader : @protobuf.BytesReader, context : String) -> Int64 { + (reader |> @protobuf.read_int64()) catch { + err => { + decode_fail(context, "\{err}") + 0L + } + } +} + +///| +fn read_double_field( + reader : @protobuf.BytesReader, + context : String, +) -> Double { + (reader |> @protobuf.read_double()) catch { + err => { + decode_fail(context, "\{err}") + 0.0 + } + } +} + +///| +fn skip_unknown( + reader : @protobuf.BytesReader, + wire_type : UInt, + context : String, +) -> Unit { + (reader |> @protobuf.read_unknown(wire_type)) catch { + err => decode_fail(context, "\{err}") + } +} + +///| +fn decode_any_value(body : Bytes) -> DecodedAnyValue { + let reader = @protobuf.BytesReader::from_bytes(body) + let mut value = decoded_any_default() + try { + for ;; { + let (field, wire_type) = reader |> @protobuf.read_tag() + match field { + 1 => { + assert_wire_type(wire_type, 2, "AnyValue.string_value") + value = { + string_value: Some( + read_string_field(reader, "AnyValue.string_value"), + ), + bool_value: value.bool_value, + int_value: value.int_value, + double_value: value.double_value, + } + } + 2 => { + assert_wire_type(wire_type, 0, "AnyValue.bool_value") + value = { + string_value: value.string_value, + bool_value: Some(read_bool_field(reader, "AnyValue.bool_value")), + int_value: value.int_value, + double_value: value.double_value, + } + } + 3 => { + assert_wire_type(wire_type, 0, "AnyValue.int_value") + value = { + string_value: value.string_value, + bool_value: value.bool_value, + int_value: Some(read_int64_field(reader, "AnyValue.int_value")), + double_value: value.double_value, + } + } + 4 => { + assert_wire_type(wire_type, 1, "AnyValue.double_value") + value = { + string_value: value.string_value, + bool_value: value.bool_value, + int_value: value.int_value, + double_value: Some( + read_double_field(reader, "AnyValue.double_value"), + ), + } + } + _ => skip_unknown(reader, wire_type, "AnyValue") + } + } + } catch { + @protobuf.EndOfStream => () + err => decode_fail("AnyValue", "\{err}") + } + value +} + +///| +fn decode_key_value(body : Bytes) -> DecodedKeyValue { + let reader = @protobuf.BytesReader::from_bytes(body) + let mut key = "" + let mut value = decoded_any_default() + try { + for ;; { + let (field, wire_type) = reader |> @protobuf.read_tag() + match field { + 1 => { + assert_wire_type(wire_type, 2, "KeyValue.key") + key = read_string_field(reader, "KeyValue.key") + } + 2 => { + assert_wire_type(wire_type, 2, "KeyValue.value") + value = decode_any_value(read_bytes_field(reader, "KeyValue.value")) + } + _ => skip_unknown(reader, wire_type, "KeyValue") + } + } + } catch { + @protobuf.EndOfStream => () + err => decode_fail("KeyValue", "\{err}") + } + { key, value } +} + +///| +fn decode_resource(body : Bytes) -> DecodedResource { + let reader = @protobuf.BytesReader::from_bytes(body) + let attributes : Array[DecodedKeyValue] = [] + try { + for ;; { + let (field, wire_type) = reader |> @protobuf.read_tag() + match field { + 1 => { + assert_wire_type(wire_type, 2, "Resource.attributes") + attributes.push( + decode_key_value(read_bytes_field(reader, "Resource.attributes")), + ) + } + _ => skip_unknown(reader, wire_type, "Resource") + } + } + } catch { + @protobuf.EndOfStream => () + err => decode_fail("Resource", "\{err}") + } + { attributes, } +} + +///| +fn decode_scope(body : Bytes) -> DecodedScope { + let reader = @protobuf.BytesReader::from_bytes(body) + let mut name = "" + let mut version = "" + let attributes : Array[DecodedKeyValue] = [] + try { + for ;; { + let (field, wire_type) = reader |> @protobuf.read_tag() + match field { + 1 => { + assert_wire_type(wire_type, 2, "InstrumentationScope.name") + name = read_string_field(reader, "InstrumentationScope.name") + } + 2 => { + assert_wire_type(wire_type, 2, "InstrumentationScope.version") + version = read_string_field(reader, "InstrumentationScope.version") + } + 3 => { + assert_wire_type(wire_type, 2, "InstrumentationScope.attributes") + attributes.push( + decode_key_value( + read_bytes_field(reader, "InstrumentationScope.attributes"), + ), + ) + } + _ => skip_unknown(reader, wire_type, "InstrumentationScope") + } + } + } catch { + @protobuf.EndOfStream => () + err => decode_fail("InstrumentationScope", "\{err}") + } + { name, version, attributes } +} + +///| +fn decode_status(body : Bytes) -> DecodedStatus { + let reader = @protobuf.BytesReader::from_bytes(body) + let mut status = decoded_status_default() + try { + for ;; { + let (field, wire_type) = reader |> @protobuf.read_tag() + match field { + 2 => { + assert_wire_type(wire_type, 2, "Status.message") + status = { + message: read_string_field(reader, "Status.message"), + code: status.code, + } + } + 3 => { + assert_wire_type(wire_type, 0, "Status.code") + status = { + message: status.message, + code: read_uint32_field(reader, "Status.code"), + } + } + _ => skip_unknown(reader, wire_type, "Status") + } + } + } catch { + @protobuf.EndOfStream => () + err => decode_fail("Status", "\{err}") + } + status +} + +///| +fn decode_span(body : Bytes) -> DecodedSpan { + let reader = @protobuf.BytesReader::from_bytes(body) + let mut trace_id = Bytes::default() + let mut span_id = Bytes::default() + let mut parent_span_id = Bytes::default() + let mut flags = 0U + let mut name = "" + let mut kind = 0U + let mut start_time_unix_nano = 0UL + let mut end_time_unix_nano = 0UL + let attributes : Array[DecodedKeyValue] = [] + let mut status = decoded_status_default() + try { + for ;; { + let (field, wire_type) = reader |> @protobuf.read_tag() + match field { + 1 => { + assert_wire_type(wire_type, 2, "Span.trace_id") + trace_id = read_bytes_field(reader, "Span.trace_id") + } + 2 => { + assert_wire_type(wire_type, 2, "Span.span_id") + span_id = read_bytes_field(reader, "Span.span_id") + } + 4 => { + assert_wire_type(wire_type, 2, "Span.parent_span_id") + parent_span_id = read_bytes_field(reader, "Span.parent_span_id") + } + 5 => { + assert_wire_type(wire_type, 2, "Span.name") + name = read_string_field(reader, "Span.name") + } + 6 => { + assert_wire_type(wire_type, 0, "Span.kind") + kind = read_uint32_field(reader, "Span.kind") + } + 7 => { + assert_wire_type(wire_type, 1, "Span.start_time_unix_nano") + start_time_unix_nano = read_fixed64_field( + reader, "Span.start_time_unix_nano", + ) + } + 8 => { + assert_wire_type(wire_type, 1, "Span.end_time_unix_nano") + end_time_unix_nano = read_fixed64_field( + reader, "Span.end_time_unix_nano", + ) + } + 9 => { + assert_wire_type(wire_type, 2, "Span.attributes") + attributes.push( + decode_key_value(read_bytes_field(reader, "Span.attributes")), + ) + } + 15 => { + assert_wire_type(wire_type, 2, "Span.status") + status = decode_status(read_bytes_field(reader, "Span.status")) + } + 16 => { + assert_wire_type(wire_type, 5, "Span.flags") + flags = read_fixed32_field(reader, "Span.flags") + } + _ => skip_unknown(reader, wire_type, "Span") + } + } + } catch { + @protobuf.EndOfStream => () + err => decode_fail("Span", "\{err}") + } + { + trace_id, + span_id, + parent_span_id, + flags, + name, + kind, + start_time_unix_nano, + end_time_unix_nano, + attributes, + status, + } +} + +///| +fn decode_scope_spans(body : Bytes) -> DecodedScopeSpans { + let reader = @protobuf.BytesReader::from_bytes(body) + let mut scope = decoded_scope_default() + let spans : Array[DecodedSpan] = [] + try { + for ;; { + let (field, wire_type) = reader |> @protobuf.read_tag() + match field { + 1 => { + assert_wire_type(wire_type, 2, "ScopeSpans.scope") + scope = decode_scope(read_bytes_field(reader, "ScopeSpans.scope")) + } + 2 => { + assert_wire_type(wire_type, 2, "ScopeSpans.spans") + spans.push(decode_span(read_bytes_field(reader, "ScopeSpans.spans"))) + } + _ => skip_unknown(reader, wire_type, "ScopeSpans") + } + } + } catch { + @protobuf.EndOfStream => () + err => decode_fail("ScopeSpans", "\{err}") + } + { scope, spans } +} + +///| +fn decode_resource_spans(body : Bytes) -> DecodedResourceSpans { + let reader = @protobuf.BytesReader::from_bytes(body) + let mut resource = decoded_resource_default() + let scope_spans : Array[DecodedScopeSpans] = [] + try { + for ;; { + let (field, wire_type) = reader |> @protobuf.read_tag() + match field { + 1 => { + assert_wire_type(wire_type, 2, "ResourceSpans.resource") + resource = decode_resource( + read_bytes_field(reader, "ResourceSpans.resource"), + ) + } + 2 => { + assert_wire_type(wire_type, 2, "ResourceSpans.scope_spans") + scope_spans.push( + decode_scope_spans( + read_bytes_field(reader, "ResourceSpans.scope_spans"), + ), + ) + } + _ => skip_unknown(reader, wire_type, "ResourceSpans") + } + } + } catch { + @protobuf.EndOfStream => () + err => decode_fail("ResourceSpans", "\{err}") + } + { resource, scope_spans } +} + +///| +fn decode_trace_request(body : Bytes) -> DecodedTraceRequest { + let reader = @protobuf.BytesReader::from_bytes(body) + let resource_spans : Array[DecodedResourceSpans] = [] + try { + for ;; { + let (field, wire_type) = reader |> @protobuf.read_tag() + match field { + 1 => { + assert_wire_type( + wire_type, 2, "ExportTraceServiceRequest.resource_spans", + ) + resource_spans.push( + decode_resource_spans( + read_bytes_field( + reader, "ExportTraceServiceRequest.resource_spans", + ), + ), + ) + } + _ => skip_unknown(reader, wire_type, "ExportTraceServiceRequest") + } + } + } catch { + @protobuf.EndOfStream => () + err => decode_fail("ExportTraceServiceRequest", "\{err}") + } + { resource_spans, } +} + +///| +fn decode_log_record(body : Bytes) -> DecodedLogRecord { + let reader = @protobuf.BytesReader::from_bytes(body) + let mut time_unix_nano = 0UL + let mut severity_number = 0U + let mut severity_text = "" + let mut body_value = decoded_any_default() + let attributes : Array[DecodedKeyValue] = [] + try { + for ;; { + let (field, wire_type) = reader |> @protobuf.read_tag() + match field { + 1 => { + assert_wire_type(wire_type, 1, "LogRecord.time_unix_nano") + time_unix_nano = read_fixed64_field( + reader, "LogRecord.time_unix_nano", + ) + } + 2 => { + assert_wire_type(wire_type, 0, "LogRecord.severity_number") + severity_number = read_uint32_field( + reader, "LogRecord.severity_number", + ) + } + 3 => { + assert_wire_type(wire_type, 2, "LogRecord.severity_text") + severity_text = read_string_field(reader, "LogRecord.severity_text") + } + 5 => { + assert_wire_type(wire_type, 2, "LogRecord.body") + body_value = decode_any_value( + read_bytes_field(reader, "LogRecord.body"), + ) + } + 6 => { + assert_wire_type(wire_type, 2, "LogRecord.attributes") + attributes.push( + decode_key_value(read_bytes_field(reader, "LogRecord.attributes")), + ) + } + _ => skip_unknown(reader, wire_type, "LogRecord") + } + } + } catch { + @protobuf.EndOfStream => () + err => decode_fail("LogRecord", "\{err}") + } + { + time_unix_nano, + severity_number, + severity_text, + body: body_value, + attributes, + } +} + +///| +fn decode_scope_logs(body : Bytes) -> DecodedScopeLogs { + let reader = @protobuf.BytesReader::from_bytes(body) + let mut scope = decoded_scope_default() + let log_records : Array[DecodedLogRecord] = [] + try { + for ;; { + let (field, wire_type) = reader |> @protobuf.read_tag() + match field { + 1 => { + assert_wire_type(wire_type, 2, "ScopeLogs.scope") + scope = decode_scope(read_bytes_field(reader, "ScopeLogs.scope")) + } + 2 => { + assert_wire_type(wire_type, 2, "ScopeLogs.log_records") + log_records.push( + decode_log_record(read_bytes_field(reader, "ScopeLogs.log_records")), + ) + } + _ => skip_unknown(reader, wire_type, "ScopeLogs") + } + } + } catch { + @protobuf.EndOfStream => () + err => decode_fail("ScopeLogs", "\{err}") + } + { scope, log_records } +} + +///| +fn decode_resource_logs(body : Bytes) -> DecodedResourceLogs { + let reader = @protobuf.BytesReader::from_bytes(body) + let mut resource = decoded_resource_default() + let scope_logs : Array[DecodedScopeLogs] = [] + try { + for ;; { + let (field, wire_type) = reader |> @protobuf.read_tag() + match field { + 1 => { + assert_wire_type(wire_type, 2, "ResourceLogs.resource") + resource = decode_resource( + read_bytes_field(reader, "ResourceLogs.resource"), + ) + } + 2 => { + assert_wire_type(wire_type, 2, "ResourceLogs.scope_logs") + scope_logs.push( + decode_scope_logs( + read_bytes_field(reader, "ResourceLogs.scope_logs"), + ), + ) + } + _ => skip_unknown(reader, wire_type, "ResourceLogs") + } + } + } catch { + @protobuf.EndOfStream => () + err => decode_fail("ResourceLogs", "\{err}") + } + { resource, scope_logs } +} + +///| +fn decode_logs_request(body : Bytes) -> DecodedLogsRequest { + let reader = @protobuf.BytesReader::from_bytes(body) + let resource_logs : Array[DecodedResourceLogs] = [] + try { + for ;; { + let (field, wire_type) = reader |> @protobuf.read_tag() + match field { + 1 => { + assert_wire_type( + wire_type, 2, "ExportLogsServiceRequest.resource_logs", + ) + resource_logs.push( + decode_resource_logs( + read_bytes_field(reader, "ExportLogsServiceRequest.resource_logs"), + ), + ) + } + _ => skip_unknown(reader, wire_type, "ExportLogsServiceRequest") + } + } + } catch { + @protobuf.EndOfStream => () + err => decode_fail("ExportLogsServiceRequest", "\{err}") + } + { resource_logs, } +} + +///| +fn bytes_from_hex(value : String) -> Bytes { + if value.length() % 2 != 0 { + abort("odd-length hex fixture") + } + let bytes = Buffer(size_hint=value.length() / 2) + for index = 0; index < value.length(); { + let pair = value[index:index + 2].to_owned() + let byte = @string.parse_int(pair, base=16) catch { + _ => abort("invalid hex fixture") + } + bytes.write_byte(byte.to_byte()) + continue index + 2 + } + bytes.to_bytes() +} + +///| +fn find_attribute( + attributes : Array[DecodedKeyValue], + key : String, +) -> DecodedAnyValue { + for attribute in attributes { + if attribute.key == key { + return attribute.value + } + } + abort("missing attribute " + key) +} + +///| +fn assert_string_value(value : DecodedAnyValue, expected : String) -> Unit { + match value.string_value { + Some(actual) => + if actual != expected { + abort("expected string value " + expected + ", got " + actual) + } + None => abort("expected string value " + expected) + } +} + +///| +fn assert_bool_value(value : DecodedAnyValue, expected : Bool) -> Unit { + match value.bool_value { + Some(actual) => if actual != expected { abort("unexpected bool value") } + None => abort("expected bool value") + } +} + +///| +fn assert_int_value(value : DecodedAnyValue, expected : Int64) -> Unit { + match value.int_value { + Some(actual) => if actual != expected { abort("unexpected int value") } + None => abort("expected int value") + } +} + +///| +fn assert_double_value(value : DecodedAnyValue, expected : Double) -> Unit { + match value.double_value { + Some(actual) => if actual != expected { abort("unexpected double value") } + None => abort("expected double value") + } +} + +///| +fn unwrap_payload( + result : Result[@transport.OtlpPayload, @transport.EncodingError], +) -> @transport.OtlpPayload { + match result { + Ok(payload) => payload + Err(err) => abort("expected protobuf payload, got \{err}") + } +} + +///| +fn protobuf_test_resource() -> @otlp.Resource { + @otlp.resource(service_name="svc", service_version="1.2.3", attributes=[ + @otlp.OtlpAttribute::{ key: "resource.bool", value: @otlp.BoolVal(true) }, + ]) +} + +///| +fn protobuf_test_scope() -> @otlp.InstrumentationScope { + @otlp.instrumentation_scope(name="moontrace-test", version="0.12.0", attributes=[ + @otlp.OtlpAttribute::{ + key: "scope.attr", + value: @otlp.StringVal("scope-value"), + }, + ]) +} + +///| +fn protobuf_test_span() -> @otlp.OtlpSpan { + @otlp.OtlpSpan::{ + trace_id: "0000000000000000000000000000000a", + span_id: "000000000000000b", + parent_span_id: "000000000000000c", + flags: 1, + name: "work", + kind: 1, + start_time_unix_nano: (100 : UInt64), + end_time_unix_nano: (200 : UInt64), + attributes: [ + @otlp.OtlpAttribute::{ key: "answer", value: @otlp.IntVal(42L) }, + @otlp.OtlpAttribute::{ key: "ratio", value: @otlp.DoubleVal(2.5) }, + ], + links: [], + status_code: 2, + status_message: "boom", + } +} + +///| +fn protobuf_test_log_record() -> @otlp.OtlpLogRecord { + @otlp.OtlpLogRecord::{ + time_unix_nano: (300 : UInt64), + severity_number: 17, + severity_text: "ERROR", + body: "log body", + attributes: [ + @otlp.OtlpAttribute::{ key: "log.attr", value: @otlp.StringVal("value") }, + @otlp.OtlpAttribute::{ key: "log.ok", value: @otlp.BoolVal(true) }, + ], + } +} + ///| test "json encoder encodes spans with JSON content type bytes" { let span = encoding_test_span("work") @@ -79,27 +931,156 @@ test "json encoder encodes logs with JSON content type bytes" { } ///| -test "protobuf encoder reports structured not implemented errors" { - match - @transport.protobuf_encoder().encode_spans([encoding_test_span("work")]) { - Ok(_) => abort("protobuf span encoding should be stubbed") - Err(@transport.ProtobufNotImplemented(@transport.Traces)) => () - Err(err) => abort("unexpected protobuf span error: \{err}") +test "protobuf encoder encodes spans with protobuf content type bytes" { + let resource = protobuf_test_resource() + let scope = protobuf_test_scope() + let span = protobuf_test_span() + let payload = unwrap_payload( + @transport.protobuf_encoder().encode_spans([span], resource~, scope~), + ) + assert_eq(payload.content_type, @transport.otlp_protobuf_content_type()) + assert_true(payload.body.length() > 0) + + let request = decode_trace_request(payload.body) + assert_true(request.resource_spans.length() == 1) + let resource_spans = request.resource_spans[0] + assert_string_value( + find_attribute(resource_spans.resource.attributes, "service.name"), + "svc", + ) + assert_string_value( + find_attribute(resource_spans.resource.attributes, "service.version"), + "1.2.3", + ) + assert_bool_value( + find_attribute(resource_spans.resource.attributes, "resource.bool"), + true, + ) + assert_true(resource_spans.scope_spans.length() == 1) + + let scope_spans = resource_spans.scope_spans[0] + assert_eq(scope_spans.scope.name, "moontrace-test") + assert_eq(scope_spans.scope.version, "0.12.0") + assert_string_value( + find_attribute(scope_spans.scope.attributes, "scope.attr"), + "scope-value", + ) + assert_true(scope_spans.spans.length() == 1) + + let decoded_span = scope_spans.spans[0] + assert_true( + decoded_span.trace_id == bytes_from_hex("0000000000000000000000000000000a"), + ) + assert_true(decoded_span.span_id == bytes_from_hex("000000000000000b")) + assert_true(decoded_span.parent_span_id == bytes_from_hex("000000000000000c")) + assert_eq(decoded_span.name, "work") + assert_true(decoded_span.kind == 1) + assert_true(decoded_span.start_time_unix_nano == 100UL) + assert_true(decoded_span.end_time_unix_nano == 200UL) + assert_true(decoded_span.flags == 1) + assert_eq(decoded_span.status.message, "boom") + assert_true(decoded_span.status.code == 2) + assert_int_value(find_attribute(decoded_span.attributes, "answer"), 42L) + assert_double_value(find_attribute(decoded_span.attributes, "ratio"), 2.5) +} + +///| +test "protobuf trace request matches golden bytes" { + let resource = protobuf_test_resource() + let scope = protobuf_test_scope() + let span = protobuf_test_span() + let payload = unwrap_payload( + @transport.protobuf_encoder().encode_spans([span], resource~, scope~), + ) + let golden = bytes_from_hex( + "0af8010a480a150a0c736572766963652e6e616d6512050a037376630a1a0a0f736572766963652e76657273696f6e12070a05312e322e330a130a0d7265736f757263652e626f6f6c1202100112ab010a350a0e6d6f6f6e74726163652d746573741206302e31322e301a1b0a0a73636f70652e61747472120d0a0b73636f70652d76616c756512720a100000000000000000000000000000000a1208000000000000000b2208000000000000000c2a04776f726b300139640000000000000041c8000000000000004a0c0a06616e737765721202182a4a120a05726174696f12092100000000000004407a081204626f6f6d1802850101000000", + ) + assert_true(payload.body == golden) +} + +///| +test "protobuf encoder encodes logs with protobuf content type bytes" { + let resource = protobuf_test_resource() + let scope = protobuf_test_scope() + let record = protobuf_test_log_record() + let payload = unwrap_payload( + @transport.protobuf_encoder().encode_logs([record], resource~, scope~), + ) + assert_eq(payload.content_type, @transport.otlp_protobuf_content_type()) + assert_true(payload.body.length() > 0) + + let request = decode_logs_request(payload.body) + assert_true(request.resource_logs.length() == 1) + let resource_logs = request.resource_logs[0] + assert_string_value( + find_attribute(resource_logs.resource.attributes, "service.name"), + "svc", + ) + assert_string_value( + find_attribute(resource_logs.resource.attributes, "service.version"), + "1.2.3", + ) + assert_bool_value( + find_attribute(resource_logs.resource.attributes, "resource.bool"), + true, + ) + assert_true(resource_logs.scope_logs.length() == 1) + + let scope_logs = resource_logs.scope_logs[0] + assert_eq(scope_logs.scope.name, "moontrace-test") + assert_eq(scope_logs.scope.version, "0.12.0") + assert_string_value( + find_attribute(scope_logs.scope.attributes, "scope.attr"), + "scope-value", + ) + assert_true(scope_logs.log_records.length() == 1) + + let decoded_record = scope_logs.log_records[0] + assert_true(decoded_record.time_unix_nano == 300UL) + assert_true(decoded_record.severity_number == 17) + assert_eq(decoded_record.severity_text, "ERROR") + assert_string_value(decoded_record.body, "log body") + assert_string_value( + find_attribute(decoded_record.attributes, "log.attr"), + "value", + ) + assert_bool_value(find_attribute(decoded_record.attributes, "log.ok"), true) +} + +///| +test "protobuf encoder reports invalid id errors" { + let span = @otlp.OtlpSpan::{ + trace_id: "bad", + span_id: "000000000000000b", + parent_span_id: "", + flags: 1, + name: "work", + kind: 1, + start_time_unix_nano: (100 : UInt64), + end_time_unix_nano: (200 : UInt64), + attributes: [], + links: [], + status_code: 2, + status_message: "boom", } - match - @transport.protobuf_encoder().encode_logs([ - encoding_test_log_record("hello"), - ]) { - Ok(_) => abort("protobuf log encoding should be stubbed") - Err(@transport.ProtobufNotImplemented(@transport.Logs)) => () - Err(err) => abort("unexpected protobuf log error: \{err}") + match @transport.protobuf_encoder().encode_spans([span]) { + Ok(_) => abort("invalid protobuf span id should fail") + Err(err) => + assert_eq( + err.to_string(), + "protobuf OTLP traces encoding failed: invalid hex bytes for span.trace_id: bad", + ) } +} + +///| +test "protobuf encoding errors have manual show text" { assert_eq( @transport.ProtobufNotImplemented(@transport.Traces).to_string(), "protobuf OTLP traces encoding is not implemented", ) assert_eq( - "\{@transport.ProtobufNotImplemented(@transport.Logs)}", - "protobuf OTLP logs encoding is not implemented", + "\{@transport.ProtobufEncodeFailed(@transport.Logs, "bad wire")}", + "protobuf OTLP logs encoding failed: bad wire", ) } diff --git a/src/otlp/transport/moon.pkg b/src/otlp/transport/moon.pkg index 135bd91..c86619f 100644 --- a/src/otlp/transport/moon.pkg +++ b/src/otlp/transport/moon.pkg @@ -5,6 +5,7 @@ import { "moonbitlang/async/io", "moonbitlang/core/encoding/utf8", "moonbitlang/core/string", + "moonbitlang/protobuf", } import { diff --git a/src/otlp/transport/pkg.generated.mbti b/src/otlp/transport/pkg.generated.mbti index 8df16a4..02b90db 100644 --- a/src/otlp/transport/pkg.generated.mbti +++ b/src/otlp/transport/pkg.generated.mbti @@ -39,6 +39,7 @@ pub impl OtlpHttpClient for AsyncHttpClient pub(all) enum EncodingError { ProtobufNotImplemented(OtlpSignal) + ProtobufEncodeFailed(OtlpSignal, String) } derive(Compare, Eq, @debug.Debug) pub fn EncodingError::to_string(Self) -> String pub impl Show for EncodingError