Begleitbeitrag zu Ein Port, drei Client-Crates. Jener Beitrag beschrieb, wie ein Rust-Service drei Blockchains über ein einziges Trait liest, und endete an der Grenze, an der das Design fertig war. Dieser setzt danach an: derselbe Service, ein Issue mit einem Nachmittag veranschlagt, und die vier Bedingungen, die erfüllt sein mussten, bevor das Ergebnis überhaupt etwas bedeutete — dazu eine fünfte, die mit dem Folge-Issue kam. Den ersten zu lesen ist nicht nötig; die Fallen unten drehen sich um OpenTelemetry,
tracingund Loki, nicht um Chains.
Der Auftaktbeitrag listete auf, woraus die schwierige Mitte einer Datenplattform tatsächlich besteht, und ein Punkt auf dieser Liste war „Observability, die auf die richtige Leitung zeigt, wenn etwas kaputtgeht". Dieser Beitrag ist das, was dieser eine Punkt gekostet hat.
Das System ist eine Multi-Chain-Ingestion-Pipeline: Rust-Services, die Ethereum, Solana und Polkadot abonnieren, nach Kafka publizieren und auf Kubernetes laufen, mit Prometheus, Loki und Tempo dahinter. Eine gewöhnliche Form. Ungewöhnlich an der Observability-Anforderung ist nicht die Architektur, sondern das, was die Daten mit ihr machen.
Blockchain-Quellen sind flüchtig auf eine Art, wie es die meisten Quellen nicht sind. Eine Datenbank, aus der Sie replizieren, hat die Zeile morgen noch. Die jüngsten Blöcke einer Chain werden eine Weile von einem RPC-Anbieter ausgeliefert und dann nicht mehr, und dessen Aufbewahrungsdauer ist eine kommerzielle Entscheidung, kein Versprechen an Sie. Verliert die Pipeline ein Event lautlos, kann der Beweis seiner Existenz verschwunden sein, bevor es jemandem auffällt — und niemand stromaufwärts beschwert sich, weil dort niemand weiß, dass Sie zugehört haben.
Damit ist „haben wir alles gesehen, und haben wir es korrekt verarbeitet" auf dieser Plattform keine Beruhigungsfrage — sie macht den größten Teil dessen aus, was das Produkt ist. Deshalb war ein Issue, das sich wie eine Formatierungs-Nettigkeit liest, keine Kosmetik.
Das Issue war ein Satz: Logzeilen sollen die aktive OpenTelemetry-trace_id tragen, damit eine Zeile in Loki auf ihren Trace in Tempo verlinkt. Veranschlagt war es mit einem Feld, einem Formatter und einem Nachmittag.
Der Formatter hat tatsächlich etwa einen Nachmittag gedauert. Das Feld dazu zu bringen, etwas zu bedeuten, hat die restliche Arbeit gekostet, und drei der vier Bedingungen, die erfüllt sein mussten, scheiterten lautlos — keine warf einen Fehler, keine panickte, keine färbte einen Test rot, und jede erzeugte Ausgabe, die vollkommen korrekt aussah.
Diese Stille ist der verallgemeinerbare Teil, denn Observability-Code hat eine Eigenschaft, die ihn dafür ungewöhnlich anfällig macht: Er ist das Instrument, mit dem Sie prüfen, ob alles andere funktioniert — also schaut nichts anderes auf ihn. Eine kaputte Pipeline wirft einen Fehler, den jemand sieht. Eine kaputte Metrik zeigt null, und null ist eine plausible Zahl.
Vier Dinge, nicht eines
Bevor eine trace_id in einer Logzeile nützlich ist, muss all das gelten:
- Um die Arbeit herum existiert ein Span.
- Dieser Span schließt, damit er exportiert wird.
- Das Log-Event wird innerhalb davon ausgegeben, und zwar innerhalb des richtigen.
- Der Formatter kann den OTel-Kontext des Spans sehen und schreibt ihn heraus.
Das Issue benannte nur das vierte, und der Service hatte das erste und sonst nichts.
Das Diagramm in Worten: Zwei Pfade verlassen den geöffneten Span. Der erste schließt ihn und exportiert ihn und erzeugt damit den Trace. Der zweite trägt das Log-Event durch den Formatter in die JSON-Zeile und dann durch die Log-Pipeline zum klickbaren Link. Beide müssen fertig werden, bevor dieser Link überhaupt etwas öffnet; das Issue beschrieb nur den letzten Schritt des zweiten.
Der Formatter ist der einzige Schritt dieser Kette, der von einer Logzeile aus sichtbar ist — weshalb ein Nachmittag für ihn eine faire Schätzung war und für das Feature eine schlechte.
Falle 1 — ein Span, der nie schließt, wird nie exportiert
Die Ingestion-Schleife des Service trug #[instrument] auf ihrer obersten run-Funktion, es existierte also ein Span — genau einer, geöffnet beim Start und geschlossen beim Herunterfahren.
tracing-opentelemetry beendet und exportiert einen OTel-Span im on_close-Callback des Subscribers. Ein Span, der nie schließt, wird nie gesendet. Der Prozess könnte eine Woche laufen, mit vollständig konfiguriertem Tracing und verbundenem, gesundem OTLP-Exporter, und Tempo bekäme überhaupt nichts.
Nichts im System meldet das, denn aus Sicht jeder einzelnen Komponente funktioniert alles: Der Exporter läuft, der Sampler sagt AlwaysOn, und die Konfiguration stimmt. Es gibt schlicht nie etwas zu senden.
Die Korrektur ist ein Span, dessen Lebensdauer einer Arbeitseinheit entspricht:
Some(Ok(event)) => {
let span = info_span!("ingest_event", event_id = %event.event_id());
span.in_scope(|| {
// ... die Arbeit für genau ein Event
});
// hier fällt der Span, und erst hier wird er exportiert
}Ein #[instrument] auf einer langlaufenden Funktion ist ein Logging-Konstrukt, kein Tracing-Konstrukt. Es umschließt Ihre Logzeilen korrekt und wird nie einen Trace erzeugen.
Falle 2 — builder.trace_id ist eine Antwort nur für Root-Spans
Um das Feld zu schreiben, braucht der Formatter die Trace-ID des Spans, in dem er sich befindet. tracing-opentelemetry legt seinen Zustand pro Span in den Extensions der Registry ab, und die naheliegende Implementierung sind drei Zeilen:
let extensions = span.extensions();
let otel = extensions.get::<OtelData>()?;
otel.builder.trace_idOtelData ist öffentlich, das Feld liegt direkt da, und das funktioniert. Auf Root-Spans.
Aus tracing-opentelemetry-0.28.0/src/layer.rs:887:
// Record new trace id if there is no active parent span
if !parent_cx.has_active_span() {
builder.trace_id = Some(self.tracer.new_trace_id());
}Auf einem Kind-Span ist es None. Die Trace-ID liegt stattdessen im Parent-Kontext, erreichbar über parent_cx.span().span_context().trace_id().
Das ist eine besonders unangenehme Form von Bug, denn die naive Variante besteht den naiven Test. Einen Span öffnen, darin loggen, Feld-Vorhandensein prüfen — grün. Jede echte Logzeile im Service liegt in einem Kind-Span, das Feld hätte also in Produktion lautlos gefehlt und wäre in der CI zuverlässig da gewesen.
Der Test, der das fängt, loggt aus einem Kind heraus:
let root = tracing::info_span!("root");
let _root = root.enter();
let child = tracing::info_span!("child");
let _child = child.enter();
tracing::info!("block ingested");Es lohnt sich, nachzuweisen statt anzunehmen, dass der Test unterscheidet. Ersetzt man den Rückgriff auf den Parent-Kontext durch return None, scheitert genau dieser eine Test — die anderen drei bleiben grün.
Die naheliegende Fixture ist keine einfachere Fassung des echten Falls. Sie ist ein anderer Fall — und wenn die Bibliothek genau auf diesem Unterschied verzweigt, fixiert ein grüner Test den Zweig, den Sie nicht ausliefern.
Falle 3 — .instrument() auf dem falschen Future kompiliert und tut nichts
Jeder Chain-Adapter betreibt seine Subscribe-und-Reconnect-Schleife in einem gespawnten Task. tokio::spawn erbt den Span des Aufrufers nicht, jede Reconnect-Warnung wurde also gänzlich außerhalb jedes Spans ausgegeben — unsichtbar für das ganze Schema.
Die naheliegende Korrektur, dreimal angewandt:
tokio::spawn(async move { run_subscription_loop(...).await; })
.instrument(span);Es liest sich richtig, und es ist vollkommen wirkungslos.
tokio::spawn gibt ein JoinHandle zurück, und JoinHandle ist selbst ein Future. .instrument() nimmt es also an und erzeugt ein instrumentiertes Handle, das in der nächsten Zeile fällt, ohne je gepollt worden zu sein. Der Task — längst an den Executor übergeben — hat weiterhin keinen Span.
Das Typsystem hat nichts einzuwenden, denn hier ist nichts typ-inkorrekt. Das einzige Signal ist ein Lint:
warning: unused `tracing::instrument::Instrumented` that must be used
= note: futures do nothing unless you `.await` or poll them
help: use `let _ = ...` to ignore the resulting valueBeachten Sie, dass die vorgeschlagene Korrektur genau falsch ist — let _ = ... bringt den einen Hinweis zum Schweigen, dass die Änderung nichts bewirkt hat. Die eigentliche Information steht im Text der Meldung: Sie haben ein Future gebaut und weggeworfen.
tokio::spawn(
async move { run_subscription_loop(...).await; }.instrument(span)
);.instrument() dekoriert ein Future, das Ihnen noch gehört. Sobald ein Future beim Executor ist, bleibt nichts mehr zu dekorieren — dasselbe gilt für .timeout(), .boxed() und jeden anderen Kombinator. Wenn Sie einen davon auf einem JoinHandle aufrufen, fragen Sie sich, was Sie eigentlich gemeint haben.
Falle 4 — jeder Span schloss, und alle landeten in einem Trace
Diese hier hat die Implementierung, das Review der Implementierung und eine schriftliche Retrospektive überlebt. Gefangen wurde sie in einem zweiten Review-Durchgang, durch eine Frage, die kein Test gestellt hatte: nicht schließt der Span, sondern wessen Kind ist er.
ingest_event wurde kontextuell erzeugt, was ihn zu einem Kind von run macht — dem prozesslangen Span aus Falle 1. run ist die Wurzel, run erzeugt also die einzige Trace-ID, die je existiert, und jedes Kind erbt sie.
Zwei Geschwister-Event-Spans unter einer Wurzel, ausgegeben aus einem echten Testlauf:
trace_ids: [
"f3da095a59a8db160b9f4605b879b2e0",
"f3da095a59a8db160b9f4605b879b2e0",
] same = trueAlso: Spans schließen, Spans exportieren, Logs tragen eine wohlgeformte 32-stellige Hex-Trace-ID, jeder Test besteht — und ein Klick auf eine Logzeile öffnet einen „Trace", der jedes Event enthält, das der Prozess innerhalb der Aufbewahrungsdauer des Backends verarbeitet hat. Auf einem Blockchain-Ingester, der drei Chains zieht, sind das mehrere hunderttausend Spans pro Tag in einem Trace. Das ist in keinem nützlichen Sinne ein Trace; es ist ein Heuhaufen mit einem Link darauf.
Die Korrektur ist ein Schlüsselwort — jede Arbeitseinheit zu ihrer eigenen Wurzel machen:
let span = info_span!(parent: None, "ingest_event", event_id = %event.event_id());Dieselbe Sonde nach der Änderung:
trace_ids: [
"508d439ce5a118553871703aea27d140",
"29ec1a9e882c4970d778e47143d50833",
] same = falseAn run zu hängen hat ohnehin nichts gebracht — run schließt nie, wird also nie exportiert. Das Kind erbte eine ID von einem Span, der stromabwärts gar nicht existiert.
Die allgemeine Form: „der Span hat geschlossen" und „der Span liegt in einem sinnvollen Trace" sind zwei getrennte Behauptungen, und nur die erste ist leicht zu testen. Ein Test, der prüft, ob eine Trace-ID vorhanden ist, besteht identisch, ob Sie einen Trace pro Event oder einen Trace pro Prozess haben.
Der Formatter, und der Rat, den ich mir selbst gab und der falsch war
Der fmt-Layer von tracing-subscriber und der OTel-Layer sind unabhängig voneinander — der JSON-Formatter serialisiert Felder und Span-Namen und schaut nie in den OTel-SpanContext. Ein Layer kann einem bereits konstruierten Event auch keine Felder hinzufügen; der Visitor ist nur lesend. Die Injektion muss also während der Serialisierung passieren, und das heißt: FormatEvent implementieren.
Es gibt zwei Wege dorthin, und ich habe zuerst den falschen gewählt. Meine eigene Notiz an mich sagte: die Serialisierung mit serde_json selbst übernehmen, denn ein Feld in die Ausgabe eines anderen Formatters zu spleißen ist fragile String-Chirurgie.
Ein Blick in fmt/format/json.rs von tracing-subscriber hat mich umgestimmt. Die Standardimplementierung nutzt WriteAdaptor und SerializableSpan, von denen keines öffentlich ist. Nachbauen heißt, beide von Hand zu schreiben — einschließlich des Teils, der formatierte Felder aus den Span-Extensions zieht und sie erneut als JSON parst, was in dieser Datei einen Maintainer-Kommentar trägt, der es einen hässlichen Behelf nennt. Grob 120 Zeilen nachgebauter privater Interna, die mit jeder Weiterentwicklung des Crates von der Vorlage abdriften, um einen einzigen Schlüssel hinzuzufügen.
Auch das Fragilitätsargument stimmte nur zur Hälfte. Suffix-Chirurgie ist fragil — vor der schließenden Klammer einzufügen heißt, darüber nachdenken zu müssen, ob das Objekt leer ist. Präfix-Chirurgie kennt diesen Fall nicht: Der Datensatz beginnt immer mit {, und direkt danach einzufügen erhält die Schlüsselreihenfolge des Originals exakt.
let mut record = String::new();
self.inner.format_event(ctx, Writer::new(&mut record), event)?;
match current_trace_id(ctx, event) {
Some(trace_id) => write_with_trace_id(&mut writer, &record, trace_id),
None => writer.write_str(&record),
}Der gesamte Eingriff besteht aus einem strip_prefix, einer Absicherung und einem Rückfall, der den Datensatz unverändert ausgibt, falls die Annahme je bricht. Ein Test fixiert, dass die delegierte JSON-Form erhalten bleibt.
„Keine String-Chirurgie" ist eine gute Grundannahme, keine Regel. Sie ist für den Fall geschrieben, in dem Ihnen das Format gehört — und hier war der ganze Punkt, dass es Ihnen nicht gehört.
Fehlend, nicht null
Ein ungültiger Span-Kontext ergibt die Null-Trace-ID. "trace_id":"00000000000000000000000000000000" zu schreiben würde einen unachtsamen Test zufriedenstellen — der Schlüssel ist da, er hat 32 Hex-Zeichen — und würde dann auf den Derived-Field-Regex des Dashboards passen und einen klickbaren Link auf einen Trace erzeugen, der nicht existieren kann.
Der Formatter lässt den Schlüssel stattdessen ganz weg. Zwei seiner vier Tests existieren nur, um das zu fixieren: kein OTel-Layer installiert (ein unterstütztes Deployment — der Exporter ist optional) und kein Span betreten.
Für eine Korrelations-ID sind fehlend und falsch keine benachbarten Fehlermodi — sie sind Gegensätze. Ein fehlendes Feld sagt der Leserin, dass es nichts zu verfolgen gibt, während ein falsches jemanden in eine Sackgasse schickt und ihn genau die Zeit kostet, die es braucht, um zu beweisen, dass es eine ist.
Und dann war auch die Pipeline falsch
Die Verifikation von Ende zu Ende förderte den Teil zutage, den keine Menge Rust behoben hätte: zwei Brüche zwischen Service und Dashboard-Link, keiner davon in der Anwendung.
Der Derived-Field-Matcher war in logfmt geschrieben. Die Grafana-Datasource-Konfiguration trug das seit dem Aufbau des Stacks mit sich:
derivedFields:
- datasourceUid: tempo
matcherRegex: "trace_id=(\\w+)"Der Service gibt JSON aus, trace_id= taucht also nie in einer Zeile auf, die "trace_id":"..." lautet. Es hatte wochenlang auf nichts gepasst, und es gab keine Möglichkeit, das zu bemerken, weil auch nichts eine Trace-ID ausgab. Zwei Hälften eines Features, beide fehlend, jede die andere verdeckend.
Der Log-Aggregator parste nur den äußeren Umschlag. Der Aggregator von Vector führte eine Transformation aus:
. = parse_json!(.message)aber seine Kafka-Source hatte keinen decoding-Block, sodass der Standard-Byte-Codec .message zum Umschlag des Agents machte statt zur Logzeile des Service. Dieser eine Parse hebt den Umschlag ab und lässt das JSON der Anwendung als escapten String weiterhin in .message liegen. Beim direkten Abfragen des Log-Stores waren die gespeicherten obersten Schlüssel:
['file', 'kubernetes', 'message', 'source_type', 'stream']Keines der eigenen Felder des Service hat es in diese Liste geschafft: level, target, span und die Event-ID lagen allesamt noch im escapten String in .message. Jedes strukturierte Feld, das der Service mühsam ausgibt, war ein parse_json davon entfernt, abfragbar zu sein — was auch etwas erklärte, das ich sonst dem Log-Store angelastet hätte, nämlich dass die Feldliste für diese Streams immer nur Labels zeigte.
Auch beim Beheben lauert eine Falle. Das vorhandene ! bedeutet bei Fehlschlag abbrechen, und Vector verwirft das Event und zählt einen Fehlerzähler hoch. Vertretbar für den Umschlag, den der Agent immer erzeugt. Nicht vertretbar für die innere Nutzlast: Jeder andere Container im Cluster loggt Klartext, und ein ! an dieser Stelle verwirft das alles lautlos.
Mit beiden reparierten Enden beschreiben die zwei Hälften endlich denselben String. Der Matcher, in JSON statt in logfmt:
derivedFields:
- datasourceUid: tempo
matcherRegex: '"trace_id":\s*"(\w+)"'Und eine Zeile vom Ingest-Pfad, aufgezeichnet aus einem Test, der Datensätze durch denselben Formatter rendert, den auch die Produktion nutzt:
{"trace_id":"90a79d9c4db18e37d9f45c22338fa3e1","timestamp":"2026-08-25T14:37:59.805729Z","level":"INFO","fields":{"message":"chain event ingested","event_id":"01a0395b-4c7a-7112-b4ee-2c154b83f8c0","event_type":"block_ingested","lag_us":3259,"block_number":1},"target":"chain_ingestor::application::ingest_blocks","span":{"chain":"ethereum","event_id":"01a0395b-4c7a-7112-b4ee-2c154b83f8c0","name":"ingest_event"},"spans":[{"chain":"ethereum","event_id":"01a0395b-4c7a-7112-b4ee-2c154b83f8c0","name":"ingest_event"}]}trace_id ist der erste Schlüssel, weil der Formatter ihn nach der öffnenden Klammer einspleißt statt ihn anzuhängen — und genau das hält den Matcher bei einem schlichten Treffer, statt ihn über Positionen nachdenken zu lassen. Alles danach ist abfragbar, jetzt wo die innere Nutzlast geparst wird, level, event_type und lag_us sind also Filter statt Text, den ein Mensch liest.
Das ist das gesamte Feature: eine Zeile im Log-Store, ein Muster, das darauf passt, und ein Link, der auf einen Trace mit genau diesem einen Event auflöst.
Falle 5 — ein Sampler, der pro Span entscheidet, nicht pro Trace
Die Korrektur aus Falle 4 ist ein Schlüsselwort, und so viel kostet sie im Lesen. Was sie im Betrieb kostet, ist Arithmetik.
Ein Root-Span pro Event bindet die Span-Zahl an die Event-Zahl, und der Kafka-Publisher trägt bereits sein eigenes #[instrument], jedes Event erzeugt also zwei Spans statt einem. Drei Chains bei aktuellen Blockraten ergeben rund 222.000 Events pro Tag — sagen wir 444.000 Spans. Das nächste Issue bringt Transaction-Streaming in den Ethereum-Adapter, was Ethereum allein von 7.200 Events pro Tag auf etwa 1,3 Millionen hebt. Zweieinhalb Millionen Spans, aus einer einzigen Chain, gegen einen Tempo-Ingester mit einem Speicherlimit von 384Mi.
Am Storage bricht das nicht — die Aufbewahrung liegt bei 24 Stunden und das Volumen bleibt im einstelligen Gigabyte-Bereich. Was nicht skaliert, sind der Speicher des Ingesters und die Batch-Queue des OTLP-Exporters, die Spans verwirft, wenn sie voll läuft, und nichts meldet, wenn sie es tut. Diese Verluste häufen sich genau in den geschäftigen Phasen, die anzusehen sich lohnt — ein unbeabsichtigtes Sampling zu unbekannter Rate ist damit strikt schlechter als ein bewusstes zu bekannter Rate.
Das macht AlwaysOn — den bis dahin eingesetzten Sampler, der jeden Trace exportiert und nichts entscheidet — bei diesem Volumen zur falschen Voreinstellung. Ihn zu ersetzen ist der naheliegende Schritt, und der Ersatz trägt eine Falle von genau derselben Form wie die vier oben:
// entscheidet bei jedem Span neu
Sampler::TraceIdRatioBased(ratio)
// entscheidet einmal, an der Wurzel, und jeder Nachfahre erbt es
Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased(ratio)))should_sample wird pro Span aufgerufen. Ein reiner Ratio-Sampler bewertet also jeden Span unabhängig, ein Parent kann folglich einbezogen sein, während sein Kind herausfällt. Der Trace erreicht Tempo mit fehlenden Spans, und nichts in ihm zeigt an, dass etwas fehlt. Das ist schlimmer als gar nicht zu samplen, aus demselben Grund, aus dem eine Null-trace_id schlimmer ist als eine fehlende: Es ist eine Sackgasse, die jemanden genau die Zeit kostet, die es braucht, um zu beweisen, dass es eine ist.
Den Test dafür zu schreiben war der interessante Teil. Die naheliegende Fixture — eine Rate wählen, schauen was die Kinder tun — besteht gegen beide Sampler, denn bei den meisten Raten stimmen sie überein. Sie unterscheiden sich in genau einem Fall, und der Test muss darauf sitzen: ein Span, dessen Parent bereits herausgesampelt ankam, bei Rate 1.0. Bei 1.0 behält der reine Sampler jede Trace-ID bedingungslos, er räumt also jede andere Zusicherung im Modul ab und scheitert nur hier, wo die richtige Antwort lautet, einen Span zu verwerfen, den die Rate behalten hätte. Wählen Sie diese Fixture falsch, besteht die Suite gegen den kaputten Sampler.
Die Korrektur kam dann mit einer eigenen Variante derselben Falle. Die Rate ist ein Bruchteil, und 100 ist das, was jemand schreibt, der in Prozent denkt — ein gültiger f64, und TraceIdRatioBased liest alles >= 1.0 als immer-an. Eine Konfigurationsänderung, die den Export um fünfundneunzig Prozent senken soll, erzeugt lautlos das Maximum. Sie wird jetzt beim Laden abgelehnt; sie zu kappen wäre eine weitere Sache gewesen, die aussieht, als hätte sie funktioniert.
Die tatsächliche Span-Rate ist weiterhin ungemessen, die ausgerollte Rate steht also auf 1.0, und die Zahl folgt der Messung. Der Wrapper war allerdings nicht der Teil zum Aufschieben. Eine zu hohe Rate zeigt sich auf einem Dashboard; ein halb gesampelter Trace sieht aus wie ein ganzer.
Zum Mitnehmen
Fünf Fallen, eine gemeinsame Eigenschaft: Jede erzeugte Ausgabe, die richtig aussah.
- Ein Span, der nie schließt, exportiert nichts, und nichts meldet es.
- Eine Trace-ID aus dem falschen Feld fehlt in Produktion und ist in der CI vorhanden.
- Ein Kombinator auf dem falschen Future kompiliert, läuft und tut nichts.
- Spans, die korrekt schließen, können trotzdem alle in einem Trace landen, und jeder Test besteht weiterhin.
- Ein Sampler, der pro Span statt pro Trace entscheidet, liefert Traces, die halb da sind und ganz aussehen — und nur eine einzige Fixture im ganzen Raum unterscheidet den kaputten vom richtigen.
Der rote Faden dahinter: Observability-Code hat keinen unabhängigen Beobachter. Überall sonst in einem System zeigt sich Falschheit irgendwann als Fehler, den jemand sieht. Hier zeigt sie sich als Telemetrie, die in Ordnung aussieht — und Sie erfahren es während des Vorfalls, für den sie gedacht war.
Zwei Gewohnheiten hätten alle fünf gefangen. Zeichnen Sie die Kette von Arbeit passiert bis Mensch sieht es, bevor Sie irgendein Glied darin schätzen. Und fragen Sie für jedes Glied, wie es aussähe, wenn es kaputt wäre — lautet die Antwort „genauso", dann braucht dieses Glied einen Test, der fehlschlagen würde.
Wenn Sie über eine andere Spielart lautlos-in-Ordnung aussehender Telemetrie gestolpert sind — oder finden, dass Root-Spans pro Event die falsche Entscheidung sind und run der Trace hätte sein sollen — würde ich wirklich gerne hören, welche Sie erwischt hat. Die Tür steht offen unter cordata.tech/contact. Weiterführend: Ein Port, drei Client-Crates für die Architektur des Service, den dieser Beitrag instrumentiert.