diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9952207..9be1904 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: name: Conformance suite in sync and results complete runs-on: ubuntu-latest env: - BQ_CONFORMANCE_VERSION: "1.6.0" + BQ_CONFORMANCE_VERSION: "1.9.0" # sha256 of that release's fetch.sh: the script runs on the runner, so it is pinned rather # than trusted from a mutable tag. Bump together with BQ_CONFORMANCE_VERSION. BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849 diff --git a/CHANGELOG.md b/CHANGELOG.md index de1a3a3..e42b72f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,80 @@ The envelope wire format is versioned separately by `meta.schema_version` ## [Unreleased] +## [1.14.0] - 2026-11-01 + +Implements ADR-0038 (OpenTelemetry semantic-conventions alignment) and ADR-0039 (standard metrics) for the +`System.Diagnostics` tracing helpers in `BabelQueue.Core`, and ADR-0036 decision 4 (body classification) with the +additive `BodyVerdict` API. The envelope stays at `schema_version: 1`; nothing on the wire changes and no package +dependency is added (GR-7). + +### Changed (dashboard-breaking, ADR-0038 section 5) +- Span names are now `send ` (producer) and `process ` (consumer); they were `publish ` and + `process `. With no destination (a blank `meta.queue`) the name is the bare `send` / `process`. The URN is + never in the span name; it is the `babelqueue.urn` attribute. +- The producer span's `messaging.destination.name` is the queue it publishes to; it was the URN. It is not written + when the queue is blank. +- `messaging.system` is the new optional `system` argument (for example `"kafka"`, `"rabbitmq"`, `"aws_sqs"`, + `"servicebus"`, `"pulsar"`, `"activemq"`, `"redis"`; see `Telemetry.Systems`). **Omitting it keeps `babelqueue` and + that default is deprecated** (it matches no APM messaging view). A value outside the set is written as given. +- A `null` queue given to `PublishAsync` is treated as `"default"`, as `EnvelopeCodec.Make` does, so the span's + destination equals the produced `meta.queue`. +- Consumer attributes are set when the span starts (a sampler can read them); they used to be set afterwards. +- A failing publish or handler now also sets `error.type` (the exception's full type name, `_OTHER` if none) next to + the existing error status and `exception` event. On a failing publish the envelope's `messaging.message.id` and + `messaging.message.conversation_id` are on the span (they were set only after a successful send). + +### Added +- `Telemetry.Wrap(handler, headers, system, consumerGroup)` and `Telemetry.PublishAsync(..., queue, system)`: optional + `system` and `consumerGroup` (`messaging.consumer.group.name`, consumer span only, written whenever given). Existing + signatures are unchanged in source and in binary: `Wrap(handler)`, `Wrap(handler, headers)` and both pre-existing + `PublishAsync` shapes (with a required `queue`, hidden from IntelliSense) stay as their own methods and forward to the + new overloads with `system: null`, so assemblies compiled against 1.13 keep binding to them and no call shape is + ambiguous. +- New span attributes: `messaging.operation.type`, `messaging.operation.name`, `babelqueue.urn`, + `babelqueue.schema_version`, `babelqueue.attempts`; the producer also writes `messaging.message.conversation_id`. +- `Telemetry.SemconvVersion` (`1.44.0`) and `Telemetry.SchemaUrl` (`https://opentelemetry.io/schemas/1.44.0`). The + `net8.0` `ActivitySource` and `Meter` cannot carry a schema URL, so the core only exposes the constant; the source + and meter are created with the library version. +- Metrics (ADR-0039) on `Meter("BabelQueue")` (`TelemetryMetrics`): `messaging.process.duration` (histogram, `s`), + `messaging.client.sent.messages`, `messaging.client.consumed.messages`, `babelqueue.retries` and + `babelqueue.dlq.messages` (counters, `{message}`). Attributes are only `messaging.system`, + `messaging.operation.name`, `messaging.destination.name` (when known) and `error.type` (on failure); no URN, id, + trace id, attempts or reason. Only the metric-name constants and the read-only `AdvisoryBucketBoundaries` are public; + the instruments themselves are internal, so nothing outside the SDK can record on them. The instruments always exist and a listener collects them only when it subscribes + (`AddMeter("BabelQueue")`). `net8.0` cannot attach the advisory bucket boundaries, so they are exposed as + `TelemetryMetrics.AdvisoryBucketBoundaries` for a view. +- Dead-letter observer seam (ADR-0039 section 5.1): `IDeadLetterObserver`, `DeadLetterEvent(Queue, DeadLetterQueue, + Reason)` and `DeadLetterObservers.NotifySafely` (catches and logs an observer exception so it cannot change the + settle outcome), plus `MetricsDeadLetterObserver(system)` which increments `babelqueue.dlq.messages`. The core has no + consume runtime or DLQ write point, so nothing calls the observer yet; the transport packages wire it in a later + release. +- A `trace_id` that is not a UUID is still mapped by hashing (unchanged); the consumer helper now logs a warning + through `System.Diagnostics.Trace`, once per distinct value, and preserves the value byte for byte + (message-envelope.md section 5.1). +- The vendored conformance suite is now v1.9.0. The runner covers the `span-shape`, `trace-id` and `metrics` sections + and the core-scope `identity_verdict` section added in v1.7.0 (97 cases, all passing). +- `BodyVerdict.Classify(string?)` (ADR-0036 decision 4, ADR-0032 section 6): a strict, never-throwing classifier that + returns a `BodyVerdictResult` (`Kind` of `BodyVerdictKind.Ok` or `Poison`, plus a `Cause`). A body is `Ok` when it + decodes into an envelope with a valid identity; it is `Poison` when it is not one JSON object, when a known member + has the wrong type (for example `attempts: "3"` or `data: []`), when it nests deeper than `BodyVerdict.CoreMaxDepth` + (64), or when neither `job` nor `urn` is a non-blank string. It lets a consumer park a malformed body on the poison + route. `BodyVerdictKind.Poison` is the zero value, so `default(BodyVerdictResult)` is poison, never `Ok`. + **`EnvelopeCodec.Decode` is unchanged**: it stays lenient about member types, with the same signatures and behaviour. +- The nesting limit (64) is .NET-specific: Go and Node accept 10000 levels, PHP 511, Java 256, Python depends on the + interpreter (ADR-0036 decision 4, "SDK note"). +- Documented platform deviation (owner decision 2026-10-31; recorded in ADR-0036 decision 4, "SDK note"): a body holding a lone UTF-16 surrogate escape (`"\ud800"`) anywhere is `Poison` here, + because System.Text.Json cannot materialise it and `EnvelopeCodec.Decode` throws `InvalidOperationException` on it + (so an `Ok` body is always one `Decode` returns for). The Go, Python and Node classifiers accept such a body; the + conformance suite has no `identity_verdict` case for it. + +### Deprecated +- Removed in the next MINOR: the dual-emitted span attributes `messaging.operation` and + `messaging.babelqueue.attempts`; use `messaging.operation.type` / `messaging.operation.name` and + `babelqueue.attempts`. +- Leaving `system` out (the `babelqueue` default) is deprecated. What an absent value means once the deprecation window + ends is not decided yet (ADR-0038 section 5); it is not scheduled for removal. + ## [1.13.0] - 2026-10-24 Implements ADR-0034 section 9 (retry backoff). The envelope stays at `schema_version: 1`. diff --git a/src/BabelQueue.Core/BabelQueue.Core.csproj b/src/BabelQueue.Core/BabelQueue.Core.csproj index 1175555..7b971bb 100644 --- a/src/BabelQueue.Core/BabelQueue.Core.csproj +++ b/src/BabelQueue.Core/BabelQueue.Core.csproj @@ -16,7 +16,7 @@ BabelQueue.Core - 1.13.0 + 1.14.0 Muhammet Şafak BabelQueue Polyglot Queues, Simplified — the framework-agnostic .NET core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers. Zero dependencies. diff --git a/src/BabelQueue.Core/BodyVerdict.cs b/src/BabelQueue.Core/BodyVerdict.cs new file mode 100644 index 0000000..8eaaa9f --- /dev/null +++ b/src/BabelQueue.Core/BodyVerdict.cs @@ -0,0 +1,356 @@ +using System.Globalization; +using System.Text.Json; + +namespace BabelQueue; + +/// The outcome of classifying a raw message body: it decodes to an envelope with an identity, or it is poison. +/// +/// is deliberately the zero value, so a default (an unassigned +/// field, a TryX(out ...) that never ran) is poison, never a body that was silently classified as fine. +/// +public enum BodyVerdictKind +{ + /// The body cannot be decoded, or decodes without a valid identity. The zero value. + Poison = 0, + + /// The body decodes into an envelope that carries a valid identity (job / urn). + Ok = 1, +} + +/// A result: the verdict and, for a poison body, why. +/// The verdict. +/// Why the body is poison, or null when it is . +public readonly record struct BodyVerdictResult(BodyVerdictKind Kind, string? Cause) +{ + /// Whether the body decodes into an envelope with a valid identity. + public bool IsOk => Kind == BodyVerdictKind.Ok; + + /// Whether the body is poison. + public bool IsPoison => Kind == BodyVerdictKind.Poison; +} + +/// +/// Classifies a raw message body as or +/// (ADR-0036 decision 4; ADR-0032 section 6; error-handling.md section 3) — the cross-SDK identity_verdict +/// verdict. Unlike , which is deliberately lenient about the type of a +/// known member (a wrong type maps to its default), this check is strict, so a consumer can park a malformed body +/// on the poison route instead of handing a half-decoded envelope to a handler. +/// +/// +/// is unchanged; this is an additional entry point. A body is +/// poison when: +/// +/// it is not exactly one JSON value (no BOM, trailing tokens, malformed or non-finite numbers) or is not a +/// JSON object (a bare null is empty, hence has no identity); +/// a known member has the wrong type: trace_id, meta.id|queue|lang and +/// dead_letter.reason|error|exception|original_queue|lang are string-or-null; attempts, +/// meta.schema_version|created_at and dead_letter.failed_at|attempts are integer-or-null (an integer +/// literal that fits int64); data, meta and dead_letter are object-or-null, and numbers inside +/// data are finite doubles. Member names are case-sensitive and every occurrence of a duplicated key is +/// checked; any other member is only checked for syntax; +/// it nests deeper than (64) levels, which +/// cannot carry. The top-level object is level 1. This limit is specific to the .NET core: Go and Node accept 10000 +/// levels, PHP 511, Java 256, and Python depends on the interpreter (about 1000 on CPython 3.9, far more on 3.14+), so a +/// body nested between 65 and 10000 levels can be in another SDK (recorded in +/// ADR-0036 decision 4, "SDK note", owner decision 2026-10-31); +/// would throw on it. System.Text.Json cannot materialise a string or a +/// member name that holds a lone UTF-16 surrogate escape ("\ud800"), anywhere in the body, and +/// lets that escape. So a +/// body is one returns for. This is a +/// platform limit: the Go, Python, Node and Java classifiers accept such a body (Go reads it as U+FFFD). Deliberate +/// (owner decision 2026-10-31, ADR-0036 decision 4 "SDK note"): it keeps the guarantee above; +/// it carries no valid identity (ADR-0032 section 6): the LAST top-level job is used when it is a +/// string that is not blank (only ASCII whitespace is blank); otherwise the LAST urn alias when it is a +/// non-blank string. A job or urn that is not a string is ignored. +/// +/// A well-typed envelope that fails (a missing data or an +/// unsupported schema_version) is not poison here. +/// +public static class BodyVerdict +{ + /// The JSON nesting limit of (the System.Text.Json default). + public const int CoreMaxDepth = 64; + + private const int ParseMaxDepth = 10_000; + + private enum Kind { String, Integer, Object, Data } + + private static readonly Dictionary MetaMembers = new(StringComparer.Ordinal) + { + ["id"] = Kind.String, + ["queue"] = Kind.String, + ["lang"] = Kind.String, + ["schema_version"] = Kind.Integer, + ["created_at"] = Kind.Integer, + }; + + private static readonly Dictionary DeadLetterMembers = new(StringComparer.Ordinal) + { + ["reason"] = Kind.String, + ["error"] = Kind.String, + ["exception"] = Kind.String, + ["original_queue"] = Kind.String, + ["lang"] = Kind.String, + ["failed_at"] = Kind.Integer, + ["attempts"] = Kind.Integer, + }; + + private static readonly Dictionary TopMembers = new(StringComparer.Ordinal) + { + ["trace_id"] = Kind.String, + ["attempts"] = Kind.Integer, + ["data"] = Kind.Data, + ["meta"] = Kind.Object, + ["dead_letter"] = Kind.Object, + }; + + /// Classifies , the raw message body (a JSON text). + /// The raw body; null is poison. + /// The verdict and, for a poison body, the cause. + public static BodyVerdictResult Classify(string? body) + { + var cause = PoisonCause(body); + return cause is null + ? new BodyVerdictResult(BodyVerdictKind.Ok, null) + : new BodyVerdictResult(BodyVerdictKind.Poison, cause); + } + + /// Why is poison, or null when it decodes with a valid identity. + /// The raw body. + public static string? PoisonCause(string? body) + { + if (body is null) + { + return "the body is null."; + } + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(body, new JsonDocumentOptions { MaxDepth = ParseMaxDepth }); + } + catch (JsonException e) + { + return "not valid JSON: " + e.Message; + } + + using (doc) + { + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return "expected a JSON object."; + } + + try + { + var problem = CheckObject(root, string.Empty, TopMembers); + if (problem is not null) + { + return problem; + } + if (ExceedsCoreDepth(root)) + { + return "nesting is deeper than the core codec can decode (" + CoreMaxDepth + " levels)."; + } + MaterialiseStrings(root); + return HasIdentity(root) ? null : "no valid identity: neither 'job' nor 'urn' is a non-blank string."; + } + catch (InvalidOperationException e) + { + // System.Text.Json cannot materialise a member name or string holding a lone UTF-16 + // surrogate escape (e.g. "\ud800"); the core codec cannot carry it either. + return "a string is not representable (lone UTF-16 surrogate escape): " + e.Message; + } + } + } + + // ADR-0032 section 6: the last `job` wins; a non-string or blank `job` falls back to the last `urn`, + // which must be a non-blank string. Blank is the closed ASCII whitespace set (ADR-0031 (c)). + private static bool HasIdentity(JsonElement root) + { + string? job = null; + string? urn = null; + foreach (var property in root.EnumerateObject()) + { + if (string.Equals(property.Name, "job", StringComparison.Ordinal)) + { + job = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : null; + } + else if (string.Equals(property.Name, "urn", StringComparison.Ordinal)) + { + urn = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : null; + } + } + return !EnvelopeCodec.IsBlank(job) || !EnvelopeCodec.IsBlank(urn); + } + + private static string? CheckObject(JsonElement obj, string path, Dictionary members) + { + foreach (var property in obj.EnumerateObject()) + { + if (!members.TryGetValue(property.Name, out var kind)) + { + continue; // unknown member: syntax only + } + var value = property.Value; + var name = path + property.Name; + switch (kind) + { + case Kind.String: + if (value.ValueKind is not (JsonValueKind.String or JsonValueKind.Null)) + { + return $"'{name}' must be a string."; + } + break; + case Kind.Integer: + if (value.ValueKind == JsonValueKind.Null) + { + break; + } + if (value.ValueKind != JsonValueKind.Number || !IsInt64Literal(value.GetRawText())) + { + return $"'{name}' must be an integer."; + } + break; + case Kind.Data: + if (value.ValueKind == JsonValueKind.Null) + { + break; + } + if (value.ValueKind != JsonValueKind.Object) + { + return $"'{name}' must be a JSON object."; + } + if (!AllNumbersFinite(value)) + { + return $"'{name}' holds a number that is not a finite double."; + } + break; + case Kind.Object: + if (value.ValueKind == JsonValueKind.Null) + { + break; + } + if (value.ValueKind != JsonValueKind.Object) + { + return $"'{name}' must be a JSON object."; + } + var inner = string.Equals(property.Name, "meta", StringComparison.Ordinal) ? MetaMembers : DeadLetterMembers; + var problem = CheckObject(value, name + ".", inner); + if (problem is not null) + { + return problem; + } + break; + } + } + return null; + } + + // An integer literal (optional minus, no fraction or exponent) that fits int64. + private static bool IsInt64Literal(string raw) => + long.TryParse(raw, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out _) + && raw.AsSpan().IndexOfAny('.', 'e', 'E') < 0; + + // Iterative on purpose: the parse accepts 10000 levels of nesting, which a recursive walk cannot survive + // on a consumer thread's stack (a StackOverflow cannot be caught). + private static bool AllNumbersFinite(JsonElement root) + { + var pending = new Stack(); + pending.Push(root); + while (pending.Count > 0) + { + var element = pending.Pop(); + switch (element.ValueKind) + { + case JsonValueKind.Number: + if (!(double.TryParse(element.GetRawText(), NumberStyles.Float, CultureInfo.InvariantCulture, out var d) + && double.IsFinite(d))) + { + return false; + } + break; + case JsonValueKind.Object: + foreach (var property in element.EnumerateObject()) + { + pending.Push(property.Value); + } + break; + case JsonValueKind.Array: + foreach (var item in element.EnumerateArray()) + { + pending.Push(item); + } + break; + } + } + return true; + } + + // Reads every member name and string value once, as EnvelopeCodec.Decode does. A lone surrogate escape makes + // System.Text.Json throw InvalidOperationException, which Classify turns into a poison verdict. + // Iterative, like the other walks: the parse accepts 10000 levels. + private static void MaterialiseStrings(JsonElement root) + { + var pending = new Stack(); + pending.Push(root); + while (pending.Count > 0) + { + var element = pending.Pop(); + switch (element.ValueKind) + { + case JsonValueKind.String: + _ = element.GetString(); + break; + case JsonValueKind.Object: + foreach (var property in element.EnumerateObject()) + { + _ = property.Name; + pending.Push(property.Value); + } + break; + case JsonValueKind.Array: + foreach (var item in element.EnumerateArray()) + { + pending.Push(item); + } + break; + } + } + } + + private static bool ExceedsCoreDepth(JsonElement root) + { + var pending = new Stack<(JsonElement Element, int Depth)>(); + pending.Push((root, 1)); + while (pending.Count > 0) + { + var (element, depth) = pending.Pop(); + switch (element.ValueKind) + { + case JsonValueKind.Object: + if (depth > CoreMaxDepth) + { + return true; + } + foreach (var property in element.EnumerateObject()) + { + pending.Push((property.Value, depth + 1)); + } + break; + case JsonValueKind.Array: + if (depth > CoreMaxDepth) + { + return true; + } + foreach (var item in element.EnumerateArray()) + { + pending.Push((item, depth + 1)); + } + break; + } + } + return false; + } +} diff --git a/src/BabelQueue.Core/DeadLetterObserver.cs b/src/BabelQueue.Core/DeadLetterObserver.cs new file mode 100644 index 0000000..5c85a08 --- /dev/null +++ b/src/BabelQueue.Core/DeadLetterObserver.cs @@ -0,0 +1,74 @@ +using System; +using System.Diagnostics; + +namespace BabelQueue; + +/// +/// What a consumer reports to an after it carried out a dead-letter +/// (ADR-0039 section 5.1). +/// +/// +/// The source queue/topic the message was consumed from (meta.queue, else the consumer's +/// configured queue); empty when unknown. +/// +/// +/// The DLQ the record went to; empty for a native dead-letter (the broker's own +/// $DeadLetterQueue sub-entity). +/// +/// The dead_letter.reason the SDK wrote; empty for a raw body. +public sealed record DeadLetterEvent(string Queue = "", string DeadLetterQueue = "", string Reason = ""); + +/// +/// An optional hook a consuming component calls once per dead-letter the SDK itself carried out, +/// after it is confirmed (ADR-0039 section 5.1): a record written to the BabelQueue DLQ, or an +/// SDK-initiated native dead-letter that the broker confirmed. It is not called when the write or the +/// native call failed, when the broker dead-letters on its own, or when DLQ writing is disabled. +/// +/// +/// +/// The .NET core has no consume runtime and no DLQ write point of its own (ADR-0039 section 5.2): the +/// transport packages call the observer from their DLQ write primitives. The core owns only this seam +/// so every transport takes the same type. is the +/// implementation that increments babelqueue.dlq.messages. +/// +/// +/// Isolation: a transport MUST call the observer through +/// , which catches and logs any exception, so an +/// observer can never change the settle outcome (no ack, nack or retry is skipped or added). +/// +/// +public interface IDeadLetterObserver +{ + /// Called once per completed dead-letter. Keep it fast and non-blocking. + /// What was dead-lettered and where it went. + void OnDeadLetter(DeadLetterEvent deadLetterEvent); +} + +/// Helpers for transports that call an . +public static class DeadLetterObservers +{ + /// + /// Calls (a no-op when it is null) and swallows any exception it + /// raises, writing a warning to (ADR-0039 section 5.1, + /// Isolation). The exception type is logged; the message is not, since it may carry payload data. + /// + /// The registered observer, or null. + /// The event to report. + public static void NotifySafely(this IDeadLetterObserver? observer, DeadLetterEvent deadLetterEvent) + { + if (observer is null) + { + return; + } + + try + { + observer.OnDeadLetter(deadLetterEvent); + } + catch (Exception ex) + { + Trace.TraceWarning( + "BabelQueue: the dead-letter observer threw " + ex.GetType().FullName + "; ignored, the settle outcome is unchanged."); + } + } +} diff --git a/src/BabelQueue.Core/Tracing/Metrics.cs b/src/BabelQueue.Core/Tracing/Metrics.cs new file mode 100644 index 0000000..9c9e0b3 --- /dev/null +++ b/src/BabelQueue.Core/Tracing/Metrics.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Reflection; + +namespace BabelQueue.Tracing; + +/// +/// The standard BabelQueue metric set (ADR-0039) on System.Diagnostics.Metrics, the BCL +/// primitive the OpenTelemetry .NET SDK consumes — so the core takes no OpenTelemetry +/// package dependency (GR-7). +/// +/// +/// +/// The instruments live on one static named and always +/// exist; a listener collects them only when it subscribes (for example +/// builder.AddMeter("BabelQueue")), so an application that wires nothing sees no change. +/// They are recorded by +/// (, , ), +/// +/// () and (). +/// +/// +/// Every point carries only messaging.system, messaging.operation.name, +/// messaging.destination.name (when known) and error.type (on failure, where the table of +/// ADR-0039 section 2 allows it). The URN, message id, trace id, attempts, schema version and the +/// dead-letter reason are never metric attributes: they are unbounded across a fleet. +/// +/// +/// The advisory bucket boundaries of () +/// cannot be attached on net8.0, whose Meter.CreateHistogram takes no advice (it arrives +/// in .NET 9); a host configures the view with these values, otherwise the reader's defaults apply. +/// +/// +public static class TelemetryMetrics +{ + /// The meter name; add it to a provider with AddMeter("BabelQueue"). + public const string MeterName = "BabelQueue"; + + /// Histogram, unit s: the handler's wall time, once per handler invocation, success or failure. + public const string ProcessDurationName = "messaging.process.duration"; + + /// Counter, unit {message}: +1 per publish attempt, success or failure. + public const string SentMessagesName = "messaging.client.sent.messages"; + + /// Counter, unit {message}: +1 per delivery that enters the consumer wrapper. + public const string ConsumedMessagesName = "messaging.client.consumed.messages"; + + /// Counter, unit {message}: +1 per delivery that enters the wrapper with attempts > 0. + public const string RetriesName = "babelqueue.retries"; + + /// Counter, unit {message}: +1 per dead-letter the SDK completed (via ). + public const string DlqMessagesName = "babelqueue.dlq.messages"; + + /// + /// The semconv advisory bucket boundaries for , in seconds. A read-only + /// list: a host cannot change the values another host reads. + /// + public static IReadOnlyList AdvisoryBucketBoundaries { get; } = + Array.AsReadOnly(new[] { 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10 }); + + internal static readonly string LibraryVersion = ResolveLibraryVersion(); + + private static readonly Meter BabelMeter = new(MeterName, LibraryVersion); + + /// messaging.process.duration. + internal static readonly Histogram ProcessDuration = + BabelMeter.CreateHistogram(ProcessDurationName, "s", "Duration of the consume handler"); + + /// messaging.client.sent.messages. + internal static readonly Counter SentMessages = + BabelMeter.CreateCounter(SentMessagesName, "{message}", "Messages the producer attempted to send"); + + /// messaging.client.consumed.messages. + internal static readonly Counter ConsumedMessages = + BabelMeter.CreateCounter(ConsumedMessagesName, "{message}", "Messages delivered to the consumer wrapper"); + + /// babelqueue.retries. + internal static readonly Counter Retries = + BabelMeter.CreateCounter(RetriesName, "{message}", "Deliveries that had already been attempted"); + + /// babelqueue.dlq.messages. + internal static readonly Counter DlqMessages = + BabelMeter.CreateCounter(DlqMessagesName, "{message}", "Dead-letters the SDK completed"); + + internal static void RecordDelivery(string system, string? destination, int attempts) + { + if (ConsumedMessages.Enabled) + { + ConsumedMessages.Add(1, Tags(system, "process", destination, null)); + } + + if (attempts > 0 && Retries.Enabled) + { + Retries.Add(1, Tags(system, "process", destination, null)); + } + } + + internal static void RecordProcessDuration(string system, string? destination, double seconds, string? errorType) + { + if (ProcessDuration.Enabled) + { + ProcessDuration.Record(seconds, Tags(system, "process", destination, errorType)); + } + } + + internal static void RecordSent(string system, string? destination, string? errorType) + { + if (SentMessages.Enabled) + { + SentMessages.Add(1, Tags(system, "send", destination, errorType)); + } + } + + internal static void RecordDeadLetter(string system, string? destination) + { + if (DlqMessages.Enabled) + { + DlqMessages.Add(1, Tags(system, "process", destination, null)); + } + } + + private static TagList Tags(string system, string operationName, string? destination, string? errorType) + { + var tags = new TagList + { + { "messaging.system", system }, + { "messaging.operation.name", operationName }, + }; + if (Telemetry.HasDestination(destination)) + { + tags.Add("messaging.destination.name", destination); + } + + if (errorType is not null) + { + tags.Add("error.type", errorType); + } + + return tags; + } + + private static string ResolveLibraryVersion() + { + string? informational = typeof(TelemetryMetrics).Assembly + .GetCustomAttribute()?.InformationalVersion; + if (string.IsNullOrEmpty(informational)) + { + return "0.0.0"; + } + + int plus = informational.IndexOf('+', StringComparison.Ordinal); + return plus < 0 ? informational : informational[..plus]; + } +} + +/// +/// A that increments babelqueue.dlq.messages (ADR-0039 +/// section 5.1) with messaging.system, messaging.destination.name = queue (when known) +/// and messaging.operation.name = process. The dead-letter reason and the DLQ name are not +/// metric attributes. Register it on a transport consumer that accepts an +/// . +/// +public sealed class MetricsDeadLetterObserver : IDeadLetterObserver +{ + private readonly string _system; + + /// Creates the observer. + /// + /// The messaging.system value (see ); absent or empty keeps the + /// deprecated default . + /// + public MetricsDeadLetterObserver(string? system = null) + { + _system = string.IsNullOrEmpty(system) ? Telemetry.DefaultSystem : system; + } + + /// + public void OnDeadLetter(DeadLetterEvent deadLetterEvent) + { + ArgumentNullException.ThrowIfNull(deadLetterEvent); + TelemetryMetrics.RecordDeadLetter(_system, deadLetterEvent.Queue); + } +} diff --git a/src/BabelQueue.Core/Tracing/Telemetry.cs b/src/BabelQueue.Core/Tracing/Telemetry.cs index e994eb4..5136c85 100644 --- a/src/BabelQueue.Core/Tracing/Telemetry.cs +++ b/src/BabelQueue.Core/Tracing/Telemetry.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.Security.Cryptography; @@ -10,11 +12,14 @@ namespace BabelQueue.Tracing; /// -/// Optional OpenTelemetry tracing for a babelqueue producer or consumer (ADR-0025) — the .NET -/// mirror of the Go otel, Python babelqueue.otel and Node @babelqueue/core/otel -/// helpers. It emits a CONSUMER per handled message and a PRODUCER activity -/// per publish, correlating them across every hop and SDK through the envelope's trace_id — -/// a UUID, which maps 1:1 to a 16-byte . +/// Optional OpenTelemetry tracing for a babelqueue producer or consumer (ADR-0025, span shape per +/// ADR-0038) — the .NET mirror of the Go otel, Python babelqueue.otel and Node +/// @babelqueue/core/otel helpers. It emits a CONSUMER process +/// <queue> per handled message and a PRODUCER activity send <queue> per publish, +/// correlating them across every hop and SDK through the envelope's trace_id — a UUID, which +/// maps 1:1 to a 16-byte . The standard metric set of ADR-0039 is +/// recorded on the BabelQueue (see +/// ). /// /// /// @@ -25,11 +30,29 @@ namespace BabelQueue.Tracing; /// listener the helpers are nearly free and emit nothing. The wire envelope is untouched (GR-1). /// /// +/// Broker name (ADR-0038 section 2). The wrapper sees a handler and an envelope, never the +/// transport, so the caller names the broker with the optional system parameter (for example +/// "kafka", "rabbitmq", "aws_sqs"; see ). It becomes +/// messaging.system. A value outside that set is written as given and never validated. +/// An absent or empty system keeps the default babelqueue, which is deprecated +/// (it matches no APM messaging view); what an absent value means after the deprecation window is +/// not decided yet. consumerGroup becomes messaging.consumer.group.name on the +/// consumer span (intended for Kafka; written whenever given). +/// +/// +/// Dashboard-breaking change. The span names are send <queue> / +/// process <queue> (they were publish <urn> / process <urn>), the +/// producer's messaging.destination.name is the queue (it was the URN) and +/// messaging.system is the system argument. The URN moved to babelqueue.urn. +/// The deprecated keys messaging.operation and messaging.babelqueue.attempts are +/// written next to their replacements for this MINOR only (dual-emit) and are removed in the next. +/// +/// /// trace_id correlation keeps every hop that shares a trace_id in one trace. For /// exact cross-hop span parent-child linkage — propagating a W3C traceparent as an /// out-of-band transport header (ADR-0028, v0.2) — use the header-aware /// and -/// +/// /// overloads with ; a message produced without a traceparent /// header falls back to this trace_id mapping (no regression). /// @@ -42,10 +65,55 @@ public static class Telemetry /// public const string ActivitySourceName = "BabelQueue"; - private const string System = "babelqueue"; + /// The OpenTelemetry semantic-conventions release this helper targets (ADR-0038 section 1). + public const string SemconvVersion = "1.44.0"; + + /// + /// The schema URL of . The net8.0 and + /// Meter have no schema-URL property (it arrives with ActivitySourceOptions in .NET 9), + /// so the core cannot attach it; a host that builds its own source or meter can use this constant. + /// + public const string SchemaUrl = "https://opentelemetry.io/schemas/" + SemconvVersion; + + /// The messaging.system default when the caller passes none (deprecated, ADR-0038 section 5). + public const string DefaultSystem = "babelqueue"; + private const string ZeroTraceId = "00000000000000000000000000000000"; + private const int MaxWarnedTraceIds = 1024; + + private static readonly ActivitySource Source = new(ActivitySourceName, TelemetryMetrics.LibraryVersion); + + // ADR-0038 section 7: warn once per distinct non-UUID trace_id; the set is bounded so a producer + // that emits endless distinct values cannot grow it (or flood the log) without limit. + private static readonly ConcurrentDictionary WarnedTraceIds = new(StringComparer.Ordinal); + + /// + /// The messaging.system values ADR-0038 section 3 lists, as guidance for callers (the set is + /// not an allow-list; any other value is written as given). + /// + public static class Systems + { + /// Redis (binding 1) — a custom value; redis is not a semconv well-known one. + public const string Redis = "redis"; + + /// RabbitMQ (binding 2). + public const string RabbitMq = "rabbitmq"; - private static readonly ActivitySource Source = new(ActivitySourceName); + /// Amazon SQS (binding 3). + public const string AwsSqs = "aws_sqs"; + + /// Azure Service Bus (binding 4). + public const string ServiceBus = "servicebus"; + + /// Apache Pulsar (binding 5). + public const string Pulsar = "pulsar"; + + /// Apache Kafka (binding 6). + public const string Kafka = "kafka"; + + /// Apache ActiveMQ / Artemis (binding 7). + public const string ActiveMq = "activemq"; + } /// /// Maps an envelope trace_id to a deterministic : a UUID @@ -54,16 +122,32 @@ public static class Telemetry /// public static ActivityTraceId TraceIdOf(string traceId) { - string hex = (traceId ?? string.Empty).Replace("-", string.Empty, StringComparison.Ordinal).ToLowerInvariant(); - if (hex.Length == 32 && IsHex(hex) && hex != ZeroTraceId) + if (TryDecodeUuid(traceId, out ActivityTraceId decoded)) { - return ActivityTraceId.CreateFromString(hex.AsSpan()); + return decoded; } byte[] digest = SHA256.HashData(Encoding.UTF8.GetBytes(traceId ?? string.Empty)); return ActivityTraceId.CreateFromBytes(digest.AsSpan(0, 16)); } + /// + /// Decodes a UUID-shaped trace_id (32 hex digits once the hyphens are removed, case + /// insensitive, not all zero) to its 16 bytes. No trim: padding makes the value non-UUID. + /// + private static bool TryDecodeUuid(string? traceId, out ActivityTraceId decoded) + { + string hex = (traceId ?? string.Empty).Replace("-", string.Empty, StringComparison.Ordinal).ToLowerInvariant(); + if (hex.Length == 32 && IsHex(hex) && hex != ZeroTraceId) + { + decoded = ActivityTraceId.CreateFromString(hex.AsSpan()); + return true; + } + + decoded = default; + return false; + } + /// /// Formats an as a canonical UUID string — the form a producer /// stamps into the message's trace_id so a consumer can recover the same trace id via @@ -76,16 +160,17 @@ public static string UuidOf(ActivityTraceId traceId) } /// - /// Returns wrapped to emit a CONSUMER activity process <urn> - /// in the trace derived from the envelope's trace_id, recording the handler's error/status. - /// The handler still receives the full ; a throw is recorded and re-thrown so - /// the runtime's retry / dead-letter path still applies. + /// Returns wrapped to emit a CONSUMER activity process <queue> + /// (ADR-0038) in the trace derived from the envelope's trace_id, recording the handler's + /// error/status. The handler still receives the full ; a throw is recorded and + /// re-thrown so the runtime's retry / dead-letter path still applies. Metrics (ADR-0039) are recorded + /// on every delivery. /// public static Handler Wrap(Handler handler) => Wrap(handler, headers: null); /// /// The header-aware (ADR-0028, v0.2) counterpart of : starts the - /// CONSUMER activity process <urn> as a true child of the producer span + /// CONSUMER activity process <queue> as a true child of the producer span /// when the delivered message carried a valid W3C traceparent on /// (an out-of-band carrier an adapter surfaces from the transport's /// metadata channel, beside the frozen envelope). When no usable traceparent is present @@ -97,61 +182,180 @@ public static string UuidOf(ActivityTraceId traceId) /// The delivered message's out-of-band headers, or null when the adapter carries none /// (then behaviour is exactly ). /// - public static Handler Wrap(Handler handler, IReadOnlyDictionary? headers) + public static Handler Wrap(Handler handler, IReadOnlyDictionary? headers) => + Wrap(handler, headers, system: null, consumerGroup: null); + + /// + /// The full form of , naming the + /// broker and the consumer group (ADR-0038 section 2). + /// + /// The consume handler to decorate; it still receives the full envelope. + /// The delivered message's out-of-band headers, or null. + /// + /// The messaging.system value (for example "kafka"; see ). Absent or + /// empty keeps the deprecated default . It also labels the metrics. + /// + /// + /// The messaging.consumer.group.name span attribute, written whenever given (intended for + /// Kafka). It is not a metric attribute (ADR-0039 section 2). + /// + public static Handler Wrap( + Handler handler, + IReadOnlyDictionary? headers = null, + string? system = null, + string? consumerGroup = null) { ArgumentNullException.ThrowIfNull(handler); + string resolvedSystem = ResolveSystem(system); + return async envelope => { string traceId = envelope.TraceId ?? string.Empty; - string urn = EnvelopeCodec.Urn(envelope); + string? queue = envelope.Meta?.Queue; + string? destination = HasDestination(queue) ? queue : null; + string spanName = destination is null ? "process" : "process " + destination; // ADR-0028: a valid traceparent makes this span a true child of the producer span; // otherwise fall back to the v0.1 trace_id-derived parent (ADR-0025 Option 1). - ActivityContext parent = - Traceparent.RemoteParentFromHeaders(headers) - ?? ParentFromTraceId(traceId); + ActivityContext? remote = Traceparent.RemoteParentFromHeaders(headers); - using Activity? activity = Source.StartActivity($"process {urn}", ActivityKind.Consumer, parent); - ApplyConsumeTags(activity, envelope, traceId); + // Tags are set at start, so a sampler sees them (ADR-0038 section 4). + using Activity? activity = Source.HasListeners() + ? Source.StartActivity( + spanName, + ActivityKind.Consumer, + remote ?? ParentFromTraceId(traceId), + ConsumeTags(envelope, traceId, resolvedSystem, destination, consumerGroup)) + : null; + TelemetryMetrics.RecordDelivery(resolvedSystem, destination, envelope.Attempts); + long started = Stopwatch.GetTimestamp(); + string? errorType = null; try { await handler(envelope).ConfigureAwait(false); } catch (Exception ex) { - RecordError(activity, ex); + errorType = ErrorTypeOf(ex); + RecordError(activity, ex, errorType); throw; } + finally + { + TelemetryMetrics.RecordProcessDuration( + resolvedSystem, destination, Stopwatch.GetElapsedTime(started).TotalSeconds, errorType); + } }; } /// /// The remote parent derived from the envelope's /// trace_id (ADR-0025 Option 1, v0.1) — a blank trace_id yields the default - /// (root) context. + /// (root) context. A trace_id that is not a UUID is hashed (and warned about once per + /// distinct value, ADR-0038 section 7); it is never rejected or rewritten. + /// + private static ActivityContext ParentFromTraceId(string traceId) + { + if (traceId.Length == 0) + { + return default; + } + + if (!TryDecodeUuid(traceId, out ActivityTraceId mapped)) + { + WarnHashed(traceId); + mapped = TraceIdOf(traceId); + } + + return new ActivityContext(mapped, SpanIdOf(traceId), ActivityTraceFlags.Recorded, isRemote: true); + } + + private static void WarnHashed(string traceId) + { + if (WarnedTraceIds.Count >= MaxWarnedTraceIds || !WarnedTraceIds.TryAdd(traceId, 0)) + { + return; + } + + // The value is escaped and truncated: it is untrusted input headed for a log line. + string shown = traceId.Length > 64 ? traceId[..64] + "..." : traceId; + var sb = new StringBuilder(shown.Length + 8); + foreach (char c in shown) + { + sb.Append(char.IsControl(c) ? string.Create(CultureInfo.InvariantCulture, $"\\u{(int)c:x4}") : c.ToString()); + } + + Trace.TraceWarning( + "BabelQueue: trace_id \"" + sb + "\" is not a UUID; the OpenTelemetry mapping hashes it " + + "(SHA-256, first 16 bytes) instead of decoding it. The value itself is preserved unchanged."); + } + + /// + /// Runs a publish under a PRODUCER activity send <queue> with the default + /// messaging.system (, deprecated). This is the pre-ADR-0038 signature, kept + /// as its own overload so assemblies compiled against earlier Core versions keep binding to it + /// (a trailing optional parameter would be a binary-breaking change); it forwards to + /// . /// - private static ActivityContext ParentFromTraceId(string traceId) => - traceId.Length == 0 - ? default - : new ActivityContext(TraceIdOf(traceId), SpanIdOf(traceId), ActivityTraceFlags.Recorded, isRemote: true); + /// The message URN to publish. + /// The message payload, or null for an empty body. + /// Performs the real transport write; its result is returned. + /// The destination queue stamped into meta.queue. + [EditorBrowsable(EditorBrowsableState.Never)] + public static Task PublishAsync( + string urn, + IReadOnlyDictionary? data, + Func> send, + string queue) => + PublishAsync(urn, data, headers: null, send, queue, system: null); + + /// + /// Runs a publish under a PRODUCER activity send <queue> (ADR-0038), carrying the active + /// trace's id into the built envelope's trace_id so the downstream consumer recovers the same + /// trace. performs the real transport write and its result is returned. + /// messaging.client.sent.messages is incremented once per call, success or failure. + /// + /// The message URN to publish. + /// The message payload, or null for an empty body. + /// Performs the real transport write; its result is returned. + /// The destination queue stamped into meta.queue. + /// + /// The messaging.system value (see ); absent or empty keeps the deprecated + /// default . + /// + public static Task PublishAsync( + string urn, + IReadOnlyDictionary? data, + Func> send, + string queue = "default", + string? system = null) => + PublishAsync(urn, data, headers: null, send, queue, system); /// - /// Runs a publish under a PRODUCER activity publish <urn>, carrying the active trace's - /// id into the built envelope's trace_id so the downstream consumer recovers the same trace. - /// performs the real transport write and its result is returned. + /// The header-aware publish with the default messaging.system (, + /// deprecated). This is the pre-ADR-0038 signature, kept as its own overload for binary compatibility; it + /// forwards to + /// . /// + /// The message URN to publish. + /// The message payload, or null for an empty body. + /// The out-of-band carrier to receive the producer span's traceparent. + /// Performs the real transport write; its result is returned. + /// The destination queue stamped into meta.queue. + [EditorBrowsable(EditorBrowsableState.Never)] public static Task PublishAsync( string urn, IReadOnlyDictionary? data, + IDictionary? headers, Func> send, - string queue = "default") => - PublishAsync(urn, data, headers: null, send, queue); + string queue) => + PublishAsync(urn, data, headers, send, queue, system: null); /// /// The header-aware (ADR-0028, v0.2) counterpart of - /// : + /// : /// in addition to stamping the active trace's id into the envelope's trace_id (v0.1), /// it injects the active PRODUCER span's W3C traceparent (and tracestate) into /// . An adapter carries that same carrier beside the frozen envelope @@ -167,59 +371,144 @@ public static Task PublishAsync( /// carrier is left untouched and only the v0.1 trace_id propagation applies. /// /// Performs the real transport write; its result is returned. - /// The destination queue stamped into meta.queue. + /// + /// The destination queue stamped into meta.queue and reported as + /// messaging.destination.name (not written when blank). + /// + /// + /// The messaging.system value (see ); absent or empty keeps the deprecated + /// default . + /// public static async Task PublishAsync( string urn, IReadOnlyDictionary? data, IDictionary? headers, Func> send, - string queue = "default") + string queue = "default", + string? system = null) { ArgumentNullException.ThrowIfNull(send); - using Activity? activity = Source.StartActivity($"publish {urn}", ActivityKind.Producer); - activity?.SetTag("messaging.system", System); - activity?.SetTag("messaging.operation", "publish"); - activity?.SetTag("messaging.destination.name", urn); + string resolvedSystem = ResolveSystem(system); - // ADR-0028: inject this producer span's traceparent onto the out-of-band carrier (when one - // was supplied), beside the frozen envelope, for true cross-hop span linkage. - if (headers is not null && activity is not null) + // Same rule as EnvelopeCodec.Make: a null queue is stamped as "default", and the span reports meta.queue. + queue ??= "default"; + + // ADR-0038 section 4: the destination is the queue the envelope is built for (meta.queue), + // which is known before the span starts, so the name needs no later update. + string? destination = HasDestination(queue) ? queue : null; + + using Activity? activity = Source.StartActivity( + destination is null ? "send" : "send " + destination, + ActivityKind.Producer); + if (activity is not null) { - Traceparent.Inject(headers, activity); + activity.SetTag("messaging.system", resolvedSystem); + activity.SetTag("messaging.operation.type", "send"); + activity.SetTag("messaging.operation.name", "send"); + if (destination is not null) + { + activity.SetTag("messaging.destination.name", destination); + } + + activity.SetTag("babelqueue.urn", urn); + activity.SetTag("babelqueue.schema_version", EnvelopeCodec.SchemaVersion); + activity.SetTag("messaging.operation", "publish"); // deprecated, dual-emit (ADR-0038 section 5) + + // ADR-0028: inject this producer span's traceparent onto the out-of-band carrier (when one + // was supplied), beside the frozen envelope, for true cross-hop span linkage. + if (headers is not null) + { + Traceparent.Inject(headers, activity); + } } + string? errorType = null; try { string traceId = activity is not null ? UuidOf(activity.TraceId) : Guid.NewGuid().ToString(); Envelope envelope = EnvelopeCodec.Make(urn, data, queue, traceId); - TResult result = await send(envelope).ConfigureAwait(false); + + // Set before the transport write so a failing send still carries them (ADR-0038 section 4). + activity?.SetTag("babelqueue.urn", envelope.Job); activity?.SetTag("messaging.message.id", envelope.Meta?.Id); - return result; + activity?.SetTag("messaging.message.conversation_id", envelope.TraceId); + + return await send(envelope).ConfigureAwait(false); } catch (Exception ex) { - RecordError(activity, ex); + errorType = ErrorTypeOf(ex); + RecordError(activity, ex, errorType); throw; } + finally + { + TelemetryMetrics.RecordSent(resolvedSystem, destination, errorType); + } } - private static void ApplyConsumeTags(Activity? activity, Envelope envelope, string traceId) + private static string ResolveSystem(string? system) => string.IsNullOrEmpty(system) ? DefaultSystem : system; + + /// A destination exists unless the queue is absent or blank under the closed ASCII whitespace set. + internal static bool HasDestination(string? queue) { - if (activity is null) + if (queue is null) { - return; + return false; + } + + foreach (char c in queue) + { + if (c is not (' ' or '\t' or '\n' or '\v' or '\f' or '\r')) + { + return true; + } } - activity.SetTag("messaging.system", System); - activity.SetTag("messaging.operation", "process"); - activity.SetTag("messaging.destination.name", envelope.Meta?.Queue ?? string.Empty); - activity.SetTag("messaging.message.id", envelope.Meta?.Id ?? string.Empty); - activity.SetTag("messaging.message.conversation_id", traceId); - activity.SetTag("messaging.babelqueue.attempts", envelope.Attempts); + return false; } - private static void RecordError(Activity? activity, Exception ex) + private static List> ConsumeTags( + Envelope envelope, string traceId, string system, string? destination, string? consumerGroup) + { + string urn = EnvelopeCodec.Urn(envelope); + string? messageId = envelope.Meta?.Id; + var tags = new List>(13) + { + new("messaging.system", system), + new("messaging.operation.type", "process"), + new("messaging.operation.name", "process"), + new("messaging.message.conversation_id", traceId), + new("babelqueue.urn", urn), + new("babelqueue.schema_version", envelope.Meta?.SchemaVersion ?? EnvelopeCodec.SchemaVersion), + new("babelqueue.attempts", envelope.Attempts), + // deprecated, dual-emit for one MINOR (ADR-0038 section 5) + new("messaging.operation", "process"), + new("messaging.babelqueue.attempts", envelope.Attempts), + }; + if (destination is not null) + { + tags.Add(new("messaging.destination.name", destination)); + } + + if (!string.IsNullOrEmpty(messageId)) + { + tags.Add(new("messaging.message.id", messageId)); + } + + if (!string.IsNullOrEmpty(consumerGroup)) + { + tags.Add(new("messaging.consumer.group.name", consumerGroup)); + } + + return tags; + } + + /// The error.type value: the exception's full type name, else _OTHER (ADR-0038 section 6). + private static string ErrorTypeOf(Exception ex) => ex.GetType().FullName is { Length: > 0 } name ? name : "_OTHER"; + + private static void RecordError(Activity? activity, Exception ex, string errorType) { if (activity is null) { @@ -227,6 +516,7 @@ private static void RecordError(Activity? activity, Exception ex) } activity.SetStatus(ActivityStatusCode.Error, ex.Message); + activity.SetTag("error.type", errorType); activity.AddEvent(new ActivityEvent("exception", tags: new ActivityTagsCollection { { "exception.type", ex.GetType().FullName }, diff --git a/tests/BabelQueue.Core.Tests/BodyVerdictTests.cs b/tests/BabelQueue.Core.Tests/BodyVerdictTests.cs new file mode 100644 index 0000000..db82acc --- /dev/null +++ b/tests/BabelQueue.Core.Tests/BodyVerdictTests.cs @@ -0,0 +1,174 @@ +using System.Text; +using Xunit; + +namespace BabelQueue.Tests; + +public class BodyVerdictTests +{ + [Fact] + public void WellFormedEnvelopeIsOk() + { + var body = EnvelopeCodec.Encode(EnvelopeCodec.Make("urn:babel:orders:created")); + + var verdict = BodyVerdict.Classify(body); + + Assert.True(verdict.IsOk); + Assert.False(verdict.IsPoison); + Assert.Equal(BodyVerdictKind.Ok, verdict.Kind); + Assert.Null(verdict.Cause); + } + + [Theory] + [InlineData("{\"job\":\"urn:x\",\"attempts\":\"3\"}", "attempts")] + [InlineData("{\"job\":\"urn:x\",\"data\":[]}", "data")] + [InlineData("{\"job\":\"urn:x\",\"meta\":{\"id\":5}}", "meta.id")] + [InlineData("{\"job\":\"urn:x\",\"trace_id\":7}", "trace_id")] + [InlineData("{\"job\":\"urn:x\",\"dead_letter\":{\"failed_at\":1.5}}", "dead_letter.failed_at")] + public void WrongTypedKnownMemberIsPoisonWithTheMemberInTheCause(string body, string member) + { + var verdict = BodyVerdict.Classify(body); + + Assert.True(verdict.IsPoison); + Assert.Contains(member, verdict.Cause); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-json")] + [InlineData("[]")] + [InlineData("null")] + [InlineData("{}")] + [InlineData("{\"job\":5}")] + [InlineData("{\"job\":\" \"}")] + [InlineData("{\"job\":\"urn:x\"} trailing")] + [InlineData("{\"job\":\"urn:x\",\"job\":5}")] + public void UndecodableOrIdentitylessBodyIsPoison(string? body) + { + var verdict = BodyVerdict.Classify(body); + + Assert.True(verdict.IsPoison); + Assert.False(string.IsNullOrEmpty(verdict.Cause)); + } + + [Theory] + [InlineData("{\"urn\":\"urn:x\"}")] + [InlineData("{\"job\":\" \",\"urn\":\"urn:x\"}")] + [InlineData("{\"job\":5,\"urn\":\"urn:x\"}")] + [InlineData("{\"job\":5,\"job\":\"urn:x\"}")] + [InlineData("{\"job\":\"urn:x\",\"data\":null,\"meta\":null,\"attempts\":null,\"dead_letter\":null}")] + public void ValidIdentityIsOk(string body) + { + Assert.True(BodyVerdict.Classify(body).IsOk); + } + + [Fact] + public void BomPrefixedBodyIsPoison() + { + Assert.True(BodyVerdict.Classify("\uFEFF{\"job\":\"urn:x\"}").IsPoison); + } + + [Fact] + public void BodyNestedDeeperThanTheCoreCodecIsPoison() + { + var depth = BodyVerdict.CoreMaxDepth + 1; + var body = new StringBuilder("{\"job\":\"urn:x\",\"x\":"); + for (var i = 0; i < depth; i++) + { + body.Append('['); + } + for (var i = 0; i < depth; i++) + { + body.Append(']'); + } + body.Append('}'); + + Assert.True(BodyVerdict.Classify(body.ToString()).IsPoison); + } + + // System.Text.Json cannot materialise a lone UTF-16 surrogate escape, so EnvelopeCodec.Decode throws on it + // wherever it sits. Go, Python and Node accept such a body; here it is poison (a documented platform limit), so + // a consumer that decodes every Ok body never meets an uncaught exception. + [Theory] + [InlineData("{\"job\":\"urn:x\",\"\\ud800\":1}")] + [InlineData("{\"job\":\"\\ud800\"}")] + [InlineData("{\"job\":\"urn:x\",\"urn\":\"\\ud800\"}")] + [InlineData("{\"job\":\"urn:x\",\"trace_id\":\"\\ud800\"}")] + [InlineData("{\"job\":\"urn:x\",\"data\":{\"a\":\"\\ud800\"}}")] + [InlineData("{\"job\":\"urn:x\",\"data\":{\"\\udc00\":1}}")] + [InlineData("{\"job\":\"urn:x\",\"meta\":{\"id\":\"\\ud800\"}}")] + [InlineData("{\"job\":\"urn:x\",\"meta\":{\"\\ud800\":1}}")] + [InlineData("{\"job\":\"urn:x\",\"x\":[\"\\ud800\"]}")] + public void LoneSurrogateEscapeAnywhereIsPoisonNotAThrow(string body) + { + Assert.True(BodyVerdict.Classify(body).IsPoison); + } + + [Theory] + [InlineData("{\"job\":\"urn:x\",\"data\":{\"a\":\"\\ud83d\\ude00\"}}")] + [InlineData("{\"job\":\"urn:x\",\"trace_id\":\"t\u00e9\"}")] + public void ValidSurrogatePairIsOk(string body) + { + Assert.True(BodyVerdict.Classify(body).IsOk); + Assert.Equal("urn:x", EnvelopeCodec.Urn(EnvelopeCodec.Decode(body, static _ => { }))); + } + + [Theory] + [InlineData("{\"job\":\"urn:x\"}")] + [InlineData("{\"urn\":\"urn:x\",\"meta\":null,\"data\":null,\"attempts\":null}")] + [InlineData("{\"job\":\"urn:x\",\"data\":{\"a\":[1,\"two\",{\"b\":null}]},\"meta\":{\"id\":\"i\",\"x\":\"y\"}}")] + [InlineData("{\"job\":\"urn:x\",\"attempts\":2,\"dead_letter\":{\"reason\":\"r\",\"failed_at\":1}}")] + public void AnOkBodyIsOneDecodeReturnsFor(string body) + { + Assert.True(BodyVerdict.Classify(body).IsOk); + var envelope = EnvelopeCodec.Decode(body, static _ => { }); + Assert.Equal("urn:x", EnvelopeCodec.Urn(envelope)); + } + + [Theory] + [InlineData("{\"job\":\"urn:x\",\"trace_id\":\"\\ud800\"}")] + [InlineData("{\"job\":\"urn:x\",\"data\":{\"a\":\"\\ud800\"}}")] + [InlineData("{\"job\":\"urn:x\",\"meta\":{\"id\":\"\\ud800\"}}")] + public void EveryBodyDecodeThrowsOnIsPoison(string body) + { + Assert.ThrowsAny(() => EnvelopeCodec.Decode(body, static _ => { })); + Assert.True(BodyVerdict.Classify(body).IsPoison); + } + + [Fact] + public void DefaultResultIsPoisonNeverOk() + { + BodyVerdictResult unclassified = default; + + Assert.False(unclassified.IsOk); + Assert.True(unclassified.IsPoison); + Assert.Equal(BodyVerdictKind.Poison, default(BodyVerdictKind)); + Assert.Equal(BodyVerdictKind.Poison, unclassified.Kind); + } + + [Fact] + public void DecodeStaysLenientForAWronglyTypedKnownMember() + { + // The additive verdict API must not change EnvelopeCodec.Decode: it still maps a wrong type to a default. + const string body = "{\"job\":\"urn:x\",\"attempts\":\"3\"}"; + + var envelope = EnvelopeCodec.Decode(body, static _ => { }); + + Assert.Equal("urn:x", EnvelopeCodec.Urn(envelope)); + Assert.Equal(0, envelope.Attempts); + Assert.True(BodyVerdict.Classify(body).IsPoison); + } + + [Fact] + public void DecodeStaysLenientForAWronglyTypedData() + { + const string body = "{\"job\":\"urn:x\",\"data\":[]}"; + + var envelope = EnvelopeCodec.Decode(body, static _ => { }); + + Assert.Equal("urn:x", EnvelopeCodec.Urn(envelope)); + Assert.Null(envelope.Data); + Assert.True(BodyVerdict.Classify(body).IsPoison); + } +} diff --git a/tests/BabelQueue.Core.Tests/ConformanceTests.cs b/tests/BabelQueue.Core.Tests/ConformanceTests.cs index 8a84b38..da43cf5 100644 --- a/tests/BabelQueue.Core.Tests/ConformanceTests.cs +++ b/tests/BabelQueue.Core.Tests/ConformanceTests.cs @@ -172,6 +172,9 @@ private static bool JsonValueEquals(object? a, object? b) /// Writes the machine-readable conformance result (ADR-0032 section 2) when /// BQ_CONFORMANCE_RESULTS names a file, and always asserts that nothing failed. /// +// The otel sections drive the process-wide "BabelQueue" ActivitySource and Meter, so this class joins the +// collection of every other class that listens to them (they then run one after another). +[Collection("ActivityListener")] public class ConformanceResultsTests { [Fact] @@ -182,7 +185,10 @@ public void EverySectionPassesAndResultsAreWrittenOnRequest() var failures = results.Sections .SelectMany(s => s.Value.Select(c => (Section: s.Key, Case: c.Key, Result: c.Value))) .Where(x => x.Result.Status == "fail") - .Select(x => $"{x.Section}/{x.Case}: {x.Result.Reason}") + .ToList(); + + var failureLines = failures + .Select(f => $"{f.Section}/{f.Case}: {f.Result.Reason}") .ToList(); var path = Environment.GetEnvironmentVariable("BQ_CONFORMANCE_RESULTS"); @@ -191,7 +197,7 @@ public void EverySectionPassesAndResultsAreWrittenOnRequest() File.WriteAllText(path, results.ToJson()); } - Assert.True(failures.Count == 0, string.Join(Environment.NewLine, failures)); + Assert.True(failureLines.Count == 0, string.Join(Environment.NewLine, failureLines)); } [Fact] @@ -280,6 +286,10 @@ internal sealed class ConformanceResults ["traceparent"] = section => Named("traceparent", section, c => Check(ConformanceRunner.Traceparent(c))), ["retry_backoff"] = section => Named("retry_backoff", section, c => Check(ConformanceRunner.RetryBackoff(c))), ["poison_dlq"] = section => Named("poison_dlq", section, c => Check(ConformanceRunner.PoisonDlq(c, section.GetProperty("original_queue").GetString()!))), + ["identity_verdict"] = section => Named("identity_verdict", section, c => Check(OtelConformanceRunner.IdentityVerdict(c))), + ["span-shape"] = section => Named("span-shape", section, c => Check(OtelConformanceRunner.SpanShape(c))), + ["trace-id"] = section => Named("trace-id", section, c => Check(OtelConformanceRunner.TraceId(c))), + ["metrics"] = section => Named("metrics", section, c => Check(OtelConformanceRunner.Metrics(c))), }; public string ConformanceVersion { get; private init; } = string.Empty; diff --git a/tests/BabelQueue.Core.Tests/MetricsTests.cs b/tests/BabelQueue.Core.Tests/MetricsTests.cs new file mode 100644 index 0000000..4ee4503 --- /dev/null +++ b/tests/BabelQueue.Core.Tests/MetricsTests.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BabelQueue; +using BabelQueue.Tracing; +using Xunit; + +namespace BabelQueue.Tests; + +/// +/// ADR-0039 beyond the conformance cases: instrument identity, the cardinality rule, the observer seam +/// and its isolation. The conformance runner () covers the cases. +/// +// Shares the "ActivityListener" collection: the static Meter is process-wide, so classes that read its +// measurements must not run in parallel. +[Collection("ActivityListener")] +public sealed class MetricsTests +{ + private static Envelope MakeEnvelope(int attempts = 0, string queue = "orders") => + EnvelopeCodec.Make("urn:babel:orders:created", new Dictionary { ["order_id"] = 1 }, queue) + with + { Attempts = attempts }; + + [Fact] + public void TheMeterIsNamedBabelQueueAndTheInstrumentsHaveTheAdr0039Identity() + { + using var capture = new MetricCapture(); + + var byName = capture.Instruments.ToDictionary(i => i.Name); + Assert.Equal( + new[] { "babelqueue.dlq.messages", "babelqueue.retries", "messaging.client.consumed.messages", "messaging.client.sent.messages", "messaging.process.duration" }, + byName.Keys.OrderBy(n => n, StringComparer.Ordinal)); + Assert.Equal("s", byName["messaging.process.duration"].Unit); + Assert.All( + byName.Values.Where(i => i.Name != "messaging.process.duration"), + i => Assert.Equal("{message}", i.Unit)); + Assert.All(byName.Values, i => Assert.Equal("BabelQueue", i.Meter.Name)); + Assert.Equal(Telemetry.ActivitySourceName, TelemetryMetrics.MeterName); + } + + [Fact] + public void TheAdvisoryBucketBoundariesAreExposed() + { + Assert.Equal( + new[] { 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10 }, + TelemetryMetrics.AdvisoryBucketBoundaries); + } + + [Fact] + public void TheAdvisoryBucketBoundariesCannotBeMutated() + { + Assert.IsNotType(TelemetryMetrics.AdvisoryBucketBoundaries); + var asList = Assert.IsAssignableFrom>(TelemetryMetrics.AdvisoryBucketBoundaries); + Assert.Throws(() => asList[0] = 60); + Assert.Equal(0.005, TelemetryMetrics.AdvisoryBucketBoundaries[0]); + } + + [Fact] + public void TheInstrumentsAreNotPartOfThePublicSurface() + { + // only the *Name constants and the read-only boundaries are public: nothing outside the SDK can Add() to a counter + var publicMembers = typeof(TelemetryMetrics).GetMembers(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.DeclaredOnly) + .Select(m => m.Name) + .OrderBy(n => n, StringComparer.Ordinal); + Assert.Equal( + new[] + { + "AdvisoryBucketBoundaries", "ConsumedMessagesName", "DlqMessagesName", "MeterName", "ProcessDurationName", + "RetriesName", "SentMessagesName", "get_AdvisoryBucketBoundaries", + }.OrderBy(n => n, StringComparer.Ordinal), + publicMembers); + } + + [Fact] + public async Task ANormalDeliveryRecordsConsumedAndDurationButNoRetry() + { + using var capture = new MetricCapture(); + + await Telemetry.Wrap(_ => Task.CompletedTask, headers: null, system: "rabbitmq")(MakeEnvelope()); + + Assert.Equal( + new[] { "messaging.client.consumed.messages", "messaging.process.duration" }, + capture.Points.Select(p => p.Metric).OrderBy(n => n, StringComparer.Ordinal)); + } + + [Fact] + public async Task ARedeliveryRecordsARetryAndAFailureCarriesErrorTypeOnTheDurationOnly() + { + using var capture = new MetricCapture(); + Handler handler = Telemetry.Wrap(_ => Task.FromException(new TimeoutException("slow")), headers: null, system: "rabbitmq"); + + await Assert.ThrowsAsync(() => handler(MakeEnvelope(attempts: 1))); + + Assert.Single(capture.Points, p => p.Metric == "babelqueue.retries"); + MetricPoint duration = Assert.Single(capture.Points, p => p.Metric == "messaging.process.duration"); + Assert.Equal(typeof(TimeoutException).FullName, duration.Tags["error.type"]); + Assert.All( + capture.Points.Where(p => p.Metric != "messaging.process.duration"), + p => Assert.DoesNotContain("error.type", p.Tags.Keys)); + } + + [Fact] + public async Task NoMetricPointCarriesAHighCardinalityAttribute() + { + using var capture = new MetricCapture(); + await Telemetry.Wrap(_ => Task.CompletedTask, headers: null, system: "kafka", consumerGroup: "g")(MakeEnvelope(attempts: 2)); + await Telemetry.PublishAsync("urn:babel:orders:created", null, e => Task.FromResult(e.Meta!.Id), "orders", "kafka"); + new MetricsDeadLetterObserver("kafka").NotifySafely(new DeadLetterEvent("orders", "orders.dlq", "poison")); + + string[] forbidden = + { + "babelqueue.urn", "messaging.message.id", "messaging.message.conversation_id", "babelqueue.attempts", + "babelqueue.schema_version", "messaging.consumer.group.name", "dead_letter.reason", "trace_id", + }; + Assert.NotEmpty(capture.Points); + Assert.All(capture.Points, p => Assert.Empty(p.Tags.Keys.Intersect(forbidden))); + } + + [Fact] + public async Task ASentMessageIsCountedForAFailedPublishAndThePublishResultIsUnchanged() + { + using var capture = new MetricCapture(); + + await Assert.ThrowsAsync(() => + Telemetry.PublishAsync("urn:babel:orders:created", null, _ => Task.FromException(new InvalidOperationException("down")), "orders", "rabbitmq")); + string ok = await Telemetry.PublishAsync("urn:babel:orders:created", null, _ => Task.FromResult("sent"), "orders", "rabbitmq"); + + Assert.Equal("sent", ok); + Assert.Equal(2, capture.Points.Count(p => p.Metric == "messaging.client.sent.messages")); + Assert.Single(capture.Points, p => p.Tags.ContainsKey("error.type")); + } + + [Fact] + public void TheDeadLetterObserverCountsOneWithSystemQueueAndOperation() + { + using var capture = new MetricCapture(); + + new MetricsDeadLetterObserver("servicebus").OnDeadLetter(new DeadLetterEvent("orders")); + + MetricPoint point = Assert.Single(capture.Points); + Assert.Equal("babelqueue.dlq.messages", point.Metric); + Assert.Equal(1, point.Value); + Assert.Equal("servicebus", point.Tags["messaging.system"]); + Assert.Equal("process", point.Tags["messaging.operation.name"]); + Assert.Equal("orders", point.Tags["messaging.destination.name"]); + Assert.Equal(3, point.Tags.Count); + } + + [Fact] + public void TheDeadLetterObserverWithoutASystemKeepsTheDefault() + { + using var capture = new MetricCapture(); + + new MetricsDeadLetterObserver().OnDeadLetter(new DeadLetterEvent()); + + MetricPoint point = Assert.Single(capture.Points); + Assert.Equal("babelqueue", point.Tags["messaging.system"]); + Assert.DoesNotContain("messaging.destination.name", point.Tags.Keys); // no source queue known + } + + [Fact] + public void AnObserverThatThrowsNeverEscapesNotifySafely() + { + var throwing = new ThrowingObserver(); + + var ex = Record.Exception(() => throwing.NotifySafely(new DeadLetterEvent("orders", "orders.dlq", "failed"))); + + Assert.Null(ex); + Assert.Equal(1, throwing.Calls); + } + + [Fact] + public void NotifySafelyIgnoresANullObserver() + { + IDeadLetterObserver? none = null; + + Assert.Null(Record.Exception(() => none.NotifySafely(new DeadLetterEvent()))); + } + + [Fact] + public void TheEventDefaultsAreEmptyStrings() + { + var empty = new DeadLetterEvent(); + + Assert.Equal(string.Empty, empty.Queue); + Assert.Equal(string.Empty, empty.DeadLetterQueue); + Assert.Equal(string.Empty, empty.Reason); + } + + private sealed class ThrowingObserver : IDeadLetterObserver + { + public int Calls { get; private set; } + + public void OnDeadLetter(DeadLetterEvent deadLetterEvent) + { + Calls++; + throw new InvalidOperationException("observer bug"); + } + } +} diff --git a/tests/BabelQueue.Core.Tests/OtelConformanceTests.cs b/tests/BabelQueue.Core.Tests/OtelConformanceTests.cs new file mode 100644 index 0000000..e47f022 --- /dev/null +++ b/tests/BabelQueue.Core.Tests/OtelConformanceTests.cs @@ -0,0 +1,852 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using BabelQueue; +using BabelQueue.Tracing; +using Xunit; + +namespace BabelQueue.Tests; + +/// +/// Runs the otel sections of the vendored conformance manifest (v1.9.0): span-shape +/// (ADR-0038 span name, kind, status and the exact attribute set), trace-id (ADR-0038 section 7, +/// the non-UUID rule and the OTel mapping) and metrics (ADR-0039 instruments, attributes and +/// recording rules). The .NET specifics are in . +/// +// All classes that listen to the process-wide "BabelQueue" ActivitySource / Meter share this +// collection so they run one after another and never see each other's activities or measurements. +[Collection("ActivityListener")] +public sealed class OtelConformanceTests +{ + private static JsonElement Section(string name) + { + using var doc = JsonDocument.Parse(File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "conformance", "manifest.json"))); + return doc.RootElement.GetProperty(name).Clone(); + } + + private static List Cases(string name) + { + var cases = Section(name).GetProperty("cases").EnumerateArray().ToList(); + Assert.NotEmpty(cases); + return cases; + } + + [Fact] + public void SpanShapeSectionMatchesTheAdr0038Table() + { + var violations = Cases("span-shape").SelectMany(OtelConformanceRunner.SpanShape).ToList(); + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + } + + [Fact] + public void TraceIdSectionPreservesNonUuidValuesAndMapsThemToTheOtelTraceId() + { + var violations = Cases("trace-id").SelectMany(OtelConformanceRunner.TraceId).ToList(); + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + } + + [Fact] + public void MetricsSectionMatchesTheAdr0039InstrumentsAndRecordingRules() + { + var violations = Cases("metrics").SelectMany(OtelConformanceRunner.Metrics).ToList(); + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + } + + [Fact] + public void IdentityVerdictSectionClassifiesRawBodiesLikeTheBodyVerdict() + { + // core-scope section added in conformance v1.7.0 (ADR-0036 decision 4); vendored here with v1.9.0 + var cases = Cases("identity_verdict"); + + Assert.Equal(97, cases.Count); + var violations = cases.SelectMany(OtelConformanceRunner.IdentityVerdict).ToList(); + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + } + + [Fact] + public void EverySpanShapeCaseHasABuiltInRunnerMode() + { + // A case with a mode the runner does not know would pass silently otherwise. + Assert.All(Cases("span-shape"), c => Assert.Equal("span", c.GetProperty("mode").GetString())); + Assert.All( + Cases("trace-id"), + c => Assert.Contains(c.GetProperty("mode").GetString(), new[] { "map", "decode", "reemit", "downstream", "make" })); + Assert.All( + Cases("metrics"), + c => Assert.Contains(c.GetProperty("mode").GetString(), new[] { "instrument", "consume", "publish", "dead_letter" })); + } +} + +/// The runner of the otel manifest sections; also drives the results.json entries. +internal static class OtelConformanceRunner +{ + private const string Urn = "urn:acme:order.created"; + + // The ADR-0038 section 4 attribute keys: a key of this set that a case does not list must not be on the span. + private static readonly HashSet TableKeys = new(StringComparer.Ordinal) + { + "messaging.system", "messaging.operation.type", "messaging.operation.name", + "messaging.destination.name", "messaging.message.id", "messaging.message.conversation_id", + "messaging.consumer.group.name", "babelqueue.urn", "babelqueue.schema_version", "babelqueue.attempts", + "error.type", "messaging.operation", "messaging.babelqueue.attempts", + }; + + // ---------------------------------------------------------------------------------------------- + // span-shape + // ---------------------------------------------------------------------------------------------- + + public static List SpanShape(JsonElement testCase) + { + var name = testCase.GetProperty("name").GetString(); + var violations = new List(); + void Fail(string message) => violations.Add($"{name}: {message}"); + + var activities = new List(); + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == Telemetry.ActivitySourceName, + Sample = static (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => activities.Add(activity), + }; + ActivitySource.AddActivityListener(listener); + + var operation = testCase.GetProperty("operation").GetString(); + var expectError = testCase.GetProperty("outcome").GetString() == "error"; + var system = OptionalString(testCase, "system"); + Envelope? produced = null; + string? givenQueue = null; + Exception? thrown = null; + + if (operation == "send") + { + givenQueue = OptionalString(testCase, "queue") ?? "default"; // absent: the API default + var envelopeBuilt = !testCase.TryGetProperty("envelope_built", out var built) || built.GetBoolean(); + // The build fails by an SDK-specific means: a data map that cannot be enumerated. + IReadOnlyDictionary? data = envelopeBuilt ? new Dictionary { ["order_id"] = 1 } : new ThrowingDictionary(); + thrown = Record(() => Telemetry.PublishAsync( + testCase.GetProperty("urn").GetString()!, + data, + headers: null, + envelope => + { + produced = envelope; + return expectError ? Task.FromException(new InvalidOperationException("send failed")) : Task.FromResult("ok"); + }, + givenQueue, + system)); + } + else + { + var queue = OptionalString(testCase, "queue"); + var messageId = OptionalString(testCase, "message_id"); + var meta = new Meta(messageId, queue, "php", testCase.GetProperty("schema_version").GetInt32(), 1L); + var envelope = new Envelope( + testCase.GetProperty("urn").GetString(), + testCase.GetProperty("trace_id").GetString(), + new Dictionary(), + meta, + testCase.GetProperty("attempts").GetInt32(), + null); + var wrapped = Telemetry.Wrap( + _ => expectError ? Task.FromException(new InvalidOperationException("handler failed")) : Task.CompletedTask, + headers: null, + system, + OptionalString(testCase, "consumer_group")); + thrown = Record(() => wrapped(envelope)); + } + + if (expectError != (thrown is not null)) + { + Fail($"expected the {operation} outcome to be {(expectError ? "an error" : "ok")} but the call {(thrown is null ? "succeeded" : "threw " + thrown.GetType().Name)}"); + } + + if (activities.Count != 1) + { + Fail($"expected exactly one span but {activities.Count} were recorded"); + return violations; + } + + var activity = activities[0]; + var expect = testCase.GetProperty("expect"); + var producedQueue = produced?.Meta?.Queue; + var producedQueueBlank = IsBlank(producedQueue); + + string Placeholder(string value) => value switch + { + "$produced.meta.id" => produced?.Meta?.Id ?? "", + "$produced.trace_id" => produced?.TraceId ?? "", + "$produced.meta.queue" => producedQueue ?? string.Empty, + _ => value, + }; + + // span name + var actualName = activity.DisplayName; + if (expect.TryGetProperty("span_name", out var spanName)) + { + var want = spanName.GetString()!; + if (want.EndsWith("$produced.meta.queue", StringComparison.Ordinal)) + { + want = producedQueueBlank ? want[..^"$produced.meta.queue".Length].TrimEnd() : want.Replace("$produced.meta.queue", producedQueue, StringComparison.Ordinal); + } + if (actualName != want) + { + Fail($"span name expected '{want}' but was '{actualName}'"); + } + } + else + { + var oneOf = expect.GetProperty("span_name_one_of").EnumerateArray().Select(e => e.GetString()).ToList(); + if (!oneOf.Contains(actualName)) + { + Fail($"span name '{actualName}' is not one of [{string.Join(", ", oneOf)}]"); + } + } + + if (actualName.Contains(Urn, StringComparison.Ordinal)) + { + Fail("the URN must never be in the span name"); + } + + var wantKind = expect.GetProperty("span_kind").GetString() == "PRODUCER" ? ActivityKind.Producer : ActivityKind.Consumer; + if (activity.Kind != wantKind) + { + Fail($"span kind expected {wantKind} but was {activity.Kind}"); + } + + var wantStatus = expect.GetProperty("status").GetString() == "error" ? ActivityStatusCode.Error : ActivityStatusCode.Unset; + if (activity.Status != wantStatus) + { + Fail($"status expected {wantStatus} but was {activity.Status}"); + } + + var actual = new Dictionary(StringComparer.Ordinal); + foreach (var tag in activity.TagObjects) + { + actual[tag.Key] = tag.Value; + } + + // expected attributes: exact + var wanted = new Dictionary(StringComparer.Ordinal); + foreach (var attribute in expect.GetProperty("attributes").EnumerateObject()) + { + if (attribute.Value.ValueKind == JsonValueKind.String + && attribute.Value.GetString() == "$produced.meta.queue" + && producedQueueBlank) + { + continue; // the destination is not written when the produced queue is blank + } + wanted[attribute.Name] = attribute.Value; + } + + foreach (var (key, value) in wanted) + { + if (!actual.TryGetValue(key, out var got)) + { + Fail($"attribute '{key}' is missing"); + continue; + } + if (value.ValueKind == JsonValueKind.Number) + { + if (got is not (int or long) || Convert.ToInt64(got, System.Globalization.CultureInfo.InvariantCulture) != value.GetInt64()) + { + Fail($"attribute '{key}' expected integer {value.GetInt64()} but was {Describe(got)}"); + } + } + else if (got is not string text || text != Placeholder(value.GetString()!)) + { + Fail($"attribute '{key}' expected '{Placeholder(value.GetString()!)}' but was {Describe(got)}"); + } + } + + var optional = expect.TryGetProperty("optional_attributes", out var optionalElement) + ? optionalElement.EnumerateArray().Select(e => e.GetString()!).ToHashSet(StringComparer.Ordinal) + : new HashSet(StringComparer.Ordinal); + var errorTypeRequired = expect.TryGetProperty("error_type", out var errorType) && errorType.GetString() == "present"; + + foreach (var (key, got) in actual) + { + if (wanted.ContainsKey(key)) + { + continue; + } + if (key == "error.type") + { + if (!errorTypeRequired) + { + Fail("error.type must not be on a span that did not fail"); + } + else if (got is not string { Length: > 0 }) + { + Fail("error.type must be a non-empty string"); + } + continue; + } + if (optional.Contains(key)) + { + if (key == "messaging.destination.name" && (got as string != givenQueue || actualName != "send " + givenQueue)) + { + Fail($"optional attribute '{key}' is {Describe(got)} but the queue given to the producer is '{givenQueue}' (span name '{actualName}')"); + } + continue; + } + if (TableKeys.Contains(key)) + { + Fail($"attribute '{key}' must not be on this span"); + } + else if (key.StartsWith("babelqueue.", StringComparison.Ordinal)) + { + Fail($"attribute '{key}' is a babelqueue.* key outside the ADR-0038 table"); + } + } + + if (errorTypeRequired && !actual.ContainsKey("error.type")) + { + Fail("error.type is missing on a failed span"); + } + + if (errorTypeRequired && !activity.Events.Any(e => e.Name == "exception")) + { + Fail("the error was not recorded as an exception event"); + } + + // optional destination consistency when the case does not pin the name + if (optional.Contains("messaging.destination.name") && !actual.ContainsKey("messaging.destination.name") && actualName != "send") + { + Fail($"span name '{actualName}' disagrees with the absent destination"); + } + + return violations; + } + + // ---------------------------------------------------------------------------------------------- + // trace-id + // ---------------------------------------------------------------------------------------------- + + public static List TraceId(JsonElement testCase) + { + var name = testCase.GetProperty("name").GetString(); + var violations = new List(); + void Fail(string message) => violations.Add($"{name}: {message}"); + + var mode = testCase.GetProperty("mode").GetString(); + var traceId = testCase.GetProperty("trace_id").GetString()!; + var expect = testCase.GetProperty("expect"); + + switch (mode) + { + case "map": + { + var hex = Telemetry.TraceIdOf(traceId).ToHexString(); + if (hex != expect.GetProperty("trace_id_hex").GetString()) + { + Fail($"trace_id_hex expected '{expect.GetProperty("trace_id_hex").GetString()}' but was '{hex}'"); + } + + // independent oracle for the declared mapping + var oracle = expect.GetProperty("mapping").GetString() == "decode" + ? traceId.Replace("-", string.Empty, StringComparison.Ordinal).ToLowerInvariant() + : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(traceId)).AsSpan(0, 16)).ToLowerInvariant(); + if (hex != oracle) + { + Fail($"the {expect.GetProperty("mapping").GetString()} mapping gives '{oracle}' but TraceIdOf gave '{hex}'"); + } + break; + } + case "decode": + { + var decoded = EnvelopeCodec.Decode(EnvelopeJson(traceId)); + if (decoded.TraceId != expect.GetProperty("trace_id").GetString()) + { + Fail($"decoded trace_id expected '{expect.GetProperty("trace_id").GetString()}' but was '{decoded.TraceId}'"); + } + break; + } + case "reemit": + { + var decoded = EnvelopeCodec.Decode(EnvelopeJson(traceId)); + var encoded = EnvelopeCodec.Encode(decoded with { Attempts = decoded.Attempts + 1 }); + using var doc = JsonDocument.Parse(encoded); + var written = doc.RootElement.GetProperty("trace_id").GetString(); + if (written != expect.GetProperty("trace_id").GetString()) + { + Fail($"re-emitted trace_id expected '{expect.GetProperty("trace_id").GetString()}' but was '{written}'"); + } + break; + } + case "downstream": + { + var inbound = EnvelopeCodec.Decode(EnvelopeJson(traceId)); + var produced = EnvelopeCodec.Make("urn:acme:next", null, "orders", inbound.TraceId); + if (produced.TraceId != expect.GetProperty("trace_id").GetString()) + { + Fail($"downstream trace_id expected '{expect.GetProperty("trace_id").GetString()}' but was '{produced.TraceId}'"); + } + break; + } + case "make": + { + var produced = EnvelopeCodec.Make(testCase.GetProperty("urn").GetString()!, null, "orders", traceId); + if (produced.TraceId != expect.GetProperty("trace_id").GetString()) + { + Fail($"make trace_id expected '{expect.GetProperty("trace_id").GetString()}' but was '{produced.TraceId}'"); + } + var hex = Telemetry.TraceIdOf(produced.TraceId!).ToHexString(); + if (hex != expect.GetProperty("trace_id_hex").GetString()) + { + Fail($"trace_id_hex of the stored value expected '{expect.GetProperty("trace_id_hex").GetString()}' but was '{hex}'"); + } + break; + } + default: + Fail($"unknown trace-id mode '{mode}'"); + break; + } + + return violations; + } + + private static string EnvelopeJson(string traceId) => + "{\"job\":\"urn:acme:order.created\",\"trace_id\":" + JsonSerializer.Serialize(traceId) + + ",\"data\":{},\"meta\":{\"id\":\"0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90\",\"queue\":\"orders\",\"lang\":\"php\"," + + "\"schema_version\":1,\"created_at\":1761000000000},\"attempts\":0}"; + + // ---------------------------------------------------------------------------------------------- + // metrics + // ---------------------------------------------------------------------------------------------- + + public static List Metrics(JsonElement testCase) + { + var name = testCase.GetProperty("name").GetString(); + var mode = testCase.GetProperty("mode").GetString(); + return mode switch + { + "instrument" => MetricsInstrument(name!, testCase), + "consume" or "publish" or "dead_letter" => MetricsScenario(name!, mode, testCase), + _ => new List { $"{name}: unknown metrics mode '{mode}'" }, + }; + } + + private static List MetricsInstrument(string name, JsonElement testCase) + { + var violations = new List(); + var expect = testCase.GetProperty("expect"); + var metric = expect.GetProperty("metric").GetString()!; + + // Collect every point of that metric over a small matrix of scenarios. + using var capture = new MetricCapture(); + RunConsume(capture, "kafka", "orders", 0, error: false, meter: true); + RunConsume(capture, "kafka", "orders", 2, error: true, meter: true); + RunConsume(capture, null, null, 0, error: false, meter: true); + RunPublish(capture, "kafka", "orders", error: false); + RunPublish(capture, "kafka", "orders", error: true); + RunPublish(capture, null, "", error: false); + new MetricsDeadLetterObserver("kafka").NotifySafely(new DeadLetterEvent("orders", "orders.dlq", "failed")); + new MetricsDeadLetterObserver().NotifySafely(new DeadLetterEvent("", "orders.dlq", "failed")); + + var instrument = capture.Instruments.FirstOrDefault(i => i.Name == metric); + if (instrument is null) + { + violations.Add($"{name}: instrument '{metric}' does not exist on the 'BabelQueue' meter"); + return violations; + } + + var wantKind = expect.GetProperty("instrument").GetString(); + var gotKind = instrument is Histogram ? "histogram" : instrument is Counter ? "counter" : instrument.GetType().Name; + if (gotKind != wantKind) + { + violations.Add($"{name}: instrument kind expected {wantKind} but was {gotKind}"); + } + if (instrument.Unit != expect.GetProperty("unit").GetString()) + { + violations.Add($"{name}: unit expected '{expect.GetProperty("unit").GetString()}' but was '{instrument.Unit}'"); + } + + var attributes = expect.GetProperty("attributes"); + var always = attributes.GetProperty("always").EnumerateArray().Select(e => e.GetString()!).ToList(); + var whenKnown = attributes.GetProperty("when_destination_known").EnumerateArray().Select(e => e.GetString()!).ToList(); + var onFailure = attributes.TryGetProperty("on_failure", out var failureElement) + ? failureElement.EnumerateArray().Select(e => e.GetString()!).ToList() + : new List(); + var forbidden = expect.GetProperty("forbidden_attributes").EnumerateArray().Select(e => e.GetString()!).ToList(); + var allowed = always.Concat(whenKnown).Concat(onFailure).ToHashSet(StringComparer.Ordinal); + + var points = capture.Points.Where(p => p.Metric == metric).ToList(); + if (points.Count == 0) + { + violations.Add($"{name}: no point was recorded for '{metric}'"); + } + foreach (var point in points) + { + foreach (var key in always.Where(k => !point.Tags.ContainsKey(k))) + { + violations.Add($"{name}: a '{metric}' point lacks the always-attribute '{key}'"); + } + foreach (var key in point.Tags.Keys.Where(k => !allowed.Contains(k))) + { + violations.Add($"{name}: a '{metric}' point carries the attribute '{key}' outside the allowed set"); + } + foreach (var key in point.Tags.Keys.Where(forbidden.Contains)) + { + violations.Add($"{name}: a '{metric}' point carries the forbidden attribute '{key}'"); + } + if (point.Tags.TryGetValue("messaging.operation.name", out var op) && op != expect.GetProperty("operation_name").GetString()) + { + violations.Add($"{name}: messaging.operation.name expected '{expect.GetProperty("operation_name").GetString()}' but was '{op}'"); + } + } + + // the destination attribute is written exactly when the destination is known + if (!points.Any(p => p.Tags.ContainsKey("messaging.destination.name")) && whenKnown.Count > 0) + { + violations.Add($"{name}: no '{metric}' point carried the destination although it was known"); + } + + return violations.Distinct().ToList(); + } + + private static List MetricsScenario(string name, string mode, JsonElement testCase) + { + var violations = new List(); + var expect = testCase.GetProperty("expect"); + var meter = !testCase.TryGetProperty("meter", out var meterElement) || meterElement.GetBoolean(); + var system = OptionalString(testCase, "system"); + var error = testCase.TryGetProperty("outcome", out var outcome) && outcome.GetString() == "error"; + + if (mode == "dead_letter" && !testCase.GetProperty("observer_called").GetBoolean()) + { + // sdk_test_required: nothing is delivered at observer level, whatever the SDK does; the rule is + // enforced by the transport tests (the core has no DLQ write point of its own, ADR-0039 section 5.2). + if (!(testCase.TryGetProperty("sdk_test_required", out var required) && required.GetBoolean())) + { + violations.Add($"{name}: observer_called false without sdk_test_required is not runnable at observer level"); + } + return violations; + } + + using var capture = new MetricCapture(subscribe: meter); + Envelope? produced = null; + + switch (mode) + { + case "consume": + RunConsume( + capture, + system, + OptionalString(testCase, "queue"), + testCase.GetProperty("attempts").GetInt32(), + error, + meter); + break; + case "publish": + produced = RunPublish(capture, system, OptionalString(testCase, "queue") ?? "default", error); + break; + default: + { + var eventElement = testCase.GetProperty("event"); + new MetricsDeadLetterObserver(system).NotifySafely(new DeadLetterEvent( + eventElement.GetProperty("queue").GetString()!, + eventElement.GetProperty("dead_letter_queue").GetString()!, + eventElement.GetProperty("reason").GetString()!)); + break; + } + } + + if (!meter && MetricCapture.AnyInstrumentEnabled()) + { + violations.Add($"{name}: with no listener subscribed the instruments must not be enabled"); + } + + var producedQueue = produced?.Meta?.Queue; + var wantedRecords = expect.GetProperty("records").EnumerateArray().ToList(); + var unmatched = capture.Points.ToList(); + + foreach (var record in wantedRecords) + { + var wantMetric = record.GetProperty("metric").GetString()!; + var wantTags = new Dictionary(StringComparer.Ordinal); + foreach (var attribute in record.GetProperty("attributes").EnumerateObject()) + { + var value = attribute.Value.GetString()!; + if (value == "$produced.meta.queue") + { + if (IsBlank(producedQueue)) + { + continue; + } + value = producedQueue!; + } + wantTags[attribute.Name] = value; + } + var wantErrorType = record.TryGetProperty("error_type", out var errorTypeElement) && errorTypeElement.GetString() == "present"; + var kind = record.GetProperty("kind").GetString(); + + var index = unmatched.FindIndex(p => + p.Metric == wantMetric + && p.Kind == (kind == "add" ? "add" : "record") + && SameTags(p.Tags, wantTags, wantErrorType) + && ValueMatches(p.Value, record.GetProperty("value"))); + if (index < 0) + { + violations.Add($"{name}: expected a '{wantMetric}' {kind} point with {Format(wantTags)}{(wantErrorType ? " + error.type" : string.Empty)} but none was recorded"); + } + else + { + unmatched.RemoveAt(index); + } + } + + foreach (var extra in unmatched) + { + violations.Add($"{name}: unexpected '{extra.Metric}' point {Format(extra.Tags)}"); + } + + return violations; + } + + private static bool SameTags(Dictionary actual, Dictionary wanted, bool errorTypePresent) + { + var copy = new Dictionary(actual, StringComparer.Ordinal); + if (errorTypePresent) + { + if (!copy.Remove("error.type", out var value) || value.Length == 0) + { + return false; + } + } + return copy.Count == wanted.Count && copy.All(kv => wanted.TryGetValue(kv.Key, out var w) && w == kv.Value); + } + + private static bool ValueMatches(double value, JsonElement wanted) => + wanted.ValueKind == JsonValueKind.String + ? double.IsFinite(value) && value >= 0 // "non_negative_seconds" + : value == wanted.GetDouble(); + + private static string Format(Dictionary tags) => + "{" + string.Join(", ", tags.OrderBy(kv => kv.Key, StringComparer.Ordinal).Select(kv => $"{kv.Key}={kv.Value}")) + "}"; + + /// Drives the consumer wrapper once; the handler fails when is set. + internal static void RunConsume(MetricCapture capture, string? system, string? queue, int attempts, bool error, bool meter) + { + _ = capture; + _ = meter; + var envelope = new Envelope( + Urn, + "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + new Dictionary(), + new Meta("0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", queue, "php", 1, 1L), + attempts, + null); + var wrapped = Telemetry.Wrap( + _ => error ? Task.FromException(new InvalidOperationException("handler failed")) : Task.CompletedTask, + headers: null, + system); + var thrown = Record(() => wrapped(envelope)); + if (error != (thrown is InvalidOperationException)) + { + throw new InvalidOperationException("the consumer wrapper changed the handler's outcome"); + } + } + + /// Drives the producer wrapper once; the transport write fails when is set. + internal static Envelope? RunPublish(MetricCapture capture, string? system, string queue, bool error) + { + _ = capture; + Envelope? produced = null; + var thrown = Record(() => Telemetry.PublishAsync( + Urn, + new Dictionary { ["order_id"] = 1 }, + headers: null, + envelope => + { + produced = envelope; + return error ? Task.FromException(new InvalidOperationException("send failed")) : Task.FromResult("ok"); + }, + queue, + system)); + if (error != (thrown is InvalidOperationException)) + { + throw new InvalidOperationException("the producer wrapper changed the publish outcome"); + } + return produced; + } + + // ---------------------------------------------------------------------------------------------- + // identity_verdict (core scope): the decoder verdict on a raw body + // ---------------------------------------------------------------------------------------------- + + /// + /// Runs one identity_verdict case: the raw body goes to , the + /// core's strict body classifier (ADR-0036 decision 4), and its verdict must equal expect. The lenient + /// is not the oracle: it maps a wrongly typed known member to a default. + /// + public static List IdentityVerdict(JsonElement testCase) + { + var name = testCase.GetProperty("name").GetString(); + var body = testCase.GetProperty("body").GetString()!; + var want = testCase.GetProperty("expect").GetString(); + + var verdict = BodyVerdict.Classify(body).IsOk ? "ok" : "poison"; + + return verdict == want + ? new List() + : new List { $"{name}: expected verdict '{want}' but the body verdict is '{verdict}'" }; + } + + // ---------------------------------------------------------------------------------------------- + // helpers + // ---------------------------------------------------------------------------------------------- + + private static string? OptionalString(JsonElement element, string property) => + element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + + private static bool IsBlank(string? value) => value is null || value.All(c => c is ' ' or '\t' or '\n' or '\v' or '\f' or '\r'); + + private static string Describe(object? value) => value is null ? "absent/null" : $"{value.GetType().Name} '{value}'"; + + private static Exception? Record(Func action) + { + try + { + action().GetAwaiter().GetResult(); + return null; + } + catch (Exception e) + { + return e; + } + } + + /// A data map whose enumeration throws, so fails before an envelope exists. + private sealed class ThrowingDictionary : IReadOnlyDictionary + { + public object? this[string key] => throw new InvalidOperationException("the data map cannot be read"); + + public IEnumerable Keys => throw new InvalidOperationException("the data map cannot be read"); + + public IEnumerable Values => throw new InvalidOperationException("the data map cannot be read"); + + public int Count => throw new InvalidOperationException("the data map cannot be read"); + + public bool ContainsKey(string key) => throw new InvalidOperationException("the data map cannot be read"); + + public bool TryGetValue(string key, out object? value) => throw new InvalidOperationException("the data map cannot be read"); + + public IEnumerator> GetEnumerator() => throw new InvalidOperationException("the data map cannot be read"); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} + +/// One recorded metric data point. +internal sealed record MetricPoint(string Metric, string Kind, double Value, Dictionary Tags); + +/// An in-memory metric reader over the BabelQueue meter (a ). +internal sealed class MetricCapture : IDisposable +{ + private readonly MeterListener? _listener; + private readonly object _gate = new(); + private readonly List _points = new(); + private readonly List _instruments = new(); + + public MetricCapture(bool subscribe = true) + { + if (!subscribe) + { + return; + } + + _listener = new MeterListener(); + _listener.InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == TelemetryMetrics.MeterName) + { + lock (_gate) + { + _instruments.Add(instrument); + } + listener.EnableMeasurementEvents(instrument); + } + }; + _listener.SetMeasurementEventCallback((instrument, value, tags, _) => Add(instrument, "add", value, tags)); + _listener.SetMeasurementEventCallback((instrument, value, tags, _) => Add(instrument, "record", value, tags)); + _listener.Start(); + } + + /// + /// Whether any BabelQueue instrument currently has a subscriber. The instruments are internal, so they are + /// found through a listener that observes the meter without enabling anything. + /// + public static bool AnyInstrumentEnabled() + { + var seen = new List(); + using var observer = new MeterListener(); + observer.InstrumentPublished = (instrument, _) => + { + if (instrument.Meter.Name == TelemetryMetrics.MeterName) + { + seen.Add(instrument); + } + }; + observer.Start(); + return seen.Count > 0 && seen.Any(i => i.Enabled); + } + + public IReadOnlyList Points + { + get + { + lock (_gate) + { + return _points.ToList(); + } + } + } + + public IReadOnlyList Instruments + { + get + { + lock (_gate) + { + return _instruments.ToList(); + } + } + } + + public void Dispose() + { + if (_listener is null) + { + return; + } + + // MeterListener.Dispose alone leaves Instrument.Enabled true on net8.0; disable explicitly. + foreach (var instrument in Instruments) + { + _listener.DisableMeasurementEvents(instrument); + } + _listener.Dispose(); + } + + private void Add(Instrument instrument, string kind, double value, ReadOnlySpan> tags) + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var tag in tags) + { + map[tag.Key] = Convert.ToString(tag.Value, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; + } + lock (_gate) + { + _points.Add(new MetricPoint(instrument.Name, kind, value, map)); + } + } +} diff --git a/tests/BabelQueue.Core.Tests/TelemetryTests.cs b/tests/BabelQueue.Core.Tests/TelemetryTests.cs index aace2d0..ba5916c 100644 --- a/tests/BabelQueue.Core.Tests/TelemetryTests.cs +++ b/tests/BabelQueue.Core.Tests/TelemetryTests.cs @@ -68,14 +68,127 @@ public async Task WrapEmitsConsumerActivityInTheTraceIdTrace() Assert.True(called); Activity activity = Assert.Single(_activities); - Assert.Equal("process urn:babel:orders:created", activity.DisplayName); + Assert.Equal("process orders", activity.DisplayName); Assert.Equal(ActivityKind.Consumer, activity.Kind); Assert.Equal(Telemetry.TraceIdOf(TraceId), activity.TraceId); Assert.Equal(TraceId, activity.GetTagItem("messaging.message.conversation_id")); Assert.Equal("orders", activity.GetTagItem("messaging.destination.name")); + Assert.Equal("urn:babel:orders:created", activity.GetTagItem("babelqueue.urn")); + Assert.Equal(2, activity.GetTagItem("babelqueue.attempts")); + Assert.Equal("babelqueue", activity.GetTagItem("messaging.system")); + Assert.Equal("process", activity.GetTagItem("messaging.operation.type")); + Assert.Equal("process", activity.GetTagItem("messaging.operation.name")); + // deprecated transition keys, dual-emitted for one MINOR (ADR-0038 section 5) + Assert.Equal("process", activity.GetTagItem("messaging.operation")); Assert.Equal(2, activity.GetTagItem("messaging.babelqueue.attempts")); } + [Fact] + public async Task WrapNamesTheBrokerAndConsumerGroup() + { + Handler handler = Telemetry.Wrap(_ => Task.CompletedTask, headers: null, system: "kafka", consumerGroup: "billing-workers"); + + await handler(MakeEnvelope()); + + Activity activity = Assert.Single(_activities); + Assert.Equal("kafka", activity.GetTagItem("messaging.system")); + Assert.Equal("billing-workers", activity.GetTagItem("messaging.consumer.group.name")); + } + + [Fact] + public async Task WrapWithoutAConsumerGroupWritesNoGroupAttribute() + { + Handler handler = Telemetry.Wrap(_ => Task.CompletedTask, headers: null, system: "kafka"); + + await handler(MakeEnvelope()); + + Assert.Null(Assert.Single(_activities).GetTagItem("messaging.consumer.group.name")); + } + + [Fact] + public async Task WrapSetsTheConsumerTagsAtStartSoASamplerSeesThem() + { + IEnumerable>? seenAtSampling = null; + using var sampler = new ActivityListener + { + ShouldListenTo = source => source.Name == Telemetry.ActivitySourceName, + Sample = (ref ActivityCreationOptions options) => + { + seenAtSampling = options.Tags?.ToList(); + return ActivitySamplingResult.AllData; + }, + }; + ActivitySource.AddActivityListener(sampler); + + await Telemetry.Wrap(_ => Task.CompletedTask, headers: null, system: "kafka")(MakeEnvelope()); + + Assert.NotNull(seenAtSampling); + Assert.Contains(seenAtSampling!, t => t.Key == "messaging.system" && (string?)t.Value == "kafka"); + Assert.Contains(seenAtSampling!, t => t.Key == "messaging.destination.name" && (string?)t.Value == "orders"); + Assert.Contains(seenAtSampling!, t => t.Key == "babelqueue.urn"); + } + + [Fact] + public void TheSemconvPinIsExposed() + { + Assert.Equal("1.44.0", Telemetry.SemconvVersion); + Assert.Equal("https://opentelemetry.io/schemas/1.44.0", Telemetry.SchemaUrl); + } + + [Fact] + public async Task ANonUuidTraceIdIsHashedWarnedOncePerDistinctValueAndPreserved() + { + string value = "order-42-" + Guid.NewGuid().ToString("N"); + var warnings = new List(); + var listener = new CapturingTraceListener(warnings); + System.Diagnostics.Trace.Listeners.Add(listener); + try + { + Handler handler = Telemetry.Wrap(_ => Task.CompletedTask); + Envelope envelope = EnvelopeCodec.Make("urn:babel:orders:created", null, "orders", value); + + await handler(envelope); + await handler(envelope); + + Activity first = _activities[0]; + Assert.Equal(Telemetry.TraceIdOf(value), first.TraceId); + Assert.Equal(value, first.GetTagItem("messaging.message.conversation_id")); // byte for byte + Assert.Single(warnings, w => w.Contains(value, StringComparison.Ordinal)); // once, not twice + } + finally + { + System.Diagnostics.Trace.Listeners.Remove(listener); + } + } + + [Fact] + public async Task AUuidTraceIdIsNotWarnedAbout() + { + var warnings = new List(); + var listener = new CapturingTraceListener(warnings); + System.Diagnostics.Trace.Listeners.Add(listener); + try + { + await Telemetry.Wrap(_ => Task.CompletedTask)(MakeEnvelope()); + Assert.DoesNotContain(warnings, w => w.Contains("is not a UUID", StringComparison.Ordinal)); + } + finally + { + System.Diagnostics.Trace.Listeners.Remove(listener); + } + } + + private sealed class CapturingTraceListener : TraceListener + { + private readonly List _sink; + + public CapturingTraceListener(List sink) => _sink = sink; + + public override void Write(string? message) => _sink.Add(message ?? string.Empty); + + public override void WriteLine(string? message) => _sink.Add(message ?? string.Empty); + } + [Fact] public async Task WrapToleratesAnEnvelopeMissingOptionalMeta() { @@ -85,8 +198,10 @@ public async Task WrapToleratesAnEnvelopeMissingOptionalMeta() await handler(partial); Activity activity = Assert.Single(_activities); - Assert.Equal(string.Empty, activity.GetTagItem("messaging.destination.name")); - Assert.Equal(string.Empty, activity.GetTagItem("messaging.message.id")); + Assert.Equal("process", activity.DisplayName); // no destination: the bare operation name + Assert.Null(activity.GetTagItem("messaging.destination.name")); + Assert.Null(activity.GetTagItem("messaging.message.id")); + Assert.Equal("urn:babel:orders:created", activity.GetTagItem("babelqueue.urn")); } [Fact] @@ -98,6 +213,8 @@ public async Task WrapRecordsHandlerErrorAndRethrows() Activity activity = Assert.Single(_activities); Assert.Equal(ActivityStatusCode.Error, activity.Status); + Assert.Equal("boom", activity.StatusDescription); + Assert.Equal(typeof(InvalidOperationException).FullName, activity.GetTagItem("error.type")); Assert.Contains(activity.Events, e => e.Name == "exception"); } @@ -116,12 +233,46 @@ public async Task PublishStampsTraceIdFromTheProducerActivity() Activity activity = Assert.Single(_activities); Assert.Equal(ActivityKind.Producer, activity.Kind); + Assert.Equal("send default", activity.DisplayName); + Assert.Equal("babelqueue", activity.GetTagItem("messaging.system")); + Assert.Equal("send", activity.GetTagItem("messaging.operation.type")); + Assert.Equal("send", activity.GetTagItem("messaging.operation.name")); + Assert.Equal("default", activity.GetTagItem("messaging.destination.name")); // the queue, never the URN + Assert.Equal("urn:babel:orders:created", activity.GetTagItem("babelqueue.urn")); + Assert.Equal("publish", activity.GetTagItem("messaging.operation")); // deprecated, dual-emit Assert.NotNull(sent); // the published trace_id encodes the producer activity's trace, so a consumer recovers it Assert.Equal(Telemetry.UuidOf(activity.TraceId), sent!.TraceId); Assert.Equal(activity.TraceId, Telemetry.TraceIdOf(sent.TraceId!)); Assert.Equal(id, activity.GetTagItem("messaging.message.id")); + Assert.Equal(sent.TraceId, activity.GetTagItem("messaging.message.conversation_id")); + } + + [Fact] + public async Task PublishUsesTheGivenQueueAndSystem() + { + await Telemetry.PublishAsync( + "urn:babel:orders:created", + null, + env => Task.FromResult(env.Meta!.Queue), + queue: "orders.events", + system: "kafka"); + + Activity activity = Assert.Single(_activities); + Assert.Equal("send orders.events", activity.DisplayName); + Assert.Equal("kafka", activity.GetTagItem("messaging.system")); + Assert.Equal("orders.events", activity.GetTagItem("messaging.destination.name")); + } + + [Fact] + public async Task PublishToABlankQueueHasNoDestination() + { + await Telemetry.PublishAsync("urn:babel:orders:created", null, env => Task.FromResult(env.Meta!.Queue), queue: " "); + + Activity activity = Assert.Single(_activities); + Assert.Equal("send", activity.DisplayName); + Assert.Null(activity.GetTagItem("messaging.destination.name")); } [Fact] @@ -136,5 +287,129 @@ await Assert.ThrowsAsync(() => Activity activity = Assert.Single(_activities); Assert.Equal(ActivityKind.Producer, activity.Kind); Assert.Equal(ActivityStatusCode.Error, activity.Status); + Assert.Equal(typeof(InvalidOperationException).FullName, activity.GetTagItem("error.type")); + // the envelope was built before the send failed, so its ids are on the span + Assert.NotNull(activity.GetTagItem("messaging.message.id")); + Assert.NotNull(activity.GetTagItem("messaging.message.conversation_id")); + } + + [Fact] + public async Task PublishWithANullQueueReportsTheDefaultQueueTheEnvelopeCarries() + { + Envelope? sent = null; + + await Telemetry.PublishAsync( + "urn:babel:orders:created", + null, + env => + { + sent = env; + return Task.FromResult(env.Meta!.Id); + }, + queue: null!); + + Assert.Equal("default", sent!.Meta!.Queue); + Activity activity = Assert.Single(_activities); + Assert.Equal("send default", activity.DisplayName); + Assert.Equal("default", activity.GetTagItem("messaging.destination.name")); } + + // ---- binary and source compatibility of the pre-ADR-0038 signatures -------------------------------- + + [Fact] + public void ThePreAdr0038PublishAndWrapSignaturesStillExistAsTheirOwnMethods() + { + // An assembly compiled against Core 1.13 binds to these exact parameter lists; if a method with one + // of them is missing, the call throws MissingMethodException at run time. + var publishes = typeof(Telemetry).GetMethods().Where(m => m.Name == nameof(Telemetry.PublishAsync)).ToList(); + Assert.Contains(publishes, m => ParameterTypes(m).SequenceEqual(new[] + { + "System.String", "System.Collections.Generic.IReadOnlyDictionary`2", "System.Func`2", "System.String", + })); + Assert.Contains(publishes, m => ParameterTypes(m).SequenceEqual(new[] + { + "System.String", "System.Collections.Generic.IReadOnlyDictionary`2", "System.Collections.Generic.IDictionary`2", "System.Func`2", "System.String", + })); + + var wraps = typeof(Telemetry).GetMethods().Where(m => m.Name == nameof(Telemetry.Wrap)).ToList(); + Assert.Contains(wraps, m => ParameterTypes(m).SequenceEqual(new[] { "BabelQueue.Handler" })); + Assert.Contains(wraps, m => ParameterTypes(m).SequenceEqual(new[] { "BabelQueue.Handler", "System.Collections.Generic.IReadOnlyDictionary`2" })); + } + + [Fact] + public async Task AnOldPublishSignatureCalledByReflectionRunsWithTheDefaultSystem() + { + // what a pre-compiled caller does: bind to the exact old parameter list and invoke it + var old = typeof(Telemetry).GetMethods() + .Single(m => m.Name == nameof(Telemetry.PublishAsync) && ParameterTypes(m).SequenceEqual(new[] + { + "System.String", "System.Collections.Generic.IReadOnlyDictionary`2", "System.Func`2", "System.String", + })) + .MakeGenericMethod(typeof(string)); + Func> send = env => Task.FromResult(env.Meta!.Queue!); + + var result = await (Task)old.Invoke(null, new object?[] { "urn:babel:orders:created", null, send, "orders" })!; + + Assert.Equal("orders", result); + Activity activity = Assert.Single(_activities); + Assert.Equal("send orders", activity.DisplayName); + Assert.Equal("babelqueue", activity.GetTagItem("messaging.system")); + } + + [Fact] + public async Task AnOldHeaderAwarePublishSignatureCalledByReflectionStillInjectsTheTraceparent() + { + var old = typeof(Telemetry).GetMethods() + .Single(m => m.Name == nameof(Telemetry.PublishAsync) && ParameterTypes(m).SequenceEqual(new[] + { + "System.String", "System.Collections.Generic.IReadOnlyDictionary`2", "System.Collections.Generic.IDictionary`2", "System.Func`2", "System.String", + })) + .MakeGenericMethod(typeof(string)); + Func> send = env => Task.FromResult(env.Meta!.Queue!); + var carrier = new Dictionary(); + + await (Task)old.Invoke(null, new object?[] { "urn:babel:orders:created", null, carrier, send, "orders" })!; + + Assert.Contains("traceparent", carrier.Keys); + Assert.Equal("send orders", Assert.Single(_activities).DisplayName); + } + + [Fact] + public async Task EveryCallShapeCompilesWithoutAnAmbiguity() + { + // Compiling this method is the CS0121 check. The assertions only show each shape runs with the expected queue and + // system. Which overload each shape bound to is not asserted here; the reflection tests above only show that the + // old signatures exist and can be invoked. + var carrier = new Dictionary(); + IReadOnlyDictionary? data = null; + Func> send = env => Task.FromResult(env.Meta!.Queue!); + + Assert.Equal("default", await Telemetry.PublishAsync("urn:babel:orders:created", data, send)); + Assert.Equal("q", await Telemetry.PublishAsync("urn:babel:orders:created", data, send, "q")); + Assert.Equal("q", await Telemetry.PublishAsync("urn:babel:orders:created", data, send, queue: "q")); + Assert.Equal("default", await Telemetry.PublishAsync("urn:babel:orders:created", data, send, system: "kafka")); + Assert.Equal("q", await Telemetry.PublishAsync("urn:babel:orders:created", data, send, "q", "kafka")); + Assert.Equal("default", await Telemetry.PublishAsync("urn:babel:orders:created", data, carrier, send)); + Assert.Equal("q", await Telemetry.PublishAsync("urn:babel:orders:created", data, carrier, send, "q")); + Assert.Equal("q", await Telemetry.PublishAsync("urn:babel:orders:created", data, carrier, send, "q", "kafka")); + Assert.Equal("default", await Telemetry.PublishAsync("urn:babel:orders:created", data, headers: null, send)); + Assert.Equal("default", await Telemetry.PublishAsync("urn:babel:orders:created", data, null, send)); + + Handler a = Telemetry.Wrap(_ => Task.CompletedTask); + Handler b = Telemetry.Wrap(_ => Task.CompletedTask, carrier); + Handler c = Telemetry.Wrap(_ => Task.CompletedTask, null); + Handler d = Telemetry.Wrap(_ => Task.CompletedTask, headers: null); + Handler e = Telemetry.Wrap(_ => Task.CompletedTask, system: "kafka"); + Handler f = Telemetry.Wrap(_ => Task.CompletedTask, carrier, "kafka", "g"); + await Task.WhenAll(new[] { a, b, c, d, e, f }.Select(h => h(MakeEnvelope()))); + + // 10 publishes + 6 deliveries; only the system-bearing shapes carry a non-default system + Assert.Equal(16, _activities.Count); + Assert.Equal( + new[] { "kafka", "kafka", "kafka", "kafka", "kafka" }, + _activities.Where(x => (string?)x.GetTagItem("messaging.system") != "babelqueue").Select(x => (string)x.GetTagItem("messaging.system")!)); + } + + private static string[] ParameterTypes(System.Reflection.MethodInfo method) => + method.GetParameters().Select(p => (p.ParameterType.IsGenericType ? p.ParameterType.GetGenericTypeDefinition() : p.ParameterType).FullName!).ToArray(); } diff --git a/tests/BabelQueue.Core.Tests/conformance/CONFORMANCE_VERSION b/tests/BabelQueue.Core.Tests/conformance/CONFORMANCE_VERSION index dc1e644..f8e233b 100644 --- a/tests/BabelQueue.Core.Tests/conformance/CONFORMANCE_VERSION +++ b/tests/BabelQueue.Core.Tests/conformance/CONFORMANCE_VERSION @@ -1 +1 @@ -1.6.0 +1.9.0 diff --git a/tests/BabelQueue.Core.Tests/conformance/manifest.json b/tests/BabelQueue.Core.Tests/conformance/manifest.json index 63f7ca1..70a44fc 100644 --- a/tests/BabelQueue.Core.Tests/conformance/manifest.json +++ b/tests/BabelQueue.Core.Tests/conformance/manifest.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "conformance_version": "1.6.0", + "conformance_version": "1.9.0", "sections": { "cases": { "scope": "core" @@ -79,6 +79,33 @@ }, "poison_dlq": { "scope": "core" + }, + "identity_verdict": { + "scope": "core" + }, + "pulsar_exhaustion": { + "scope": "pulsar" + }, + "version_gate_pulsar": { + "scope": "pulsar" + }, + "version_gate_artemis": { + "scope": "artemis" + }, + "version_gate_sqs": { + "scope": "sqs" + }, + "version_gate_asb": { + "scope": "asb" + }, + "span-shape": { + "scope": "otel" + }, + "trace-id": { + "scope": "otel" + }, + "metrics": { + "scope": "otel" } }, "exceptions": [ @@ -2238,7 +2265,7 @@ ] }, "traceparent": { - "description": "W3C `traceparent` out-of-band propagation (ADR-0028, ADR-0025). Scope: `otel` (ADR-0032 section 2): `otel` is not a broker but the optional W3C `traceparent` extract/inject code of ADR-0028 (in the core of Node and .NET; a separate module for Go, Python, Java and PHP), so only a package that carries it lists `otel` in `bindings` and runs this section. `mode: extract` cases give the delivered header value (`traceparent`, or null when the transport delivered none) and the envelope's `trace_id`; the consumer-side extraction MUST yield `expect`: `source: traceparent` with the header's 32-hex `trace_id_hex`, 16-hex `parent_span_id` and 2-hex `flags`, or `source: trace_id` (the v0.1 fallback, ADR-0025) with `trace_id_hex` = the envelope `trace_id` without hyphens. A malformed header is ignored (it never raises) and behaves exactly like no header. Only the structure `00-<32hex>-<16hex>-<2hex>` is pinned; the finer W3C validity rules (all-zero ids, version ff, upper case) are not part of this section. Pinning the producer side (that the header rides in the transport's own metadata channel beside the envelope, never inside it) needs a real transport and is left to the R1 OTel plan, as are the finer W3C rules.", + "description": "W3C `traceparent` out-of-band propagation (ADR-0028, ADR-0025). Scope: `otel` (ADR-0032 section 2): `otel` is not a broker but the optional W3C `traceparent` extract/inject code of ADR-0028 (in the core of Node and .NET; a separate module for Go, Python, Java and PHP), so only a package that carries it lists `otel` in `bindings` and runs this section. `mode: extract` cases give the delivered header value (`traceparent`, or null when the transport delivered none) and the envelope's `trace_id`; the consumer-side extraction MUST yield `expect`: `source: traceparent` with the header's 32-hex `trace_id_hex`, 16-hex `parent_span_id` and 2-hex `flags`, or `source: trace_id` (the v0.1 fallback, ADR-0025) with `trace_id_hex` = the envelope `trace_id` without hyphens. A malformed header is ignored (it never raises) and behaves exactly like no header. The structure `00-<32hex>-<16hex>-<2hex>` is pinned, and so are the finer W3C validity rules: a header is ignored (and behaves like no header) when its version is `ff`, when its trace-id or parent-id is all zero, or when any hex field is upper case (hex is lower case only). Only version `00` is pinned: the treatment of future versions (`01` to `fe`) and of reserved flag bits (for example `0a`, which some propagators reject for version `00`) is not pinned by this section. Pinning the producer side (that the header rides in the transport's own metadata channel beside the envelope, never inside it) needs a real transport and is covered by each SDK's own transport tests (R1 OTel plan, transport phases), not by this section.", "cases": [ { "name": "valid-sampled", @@ -2386,6 +2413,72 @@ "source": "trace_id", "trace_id_hex": "7b3f9c2ae41d4f889b2a1c0d5e6f7a8b" } + }, + { + "name": "version-ff-ignored", + "mode": "extract", + "traceparent": "ff-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "expect": { + "source": "trace_id", + "trace_id_hex": "7b3f9c2ae41d4f889b2a1c0d5e6f7a8b" + }, + "description": "Version `ff` is invalid per W3C Trace Context: the header is ignored and behaves like no header." + }, + { + "name": "zero-trace-id-ignored", + "mode": "extract", + "traceparent": "00-00000000000000000000000000000000-00f067aa0ba902b7-01", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "expect": { + "source": "trace_id", + "trace_id_hex": "7b3f9c2ae41d4f889b2a1c0d5e6f7a8b" + }, + "description": "An all-zero trace-id is invalid per W3C Trace Context: the header is ignored." + }, + { + "name": "zero-parent-id-ignored", + "mode": "extract", + "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "expect": { + "source": "trace_id", + "trace_id_hex": "7b3f9c2ae41d4f889b2a1c0d5e6f7a8b" + }, + "description": "An all-zero parent-id is invalid per W3C Trace Context: the header is ignored." + }, + { + "name": "uppercase-trace-id-ignored", + "mode": "extract", + "traceparent": "00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "expect": { + "source": "trace_id", + "trace_id_hex": "7b3f9c2ae41d4f889b2a1c0d5e6f7a8b" + }, + "description": "W3C Trace Context allows lower-case hex only: an upper-case trace-id makes the header invalid." + }, + { + "name": "uppercase-parent-id-ignored", + "mode": "extract", + "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00F067AA0BA902B7-01", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "expect": { + "source": "trace_id", + "trace_id_hex": "7b3f9c2ae41d4f889b2a1c0d5e6f7a8b" + }, + "description": "An upper-case parent-id makes the header invalid." + }, + { + "name": "uppercase-flags-ignored", + "mode": "extract", + "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-0A", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "expect": { + "source": "trace_id", + "trace_id_hex": "7b3f9c2ae41d4f889b2a1c0d5e6f7a8b" + }, + "description": "Upper-case flags make the header invalid." } ] }, @@ -5798,5 +5891,3766 @@ } } ] + }, + "identity_verdict": { + "description": "Body classification outside Kafka (ADR-0036 decision 4; ADR-0032 section 6; error-handling.md section 3). Scope: `core`, because the decoder verdict is the same for every binding. For each case, feed `body` (a JSON string; it is the raw message body, so a case may hold invalid JSON, a BOM or duplicate keys) to the SDK core decoder and assert the verdict `expect`: `ok` - the body decodes into an envelope with a valid identity; `poison` - it does not (a decode error, or no valid identity). On a transport other than Kafka a `poison` body is never handed to a handler: it takes the poison route (broker-bindings.md section 5.5 and error-handling.md section 7; ADR-0035 record with `reason: poison`), not the unknown-URN strategies of error-handling.md section 3, which remain for a valid envelope whose URN has no handler. The case set starts with EVERY `kafka_poison` case (same names, bodies and order, the first 92 cases), with `no_identity` mapped to `poison` and `ok` / `poison` unchanged, so the two sections stay in step; the 5 cases after them are extra rows that pin the ADR-0036 decision 4 bodies `kafka_poison` does not hold (a blank or non-string `job` next to a valid `urn`, and a tab-only `job`) (a body with a valid identity but a wrongly typed known member is `poison` in both); a CI oracle derives the first 92 cases from `kafka_poison`, fails on any difference, and checks the extra rows with its own identity rule. The duplicate-key rule (the LAST value of a repeated top-level key wins) and the identity rule are those of `kafka_poison`: a `job` that is not a string is ignored; a blank (empty or whitespace-only) string `job` falls back to `urn`; a `urn` that is not a non-blank string is ignored; when neither is valid the body has no identity. Rows that pin the ADR-0036 decision 4 table (a `job` of `\"\"`, `\" \\t\"`, `null` or `{}` next to a valid `urn` is `ok`; see also the extra rows): a valid envelope (`full valid`) and a body whose only valid identity is `urn` (`urn alias string`), a blank `job` with a valid `urn` (`job blank with valid urn alias`), a non-string `job` with a valid `urn` (`job number with valid urn alias`) are `ok`; `{}` (`empty object`), a non-string `job` without `urn` (`job number`, `job null`), a non-string `urn` without `job` (`urn alias number`), non-object top-level values (`null literal`, `empty array`, `string`, `number`) and a non-JSON or empty body (`not json`, `empty`) are `poison`. The edge that a transport routes on an out-of-band identity property (Pulsar / Kafka `bq-job`) is outside this section.", + "cases": [ + { + "name": "empty", + "body": "", + "expect": "poison" + }, + { + "name": "whitespace only", + "body": " ", + "expect": "poison" + }, + { + "name": "not json", + "body": "not-json", + "expect": "poison" + }, + { + "name": "truncated", + "body": "{\"job\":\"urn:a\"", + "expect": "poison" + }, + { + "name": "empty array", + "body": "[]", + "expect": "poison" + }, + { + "name": "array", + "body": "[1]", + "expect": "poison" + }, + { + "name": "string", + "body": "\"str\"", + "expect": "poison" + }, + { + "name": "number", + "body": "123", + "expect": "poison" + }, + { + "name": "boolean", + "body": "true", + "expect": "poison" + }, + { + "name": "null literal", + "body": "null", + "expect": "poison" + }, + { + "name": "empty object", + "body": "{}", + "expect": "poison" + }, + { + "name": "full valid", + "body": "{\"job\":\"urn:babel:orders:created\",\"trace_id\":\"t\",\"data\":{},\"meta\":{\"id\":\"i\",\"queue\":\"q\",\"lang\":\"php\",\"schema_version\":1,\"created_at\":1700000000000},\"attempts\":0}", + "expect": "ok" + }, + { + "name": "job number", + "body": "{\"job\":5}", + "expect": "poison" + }, + { + "name": "job null", + "body": "{\"job\":null}", + "expect": "poison" + }, + { + "name": "urn alias number", + "body": "{\"urn\":5}", + "expect": "poison" + }, + { + "name": "urn alias string", + "body": "{\"urn\":\"urn:x\"}", + "expect": "ok" + }, + { + "name": "urn alias padded", + "body": "{\"urn\":\" urn:x \"}", + "expect": "ok" + }, + { + "name": "trace_id number", + "body": "{\"trace_id\":7}", + "expect": "poison" + }, + { + "name": "data array", + "body": "{\"data\":[]}", + "expect": "poison" + }, + { + "name": "data string", + "body": "{\"data\":\"x\"}", + "expect": "poison" + }, + { + "name": "data null", + "body": "{\"data\":null}", + "expect": "poison" + }, + { + "name": "meta string", + "body": "{\"meta\":\"x\"}", + "expect": "poison" + }, + { + "name": "meta array", + "body": "{\"meta\":[]}", + "expect": "poison" + }, + { + "name": "meta null", + "body": "{\"meta\":null}", + "expect": "poison" + }, + { + "name": "meta.id number", + "body": "{\"meta\":{\"id\":5}}", + "expect": "poison" + }, + { + "name": "meta.queue number", + "body": "{\"meta\":{\"queue\":5}}", + "expect": "poison" + }, + { + "name": "meta.lang boolean", + "body": "{\"meta\":{\"lang\":true}}", + "expect": "poison" + }, + { + "name": "meta.created_at string", + "body": "{\"meta\":{\"created_at\":\"2026\"}}", + "expect": "poison" + }, + { + "name": "meta.created_at fractional", + "body": "{\"meta\":{\"created_at\":1.5}}", + "expect": "poison" + }, + { + "name": "meta.created_at integer", + "body": "{\"meta\":{\"created_at\":1700000000000}}", + "expect": "poison" + }, + { + "name": "meta.created_at exponent", + "body": "{\"meta\":{\"created_at\":1e3}}", + "expect": "poison" + }, + { + "name": "meta.created_at decimal zero", + "body": "{\"meta\":{\"created_at\":1.0}}", + "expect": "poison" + }, + { + "name": "meta.created_at int64 overflow", + "body": "{\"meta\":{\"created_at\":9223372036854775808}}", + "expect": "poison" + }, + { + "name": "meta.created_at int64 max", + "body": "{\"meta\":{\"created_at\":9223372036854775807}}", + "expect": "poison" + }, + { + "name": "meta.created_at negative", + "body": "{\"meta\":{\"created_at\":-1}}", + "expect": "poison" + }, + { + "name": "meta.created_at negative zero", + "body": "{\"meta\":{\"created_at\":-0}}", + "expect": "poison" + }, + { + "name": "meta.schema_version string", + "body": "{\"meta\":{\"schema_version\":\"1\"}}", + "expect": "poison" + }, + { + "name": "meta.schema_version two", + "body": "{\"meta\":{\"schema_version\":2}}", + "expect": "poison" + }, + { + "name": "meta.schema_version decimal zero", + "body": "{\"meta\":{\"schema_version\":1.0}}", + "expect": "poison" + }, + { + "name": "meta.schema_version null", + "body": "{\"meta\":{\"schema_version\":null}}", + "expect": "poison" + }, + { + "name": "meta.schema_version boolean", + "body": "{\"meta\":{\"schema_version\":true}}", + "expect": "poison" + }, + { + "name": "attempts fractional", + "body": "{\"attempts\":1.5}", + "expect": "poison" + }, + { + "name": "attempts string", + "body": "{\"attempts\":\"3\"}", + "expect": "poison" + }, + { + "name": "attempts integer", + "body": "{\"attempts\":2}", + "expect": "poison" + }, + { + "name": "attempts exponent", + "body": "{\"attempts\":1e0}", + "expect": "poison" + }, + { + "name": "attempts upper exponent", + "body": "{\"attempts\":1E2}", + "expect": "poison" + }, + { + "name": "attempts negative zero", + "body": "{\"attempts\":-0}", + "expect": "poison" + }, + { + "name": "attempts overflow", + "body": "{\"attempts\":9223372036854775808}", + "expect": "poison" + }, + { + "name": "attempts null", + "body": "{\"attempts\":null}", + "expect": "poison" + }, + { + "name": "dead_letter string", + "body": "{\"dead_letter\":\"x\"}", + "expect": "poison" + }, + { + "name": "dead_letter null", + "body": "{\"dead_letter\":null}", + "expect": "poison" + }, + { + "name": "dead_letter empty object", + "body": "{\"dead_letter\":{}}", + "expect": "poison" + }, + { + "name": "dead_letter.attempts string", + "body": "{\"dead_letter\":{\"attempts\":\"3\"}}", + "expect": "poison" + }, + { + "name": "dead_letter.reason number", + "body": "{\"dead_letter\":{\"reason\":5}}", + "expect": "poison" + }, + { + "name": "dead_letter.error number", + "body": "{\"dead_letter\":{\"error\":5}}", + "expect": "poison" + }, + { + "name": "dead_letter nullable strings", + "body": "{\"dead_letter\":{\"error\":null,\"exception\":null}}", + "expect": "poison" + }, + { + "name": "dead_letter.failed_at string", + "body": "{\"dead_letter\":{\"failed_at\":\"x\"}}", + "expect": "poison" + }, + { + "name": "dead_letter.failed_at fractional", + "body": "{\"dead_letter\":{\"failed_at\":1.5}}", + "expect": "poison" + }, + { + "name": "dead_letter.original_queue array", + "body": "{\"dead_letter\":{\"original_queue\":[]}}", + "expect": "poison" + }, + { + "name": "dead_letter.lang object", + "body": "{\"dead_letter\":{\"lang\":{}}}", + "expect": "poison" + }, + { + "name": "dead_letter full", + "body": "{\"dead_letter\":{\"error\":\"e\",\"exception\":\"x\",\"failed_at\":1,\"original_queue\":\"q\",\"attempts\":1,\"lang\":\"go\",\"reason\":\"r\"}}", + "expect": "poison" + }, + { + "name": "upper-case job key with number is an unknown member (names are case-sensitive)", + "body": "{\"JOB\":5}", + "expect": "poison" + }, + { + "name": "mixed-case meta.id key with number is an unknown member (names are case-sensitive)", + "body": "{\"Meta\":{\"ID\":5}}", + "expect": "poison" + }, + { + "name": "upper-case job key with string", + "body": "{\"JOB\":\"urn:x\"}", + "expect": "poison" + }, + { + "name": "trailing garbage", + "body": "{\"job\":\"x\"} trailing", + "expect": "poison" + }, + { + "name": "two objects", + "body": "{\"job\":\"x\"}{\"job\":\"y\"}", + "expect": "poison" + }, + { + "name": "unknown keys", + "body": "{\"x\":1}", + "expect": "poison" + }, + { + "name": "forbidden keys with odd types", + "body": "{\"timestamp\":\"x\",\"meta\":{\"max_retries\":\"x\",\"attempts\":\"y\",\"source\":5,\"ts\":\"z\"}}", + "expect": "poison" + }, + { + "name": "nested lookalikes in data", + "body": "{\"data\":{\"a\":[1,2,{\"b\":null}],\"meta\":5,\"job\":5},\"extra\":{\"meta\":5}}", + "expect": "poison" + }, + { + "name": "duplicate job string then number", + "body": "{\"job\":\"urn:x\",\"job\":5}", + "expect": "poison" + }, + { + "name": "duplicate job number then string", + "body": "{\"job\":5,\"job\":\"urn:x\"}", + "expect": "ok" + }, + { + "name": "byte order mark", + "body": "\ufeff{\"job\":\"x\"}", + "expect": "poison" + }, + { + "name": "leading zero number", + "body": "{\"attempts\":01}", + "expect": "poison" + }, + { + "name": "NaN literal", + "body": "{\"attempts\":NaN}", + "expect": "poison" + }, + { + "name": "single quotes", + "body": "{'a':1}", + "expect": "poison" + }, + { + "name": "meta duplicate-case key", + "body": "{\"meta\":{\"id\":\"a\",\"queue\":\"b\",\"lang\":\"c\",\"schema_version\":1,\"created_at\":1,\"ID\":\"dup\"}}", + "expect": "poison" + }, + { + "name": "attempts huge exponent", + "body": "{\"attempts\":1e400}", + "expect": "poison" + }, + { + "name": "data with huge number", + "body": "{\"data\":{\"n\":1e400}}", + "expect": "poison" + }, + { + "name": "job number with valid urn alias", + "body": "{\"job\":5,\"urn\":\"urn:x\"}", + "expect": "ok" + }, + { + "name": "job blank with valid urn alias", + "body": "{\"job\":\" \",\"urn\":\"urn:x\"}", + "expect": "ok" + }, + { + "name": "job valid with urn number", + "body": "{\"job\":\"urn:x\",\"urn\":5}", + "expect": "ok" + }, + { + "name": "job blank without urn", + "body": "{\"job\":\" \"}", + "expect": "poison" + }, + { + "name": "job and urn both empty strings", + "body": "{\"job\":\"\",\"urn\":\"\"}", + "expect": "poison" + }, + { + "name": "job and urn both arrays", + "body": "{\"job\":[],\"urn\":[]}", + "expect": "poison" + }, + { + "name": "job object without urn", + "body": "{\"job\":{}}", + "expect": "poison" + }, + { + "name": "identity with null data", + "body": "{\"job\":\"urn:x\",\"data\":null}", + "expect": "ok" + }, + { + "name": "identity with null meta", + "body": "{\"job\":\"urn:x\",\"meta\":null}", + "expect": "ok" + }, + { + "name": "identity with null attempts", + "body": "{\"job\":\"urn:x\",\"attempts\":null}", + "expect": "ok" + }, + { + "name": "identity with null dead_letter", + "body": "{\"job\":\"urn:x\",\"dead_letter\":null}", + "expect": "ok" + }, + { + "name": "identity with integer attempts", + "body": "{\"job\":\"urn:x\",\"attempts\":2}", + "expect": "ok" + }, + { + "name": "identity with wrong typed attempts", + "body": "{\"job\":\"urn:x\",\"attempts\":\"3\"}", + "expect": "poison" + }, + { + "name": "identity with wrong typed data", + "body": "{\"job\":\"urn:x\",\"data\":[]}", + "expect": "poison" + }, + { + "name": "job empty string with valid urn", + "body": "{\"job\":\"\",\"urn\":\"urn:x\"}", + "expect": "ok" + }, + { + "name": "job space and tab with valid urn", + "body": "{\"job\":\" \\t\",\"urn\":\"urn:x\"}", + "expect": "ok" + }, + { + "name": "job null with valid urn", + "body": "{\"job\":null,\"urn\":\"urn:x\"}", + "expect": "ok" + }, + { + "name": "job object with valid urn", + "body": "{\"job\":{},\"urn\":\"urn:x\"}", + "expect": "ok" + }, + { + "name": "job space and tab without urn", + "body": "{\"job\":\" \\t\"}", + "expect": "poison" + } + ] + }, + "pulsar_exhaustion": { + "description": "Pulsar max-attempts settle decision (ADR-0036 decisions 1 and 2, amends ADR-0014; broker-bindings.md section 5.5 \"Max attempts and poison\"). Scope: `pulsar`. A pure function, no broker: for each case compute the outcome the consumer takes for a delivery and assert `expect`. Case `{name, kind, body_attempts?, bq_attempts?, redelivery_count, max_attempts?, dead_letter, on_unknown_urn?, expect}`. `kind`: `failed` (the handler throws), `unknown_urn` (an unmapped URN with no hook; strategy `fail`), `poison` (the body does not decode into an envelope, including an object with no valid identity, see `identity_verdict`). Inputs: `body_attempts` is the envelope's top-level `attempts` (absent for `poison`, which has no decodable body: its input is `bq_attempts` and `redelivery_count` only); `bq_attempts` is the `bq-attempts` message property, a string (absent = not set); `redelivery_count` is the native Pulsar RedeliveryCount (0-based); `max_attempts` absent = the default 3 (the total number of deliveries, the first included; a value below 1 is a SHOULD-level configuration error and is not a case); `dead_letter` is a boolean (dead-lettering on or off) and `off` has two meanings: in the Go and Python runtimes it is the dead-letter SETTING only, the DLQ target stays writable and the `dead_letter` strategy still writes a record; in the PHP `Consume\\Dispatcher` \"on\" means a `DeadLetterPublisher` is configured, so `off` means NO publisher and nothing can be written; `on_unknown_urn` absent = `fail`. Derivation: `reconciled` = `bq_attempts` when present and valid, else `body_attempts`, then `max(reconciled, redelivery_count)` (pulsar.attempts_reconciliation); `counted = reconciled + 1`. Decision. `failed` and `unknown_urn` (strategy `fail`): `counted < max_attempts` is `retry`; otherwise dead-lettering on is `dlq` (`reason` `failed` or `unknown_urn`, `dead_letter_attempts = counted`) and off is `drop` (acknowledge and log). `poison`: `on_unknown_urn: delete` is `drop`; `on_unknown_urn: dead_letter` is `dlq` with `reason: poison` whatever `dead_letter` says (the strategy writes the record where a DLQ writer exists; see `outcome_no_writer` below); dead-lettering on (strategy `fail` or `release`) is `dlq` with `reason: poison` and NO retry (the ADR-0035 record, `dead_letter_attempts` is not compared); dead-lettering off takes the bounded `fail` path (`release` is replaced by it) - `counted < max_attempts` is `retry`, otherwise `drop`. `unknown_urn` with `on_unknown_urn: delete` is `drop`; with `dead_letter` it is a direct `dlq` with `reason: unknown_urn` whatever `dead_letter` says (`dead_letter_attempts` is not compared; see `outcome_no_writer` below). `expect.outcome_no_writer` (optional, `drop`): present only on a row whose result differs for an SDK without a DLQ writer (the PHP `Dispatcher` without a publisher: `on_unknown_urn: dead_letter` is acknowledged, dropped and logged; owner decision 2026-10-25, ADR-0036 decision 3). A runner for such an SDK asserts `expect.outcome_no_writer` instead of `expect.outcome`; every other runner asserts `expect.outcome` and ignores the field; when the field is absent both kinds of SDK assert `expect.outcome`. On Kafka the PHP `Dispatcher` rethrows instead of dropping (outside this Pulsar-scoped section). Rows with any `on_unknown_urn` value (they are the rows with that key) apply only to an SDK that has the strategy option (the Go and Python runtimes and the PHP `Consume\\Dispatcher`, ADR-0036 decision 3); a runner for the Java, .NET or Node consumer, which have no such option, skips them and reports them as `skip`, not `pass`. `unknown_urn` with `release` is not a case: on Pulsar the Go and Python runtimes release it immediately without counting (the `unknown_urn_release_delay` known gap of broker-bindings.md section 5.5). Not covered here (no broker-less test): the DLQ write itself, the acknowledgement after a successful write, the negative acknowledgement after a failed one, and the PHP queue name of ADR-0036 decision 3; each SDK covers them in its own tests.", + "cases": [ + { + "name": "failed-reconciled-0-dlq-on-retries", + "kind": "failed", + "body_attempts": 0, + "redelivery_count": 0, + "dead_letter": true, + "expect": { + "outcome": "retry" + } + }, + { + "name": "failed-reconciled-1-dlq-on-retries", + "kind": "failed", + "body_attempts": 1, + "redelivery_count": 0, + "dead_letter": true, + "expect": { + "outcome": "retry" + } + }, + { + "name": "failed-reconciled-2-dlq-on-dead-letters", + "kind": "failed", + "body_attempts": 2, + "redelivery_count": 0, + "dead_letter": true, + "expect": { + "outcome": "dlq", + "reason": "failed", + "dead_letter_attempts": 3 + } + }, + { + "name": "failed-reconciled-2-dlq-off-drops", + "kind": "failed", + "body_attempts": 2, + "redelivery_count": 0, + "dead_letter": false, + "expect": { + "outcome": "drop" + } + }, + { + "name": "failed-reconciled-7-dlq-on-counts-past-the-cap", + "kind": "failed", + "body_attempts": 7, + "redelivery_count": 0, + "dead_letter": true, + "expect": { + "outcome": "dlq", + "reason": "failed", + "dead_letter_attempts": 8 + } + }, + { + "name": "failed-max-1-dlq-on-first-delivery-dead-letters", + "kind": "failed", + "body_attempts": 0, + "redelivery_count": 0, + "max_attempts": 1, + "dead_letter": true, + "expect": { + "outcome": "dlq", + "reason": "failed", + "dead_letter_attempts": 1 + } + }, + { + "name": "failed-max-1-dlq-off-first-delivery-drops", + "kind": "failed", + "body_attempts": 0, + "redelivery_count": 0, + "max_attempts": 1, + "dead_letter": false, + "expect": { + "outcome": "drop" + } + }, + { + "name": "failed-max-5-reconciled-3-dlq-off-retries", + "kind": "failed", + "body_attempts": 3, + "redelivery_count": 0, + "max_attempts": 5, + "dead_letter": false, + "expect": { + "outcome": "retry" + } + }, + { + "name": "failed-max-5-reconciled-4-dlq-off-drops", + "kind": "failed", + "body_attempts": 4, + "redelivery_count": 0, + "max_attempts": 5, + "dead_letter": false, + "expect": { + "outcome": "drop" + } + }, + { + "name": "unknown-urn-reconciled-1-dlq-on-retries", + "kind": "unknown_urn", + "body_attempts": 1, + "redelivery_count": 0, + "dead_letter": true, + "expect": { + "outcome": "retry" + } + }, + { + "name": "unknown-urn-reconciled-2-dlq-on-dead-letters", + "kind": "unknown_urn", + "body_attempts": 2, + "redelivery_count": 0, + "dead_letter": true, + "expect": { + "outcome": "dlq", + "reason": "unknown_urn", + "dead_letter_attempts": 3 + } + }, + { + "name": "unknown-urn-reconciled-2-dlq-off-drops", + "kind": "unknown_urn", + "body_attempts": 2, + "redelivery_count": 0, + "dead_letter": false, + "expect": { + "outcome": "drop" + } + }, + { + "name": "poison-reconciled-0-dlq-on-dead-letters-without-retry", + "kind": "poison", + "redelivery_count": 0, + "dead_letter": true, + "expect": { + "outcome": "dlq", + "reason": "poison" + } + }, + { + "name": "poison-reconciled-0-dlq-off-retries", + "kind": "poison", + "redelivery_count": 0, + "dead_letter": false, + "expect": { + "outcome": "retry" + } + }, + { + "name": "poison-reconciled-2-dlq-off-drops", + "kind": "poison", + "redelivery_count": 2, + "dead_letter": false, + "expect": { + "outcome": "drop" + } + }, + { + "name": "poison-delete-dlq-on-drops", + "kind": "poison", + "redelivery_count": 0, + "dead_letter": true, + "on_unknown_urn": "delete", + "expect": { + "outcome": "drop" + } + }, + { + "name": "poison-delete-dlq-off-drops", + "kind": "poison", + "redelivery_count": 0, + "dead_letter": false, + "on_unknown_urn": "delete", + "expect": { + "outcome": "drop" + } + }, + { + "name": "poison-release-dlq-on-dead-letters-without-retry", + "kind": "poison", + "redelivery_count": 0, + "dead_letter": true, + "on_unknown_urn": "release", + "expect": { + "outcome": "dlq", + "reason": "poison" + } + }, + { + "name": "poison-release-dlq-off-reconciled-0-takes-the-bounded-fail-path", + "kind": "poison", + "redelivery_count": 0, + "dead_letter": false, + "on_unknown_urn": "release", + "expect": { + "outcome": "retry" + } + }, + { + "name": "poison-release-dlq-off-reconciled-2-drops-not-released-forever", + "kind": "poison", + "redelivery_count": 2, + "dead_letter": false, + "on_unknown_urn": "release", + "expect": { + "outcome": "drop" + } + }, + { + "name": "poison-dead-letter-strategy-dlq-off-dead-letters", + "kind": "poison", + "redelivery_count": 0, + "dead_letter": false, + "on_unknown_urn": "dead_letter", + "expect": { + "outcome": "dlq", + "reason": "poison", + "outcome_no_writer": "drop" + } + }, + { + "name": "unknown-urn-delete-dlq-on-drops", + "kind": "unknown_urn", + "body_attempts": 0, + "redelivery_count": 0, + "dead_letter": true, + "on_unknown_urn": "delete", + "expect": { + "outcome": "drop" + } + }, + { + "name": "unknown-urn-dead-letter-strategy-dlq-off-dead-letters-directly", + "kind": "unknown_urn", + "body_attempts": 0, + "redelivery_count": 0, + "dead_letter": false, + "on_unknown_urn": "dead_letter", + "expect": { + "outcome": "dlq", + "reason": "unknown_urn", + "outcome_no_writer": "drop" + } + }, + { + "name": "poison-dead-letter-strategy-dlq-on-dead-letters", + "kind": "poison", + "redelivery_count": 0, + "dead_letter": true, + "on_unknown_urn": "dead_letter", + "expect": { + "outcome": "dlq", + "reason": "poison" + } + }, + { + "name": "unknown-urn-dead-letter-strategy-dlq-on-dead-letters-directly", + "kind": "unknown_urn", + "body_attempts": 0, + "redelivery_count": 0, + "dead_letter": true, + "on_unknown_urn": "dead_letter", + "expect": { + "outcome": "dlq", + "reason": "unknown_urn" + } + }, + { + "name": "reconciliation-bq-attempts-string-over-body", + "kind": "failed", + "body_attempts": 0, + "bq_attempts": "2", + "redelivery_count": 0, + "dead_letter": true, + "expect": { + "outcome": "dlq", + "reason": "failed", + "dead_letter_attempts": 3 + } + }, + { + "name": "reconciliation-redelivery-count-floors-body", + "kind": "failed", + "body_attempts": 1, + "redelivery_count": 2, + "dead_letter": true, + "expect": { + "outcome": "dlq", + "reason": "failed", + "dead_letter_attempts": 3 + } + }, + { + "name": "reconciliation-bq-attempts-below-cap-retries", + "kind": "unknown_urn", + "body_attempts": 0, + "bq_attempts": "1", + "redelivery_count": 0, + "dead_letter": true, + "expect": { + "outcome": "retry" + } + }, + { + "name": "reconciliation-poison-bq-attempts-dlq-off-drops", + "kind": "poison", + "bq_attempts": "2", + "redelivery_count": 0, + "dead_letter": false, + "expect": { + "outcome": "drop" + } + } + ] + }, + "version_gate_pulsar": { + "description": "Version-gate settle rule for Apache Pulsar (broker-bindings.md section 5.7, property `bq-schema-version`) (ADR-0037 decision 1; broker-bindings.md section 5.7 \"Version-gate settle rule\"). Scope: `pulsar`. A pure function, no broker: for each case compute how the consumer settles a delivery whose schema-version property is present, not null, not blank and not the supported version, and assert `expect`. Which property values are version-gated is NOT decided here: the `schema_version_gate` block of this binding already locks the trigger. A runner feeds the delivery a property value that gates (any of that block's rejected values, for example `2`). Case `{name, on_unknown_urn?, dead_letter, dlq_write?, bq_attempts?, expect}`. `on_unknown_urn`: `fail` | `delete` | `release` | `dead_letter`; absent = `fail`. A row with any value other than `fail` applies only to an SDK whose consumer has the strategy option; the Java, .NET and Node Pulsar consumers have none (they behave as `fail`), so a runner for one of them reports those rows as `skip` citing ADR-0037, not as `pass`. `release` behaves as `fail` for a version-gated delivery (it is never released). The version gate reads only the explicitly configured `on_unknown_urn` strategy; with none configured it is `fail`. An `onUnknownUrn` hook, any strategy a hook implies (a hook-only Java or Node SQS consumer would otherwise be `delete`) and a hook's precedence over the strategy (the .NET SQS consumer) do not affect it: the hook is for an unknown URN only, so a hook-only consumer settles these rows as `fail`. `dead_letter`: `on` | `off` (dead-lettering on or off, each SDK's existing meaning; for the `dead_letter` strategy with `off` see `expect.outcome_no_writer`). `dlq_write`: `ok` | `fails`, whether the write of the raw record to `.dlq` succeeds; absent = `ok` (ADR-0037). It is read only on rows whose `expect.outcome` writes a record (`dlq_raw` or `unsettled`); it is present on those rows, and also on the `dead_letter` strategy rows with `dead_letter: off`, where an SDK with a writable DLQ target writes and an SDK without a writer asserts `outcome_no_writer` instead. `bq_attempts`: optional, the `bq-attempts` property as a string; present only to show that the outcome does not depend on it (the rule applies on the first delivery, with no retry, whatever the attempts, the native redelivery count or the max-attempts setting say). `expect.outcome`: `ack` | `dlq_raw` (the raw record is written to `.dlq`, then the delivery is acknowledged) | `unsettled` (the write failed: the delivery is neither acknowledged nor retried by the SDK and the failure is reported; negative acknowledgement). With dead-lettering off and a strategy other than `dead_letter` the outcome is `ack` (acknowledged and dropped, logged). `expect.record` (only on `dlq_raw` rows): `incoming_body`, the record written to `.dlq` has a body equal to the incoming body byte for byte and carries no `dead_letter` block (no re-encoding, no ADR-0035 wrapping). A runner checks it against every body of `incoming_bodies`, which are deliberately not in canonical form, so a writer that decodes and re-encodes fails. `expect.outcome_no_writer`: optional; a runner for an SDK in which `dead_letter: off` means NO DLQ writer exists (no publisher, sender or queue URL) asserts it instead of `expect.outcome`. It is present exactly on the rows with strategy `dead_letter` and `dead_letter: off`, where the two kinds differ (here: `ack`). Absent = both kinds assert `expect.outcome`. In every case the delivery is never decoded, no handler and no `onUnknownUrn` hook is called, and nothing is retried. The Laravel driver for this binding (if any) falls under the ADR-0037 decision 4 exception and reports every case of this section as `skip` with reason `ADR-0037 decision 4`; a package with no consumer for this binding (produce-only) follows the rule of the existing `sqs` / `artemis` binding sections (see README, \"Added in 1.8.0\"). Out of scope: Kafka (a version-gated record takes the section 6.5 poison path) and RabbitMQ.", + "incoming_bodies": [ + { + "name": "pretty-printed-escaped", + "body": "{\n \"job\": \"urn:babelqueue:order.created\",\n \"data\": { \"name\": \"caf\\u00e9\", \"path\": \"a\\/b\" },\n \"meta\": { \"schema_version\": 2 }\n}\n" + }, + { + "name": "key-order-and-number-forms", + "body": "{\"meta\": {\"schema_version\": 2}, \"data\": {\"z\": 1, \"a\": 2, \"ratio\": 1.50, \"exp\": 1E2, \"big\": 12345678901234567890}, \"job\": \"urn:babelqueue:order.created\"}" + }, + { + "name": "not-json", + "body": "plain text, not JSON \t " + } + ], + "cases": [ + { + "name": "default-dlq-on-write-ok", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "default-dlq-on-write-fails", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "fail-dlq-on-write-ok", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "fail-dlq-on-write-fails", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "fail-dlq-on-write-ok-bq-attempts-7", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "ok", + "bq_attempts": "7", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "release-dlq-on-write-ok", + "on_unknown_urn": "release", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "release-dlq-on-write-fails", + "on_unknown_urn": "release", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "delete-dlq-on-acks", + "on_unknown_urn": "delete", + "dead_letter": "on", + "expect": { + "outcome": "ack" + } + }, + { + "name": "delete-dlq-off-acks", + "on_unknown_urn": "delete", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "dead-letter-dlq-on-write-ok", + "on_unknown_urn": "dead_letter", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "dead-letter-dlq-on-write-fails", + "on_unknown_urn": "dead_letter", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "default-dlq-off-ack", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "fail-dlq-off-ack", + "on_unknown_urn": "fail", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "fail-dlq-off-ack-bq-attempts-7", + "on_unknown_urn": "fail", + "dead_letter": "off", + "bq_attempts": "7", + "expect": { + "outcome": "ack" + } + }, + { + "name": "release-dlq-off-ack", + "on_unknown_urn": "release", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "dead-letter-dlq-off-write-ok", + "on_unknown_urn": "dead_letter", + "dead_letter": "off", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body", + "outcome_no_writer": "ack" + } + }, + { + "name": "dead-letter-dlq-off-write-fails", + "on_unknown_urn": "dead_letter", + "dead_letter": "off", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled", + "outcome_no_writer": "ack" + } + } + ] + }, + "version_gate_artemis": { + "description": "Version-gate settle rule for Apache ActiveMQ Artemis (broker-bindings.md section 7.7, property `bq_schema_version`) (ADR-0037 decision 1; broker-bindings.md section 5.7 \"Version-gate settle rule\"). Scope: `artemis`. A pure function, no broker: for each case compute how the consumer settles a delivery whose schema-version property is present, not null, not blank and not the supported version, and assert `expect`. Which property values are version-gated is NOT decided here: the `schema_version_gate` block of this binding already locks the trigger. A runner feeds the delivery a property value that gates (any of that block's rejected values, for example `2`). Case `{name, on_unknown_urn?, dead_letter, dlq_write?, bq_attempts?, expect}`. `on_unknown_urn`: `fail` | `delete` | `release` | `dead_letter`; absent = `fail`. Every row is asserted: the Java, .NET, Node, Go and Python consumers of this binding have the strategy option, so none of its rows may be skipped for lack of it. `release` behaves as `fail` for a version-gated delivery (it is never released). The version gate reads only the explicitly configured `on_unknown_urn` strategy; with none configured it is `fail`. An `onUnknownUrn` hook, any strategy a hook implies (a hook-only Java or Node SQS consumer would otherwise be `delete`) and a hook's precedence over the strategy (the .NET SQS consumer) do not affect it: the hook is for an unknown URN only, so a hook-only consumer settles these rows as `fail`. `dead_letter`: `on` | `off` (dead-lettering on or off, each SDK's existing meaning; for the `dead_letter` strategy with `off` see `expect.outcome_no_writer`). `dlq_write`: `ok` | `fails`, whether the write of the raw record to `.dlq` succeeds; absent = `ok` (ADR-0037). It is read only on rows whose `expect.outcome` writes a record (`dlq_raw` or `unsettled`); it is present on those rows, and also on the `dead_letter` strategy rows with `dead_letter: off`, where an SDK with a writable DLQ target writes and an SDK without a writer asserts `outcome_no_writer` instead. `bq_attempts`: optional, the `bq-attempts` property as a string; present only to show that the outcome does not depend on it (the rule applies on the first delivery, with no retry, whatever the attempts, the native redelivery count or the max-attempts setting say). `expect.outcome`: `ack` | `dlq_raw` (the raw record is written to `.dlq`, then the delivery is acknowledged) | `unsettled` (the write failed: the delivery is neither acknowledged nor retried by the SDK and the failure is reported; JMS `session.recover()` / AMQP `released`). With dead-lettering off and a strategy other than `dead_letter` the outcome is `ack` (acknowledged and dropped, logged). `expect.record` (only on `dlq_raw` rows): `incoming_body`, the record written to `.dlq` has a body equal to the incoming body byte for byte and carries no `dead_letter` block (no re-encoding, no ADR-0035 wrapping). A runner checks it against every body of `incoming_bodies`, which are deliberately not in canonical form, so a writer that decodes and re-encodes fails. `expect.outcome_no_writer`: optional; a runner for an SDK in which `dead_letter: off` means NO DLQ writer exists (no publisher, sender or queue URL) asserts it instead of `expect.outcome`. It is present exactly on the rows with strategy `dead_letter` and `dead_letter: off`, where the two kinds differ (here: `ack`). Absent = both kinds assert `expect.outcome`. In every case the delivery is never decoded, no handler and no `onUnknownUrn` hook is called, and nothing is retried. The Laravel driver for this binding (if any) falls under the ADR-0037 decision 4 exception and reports every case of this section as `skip` with reason `ADR-0037 decision 4`; a package with no consumer for this binding (produce-only) follows the rule of the existing `sqs` / `artemis` binding sections (see README, \"Added in 1.8.0\"). Out of scope: Kafka (a version-gated record takes the section 6.5 poison path) and RabbitMQ.", + "incoming_bodies": [ + { + "name": "pretty-printed-escaped", + "body": "{\n \"job\": \"urn:babelqueue:order.created\",\n \"data\": { \"name\": \"caf\\u00e9\", \"path\": \"a\\/b\" },\n \"meta\": { \"schema_version\": 2 }\n}\n" + }, + { + "name": "key-order-and-number-forms", + "body": "{\"meta\": {\"schema_version\": 2}, \"data\": {\"z\": 1, \"a\": 2, \"ratio\": 1.50, \"exp\": 1E2, \"big\": 12345678901234567890}, \"job\": \"urn:babelqueue:order.created\"}" + }, + { + "name": "not-json", + "body": "plain text, not JSON \t " + } + ], + "cases": [ + { + "name": "default-dlq-on-write-ok", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "default-dlq-on-write-fails", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "fail-dlq-on-write-ok", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "fail-dlq-on-write-fails", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "fail-dlq-on-write-ok-bq-attempts-7", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "ok", + "bq_attempts": "7", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "release-dlq-on-write-ok", + "on_unknown_urn": "release", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "release-dlq-on-write-fails", + "on_unknown_urn": "release", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "delete-dlq-on-acks", + "on_unknown_urn": "delete", + "dead_letter": "on", + "expect": { + "outcome": "ack" + } + }, + { + "name": "delete-dlq-off-acks", + "on_unknown_urn": "delete", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "dead-letter-dlq-on-write-ok", + "on_unknown_urn": "dead_letter", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "dead-letter-dlq-on-write-fails", + "on_unknown_urn": "dead_letter", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "default-dlq-off-ack", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "fail-dlq-off-ack", + "on_unknown_urn": "fail", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "fail-dlq-off-ack-bq-attempts-7", + "on_unknown_urn": "fail", + "dead_letter": "off", + "bq_attempts": "7", + "expect": { + "outcome": "ack" + } + }, + { + "name": "release-dlq-off-ack", + "on_unknown_urn": "release", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "dead-letter-dlq-off-write-ok", + "on_unknown_urn": "dead_letter", + "dead_letter": "off", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body", + "outcome_no_writer": "ack" + } + }, + { + "name": "dead-letter-dlq-off-write-fails", + "on_unknown_urn": "dead_letter", + "dead_letter": "off", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled", + "outcome_no_writer": "ack" + } + } + ] + }, + "version_gate_sqs": { + "description": "Version-gate settle rule for Amazon SQS (broker-bindings.md section 3.7, message attribute `bq-schema-version`) (ADR-0037 decision 1; broker-bindings.md section 5.7 \"Version-gate settle rule\"). Scope: `sqs`. A pure function, no broker: for each case compute how the consumer settles a delivery whose schema-version property is present, not null, not blank and not the supported version, and assert `expect`. Which property values are version-gated is NOT decided here: the `schema_version_gate` block of this binding already locks the trigger. A runner feeds the delivery a property value that gates (any of that block's rejected values, for example `2`). Case `{name, on_unknown_urn?, dead_letter, dlq_write?, bq_attempts?, expect}`. `on_unknown_urn`: `fail` | `delete` | `release` | `dead_letter`; absent = `fail`. Every row is asserted: the Java, .NET, Node, Go and Python consumers of this binding have the strategy option, so none of its rows may be skipped for lack of it. `release` behaves as `fail` for a version-gated delivery (it is never released). The version gate reads only the explicitly configured `on_unknown_urn` strategy; with none configured it is `fail`. An `onUnknownUrn` hook, any strategy a hook implies (a hook-only Java or Node SQS consumer would otherwise be `delete`) and a hook's precedence over the strategy (the .NET SQS consumer) do not affect it: the hook is for an unknown URN only, so a hook-only consumer settles these rows as `fail`. `dead_letter`: `on` | `off` (dead-lettering on or off, each SDK's existing meaning; for the `dead_letter` strategy with `off` see `expect.outcome_no_writer`). `dlq_write`: `ok` | `fails`, whether the write of the raw record to `.dlq` succeeds; absent = `ok` (ADR-0037). It is read only on rows whose `expect.outcome` writes a record (`dlq_raw` or `unsettled`); it is present on those rows, and also on the `dead_letter` strategy rows with `dead_letter: off`, where an SDK with a writable DLQ target writes and an SDK without a writer asserts `outcome_no_writer` instead. `bq_attempts`: optional, the `bq-attempts` property as a string; present only to show that the outcome does not depend on it (the rule applies on the first delivery, with no retry, whatever the attempts, the native redelivery count or the max-attempts setting say). `expect.outcome`: `ack` | `dlq_raw` (the raw record is written to `.dlq`, then the delivery is acknowledged) | `unsettled` (the write failed: the delivery is neither acknowledged nor retried by the SDK and the failure is reported; no `DeleteMessage`). With dead-lettering off and a strategy other than `dead_letter` the outcome is `native` (the SDK does not settle it and leaves it to the broker's native mechanism: SQS no `DeleteMessage`, so visibility expiry redelivers it and the queue's `RedrivePolicy` / retention applies; it is reported, not retried by the SDK). `expect.record` (only on `dlq_raw` rows): `incoming_body`, the record written to `.dlq` has a body equal to the incoming body byte for byte and carries no `dead_letter` block (no re-encoding, no ADR-0035 wrapping). A runner checks it against every body of `incoming_bodies`, which are deliberately not in canonical form, so a writer that decodes and re-encodes fails. `expect.outcome_no_writer`: optional; a runner for an SDK in which `dead_letter: off` means NO DLQ writer exists (no publisher, sender or queue URL) asserts it instead of `expect.outcome`. It is present exactly on the rows with strategy `dead_letter` and `dead_letter: off`, where the two kinds differ (here: `native`). Absent = both kinds assert `expect.outcome`. In every case the delivery is never decoded, no handler and no `onUnknownUrn` hook is called, and nothing is retried. The Laravel driver for this binding (if any) falls under the ADR-0037 decision 4 exception and reports every case of this section as `skip` with reason `ADR-0037 decision 4`; a package with no consumer for this binding (produce-only) follows the rule of the existing `sqs` / `artemis` binding sections (see README, \"Added in 1.8.0\"). Out of scope: Kafka (a version-gated record takes the section 6.5 poison path) and RabbitMQ.", + "incoming_bodies": [ + { + "name": "pretty-printed-escaped", + "body": "{\n \"job\": \"urn:babelqueue:order.created\",\n \"data\": { \"name\": \"caf\\u00e9\", \"path\": \"a\\/b\" },\n \"meta\": { \"schema_version\": 2 }\n}\n" + }, + { + "name": "key-order-and-number-forms", + "body": "{\"meta\": {\"schema_version\": 2}, \"data\": {\"z\": 1, \"a\": 2, \"ratio\": 1.50, \"exp\": 1E2, \"big\": 12345678901234567890}, \"job\": \"urn:babelqueue:order.created\"}" + }, + { + "name": "not-json", + "body": "plain text, not JSON \t " + } + ], + "cases": [ + { + "name": "default-dlq-on-write-ok", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "default-dlq-on-write-fails", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "fail-dlq-on-write-ok", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "fail-dlq-on-write-fails", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "fail-dlq-on-write-ok-bq-attempts-7", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "ok", + "bq_attempts": "7", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "release-dlq-on-write-ok", + "on_unknown_urn": "release", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "release-dlq-on-write-fails", + "on_unknown_urn": "release", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "delete-dlq-on-acks", + "on_unknown_urn": "delete", + "dead_letter": "on", + "expect": { + "outcome": "ack" + } + }, + { + "name": "delete-dlq-off-acks", + "on_unknown_urn": "delete", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "dead-letter-dlq-on-write-ok", + "on_unknown_urn": "dead_letter", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "dead-letter-dlq-on-write-fails", + "on_unknown_urn": "dead_letter", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "default-dlq-off-native", + "dead_letter": "off", + "expect": { + "outcome": "native" + } + }, + { + "name": "fail-dlq-off-native", + "on_unknown_urn": "fail", + "dead_letter": "off", + "expect": { + "outcome": "native" + } + }, + { + "name": "fail-dlq-off-native-bq-attempts-7", + "on_unknown_urn": "fail", + "dead_letter": "off", + "bq_attempts": "7", + "expect": { + "outcome": "native" + } + }, + { + "name": "release-dlq-off-native", + "on_unknown_urn": "release", + "dead_letter": "off", + "expect": { + "outcome": "native" + } + }, + { + "name": "dead-letter-dlq-off-write-ok", + "on_unknown_urn": "dead_letter", + "dead_letter": "off", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body", + "outcome_no_writer": "native" + } + }, + { + "name": "dead-letter-dlq-off-write-fails", + "on_unknown_urn": "dead_letter", + "dead_letter": "off", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled", + "outcome_no_writer": "native" + } + } + ] + }, + "version_gate_asb": { + "description": "Version-gate settle rule for Azure Service Bus (broker-bindings.md section 4.7, application property `bq-schema-version`) (ADR-0037 decision 1; broker-bindings.md section 5.7 \"Version-gate settle rule\"). Scope: `asb`. A pure function, no broker: for each case compute how the consumer settles a delivery whose schema-version property is present, not null, not blank and not the supported version, and assert `expect`. Which property values are version-gated is NOT decided here: the `schema_version_gate` block of this binding already locks the trigger. A runner feeds the delivery a property value that gates (any of that block's rejected values, for example `2`). Case `{name, on_unknown_urn?, dead_letter, dlq_write?, bq_attempts?, expect}`. `on_unknown_urn`: `fail` | `delete` | `release` | `dead_letter`; absent = `fail`. A row with any value other than `fail` applies only to an SDK whose consumer has the strategy option; the Java, .NET and Node Azure Service Bus consumers have only an `onUnknownUrn` callback and no strategy option (they behave as `fail`), so a runner for one of them reports those rows as `skip` citing ADR-0037, not as `pass`; the Go and Python consumers have the option and assert every row. `release` behaves as `fail` for a version-gated delivery (it is never released). The version gate reads only the explicitly configured `on_unknown_urn` strategy; with none configured it is `fail`. An `onUnknownUrn` hook, any strategy a hook implies (a hook-only Java or Node SQS consumer would otherwise be `delete`) and a hook's precedence over the strategy (the .NET SQS consumer) do not affect it: the hook is for an unknown URN only, so a hook-only consumer settles these rows as `fail`. `dead_letter`: `on` | `off` (dead-lettering on or off, each SDK's existing meaning; for the `dead_letter` strategy with `off` see `expect.outcome_no_writer`). `dlq_write`: `ok` | `fails`, whether the write of the raw record to `.dlq` succeeds; absent = `ok` (ADR-0037). It is read only on rows whose `expect.outcome` writes a record (`dlq_raw` or `unsettled`); it is present on those rows, and also on the `dead_letter` strategy rows with `dead_letter: off`, where an SDK with a writable DLQ target writes and an SDK without a writer asserts `outcome_no_writer` instead. `bq_attempts`: optional, the `bq-attempts` property as a string; present only to show that the outcome does not depend on it (the rule applies on the first delivery, with no retry, whatever the attempts, the native redelivery count or the max-attempts setting say). `expect.outcome`: `ack` | `dlq_raw` (the raw record is written to `.dlq`, then the delivery is acknowledged) | `unsettled` (the write failed: the delivery is neither acknowledged nor retried by the SDK and the failure is reported; `Abandon`). With dead-lettering off and a strategy other than `dead_letter` the outcome is `native` (the SDK does not settle it and leaves it to the broker's native mechanism: an explicit native `DeadLetter(...)` into `$DeadLetterQueue` or `Abandon` until `MaxDeliveryCount`; it is reported, not retried by the SDK). `expect.record` (only on `dlq_raw` rows): `incoming_body`, the record written to `.dlq` has a body equal to the incoming body byte for byte and carries no `dead_letter` block (no re-encoding, no ADR-0035 wrapping). A runner checks it against every body of `incoming_bodies`, which are deliberately not in canonical form, so a writer that decodes and re-encodes fails. `expect.outcome_no_writer`: optional; a runner for an SDK in which `dead_letter: off` means NO DLQ writer exists (no publisher, sender or queue URL) asserts it instead of `expect.outcome`. It is present exactly on the rows with strategy `dead_letter` and `dead_letter: off`, where the two kinds differ (here: `native`). Absent = both kinds assert `expect.outcome`. In every case the delivery is never decoded, no handler and no `onUnknownUrn` hook is called, and nothing is retried. The Laravel driver for this binding (if any) falls under the ADR-0037 decision 4 exception and reports every case of this section as `skip` with reason `ADR-0037 decision 4`; a package with no consumer for this binding (produce-only) follows the rule of the existing `sqs` / `artemis` binding sections (see README, \"Added in 1.8.0\"). Out of scope: Kafka (a version-gated record takes the section 6.5 poison path) and RabbitMQ.", + "incoming_bodies": [ + { + "name": "pretty-printed-escaped", + "body": "{\n \"job\": \"urn:babelqueue:order.created\",\n \"data\": { \"name\": \"caf\\u00e9\", \"path\": \"a\\/b\" },\n \"meta\": { \"schema_version\": 2 }\n}\n" + }, + { + "name": "key-order-and-number-forms", + "body": "{\"meta\": {\"schema_version\": 2}, \"data\": {\"z\": 1, \"a\": 2, \"ratio\": 1.50, \"exp\": 1E2, \"big\": 12345678901234567890}, \"job\": \"urn:babelqueue:order.created\"}" + }, + { + "name": "not-json", + "body": "plain text, not JSON \t " + } + ], + "cases": [ + { + "name": "default-dlq-on-write-ok", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "default-dlq-on-write-fails", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "fail-dlq-on-write-ok", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "fail-dlq-on-write-fails", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "fail-dlq-on-write-ok-bq-attempts-7", + "on_unknown_urn": "fail", + "dead_letter": "on", + "dlq_write": "ok", + "bq_attempts": "7", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "release-dlq-on-write-ok", + "on_unknown_urn": "release", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "release-dlq-on-write-fails", + "on_unknown_urn": "release", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "delete-dlq-on-acks", + "on_unknown_urn": "delete", + "dead_letter": "on", + "expect": { + "outcome": "ack" + } + }, + { + "name": "delete-dlq-off-acks", + "on_unknown_urn": "delete", + "dead_letter": "off", + "expect": { + "outcome": "ack" + } + }, + { + "name": "dead-letter-dlq-on-write-ok", + "on_unknown_urn": "dead_letter", + "dead_letter": "on", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body" + } + }, + { + "name": "dead-letter-dlq-on-write-fails", + "on_unknown_urn": "dead_letter", + "dead_letter": "on", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled" + } + }, + { + "name": "default-dlq-off-native", + "dead_letter": "off", + "expect": { + "outcome": "native" + } + }, + { + "name": "fail-dlq-off-native", + "on_unknown_urn": "fail", + "dead_letter": "off", + "expect": { + "outcome": "native" + } + }, + { + "name": "fail-dlq-off-native-bq-attempts-7", + "on_unknown_urn": "fail", + "dead_letter": "off", + "bq_attempts": "7", + "expect": { + "outcome": "native" + } + }, + { + "name": "release-dlq-off-native", + "on_unknown_urn": "release", + "dead_letter": "off", + "expect": { + "outcome": "native" + } + }, + { + "name": "dead-letter-dlq-off-write-ok", + "on_unknown_urn": "dead_letter", + "dead_letter": "off", + "dlq_write": "ok", + "expect": { + "outcome": "dlq_raw", + "record": "incoming_body", + "outcome_no_writer": "native" + } + }, + { + "name": "dead-letter-dlq-off-write-fails", + "on_unknown_urn": "dead_letter", + "dead_letter": "off", + "dlq_write": "fails", + "expect": { + "outcome": "unsettled", + "outcome_no_writer": "native" + } + } + ] + }, + "span-shape": { + "description": "OpenTelemetry span shape (ADR-0038, semantic conventions v1.44.0). Scope: `otel` (ADR-0032 section 2). Every case has `mode: span` and describes one producer span (`operation: send`) or one consumer span (`operation: process`) for one message. Inputs: `operation`; `system` (the `system` option; absent or empty means `babelqueue`); `queue` (send: the queue/topic given to `make`, which becomes `meta.queue` of the produced envelope, and when it is absent `make` writes `default`; process: `meta.queue` of the delivered envelope; empty or blank under the ASCII whitespace set means no destination, and so does an absent `meta.queue` on a delivered envelope); `urn` (the `job`); `consumer_group` (the option, process only); for `process` cases `trace_id`, `message_id` (`meta.id`), `attempts` and `schema_version` (the delivered envelope's `trace_id`, `meta.id`, top-level `attempts` and `meta.schema_version`); `outcome` (`ok`, or `error` for a failing publish or handler); `envelope_built: false` (send only: no envelope is ever handed to the transport, because the build or the encode failed; an envelope counts as built only once it has been handed to the transport). A runner builds the span through the SDK's `otel` wrapper (with an in-memory exporter) and compares. PRODUCER VALUES: a producer does not control `meta.id` (each SDK generates it) and stamps the active trace's id into `trace_id` (ADR-0028, ADR-0038 section 4), so `send` cases carry no `message_id` or `trace_id` input. Their expectations use the placeholders `$produced.meta.id` and `$produced.trace_id` for `messaging.message.id` and `messaging.message.conversation_id`, and, where the case gives no usable queue (absent, empty or blank), `$produced.meta.queue` for `messaging.destination.name` and the span name suffix (when the produced `meta.queue` is empty or blank the attribute is absent and the name is the bare `send`): the runner reads the produced envelope (the envelope handed to the transport, also when that publish fails), substitutes the placeholders and compares the rest literally. The `meta.id` and `trace_id` values MUST be present and equal the produced envelope's. Where a `process` case fixes the delivered envelope, the runner builds the delivered envelope itself and may bypass the decoder for the cases that omit fields the envelope schema requires (an absent or empty `meta.queue`, an absent `meta.id`). `expect.span_name` and `expect.span_kind` are exact; `expect.span_name_one_of` replaces `span_name` where the case does not pin the name. ATTRIBUTES: every key of `expect.attributes` MUST be present with exactly the given value (strings; integers for `babelqueue.schema_version`, `babelqueue.attempts` and `messaging.babelqueue.attempts`). The ADR-0038 section 4 table keys are `messaging.system`, `messaging.operation.type`, `messaging.operation.name`, `messaging.destination.name`, `messaging.message.id`, `messaging.message.conversation_id`, `messaging.consumer.group.name`, `babelqueue.urn`, `babelqueue.schema_version`, `babelqueue.attempts`, `error.type`, and the deprecated transition keys `messaging.operation` and `messaging.babelqueue.attempts`. A table key that `expect.attributes` does not list MUST NOT be on the span, except those in `expect.optional_attributes`, which may be present or absent; when an optional attribute is present it MUST equal the value the case input implies (for `messaging.destination.name` the queue given to the producer) and the span name MUST agree with it. Any `babelqueue.*` key outside the table MUST NOT be on the span. A semconv-defined attribute outside the table (for example `messaging.destination.partition.id`) MAY be present: the suite asserts only the tables (ADR-0038 section 4, last note). The attributes include the deprecated transition keys, which ADR-0038 section 5 dual-emits for one MINOR; the conformance version that follows their removal drops them from every case. `expect.status` is `unset` or `error`. On `error` cases `expect.error_type` is `present`: the span MUST carry a non-empty string `error.type` (its value is a language-specific type name and is not asserted) and `attributes` omits `error.type`; on `ok` cases the span MUST NOT carry `error.type`. The URN is never in the span name. `messaging.system` values outside the ADR-0038 section 3 set are written as given.", + "cases": [ + { + "name": "send-default-system", + "mode": "span", + "operation": "send", + "queue": "orders", + "urn": "urn:acme:order.created", + "outcome": "ok", + "expect": { + "span_name": "send orders", + "span_kind": "PRODUCER", + "status": "unset", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.type": "send", + "messaging.operation.name": "send", + "messaging.destination.name": "orders", + "messaging.message.id": "$produced.meta.id", + "messaging.message.conversation_id": "$produced.trace_id", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "messaging.operation": "publish" + } + }, + "description": "No `system` option: `messaging.system` is `babelqueue` (the deprecated default, ADR-0038 section 5). Span name is `send `, never the URN." + }, + { + "name": "send-system-redis", + "mode": "span", + "operation": "send", + "system": "redis", + "queue": "orders", + "urn": "urn:acme:order.created", + "outcome": "ok", + "expect": { + "span_name": "send orders", + "span_kind": "PRODUCER", + "status": "unset", + "attributes": { + "messaging.system": "redis", + "messaging.operation.type": "send", + "messaging.operation.name": "send", + "messaging.destination.name": "orders", + "messaging.message.id": "$produced.meta.id", + "messaging.message.conversation_id": "$produced.trace_id", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "messaging.operation": "publish" + } + } + }, + { + "name": "send-system-rabbitmq", + "mode": "span", + "operation": "send", + "system": "rabbitmq", + "queue": "orders", + "urn": "urn:acme:order.created", + "outcome": "ok", + "expect": { + "span_name": "send orders", + "span_kind": "PRODUCER", + "status": "unset", + "attributes": { + "messaging.system": "rabbitmq", + "messaging.operation.type": "send", + "messaging.operation.name": "send", + "messaging.destination.name": "orders", + "messaging.message.id": "$produced.meta.id", + "messaging.message.conversation_id": "$produced.trace_id", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "messaging.operation": "publish" + } + } + }, + { + "name": "send-system-aws_sqs", + "mode": "span", + "operation": "send", + "system": "aws_sqs", + "queue": "orders", + "urn": "urn:acme:order.created", + "outcome": "ok", + "expect": { + "span_name": "send orders", + "span_kind": "PRODUCER", + "status": "unset", + "attributes": { + "messaging.system": "aws_sqs", + "messaging.operation.type": "send", + "messaging.operation.name": "send", + "messaging.destination.name": "orders", + "messaging.message.id": "$produced.meta.id", + "messaging.message.conversation_id": "$produced.trace_id", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "messaging.operation": "publish" + } + } + }, + { + "name": "send-system-servicebus", + "mode": "span", + "operation": "send", + "system": "servicebus", + "queue": "orders", + "urn": "urn:acme:order.created", + "outcome": "ok", + "expect": { + "span_name": "send orders", + "span_kind": "PRODUCER", + "status": "unset", + "attributes": { + "messaging.system": "servicebus", + "messaging.operation.type": "send", + "messaging.operation.name": "send", + "messaging.destination.name": "orders", + "messaging.message.id": "$produced.meta.id", + "messaging.message.conversation_id": "$produced.trace_id", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "messaging.operation": "publish" + } + } + }, + { + "name": "send-system-pulsar", + "mode": "span", + "operation": "send", + "system": "pulsar", + "queue": "orders", + "urn": "urn:acme:order.created", + "outcome": "ok", + "expect": { + "span_name": "send orders", + "span_kind": "PRODUCER", + "status": "unset", + "attributes": { + "messaging.system": "pulsar", + "messaging.operation.type": "send", + "messaging.operation.name": "send", + "messaging.destination.name": "orders", + "messaging.message.id": "$produced.meta.id", + "messaging.message.conversation_id": "$produced.trace_id", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "messaging.operation": "publish" + } + } + }, + { + "name": "send-system-kafka", + "mode": "span", + "operation": "send", + "system": "kafka", + "queue": "orders.events", + "urn": "urn:acme:order.created", + "outcome": "ok", + "expect": { + "span_name": "send orders.events", + "span_kind": "PRODUCER", + "status": "unset", + "attributes": { + "messaging.system": "kafka", + "messaging.operation.type": "send", + "messaging.operation.name": "send", + "messaging.destination.name": "orders.events", + "messaging.message.id": "$produced.meta.id", + "messaging.message.conversation_id": "$produced.trace_id", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "messaging.operation": "publish" + } + } + }, + { + "name": "send-system-activemq", + "mode": "span", + "operation": "send", + "system": "activemq", + "queue": "orders", + "urn": "urn:acme:order.created", + "outcome": "ok", + "expect": { + "span_name": "send orders", + "span_kind": "PRODUCER", + "status": "unset", + "attributes": { + "messaging.system": "activemq", + "messaging.operation.type": "send", + "messaging.operation.name": "send", + "messaging.destination.name": "orders", + "messaging.message.id": "$produced.meta.id", + "messaging.message.conversation_id": "$produced.trace_id", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "messaging.operation": "publish" + } + } + }, + { + "name": "send-system-custom-not-validated", + "mode": "span", + "operation": "send", + "system": "my-broker", + "queue": "orders", + "urn": "urn:acme:order.created", + "outcome": "ok", + "expect": { + "span_name": "send orders", + "span_kind": "PRODUCER", + "status": "unset", + "attributes": { + "messaging.system": "my-broker", + "messaging.operation.type": "send", + "messaging.operation.name": "send", + "messaging.destination.name": "orders", + "messaging.message.id": "$produced.meta.id", + "messaging.message.conversation_id": "$produced.trace_id", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "messaging.operation": "publish" + } + }, + "description": "A `system` outside the ADR-0038 section 3 set is written as given: no error, no warning, no normalisation." + }, + { + "name": "send-empty-system-counts-as-absent", + "mode": "span", + "operation": "send", + "system": "", + "queue": "orders", + "urn": "urn:acme:order.created", + "outcome": "ok", + "expect": { + "span_name": "send orders", + "span_kind": "PRODUCER", + "status": "unset", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.type": "send", + "messaging.operation.name": "send", + "messaging.destination.name": "orders", + "messaging.message.id": "$produced.meta.id", + "messaging.message.conversation_id": "$produced.trace_id", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "messaging.operation": "publish" + } + }, + "description": "An empty `system` counts as absent (ADR-0038 section 3): `messaging.system` is `babelqueue`." + }, + { + "name": "send-empty-queue-follows-produced-meta-queue", + "mode": "span", + "operation": "send", + "queue": "", + "urn": "urn:acme:order.created", + "outcome": "ok", + "expect": { + "span_name": "send $produced.meta.queue", + "span_kind": "PRODUCER", + "status": "unset", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.type": "send", + "messaging.operation.name": "send", + "messaging.destination.name": "$produced.meta.queue", + "messaging.message.id": "$produced.meta.id", + "messaging.message.conversation_id": "$produced.trace_id", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "messaging.operation": "publish" + } + }, + "description": "An empty queue is given to the producer. What the produced envelope then carries is the SDK's business (Python and Go fall back to the app's default queue, others may keep the empty string), and ADR-0038 section 4 makes the destination `meta.queue` of the produced envelope. So the expectation uses the placeholder `$produced.meta.queue`: if the produced `meta.queue` is empty or blank, `messaging.destination.name` is not written and the span name is the bare `send`; otherwise the attribute equals it and the span name is `send `. `babelqueue.urn` is always written." + }, + { + "name": "send-absent-queue-follows-produced-meta-queue", + "mode": "span", + "operation": "send", + "urn": "urn:acme:order.created", + "outcome": "ok", + "expect": { + "span_name": "send $produced.meta.queue", + "span_kind": "PRODUCER", + "status": "unset", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.type": "send", + "messaging.operation.name": "send", + "messaging.destination.name": "$produced.meta.queue", + "messaging.message.id": "$produced.meta.id", + "messaging.message.conversation_id": "$produced.trace_id", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "messaging.operation": "publish" + } + }, + "description": "No queue given to the producer: the envelope carries the SDK's default (`default` from `make`, or the app's configured default queue), and the producer destination equals `meta.queue` of the produced envelope (ADR-0038 section 4). The expectation uses the placeholder `$produced.meta.queue` with the same rule as the empty-queue case." + }, + { + "name": "send-failure-after-envelope-built", + "mode": "span", + "operation": "send", + "system": "rabbitmq", + "queue": "orders", + "urn": "urn:acme:order.created", + "outcome": "error", + "expect": { + "span_name": "send orders", + "span_kind": "PRODUCER", + "status": "error", + "error_type": "present", + "attributes": { + "messaging.system": "rabbitmq", + "messaging.operation.type": "send", + "messaging.operation.name": "send", + "messaging.destination.name": "orders", + "messaging.message.id": "$produced.meta.id", + "messaging.message.conversation_id": "$produced.trace_id", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "messaging.operation": "publish" + } + }, + "description": "The publish fails after the envelope was handed to the transport (so it exists and the runner can read it): status Error, `error.type` present and non-empty (its value is language-specific and not asserted, ADR-0038 section 6). `error.type` is therefore not in `attributes`; every other attribute is unchanged." + }, + { + "name": "send-failure-before-envelope-built", + "mode": "span", + "operation": "send", + "queue": "orders", + "urn": "urn:acme:order.created", + "envelope_built": false, + "outcome": "error", + "expect": { + "span_name_one_of": [ + "send", + "send orders" + ], + "span_kind": "PRODUCER", + "status": "error", + "error_type": "present", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.type": "send", + "messaging.operation.name": "send", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "messaging.operation": "publish" + }, + "optional_attributes": [ + "messaging.destination.name" + ] + }, + "description": "The build or the encode fails (for example a `data` payload the encoder rejects, which in Go and Python is an encode failure after a successful `make`), so no envelope is handed to the transport and none exists for the span: `messaging.message.id` and `messaging.message.conversation_id` are not written (ADR-0038 section 4: once the envelope exists; an envelope that was built but never handed to the transport does not count). The producer SHOULD resolve the queue before it starts the span but MAY learn it only after the build (ADR-0038 section 4), so the case does not pin the destination: `messaging.destination.name` is optional (`expect.optional_attributes`; when present it MUST equal the queue given to the producer, here `orders`, and then the span name is `send orders`, otherwise the bare `send`) and the span name is either of `expect.span_name_one_of`. The runner makes the envelope build fail by an SDK-specific means (for example a `data` payload the encoder rejects)." + }, + { + "name": "process-default-system", + "mode": "span", + "operation": "process", + "queue": "orders", + "urn": "urn:acme:order.created", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process orders", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + }, + "description": "No `system` option: `messaging.system` is `babelqueue`. Span name is `process `, never the URN; the URN is `babelqueue.urn`." + }, + { + "name": "process-system-redis", + "mode": "span", + "operation": "process", + "system": "redis", + "queue": "orders", + "urn": "urn:acme:order.created", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process orders", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "redis", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + } + }, + { + "name": "process-system-rabbitmq", + "mode": "span", + "operation": "process", + "system": "rabbitmq", + "queue": "orders", + "urn": "urn:acme:order.created", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process orders", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "rabbitmq", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + } + }, + { + "name": "process-system-aws_sqs", + "mode": "span", + "operation": "process", + "system": "aws_sqs", + "queue": "orders", + "urn": "urn:acme:order.created", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process orders", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "aws_sqs", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + } + }, + { + "name": "process-system-servicebus", + "mode": "span", + "operation": "process", + "system": "servicebus", + "queue": "orders", + "urn": "urn:acme:order.created", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process orders", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "servicebus", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + } + }, + { + "name": "process-system-pulsar", + "mode": "span", + "operation": "process", + "system": "pulsar", + "queue": "orders", + "urn": "urn:acme:order.created", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process orders", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "pulsar", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + } + }, + { + "name": "process-system-kafka", + "mode": "span", + "operation": "process", + "system": "kafka", + "queue": "orders.events", + "urn": "urn:acme:order.created", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process orders.events", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "kafka", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders.events", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + } + }, + { + "name": "process-system-activemq", + "mode": "span", + "operation": "process", + "system": "activemq", + "queue": "orders", + "urn": "urn:acme:order.created", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process orders", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "activemq", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + } + }, + { + "name": "process-system-custom-not-validated", + "mode": "span", + "operation": "process", + "system": "my-broker", + "queue": "orders", + "urn": "urn:acme:order.created", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process orders", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "my-broker", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + }, + "description": "A `system` outside the ADR-0038 section 3 set is written as given." + }, + { + "name": "process-empty-system-counts-as-absent", + "mode": "span", + "operation": "process", + "system": "", + "queue": "orders", + "urn": "urn:acme:order.created", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process orders", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + }, + "description": "An empty `system` counts as absent (ADR-0038 section 3)." + }, + { + "name": "process-kafka-consumer-group", + "mode": "span", + "operation": "process", + "system": "kafka", + "queue": "orders.events", + "urn": "urn:acme:order.created", + "consumer_group": "billing-workers", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process orders.events", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "kafka", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders.events", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "messaging.consumer.group.name": "billing-workers", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + }, + "description": "Kafka with the `consumer_group` option: `messaging.consumer.group.name` is written." + }, + { + "name": "process-consumer-group-on-non-kafka-system", + "mode": "span", + "operation": "process", + "system": "rabbitmq", + "queue": "orders", + "urn": "urn:acme:order.created", + "consumer_group": "billing-workers", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process orders", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "rabbitmq", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "messaging.consumer.group.name": "billing-workers", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + }, + "description": "`consumer_group` is intended for Kafka but is written whenever given (ADR-0038 section 2)." + }, + { + "name": "process-no-consumer-group-option", + "mode": "span", + "operation": "process", + "system": "kafka", + "queue": "orders.events", + "urn": "urn:acme:order.created", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process orders.events", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "kafka", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders.events", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + }, + "description": "Kafka without the `consumer_group` option: `messaging.consumer.group.name` is not written." + }, + { + "name": "process-empty-queue", + "mode": "span", + "operation": "process", + "queue": "", + "urn": "urn:acme:order.created", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + }, + "description": "`meta.queue` is empty: span name is the bare `process`, `messaging.destination.name` is not written, `babelqueue.urn` is." + }, + { + "name": "process-absent-queue", + "mode": "span", + "operation": "process", + "urn": "urn:acme:order.created", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + }, + "description": "`meta.queue` is absent: same as an empty queue." + }, + { + "name": "process-blank-queue", + "mode": "span", + "operation": "process", + "queue": " ", + "urn": "urn:acme:order.created", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + }, + "description": "`meta.queue` is blank under the ASCII whitespace set (ADR-0038 section 4: absent or blank): treated as no destination." + }, + { + "name": "process-absent-message-id", + "mode": "span", + "operation": "process", + "queue": "orders", + "urn": "urn:acme:order.created", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process orders", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + }, + "description": "`meta.id` is absent: `messaging.message.id` is not written (ADR-0038 section 4: when present)." + }, + { + "name": "process-attempts-zero", + "mode": "span", + "operation": "process", + "queue": "orders", + "urn": "urn:acme:order.created", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process orders", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + }, + "description": "`attempts` 0: `babelqueue.attempts` and the deprecated `messaging.babelqueue.attempts` are both written, with 0." + }, + { + "name": "process-attempts-positive", + "mode": "span", + "operation": "process", + "queue": "orders", + "urn": "urn:acme:order.created", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 3, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process orders", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 3, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 3 + } + }, + "description": "`attempts` 3: both `babelqueue.attempts` and the deprecated `messaging.babelqueue.attempts` are 3." + }, + { + "name": "process-non-uuid-trace-id", + "mode": "span", + "operation": "process", + "queue": "orders", + "urn": "urn:acme:order.created", + "trace_id": "abc", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process orders", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "abc", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + }, + "description": "A non-UUID `trace_id` is written to `messaging.message.conversation_id` byte for byte (ADR-0038 section 4 and 7)." + }, + { + "name": "process-trace-id-with-inner-spaces", + "mode": "span", + "operation": "process", + "queue": "orders", + "urn": "urn:acme:order.created", + "trace_id": "order 42 / trace", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process orders", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "order 42 / trace", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + }, + "description": "A non-UUID `trace_id` with inner spaces is written byte for byte." + }, + { + "name": "process-padded-trace-id", + "mode": "span", + "operation": "process", + "queue": "orders", + "urn": "urn:acme:order.created", + "trace_id": " 7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b\t", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 0, + "schema_version": 1, + "outcome": "ok", + "expect": { + "span_name": "process orders", + "span_kind": "CONSUMER", + "status": "unset", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": " 7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b\t", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 0, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 0 + } + }, + "description": "A `trace_id` with surrounding ASCII whitespace is written byte for byte: no trim on consume (ADR-0032, message-envelope.md section 5.1)." + }, + { + "name": "process-handler-failure", + "mode": "span", + "operation": "process", + "system": "kafka", + "queue": "orders.events", + "urn": "urn:acme:order.created", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "message_id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "attempts": 2, + "schema_version": 1, + "outcome": "error", + "expect": { + "span_name": "process orders.events", + "span_kind": "CONSUMER", + "status": "error", + "error_type": "present", + "attributes": { + "messaging.system": "kafka", + "messaging.operation.type": "process", + "messaging.operation.name": "process", + "messaging.destination.name": "orders.events", + "messaging.message.id": "0b6f3e1c-5d2a-4c7e-9a41-8f3d2b6c1e90", + "messaging.message.conversation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "babelqueue.urn": "urn:acme:order.created", + "babelqueue.schema_version": 1, + "babelqueue.attempts": 2, + "messaging.operation": "process", + "messaging.babelqueue.attempts": 2 + } + }, + "description": "The handler fails: status Error, `error.type` present and non-empty (value not asserted, ADR-0038 section 6), error recorded, error returned to the caller unchanged. Every other attribute is as on success." + } + ] + }, + "trace-id": { + "description": "Non-UUID `trace_id` rule and the OpenTelemetry TraceID mapping (ADR-0038 section 7, message-envelope.md sections 5.1 and 7 rule 2, ADR-0025, ADR-0032 section 5). Scope: `otel` (ADR-0032 section 2). Every case has a `mode`. `map`: the OTel mapping of `trace_id` MUST give `expect.trace_id_hex` (32 lower-case hex digits) through `expect.mapping`: `decode` (the value is 32 hex digits once the hyphens are removed and is not all zero: the bytes of the UUID, no hashing) or `hash` (SHA-256 of the value's UTF-8 bytes, first 16 bytes). The edge cases are pinned to the Go reference `otel.TraceIDOf`: hyphens are optional, hex is case-insensitive, the all-zero value is hashed because it is not a valid TraceID, and there is no trim, so a value with a leading or trailing ASCII space is hashed over its raw bytes. `decode`: decoding an envelope that carries `trace_id` succeeds and the decoded `trace_id` equals `expect.trace_id` byte for byte (no rejection, no regeneration). `reemit`: decode, increment `attempts`, re-encode: the written `trace_id` equals `expect.trace_id` byte for byte; re-emit never trims. `downstream`: a consumer handling an envelope produces a downstream message with `make(urn, trace_id)` given the inbound value: the produced `trace_id` equals `expect.trace_id` and `make` does not raise (no value here has ASCII padding, so the ADR-0032 trim is a no-op). `make`: the producer's `make` with a caller-supplied `trace_id` (and `urn`) produces an envelope whose `trace_id` equals `expect.trace_id`, the ADR-0032 trimmed value, never rejected and never regenerated; `expect.trace_id_hex` is the OTel mapping of that stored value. The warning the mapping SHOULD log when it hashes a value (message-envelope.md section 5.1) is not asserted.", + "cases": [ + { + "name": "uuid-lowercase-hyphenated", + "mode": "map", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "expect": { + "trace_id_hex": "7b3f9c2ae41d4f889b2a1c0d5e6f7a8b", + "mapping": "decode" + }, + "description": "A UUID decodes to its 16 bytes: `trace_id_hex` is the value without hyphens." + }, + { + "name": "uuid-lowercase-hyphenated-other", + "mode": "map", + "trace_id": "4bf92f35-77b3-4da6-a3ce-929d0e0e4736", + "expect": { + "trace_id_hex": "4bf92f3577b34da6a3ce929d0e0e4736", + "mapping": "decode" + } + }, + { + "name": "uuid-without-hyphens", + "mode": "map", + "trace_id": "7b3f9c2ae41d4f889b2a1c0d5e6f7a8b", + "expect": { + "trace_id_hex": "7b3f9c2ae41d4f889b2a1c0d5e6f7a8b", + "mapping": "decode" + }, + "description": "32 hex digits without hyphens decode as a UUID and are not hashed (Go reference `TraceIDOf`)." + }, + { + "name": "uuid-uppercase-hyphenated", + "mode": "map", + "trace_id": "7B3F9C2A-E41D-4F88-9B2A-1C0D5E6F7A8B", + "expect": { + "trace_id_hex": "7b3f9c2ae41d4f889b2a1c0d5e6f7a8b", + "mapping": "decode" + }, + "description": "Upper-case hex decodes (not hashed); `trace_id_hex` is the lower-case form of the bytes." + }, + { + "name": "uuid-uppercase-without-hyphens", + "mode": "map", + "trace_id": "7B3F9C2AE41D4F889B2A1C0D5E6F7A8B", + "expect": { + "trace_id_hex": "7b3f9c2ae41d4f889b2a1c0d5e6f7a8b", + "mapping": "decode" + } + }, + { + "name": "uuid-mixed-case", + "mode": "map", + "trace_id": "7b3F9c2A-e41D-4f88-9B2a-1c0D5e6F7a8B", + "expect": { + "trace_id_hex": "7b3f9c2ae41d4f889b2a1c0d5e6f7a8b", + "mapping": "decode" + } + }, + { + "name": "all-zero-uuid-is-hashed", + "mode": "map", + "trace_id": "00000000-0000-0000-0000-000000000000", + "expect": { + "trace_id_hex": "12b9377cbe7e5c94e8a70d9d23929523", + "mapping": "hash" + }, + "description": "The all-zero UUID is not a valid TraceID, so it is hashed (SHA-256 of the UTF-8 bytes, first 16 bytes)." + }, + { + "name": "all-zero-without-hyphens-is-hashed", + "mode": "map", + "trace_id": "00000000000000000000000000000000", + "expect": { + "trace_id_hex": "84e0c0eafaa95a34c293f278ac52e45c", + "mapping": "hash" + } + }, + { + "name": "non-uuid-short", + "mode": "map", + "trace_id": "abc", + "expect": { + "trace_id_hex": "ba7816bf8f01cfea414140de5dae2223", + "mapping": "hash" + }, + "description": "A value that is not a UUID is hashed: SHA-256 of the UTF-8 bytes, first 16 bytes (ADR-0025, ADR-0038 section 7)." + }, + { + "name": "non-uuid-dashed-word", + "mode": "map", + "trace_id": "not-a-uuid", + "expect": { + "trace_id_hex": "2184a32a3bae6ad756d745ec8678ad6a", + "mapping": "hash" + } + }, + { + "name": "non-uuid-inner-spaces", + "mode": "map", + "trace_id": "order 42 trace", + "expect": { + "trace_id_hex": "d31787beda85c54d582034d147738486", + "mapping": "hash" + } + }, + { + "name": "non-uuid-with-slash-and-colon", + "mode": "map", + "trace_id": "req/2026-10-31:abc", + "expect": { + "trace_id_hex": "23ca4f77c10633c877479239a3510ccf", + "mapping": "hash" + } + }, + { + "name": "non-uuid-non-ascii", + "mode": "map", + "trace_id": "izleme-\u65e5\u672c\u8a9e-\u00df", + "expect": { + "trace_id_hex": "a9ff8aedaf39e2ed7fbe36ddeee77627", + "mapping": "hash" + }, + "description": "Hashed over the UTF-8 bytes of the value." + }, + { + "name": "non-uuid-32-chars-not-hex", + "mode": "map", + "trace_id": "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz", + "expect": { + "trace_id_hex": "a9ca111109de4c6a0d65d6b25588f771", + "mapping": "hash" + } + }, + { + "name": "non-uuid-31-hex-digits", + "mode": "map", + "trace_id": "7b3f9c2ae41d4f889b2a1c0d5e6f7a8", + "expect": { + "trace_id_hex": "fe89ad340d456da74a71a468e51bdda6", + "mapping": "hash" + } + }, + { + "name": "non-uuid-33-hex-digits", + "mode": "map", + "trace_id": "7b3f9c2ae41d4f889b2a1c0d5e6f7a8b0", + "expect": { + "trace_id_hex": "4e80c1ce29e48dbb79ba44854128afc9", + "mapping": "hash" + } + }, + { + "name": "non-uuid-urn-uuid-prefix", + "mode": "map", + "trace_id": "urn:uuid:7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "expect": { + "trace_id_hex": "71553c8bc2cb2685b813e18d0bd074fa", + "mapping": "hash" + } + }, + { + "name": "uuid-with-leading-space-is-hashed", + "mode": "map", + "trace_id": " 7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", + "expect": { + "trace_id_hex": "a0c7cfbf156086cc3ca3aee6a2fc5e51", + "mapping": "hash" + }, + "description": "No trim before the mapping: a leading ASCII space makes the value non-UUID, so the raw value (space included) is hashed." + }, + { + "name": "uuid-with-trailing-space-is-hashed", + "mode": "map", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b ", + "expect": { + "trace_id_hex": "d93dfd3c387a0dbccaee2e95d0639b6d", + "mapping": "hash" + }, + "description": "No trim before the mapping: a trailing ASCII space makes the value non-UUID, so the raw value is hashed." + }, + { + "name": "uuid-with-tab-and-newline-is-hashed", + "mode": "map", + "trace_id": "\t7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b\n", + "expect": { + "trace_id_hex": "d9cf62d0e40c6639ee7131da53b3a920", + "mapping": "hash" + } + }, + { + "name": "uuid-with-trailing-newline-is-hashed", + "mode": "map", + "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b\n", + "expect": { + "trace_id_hex": "4e72d2e2f40df8529256d33c375dfe8c", + "mapping": "hash" + }, + "description": "A trailing newline alone makes the value non-UUID: it is hashed over the raw bytes, newline included. An implementation whose end-anchor matches before a final newline (PCRE `$`, Python `re.match` with `$`) would wrongly decode it." + }, + { + "name": "uuid-without-hyphens-with-trailing-newline-is-hashed", + "mode": "map", + "trace_id": "7b3f9c2ae41d4f889b2a1c0d5e6f7a8b\n", + "expect": { + "trace_id_hex": "2092f9b3cbf4e482796b67febc62c85e", + "mapping": "hash" + } + }, + { + "name": "decode-preserves-1", + "mode": "decode", + "trace_id": "abc", + "expect": { + "decoded": true, + "trace_id": "abc" + }, + "description": "Decoding an envelope whose `trace_id` is not a UUID succeeds and leaves the value byte for byte; it is neither rejected nor regenerated (message-envelope.md section 5.1)." + }, + { + "name": "decode-preserves-2", + "mode": "decode", + "trace_id": "order 42 trace", + "expect": { + "decoded": true, + "trace_id": "order 42 trace" + }, + "description": "Same rule." + }, + { + "name": "decode-preserves-3", + "mode": "decode", + "trace_id": "req/2026-10-31:abc", + "expect": { + "decoded": true, + "trace_id": "req/2026-10-31:abc" + }, + "description": "Same rule." + }, + { + "name": "decode-preserves-4", + "mode": "decode", + "trace_id": "izleme-\u65e5\u672c\u8a9e-\u00df", + "expect": { + "decoded": true, + "trace_id": "izleme-\u65e5\u672c\u8a9e-\u00df" + }, + "description": "Same rule." + }, + { + "name": "decode-preserves-5", + "mode": "decode", + "trace_id": " abc\t", + "expect": { + "decoded": true, + "trace_id": " abc\t" + }, + "description": "Re-emit never trims: surrounding ASCII whitespace is kept byte for byte (ADR-0032 section 5)." + }, + { + "name": "decode-preserves-6", + "mode": "decode", + "trace_id": " 7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b\t", + "expect": { + "decoded": true, + "trace_id": " 7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b\t" + }, + "description": "Same rule." + }, + { + "name": "reemit-preserves-1", + "mode": "reemit", + "trace_id": "abc", + "expect": { + "trace_id": "abc" + }, + "description": "Re-emit of the decoded envelope (retry, DLQ, redrive, relay) writes the same `trace_id`, byte for byte." + }, + { + "name": "reemit-preserves-2", + "mode": "reemit", + "trace_id": "order 42 trace", + "expect": { + "trace_id": "order 42 trace" + }, + "description": "Same rule." + }, + { + "name": "reemit-preserves-3", + "mode": "reemit", + "trace_id": "req/2026-10-31:abc", + "expect": { + "trace_id": "req/2026-10-31:abc" + }, + "description": "Same rule." + }, + { + "name": "reemit-preserves-4", + "mode": "reemit", + "trace_id": "izleme-\u65e5\u672c\u8a9e-\u00df", + "expect": { + "trace_id": "izleme-\u65e5\u672c\u8a9e-\u00df" + }, + "description": "Same rule." + }, + { + "name": "reemit-preserves-5", + "mode": "reemit", + "trace_id": " abc\t", + "expect": { + "trace_id": " abc\t" + }, + "description": "Re-emit never trims: surrounding ASCII whitespace is kept byte for byte (ADR-0032 section 5)." + }, + { + "name": "reemit-preserves-6", + "mode": "reemit", + "trace_id": " 7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b\t", + "expect": { + "trace_id": " 7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b\t" + }, + "description": "Same rule." + }, + { + "name": "downstream-copies-1", + "mode": "downstream", + "trace_id": "abc", + "expect": { + "trace_id": "abc" + }, + "description": "A handler produces a downstream message with the inbound `trace_id` (`make` given that value): the produced envelope carries it unchanged and `make` does not reject it (message-envelope.md section 5.1). Only values with no ASCII padding are used, so the ADR-0032 trim is a no-op here." + }, + { + "name": "downstream-copies-2", + "mode": "downstream", + "trace_id": "order 42 trace", + "expect": { + "trace_id": "order 42 trace" + }, + "description": "Same rule." + }, + { + "name": "downstream-copies-3", + "mode": "downstream", + "trace_id": "req/2026-10-31:abc", + "expect": { + "trace_id": "req/2026-10-31:abc" + }, + "description": "Same rule." + }, + { + "name": "downstream-copies-4", + "mode": "downstream", + "trace_id": "izleme-\u65e5\u672c\u8a9e-\u00df", + "expect": { + "trace_id": "izleme-\u65e5\u672c\u8a9e-\u00df" + }, + "description": "Same rule." + }, + { + "name": "make-keeps-non-uuid-trace-id", + "mode": "make", + "urn": "urn:acme:a", + "trace_id": "abc", + "expect": { + "trace_id": "abc", + "trace_id_hex": "ba7816bf8f01cfea414140de5dae2223" + }, + "description": "`make` with a caller-supplied non-UUID `trace_id`: the envelope carries it (only the ADR-0032 ASCII trim applies); no rejection, no regenerated UUID." + }, + { + "name": "make-keeps-non-uuid-with-inner-spaces", + "mode": "make", + "urn": "urn:acme:a", + "trace_id": "order 42 trace", + "expect": { + "trace_id": "order 42 trace", + "trace_id_hex": "62e4e86cf9f3cd31fa7beabd74380fae" + }, + "description": "Inner whitespace is part of the value; only the ends are trimmed." + }, + { + "name": "make-trims-ascii-padding-of-non-uuid", + "mode": "make", + "urn": "urn:acme:a", + "trace_id": " \torder 42 trace\n", + "expect": { + "trace_id": "order 42 trace", + "trace_id_hex": "d31787beda85c54d582034d147738486" + }, + "description": "ASCII padding is trimmed by `make` (ADR-0032 section 5) and nothing else changes; the stored value is the trimmed one." + }, + { + "name": "make-keeps-unicode-space-padding-of-non-uuid", + "mode": "make", + "urn": "urn:acme:a", + "trace_id": "\u00a0abc\u00a0", + "expect": { + "trace_id": "\u00a0abc\u00a0", + "trace_id_hex": "fb3da941d20933c9f0bc0dece6a173d5" + }, + "description": "U+00A0 is not ASCII whitespace (ADR-0032 section 5): it stays in the value." + }, + { + "name": "make-keeps-all-zero-uuid", + "mode": "make", + "urn": "urn:acme:a", + "trace_id": "00000000-0000-0000-0000-000000000000", + "expect": { + "trace_id": "00000000-0000-0000-0000-000000000000", + "trace_id_hex": "12b9377cbe7e5c94e8a70d9d23929523" + }, + "description": "The all-zero UUID is a non-UUID for the OTel mapping, but `make` still carries it as given: only the mapping hashes it." + } + ] + }, + "metrics": { + "description": "Standard metric set and the dead-letter observer (ADR-0039, semantic conventions v1.44.0). Scope: `otel` (ADR-0032 section 2). Every case has a `mode`. `instrument`: the metric `expect.metric` MUST exist with `expect.instrument` (`histogram` | `counter`), `expect.unit` and exactly this attribute key set: `attributes.always`, `attributes.when_destination_known` (written only when the destination is known: present and not blank) and `attributes.on_failure` (written only when the operation failed); `operation_name` is the value of `messaging.operation.name`; none of `forbidden_attributes` may appear (cardinality limit, ADR-0039 section 2). `advisory_bucket_boundaries` (the histogram's advice) is a SHOULD, applied where the platform API supports advice, and is not asserted otherwise. `publish`, `consume` and `dead_letter`: the scenario drives the SDK's producer wrapper, consumer wrapper or dead-letter observer (in the `otel` module) with a meter backed by an in-memory reader, then the metric data points recorded MUST equal `expect.records` as a multiset and nothing else may be recorded. A `records` entry has `metric`, `kind` (`add`: a counter increment of `value`; `record`: one histogram observation, `value` `non_negative_seconds` meaning a finite value >= 0 that is not otherwise asserted), the exact `attributes` map (string values), and `error_type: present` when the data point MUST also carry a non-empty string `error.type` (its value is language-specific and not asserted; `attributes` omits it). Inputs: `system` (the `system` option; absent or empty means `babelqueue`), `queue` (consume: `meta.queue`, where absent, empty or blank means no destination; publish: the queue given to the producer, where an absent, empty or blank queue is resolved by the SDK and the destination is `$produced.meta.queue`, the produced envelope's `meta.queue`, written only when it is not empty or blank), `attempts` (consume: top-level `attempts`), `outcome` (`ok` | `error`; for `publish` an error is a failure of the transport after the envelope was handed to it, for `consume` an error returned or raised by the wrapped handler), `meter: false` (no meter supplied, nothing is recorded). The `dead_letter` cases are defined at the level of the observer event `{queue, dead_letter_queue, reason}` so a runner needs no broker: `trigger` says what the SDK did, `observer_called` says whether the core invokes the dead-letter observer with `event` (true) or not at all (false, `records` empty). The rule (ADR-0039 section 5.1, owner decision 2026-10-31): a successful write to the BabelQueue DLQ counts (+1), and so does a successful SDK-initiated native dead-letter (+1, same attribute set, no native/BabelQueue distinction, `dead_letter_queue` empty); a failed write, a failed native call, a native no-op (SQS), a broker-autonomous dead-letter and a disabled DLQ count 0. The SDKs' own tests (ADR-0039 sections 5.2-5.3) drive their transports to the trigger; conformance pins the observer-level contract. A case with `observer_called: false` also has `sdk_test_required: true`: it states a rule a runner cannot test at observer level (no event is delivered, so nothing is recorded, whatever the SDK does), so the runner reports it `pass` trivially and the rule is only enforced by the SDK's own transport or core test, which MUST assert that the observer is not called for that trigger.", + "cases": [ + { + "name": "instrument-messaging-process-duration", + "mode": "instrument", + "when": "once per handler invocation: the handler's wall time in seconds, success or failure", + "expect": { + "metric": "messaging.process.duration", + "instrument": "histogram", + "unit": "s", + "recorded_by": "consumer wrapper", + "operation_name": "process", + "attributes": { + "always": [ + "messaging.system", + "messaging.operation.name" + ], + "when_destination_known": [ + "messaging.destination.name" + ], + "on_failure": [ + "error.type" + ] + }, + "forbidden_attributes": [ + "babelqueue.urn", + "messaging.message.id", + "messaging.message.conversation_id", + "babelqueue.attempts", + "babelqueue.schema_version", + "messaging.consumer.group.name", + "dead_letter.reason", + "trace_id" + ], + "advisory_bucket_boundaries": [ + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1, + 2.5, + 5, + 7.5, + 10 + ] + } + }, + { + "name": "instrument-messaging-client-sent-messages", + "mode": "instrument", + "when": "+1 per publish attempt, success or failure", + "expect": { + "metric": "messaging.client.sent.messages", + "instrument": "counter", + "unit": "{message}", + "recorded_by": "producer wrapper", + "operation_name": "send", + "attributes": { + "always": [ + "messaging.system", + "messaging.operation.name" + ], + "when_destination_known": [ + "messaging.destination.name" + ], + "on_failure": [ + "error.type" + ] + }, + "forbidden_attributes": [ + "babelqueue.urn", + "messaging.message.id", + "messaging.message.conversation_id", + "babelqueue.attempts", + "babelqueue.schema_version", + "messaging.consumer.group.name", + "dead_letter.reason", + "trace_id" + ] + } + }, + { + "name": "instrument-messaging-client-consumed-messages", + "mode": "instrument", + "when": "+1 per delivery that enters the wrapper (no `error.type`: the delivery was received)", + "expect": { + "metric": "messaging.client.consumed.messages", + "instrument": "counter", + "unit": "{message}", + "recorded_by": "consumer wrapper", + "operation_name": "process", + "attributes": { + "always": [ + "messaging.system", + "messaging.operation.name" + ], + "when_destination_known": [ + "messaging.destination.name" + ] + }, + "forbidden_attributes": [ + "babelqueue.urn", + "messaging.message.id", + "messaging.message.conversation_id", + "babelqueue.attempts", + "babelqueue.schema_version", + "messaging.consumer.group.name", + "dead_letter.reason", + "trace_id" + ] + } + }, + { + "name": "instrument-babelqueue-retries", + "mode": "instrument", + "when": "+1 per delivery that enters the wrapper with top-level `attempts` > 0", + "expect": { + "metric": "babelqueue.retries", + "instrument": "counter", + "unit": "{message}", + "recorded_by": "consumer wrapper", + "operation_name": "process", + "attributes": { + "always": [ + "messaging.system", + "messaging.operation.name" + ], + "when_destination_known": [ + "messaging.destination.name" + ] + }, + "forbidden_attributes": [ + "babelqueue.urn", + "messaging.message.id", + "messaging.message.conversation_id", + "babelqueue.attempts", + "babelqueue.schema_version", + "messaging.consumer.group.name", + "dead_letter.reason", + "trace_id" + ] + } + }, + { + "name": "instrument-babelqueue-dlq-messages", + "mode": "instrument", + "when": "+1 per dead-letter the SDK itself completed (ADR-0039 section 5.1); `messaging.destination.name` is the source queue", + "expect": { + "metric": "babelqueue.dlq.messages", + "instrument": "counter", + "unit": "{message}", + "recorded_by": "dead-letter observer", + "operation_name": "process", + "attributes": { + "always": [ + "messaging.system", + "messaging.operation.name" + ], + "when_destination_known": [ + "messaging.destination.name" + ] + }, + "forbidden_attributes": [ + "babelqueue.urn", + "messaging.message.id", + "messaging.message.conversation_id", + "babelqueue.attempts", + "babelqueue.schema_version", + "messaging.consumer.group.name", + "dead_letter.reason", + "trace_id" + ] + } + }, + { + "name": "consume-one-success", + "mode": "consume", + "system": "kafka", + "queue": "orders.events", + "attempts": 0, + "outcome": "ok", + "expect": { + "records": [ + { + "metric": "messaging.client.consumed.messages", + "kind": "add", + "value": 1, + "attributes": { + "messaging.system": "kafka", + "messaging.operation.name": "process", + "messaging.destination.name": "orders.events" + } + }, + { + "metric": "messaging.process.duration", + "kind": "record", + "value": "non_negative_seconds", + "attributes": { + "messaging.system": "kafka", + "messaging.operation.name": "process", + "messaging.destination.name": "orders.events" + } + } + ] + }, + "description": "One successful consume (attempts 0): `consumed.messages` +1 and one `process.duration` record without `error.type`; no `retries` record. The attribute maps are exact: no URN, id, trace id, attempts, schema version or reason." + }, + { + "name": "consume-failure-with-attempts-2", + "mode": "consume", + "system": "kafka", + "queue": "orders.events", + "attempts": 2, + "outcome": "error", + "expect": { + "records": [ + { + "metric": "messaging.client.consumed.messages", + "kind": "add", + "value": 1, + "attributes": { + "messaging.system": "kafka", + "messaging.operation.name": "process", + "messaging.destination.name": "orders.events" + } + }, + { + "metric": "babelqueue.retries", + "kind": "add", + "value": 1, + "attributes": { + "messaging.system": "kafka", + "messaging.operation.name": "process", + "messaging.destination.name": "orders.events" + } + }, + { + "metric": "messaging.process.duration", + "kind": "record", + "value": "non_negative_seconds", + "attributes": { + "messaging.system": "kafka", + "messaging.operation.name": "process", + "messaging.destination.name": "orders.events" + }, + "error_type": "present" + } + ] + }, + "description": "One consume whose handler fails (the wrapped handler returns or raises an error; the same definition as the `span-shape` case `process-handler-failure`) with `attempts` 2: `consumed.messages` +1 (no `error.type`: the delivery was received), `retries` +1, and one `process.duration` record carrying `error.type` (present, non-empty; value not asserted). The wrapper's result is unchanged." + }, + { + "name": "consume-attempts-1-counts-as-retry", + "mode": "consume", + "queue": "orders", + "attempts": 1, + "outcome": "ok", + "expect": { + "records": [ + { + "metric": "messaging.client.consumed.messages", + "kind": "add", + "value": 1, + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.name": "process", + "messaging.destination.name": "orders" + } + }, + { + "metric": "babelqueue.retries", + "kind": "add", + "value": 1, + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.name": "process", + "messaging.destination.name": "orders" + } + }, + { + "metric": "messaging.process.duration", + "kind": "record", + "value": "non_negative_seconds", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.name": "process", + "messaging.destination.name": "orders" + } + } + ] + }, + "description": "The `retries` threshold is `attempts` > 0: attempts 1 counts." + }, + { + "name": "consume-default-system", + "mode": "consume", + "queue": "orders", + "attempts": 0, + "outcome": "ok", + "expect": { + "records": [ + { + "metric": "messaging.client.consumed.messages", + "kind": "add", + "value": 1, + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.name": "process", + "messaging.destination.name": "orders" + } + }, + { + "metric": "messaging.process.duration", + "kind": "record", + "value": "non_negative_seconds", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.name": "process", + "messaging.destination.name": "orders" + } + } + ] + }, + "description": "No `system` option: `messaging.system` is `babelqueue` on every metric." + }, + { + "name": "consume-no-destination-empty-queue", + "mode": "consume", + "queue": "", + "attempts": 0, + "outcome": "ok", + "expect": { + "records": [ + { + "metric": "messaging.client.consumed.messages", + "kind": "add", + "value": 1, + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.name": "process" + } + }, + { + "metric": "messaging.process.duration", + "kind": "record", + "value": "non_negative_seconds", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.name": "process" + } + } + ] + }, + "description": "`meta.queue` empty: `messaging.destination.name` is not a metric attribute." + }, + { + "name": "consume-no-destination-absent-queue", + "mode": "consume", + "attempts": 0, + "outcome": "ok", + "expect": { + "records": [ + { + "metric": "messaging.client.consumed.messages", + "kind": "add", + "value": 1, + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.name": "process" + } + }, + { + "metric": "messaging.process.duration", + "kind": "record", + "value": "non_negative_seconds", + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.name": "process" + } + } + ] + }, + "description": "`meta.queue` absent: same." + }, + { + "name": "consume-without-meter-records-nothing", + "mode": "consume", + "system": "kafka", + "queue": "orders.events", + "attempts": 0, + "outcome": "ok", + "meter": false, + "expect": { + "records": [] + }, + "description": "No meter supplied (ADR-0039 section 3): the wrapper records nothing. On .NET the instruments exist but no listener is subscribed, so nothing is collected." + }, + { + "name": "publish-one-success", + "mode": "publish", + "system": "kafka", + "queue": "orders.events", + "outcome": "ok", + "expect": { + "records": [ + { + "metric": "messaging.client.sent.messages", + "kind": "add", + "value": 1, + "attributes": { + "messaging.system": "kafka", + "messaging.operation.name": "send", + "messaging.destination.name": "orders.events" + } + } + ] + }, + "description": "One successful publish: `sent.messages` +1 with `messaging.operation.name = send`, no `error.type`." + }, + { + "name": "publish-failure", + "mode": "publish", + "system": "rabbitmq", + "queue": "orders", + "outcome": "error", + "expect": { + "records": [ + { + "metric": "messaging.client.sent.messages", + "kind": "add", + "value": 1, + "attributes": { + "messaging.system": "rabbitmq", + "messaging.operation.name": "send", + "messaging.destination.name": "orders" + }, + "error_type": "present" + } + ] + }, + "description": "The publish fails after the envelope was handed to the transport (the transport rejects or fails the publish; the same definition as the `span-shape` case `send-failure-after-envelope-built`): `sent.messages` +1 (an attempt) carrying `error.type` (present, non-empty). The publish result is unchanged. A failure before the envelope reaches the transport (build or encode) is not pinned by this section." + }, + { + "name": "publish-default-system", + "mode": "publish", + "queue": "orders", + "outcome": "ok", + "expect": { + "records": [ + { + "metric": "messaging.client.sent.messages", + "kind": "add", + "value": 1, + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.name": "send", + "messaging.destination.name": "orders" + } + } + ] + }, + "description": "No `system` option: `messaging.system` is `babelqueue`." + }, + { + "name": "publish-empty-queue-follows-produced-meta-queue", + "mode": "publish", + "queue": "", + "outcome": "ok", + "expect": { + "records": [ + { + "metric": "messaging.client.sent.messages", + "kind": "add", + "value": 1, + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.name": "send", + "messaging.destination.name": "$produced.meta.queue" + } + } + ] + }, + "description": "An empty queue is given to the producer. The destination is `meta.queue` of the produced envelope, which the SDK decides (Python and Go fall back to the app's default queue): the expectation uses the placeholder `$produced.meta.queue`; when the produced `meta.queue` is empty or blank `messaging.destination.name` is not written." + }, + { + "name": "publish-without-meter-records-nothing", + "mode": "publish", + "system": "kafka", + "queue": "orders.events", + "outcome": "ok", + "meter": false, + "expect": { + "records": [] + } + }, + { + "name": "dlq-write-ok-retries-exhausted", + "mode": "dead_letter", + "trigger": "bq_dlq_write_ok", + "system": "redis", + "observer_called": true, + "event": { + "queue": "orders", + "dead_letter_queue": "orders.dlq", + "reason": "failed" + }, + "expect": { + "observer_called": true, + "records": [ + { + "metric": "babelqueue.dlq.messages", + "kind": "add", + "value": 1, + "attributes": { + "messaging.system": "redis", + "messaging.operation.name": "process", + "messaging.destination.name": "orders" + } + } + ] + }, + "description": "A successful write to the BabelQueue DLQ after the retries are exhausted: the observer is called once and `babelqueue.dlq.messages` +1 with `messaging.operation.name = process` and the source queue. The reason is not a metric attribute." + }, + { + "name": "dlq-write-ok-unknown-urn", + "mode": "dead_letter", + "trigger": "bq_dlq_write_ok", + "system": "redis", + "observer_called": true, + "event": { + "queue": "orders", + "dead_letter_queue": "orders.dlq", + "reason": "unknown_urn" + }, + "expect": { + "observer_called": true, + "records": [ + { + "metric": "babelqueue.dlq.messages", + "kind": "add", + "value": 1, + "attributes": { + "messaging.system": "redis", + "messaging.operation.name": "process", + "messaging.destination.name": "orders" + } + } + ] + } + }, + { + "name": "dlq-write-ok-poison", + "mode": "dead_letter", + "trigger": "bq_dlq_write_ok", + "system": "kafka", + "observer_called": true, + "event": { + "queue": "orders.events", + "dead_letter_queue": "orders.events.dlq", + "reason": "poison" + }, + "expect": { + "observer_called": true, + "records": [ + { + "metric": "babelqueue.dlq.messages", + "kind": "add", + "value": 1, + "attributes": { + "messaging.system": "kafka", + "messaging.operation.name": "process", + "messaging.destination.name": "orders.events" + } + } + ] + } + }, + { + "name": "dlq-write-ok-version-gate-raw-body", + "mode": "dead_letter", + "trigger": "bq_dlq_write_ok", + "system": "aws_sqs", + "observer_called": true, + "event": { + "queue": "orders", + "dead_letter_queue": "orders-dlq", + "reason": "" + }, + "expect": { + "observer_called": true, + "records": [ + { + "metric": "babelqueue.dlq.messages", + "kind": "add", + "value": 1, + "attributes": { + "messaging.system": "aws_sqs", + "messaging.operation.name": "process", + "messaging.destination.name": "orders" + } + } + ] + }, + "description": "A raw version-gated body written to the DLQ (ADR-0037 decision 1) has no `dead_letter` block, so the event's `reason` is empty. Counted." + }, + { + "name": "dlq-native-dead-letter-ok", + "mode": "dead_letter", + "trigger": "native_dead_letter_ok", + "system": "servicebus", + "observer_called": true, + "event": { + "queue": "orders", + "dead_letter_queue": "", + "reason": "" + }, + "expect": { + "observer_called": true, + "records": [ + { + "metric": "babelqueue.dlq.messages", + "kind": "add", + "value": 1, + "attributes": { + "messaging.system": "servicebus", + "messaging.operation.name": "process", + "messaging.destination.name": "orders" + } + } + ] + }, + "description": "An SDK-initiated native dead-letter that the broker confirmed (Azure Service Bus `DeadLetterMessage`): the observer is called with an empty `dead_letter_queue` and `babelqueue.dlq.messages` +1. The attribute set is the same as for a BabelQueue DLQ write: there is no native/BabelQueue split (ADR-0039 section 5.1, owner decision 2026-10-31)." + }, + { + "name": "dlq-default-system", + "mode": "dead_letter", + "trigger": "bq_dlq_write_ok", + "observer_called": true, + "event": { + "queue": "orders", + "dead_letter_queue": "orders.dlq", + "reason": "failed" + }, + "expect": { + "observer_called": true, + "records": [ + { + "metric": "babelqueue.dlq.messages", + "kind": "add", + "value": 1, + "attributes": { + "messaging.system": "babelqueue", + "messaging.operation.name": "process", + "messaging.destination.name": "orders" + } + } + ] + }, + "description": "No `system` option on the observer: `messaging.system` is `babelqueue`." + }, + { + "name": "dlq-no-source-queue", + "mode": "dead_letter", + "trigger": "bq_dlq_write_ok", + "system": "redis", + "observer_called": true, + "event": { + "queue": "", + "dead_letter_queue": "orders.dlq", + "reason": "failed" + }, + "expect": { + "observer_called": true, + "records": [ + { + "metric": "babelqueue.dlq.messages", + "kind": "add", + "value": 1, + "attributes": { + "messaging.system": "redis", + "messaging.operation.name": "process" + } + } + ] + }, + "description": "No source queue known: `messaging.destination.name` is not written." + }, + { + "name": "dlq-write-failed", + "mode": "dead_letter", + "trigger": "bq_dlq_write_failed", + "system": "redis", + "observer_called": false, + "sdk_test_required": true, + "expect": { + "observer_called": false, + "records": [] + }, + "description": "The DLQ write failed: the message stays unsettled and the observer is NOT called; the counter stays unchanged." + }, + { + "name": "dlq-native-call-failed", + "mode": "dead_letter", + "trigger": "native_dead_letter_failed", + "system": "servicebus", + "observer_called": false, + "sdk_test_required": true, + "expect": { + "observer_called": false, + "records": [] + }, + "description": "The native dead-letter call failed (or the receiver lacks the call and the SDK fell back to abandon): the observer is NOT called." + }, + { + "name": "dlq-native-noop-sqs", + "mode": "dead_letter", + "trigger": "native_noop", + "system": "aws_sqs", + "observer_called": false, + "sdk_test_required": true, + "expect": { + "observer_called": false, + "records": [] + }, + "description": "The SQS native settle is a no-op that leaves the message to `RedrivePolicy`; it is not a dead-letter by the SDK, so the observer is NOT called." + }, + { + "name": "dlq-broker-own-dead-letter", + "mode": "dead_letter", + "trigger": "broker_autonomous", + "system": "servicebus", + "observer_called": false, + "sdk_test_required": true, + "expect": { + "observer_called": false, + "records": [] + }, + "description": "The broker dead-letters on its own (SQS `RedrivePolicy` after `maxReceiveCount`, Azure Service Bus `MaxDeliveryCount`, Pulsar or Artemis broker-side policy): the SDK did nothing, so the observer is NOT called." + }, + { + "name": "dlq-writing-disabled-message-dropped", + "mode": "dead_letter", + "trigger": "dlq_disabled_dropped", + "system": "redis", + "observer_called": false, + "sdk_test_required": true, + "expect": { + "observer_called": false, + "records": [] + }, + "description": "DLQ writing is disabled and the message is dropped or acknowledged instead: the observer is NOT called." + } + ] } } diff --git a/tests/BabelQueue.Core.Tests/conformance/schema/results.example.json b/tests/BabelQueue.Core.Tests/conformance/schema/results.example.json index 9130d29..736688d 100644 --- a/tests/BabelQueue.Core.Tests/conformance/schema/results.example.json +++ b/tests/BabelQueue.Core.Tests/conformance/schema/results.example.json @@ -1,7 +1,7 @@ { "sdk": "example-sdk/redis", "version": "1.9.0", - "conformance_version": "1.6.0", + "conformance_version": "1.9.0", "generated_at": "2026-10-18T09:30:00Z", "bindings": [ "redis", @@ -43,6 +43,30 @@ "status": "pass" } }, + "span-shape": { + "send-default-system": { + "status": "pass" + }, + "process-kafka-consumer-group": { + "status": "pass" + } + }, + "trace-id": { + "uuid-lowercase-hyphenated": { + "status": "pass" + }, + "all-zero-uuid-is-hashed": { + "status": "pass" + } + }, + "metrics": { + "instrument-messaging-process-duration": { + "status": "pass" + }, + "dlq-native-noop-sqs": { + "status": "pass" + } + }, "redis": { "payload_identity": { "status": "pass"