diff --git a/.config/mise/tasks/semconv/check b/.config/mise/tasks/semconv/check
new file mode 100755
index 0000000..38299fc
--- /dev/null
+++ b/.config/mise/tasks/semconv/check
@@ -0,0 +1,12 @@
+#!/bin/sh
+#MISE description="Validate the semantic convention registry and generated contract"
+
+set -eu
+
+root=$(CDPATH='' cd -- "$(dirname -- "$0")/../../../.." && pwd)
+expected="$root/trogon-eventstore/src/observability"
+generated=$(mktemp -d)
+trap 'rm -rf "$generated"' EXIT HUP INT TERM
+
+"$root/.config/mise/tasks/semconv/generate" "$generated"
+diff -u "$expected/generated.rs" "$generated/generated.rs"
diff --git a/.config/mise/tasks/semconv/generate b/.config/mise/tasks/semconv/generate
new file mode 100755
index 0000000..45bcdad
--- /dev/null
+++ b/.config/mise/tasks/semconv/generate
@@ -0,0 +1,28 @@
+#!/bin/sh
+#MISE description="Generate the OpenTelemetry semantic convention contract"
+
+set -eu
+
+root=$(CDPATH='' cd -- "$(dirname -- "$0")/../../../.." && pwd)
+output=${1:-"$root/trogon-eventstore/src/observability"}
+registry_version=$(sed -n '1p' "$root/otel/semconv/registry-version")
+registry="$root/otel/semconv/registry"
+official_registry="https://github.com/open-telemetry/semantic-conventions@${registry_version}[model]"
+staging=$(mktemp -d)
+trap 'rm -rf "$staging"' EXIT HUP INT TERM
+
+grep -Fqx " registry_path: $official_registry" "$registry/manifest.yaml"
+
+weaver registry check \
+ --future \
+ --registry "$registry"
+
+weaver registry generate rust "$staging" \
+ --future \
+ --registry "$registry" \
+ --templates "$root/otel/semconv/templates"
+
+test -f "$staging/observability.rs"
+rustfmt --edition 2024 "$staging/observability.rs"
+mkdir -p "$output"
+mv "$staging/observability.rs" "$output/generated.rs"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 702a27e..3fddd1d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -58,6 +58,14 @@ jobs:
rustup update stable
rustup default stable
rustup component add clippy rustfmt
+ - name: Install repository tools
+ uses: jdx/mise-action@9e7f7633ff6f6d6048a9418a68d48f288f50eb14 # v4.2.3
+ with:
+ version: 2026.8.2
+ install_args: github:open-telemetry/weaver
+ cache: false
+ - name: Verify semantic conventions
+ run: mise run --skip-tools semconv:check
- name: Check formatting
run: cargo fmt --all -- --check
- name: Run Clippy
diff --git a/Cargo.lock b/Cargo.lock
index 9391804..8c321b4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1339,6 +1339,36 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+[[package]]
+name = "opentelemetry"
+version = "0.32.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "js-sys",
+ "pin-project-lite",
+ "thiserror",
+]
+
+[[package]]
+name = "opentelemetry_sdk"
+version = "0.32.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9"
+dependencies = [
+ "futures-channel",
+ "futures-executor",
+ "futures-util",
+ "opentelemetry",
+ "percent-encoding",
+ "portable-atomic",
+ "rand 0.9.5",
+ "thiserror",
+ "tokio",
+]
+
[[package]]
name = "os_str_bytes"
version = "6.6.1"
@@ -2659,6 +2689,8 @@ dependencies = [
"lazy_static",
"names",
"nom",
+ "opentelemetry",
+ "opentelemetry_sdk",
"prost",
"prost-types",
"rand 0.9.5",
diff --git a/mise.toml b/mise.toml
new file mode 100644
index 0000000..8d9f559
--- /dev/null
+++ b/mise.toml
@@ -0,0 +1,2 @@
+[tools]
+"github:open-telemetry/weaver" = "0.24.2"
diff --git a/otel/semconv/registry-version b/otel/semconv/registry-version
new file mode 100644
index 0000000..518f2ea
--- /dev/null
+++ b/otel/semconv/registry-version
@@ -0,0 +1 @@
+v1.43.0
diff --git a/otel/semconv/registry/manifest.yaml b/otel/semconv/registry/manifest.yaml
new file mode 100644
index 0000000..ab2efcf
--- /dev/null
+++ b/otel/semconv/registry/manifest.yaml
@@ -0,0 +1,6 @@
+name: trogon_eventstore_client
+description: Semantic conventions for TrogonEventStore client telemetry.
+schema_url: https://trogondb.com/schemas/0.1.0
+dependencies:
+ - schema_url: https://opentelemetry.io/schemas/1.43.0
+ registry_path: https://github.com/open-telemetry/semantic-conventions@v1.43.0[model]
diff --git a/otel/semconv/registry/trogon/eventstore/client-spans.yaml b/otel/semconv/registry/trogon/eventstore/client-spans.yaml
new file mode 100644
index 0000000..9354d5a
--- /dev/null
+++ b/otel/semconv/registry/trogon/eventstore/client-spans.yaml
@@ -0,0 +1,302 @@
+groups:
+ - id: registry.trogon.eventstore.client.attributes
+ type: attribute_group
+ brief: TrogonEventStore client attributes.
+ attributes:
+ - id: trogon.eventstore.batch.correlation_id
+ type: string
+ stability: development
+ brief: The identifier used to correlate a batch append request with its response.
+ examples: ["00000000-0000-0000-0000-000000000000"]
+
+ - id: span.trogon.eventstore.client
+ type: span
+ extends: span.db.client
+ span_kind: client
+ stability: development
+ brief: A logical TrogonEventStore client operation.
+ attributes:
+ - ref: db.system.name
+ requirement_level: required
+ note: The value MUST be `trogoneventstore` and SHOULD be set when the span is created.
+ - ref: db.operation.name
+ requirement_level: required
+ - ref: db.collection.name
+ requirement_level:
+ conditionally_required: If the operation targets a single stream.
+ - ref: error.type
+ requirement_level:
+ conditionally_required: If and only if the operation failed.
+
+ - id: span.trogon.eventstore.client.append_to_stream
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Appends events to a stream.
+ annotations:
+ code_generation:
+ operation_name: append_to_stream
+
+ - id: span.trogon.eventstore.client.set_stream_metadata
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Sets metadata for a stream.
+ annotations:
+ code_generation:
+ operation_name: set_stream_metadata
+
+ - id: span.trogon.eventstore.client.batch_append
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Starts a batch append session.
+ annotations:
+ code_generation:
+ operation_name: batch_append
+
+ - id: span.trogon.eventstore.client.batch_append_to_stream
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Appends a batch of events to a stream.
+ attributes:
+ - ref: trogon.eventstore.batch.correlation_id
+ requirement_level: required
+ annotations:
+ code_generation:
+ operation_name: batch_append_to_stream
+
+ - id: span.trogon.eventstore.client.read_stream
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Reads events from a stream.
+ annotations:
+ code_generation:
+ operation_name: read_stream
+
+ - id: span.trogon.eventstore.client.read_all
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Reads events from the all stream.
+ annotations:
+ code_generation:
+ operation_name: read_all
+
+ - id: span.trogon.eventstore.client.get_stream_metadata
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Reads metadata for a stream.
+ annotations:
+ code_generation:
+ operation_name: get_stream_metadata
+
+ - id: span.trogon.eventstore.client.delete_stream
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Soft deletes a stream.
+ annotations:
+ code_generation:
+ operation_name: delete_stream
+
+ - id: span.trogon.eventstore.client.tombstone_stream
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Permanently deletes a stream.
+ annotations:
+ code_generation:
+ operation_name: tombstone_stream
+
+ - id: span.trogon.eventstore.client.subscribe_to_stream
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Starts a volatile stream subscription.
+ annotations:
+ code_generation:
+ operation_name: subscribe_to_stream
+
+ - id: span.trogon.eventstore.client.subscribe_to_all
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Starts a volatile subscription to the all stream.
+ annotations:
+ code_generation:
+ operation_name: subscribe_to_all
+
+ - id: span.trogon.eventstore.client.create_persistent_subscription
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Creates a persistent subscription for a stream.
+ annotations:
+ code_generation:
+ operation_name: create_persistent_subscription
+
+ - id: span.trogon.eventstore.client.create_persistent_subscription_to_all
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Creates a persistent subscription for the all stream.
+ annotations:
+ code_generation:
+ operation_name: create_persistent_subscription_to_all
+
+ - id: span.trogon.eventstore.client.update_persistent_subscription
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Updates a persistent subscription for a stream.
+ annotations:
+ code_generation:
+ operation_name: update_persistent_subscription
+
+ - id: span.trogon.eventstore.client.update_persistent_subscription_to_all
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Updates a persistent subscription for the all stream.
+ annotations:
+ code_generation:
+ operation_name: update_persistent_subscription_to_all
+
+ - id: span.trogon.eventstore.client.delete_persistent_subscription
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Deletes a persistent subscription for a stream.
+ annotations:
+ code_generation:
+ operation_name: delete_persistent_subscription
+
+ - id: span.trogon.eventstore.client.delete_persistent_subscription_to_all
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Deletes a persistent subscription for the all stream.
+ annotations:
+ code_generation:
+ operation_name: delete_persistent_subscription_to_all
+
+ - id: span.trogon.eventstore.client.subscribe_to_persistent_subscription
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Connects to a persistent subscription for a stream.
+ annotations:
+ code_generation:
+ operation_name: subscribe_to_persistent_subscription
+
+ - id: span.trogon.eventstore.client.subscribe_to_persistent_subscription_to_all
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Connects to a persistent subscription for the all stream.
+ annotations:
+ code_generation:
+ operation_name: subscribe_to_persistent_subscription_to_all
+
+ - id: span.trogon.eventstore.client.replay_parked_messages
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Replays parked messages for a stream.
+ annotations:
+ code_generation:
+ operation_name: replay_parked_messages
+
+ - id: span.trogon.eventstore.client.replay_parked_messages_to_all
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Replays parked messages for the all stream.
+ annotations:
+ code_generation:
+ operation_name: replay_parked_messages_to_all
+
+ - id: span.trogon.eventstore.client.list_all_persistent_subscriptions
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Lists all persistent subscriptions.
+ annotations:
+ code_generation:
+ operation_name: list_all_persistent_subscriptions
+
+ - id: span.trogon.eventstore.client.list_persistent_subscriptions_for_stream
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Lists persistent subscriptions for a stream.
+ annotations:
+ code_generation:
+ operation_name: list_persistent_subscriptions_for_stream
+
+ - id: span.trogon.eventstore.client.list_persistent_subscriptions_to_all
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Lists persistent subscriptions for the all stream.
+ annotations:
+ code_generation:
+ operation_name: list_persistent_subscriptions_to_all
+
+ - id: span.trogon.eventstore.client.get_persistent_subscription_info
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Gets persistent subscription information for a stream.
+ annotations:
+ code_generation:
+ operation_name: get_persistent_subscription_info
+
+ - id: span.trogon.eventstore.client.get_persistent_subscription_info_to_all
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Gets persistent subscription information for the all stream.
+ annotations:
+ code_generation:
+ operation_name: get_persistent_subscription_info_to_all
+
+ - id: span.trogon.eventstore.client.restart_persistent_subscription_subsystem
+ type: span
+ extends: span.trogon.eventstore.client
+ span_kind: client
+ stability: development
+ brief: Restarts the persistent subscription subsystem.
+ annotations:
+ code_generation:
+ operation_name: restart_persistent_subscription_subsystem
diff --git a/otel/semconv/templates/registry/rust/observability.rs.j2 b/otel/semconv/templates/registry/rust/observability.rs.j2
new file mode 100644
index 0000000..bb70c25
--- /dev/null
+++ b/otel/semconv/templates/registry/rust/observability.rs.j2
@@ -0,0 +1,19 @@
+//
+
+use super::ClientOperation;
+use opentelemetry::trace::SpanKind;
+
+{% for attribute in ctx.attributes | sort(attribute="name") %}
+pub(crate) const {{ attribute.name | screaming_snake_case }}: &str = "{{ attribute.name }}";
+{% endfor %}
+
+pub(crate) const CLIENT_SPAN_KIND: SpanKind = SpanKind::{{ ctx.base.span_kind | pascal_case }};
+
+pub(crate) mod operation {
+ use super::ClientOperation;
+
+{% for span in ctx.operations | sort(attribute="id") %}
+ pub(crate) const {{ span.id | replace("span.trogon.eventstore.client.", "") | screaming_snake_case }}: ClientOperation =
+ ClientOperation::new("{{ span.annotations.code_generation.operation_name }}");
+{% endfor %}
+}
diff --git a/otel/semconv/templates/registry/rust/weaver.yaml b/otel/semconv/templates/registry/rust/weaver.yaml
new file mode 100644
index 0000000..48e9288
--- /dev/null
+++ b/otel/semconv/templates/registry/rust/weaver.yaml
@@ -0,0 +1,20 @@
+whitespace_control:
+ trim_blocks: true
+ lstrip_blocks: true
+
+templates:
+ - template: observability.rs.j2
+ filter: >
+ {
+ base: [semconv_grouped_spans[].spans[] | select(.id == "span.trogon.eventstore.client")][0],
+ operations: [semconv_grouped_spans[].spans[] | select(.id | startswith("span.trogon.eventstore.client."))],
+ attributes: [semconv_grouped_spans[].spans[].attributes[] | select(
+ .name == "db.collection.name" or
+ .name == "db.operation.name" or
+ .name == "db.system.name" or
+ .name == "error.type" or
+ .name == "trogon.eventstore.batch.correlation_id"
+ )] | unique_by(.name)
+ }
+ application_mode: single
+ file_name: observability.rs
diff --git a/trogon-eventstore/Cargo.toml b/trogon-eventstore/Cargo.toml
index 8d78df4..77befe6 100755
--- a/trogon-eventstore/Cargo.toml
+++ b/trogon-eventstore/Cargo.toml
@@ -37,6 +37,7 @@ hyper-util = { version = "0.1", features = ["client-legacy", "http2"] }
hyper-rustls = { version = "0.27", features = ["rustls-native-certs", "http2"] }
tracing = "0.1"
nom = "7"
+opentelemetry = { version = "0.32", default-features = false, features = ["trace"] }
prost = "0.13"
prost-types = "0.13"
rand = { version = "0.9", features = ["small_rng"] }
@@ -68,6 +69,7 @@ name = "integration"
[dev-dependencies]
names = "0.14"
+opentelemetry_sdk = { version = "0.32", default-features = false, features = ["testing", "trace"] }
serde = { version = "1", features = ["derive"] }
testcontainers = "0.23"
tokio = { version = "1", default-features = false, features = [
diff --git a/trogon-eventstore/src/batch.rs b/trogon-eventstore/src/batch.rs
index 084e778..16c1c32 100644
--- a/trogon-eventstore/src/batch.rs
+++ b/trogon-eventstore/src/batch.rs
@@ -1,4 +1,8 @@
+use crate::observability::{TROGON_EVENTSTORE_BATCH_CORRELATION_ID, client_operation, operation};
use crate::{EventData, Position, StreamState};
+use opentelemetry::Context;
+use opentelemetry::KeyValue;
+use opentelemetry::trace::TraceContextExt;
use tokio::sync::{
mpsc::{UnboundedReceiver, UnboundedSender},
oneshot,
@@ -19,6 +23,17 @@ pub(crate) struct Req {
pub(crate) expected_revision: StreamState,
}
+impl Req {
+ fn new(stream_name: String, events: Vec, expected_revision: StreamState) -> Self {
+ Self {
+ id: uuid::Uuid::new_v4(),
+ stream_name,
+ events,
+ expected_revision,
+ }
+ }
+}
+
#[derive(Debug)]
pub(crate) struct Out {
pub(crate) correlation_id: uuid::Uuid,
@@ -140,29 +155,174 @@ impl BatchAppendClient {
stream_state: StreamState,
events: Vec,
) -> crate::Result {
- let (sender, receiver) = oneshot::channel();
- let req = Req {
- id: uuid::Uuid::new_v4(),
- stream_name: stream_name.as_ref().to_string(),
- events,
- expected_revision: stream_state,
+ let stream_name = stream_name.as_ref().to_string();
+ client_operation(
+ operation::BATCH_APPEND_TO_STREAM.on_collection(stream_name.clone()),
+ async {
+ let (sender, receiver) = oneshot::channel();
+ let req = Req::new(stream_name, events, stream_state);
+ let context = Context::current();
+ let span = context.span();
+ if span.is_recording() {
+ span.set_attribute(KeyValue::new(
+ TROGON_EVENTSTORE_BATCH_CORRELATION_ID,
+ req.id.to_string(),
+ ));
+ }
+
+ let req = In { sender, req };
+
+ if let Err(e) = self.sender.send(BatchMsg::In(req)) {
+ error!("[sending-end] Batch-append stream is closed: {}", e);
+
+ let status = tonic::Status::cancelled("Batch-append stream has been closed");
+ return Err(crate::Error::ServerError(status.to_string()));
+ }
+
+ receiver.await.unwrap_or_else(|e| {
+ error!("[receiving-end] Batch-append stream is closed: {}", e);
+
+ let status = tonic::Status::cancelled("Batch-append stream has been closed");
+
+ Err(crate::Error::ServerError(status.to_string()))
+ })
+ },
+ )
+ .await
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{BatchAppendClient, BatchMsg, BatchWriteResult, In};
+ use crate::StreamState;
+ use crate::observability::{
+ DB_COLLECTION_NAME, DB_OPERATION_NAME, TROGON_EVENTSTORE_BATCH_CORRELATION_ID,
+ };
+ use opentelemetry::global;
+ use opentelemetry::trace::{Status, noop::NoopTracerProvider};
+ use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider};
+
+ #[tokio::test]
+ async fn batch_append_to_stream_records_its_protocol_correlation_id() {
+ let _guard = crate::observability::TEST_GLOBALS.lock().await;
+ let exporter = InMemorySpanExporter::default();
+ let provider = SdkTracerProvider::builder()
+ .with_simple_exporter(exporter.clone())
+ .build();
+ global::set_tracer_provider(provider.clone());
+ let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
+ let client = BatchAppendClient { sender };
+
+ let operation = tokio::spawn(async move {
+ client
+ .append_to_stream("stream", StreamState::Any, Vec::new())
+ .await
+ });
+ let BatchMsg::In(In { req, sender }) = receiver.recv().await.expect("batch request") else {
+ panic!("expected an inbound batch request");
};
+ let correlation_id = req.id.to_string();
+ sender
+ .send(Ok(BatchWriteResult::new(
+ "stream".to_string(),
+ None,
+ None,
+ Some(StreamState::Any),
+ )))
+ .expect("batch response receiver");
+ operation.await.expect("batch task").expect("batch result");
+ provider.force_flush().unwrap();
- let req = In { sender, req };
+ let spans = exporter.get_finished_spans().unwrap();
+ let span = spans
+ .iter()
+ .find(|span| span.name == "batch_append_to_stream stream")
+ .expect("batch append client span");
+ assert!(span.attributes.iter().any(|attribute| {
+ attribute.key.as_str() == TROGON_EVENTSTORE_BATCH_CORRELATION_ID
+ && attribute.value.to_string() == correlation_id
+ }));
- if let Err(e) = self.sender.send(BatchMsg::In(req)) {
- error!("[sending-end] Batch-append stream is closed: {}", e);
+ global::set_tracer_provider(NoopTracerProvider::new());
+ }
- let status = tonic::Status::cancelled("Batch-append stream has been closed");
- return Err(crate::Error::ServerError(status.to_string()));
- }
+ #[tokio::test]
+ async fn concurrent_appends_route_reversed_responses_by_correlation_id() {
+ let _guard = crate::observability::TEST_GLOBALS.lock().await;
+ let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
+ let response_sender = sender.clone();
+ let (forward, mut forwarded) = tokio::sync::mpsc::unbounded_channel();
+ let client = BatchAppendClient::new(sender, receiver, forward);
+
+ let first = client.append_to_stream("first", StreamState::Any, Vec::new());
+ let second = client.append_to_stream("second", StreamState::Any, Vec::new());
+ let responses = async move {
+ let first = forwarded.recv().await.expect("first request");
+ let second = forwarded.recv().await.expect("second request");
+ assert_ne!(first.id, second.id);
+
+ response_sender
+ .send(BatchMsg::Out(super::Out {
+ correlation_id: second.id,
+ result: Ok(BatchWriteResult::new(
+ second.stream_name,
+ None,
+ None,
+ Some(StreamState::Any),
+ )),
+ }))
+ .expect("second response");
+ response_sender
+ .send(BatchMsg::Out(super::Out {
+ correlation_id: first.id,
+ result: Ok(BatchWriteResult::new(
+ first.stream_name,
+ None,
+ None,
+ Some(StreamState::Any),
+ )),
+ }))
+ .expect("first response");
+ };
+
+ let (first, second, ()) = tokio::join!(first, second, responses);
+ assert_eq!(first.expect("first result").stream_name(), "first");
+ assert_eq!(second.expect("second result").stream_name(), "second");
+ }
+
+ #[tokio::test]
+ async fn batch_append_to_stream_emits_a_logical_client_span() {
+ let _guard = crate::observability::TEST_GLOBALS.lock().await;
+ let exporter = InMemorySpanExporter::default();
+ let provider = SdkTracerProvider::builder()
+ .with_simple_exporter(exporter.clone())
+ .build();
+ global::set_tracer_provider(provider.clone());
+ let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
+ drop(receiver);
+ let client = BatchAppendClient { sender };
- receiver.await.unwrap_or_else(|e| {
- error!("[receiving-end] Batch-append stream is closed: {}", e);
+ let result = client
+ .append_to_stream("stream", StreamState::Any, Vec::new())
+ .await;
+ assert!(result.is_err());
+ provider.force_flush().unwrap();
- let status = tonic::Status::cancelled("Batch-append stream has been closed");
+ let spans = exporter.get_finished_spans().unwrap();
+ let span = spans
+ .iter()
+ .find(|span| span.name == "batch_append_to_stream stream")
+ .expect("batch append client span");
+ assert!(matches!(span.status, Status::Error { .. }));
+ assert!(span.attributes.iter().any(|attribute| {
+ attribute.key.as_str() == DB_OPERATION_NAME
+ && attribute.value.to_string() == "batch_append_to_stream"
+ }));
+ assert!(span.attributes.iter().any(|attribute| {
+ attribute.key.as_str() == DB_COLLECTION_NAME && attribute.value.to_string() == "stream"
+ }));
- Err(crate::Error::ServerError(status.to_string()))
- })
+ global::set_tracer_provider(NoopTracerProvider::new());
}
}
diff --git a/trogon-eventstore/src/client.rs b/trogon-eventstore/src/client.rs
index d45c02e..0765224 100644
--- a/trogon-eventstore/src/client.rs
+++ b/trogon-eventstore/src/client.rs
@@ -1,5 +1,6 @@
use crate::batch::BatchAppendClient;
use crate::grpc::{ClientSettings, GrpcClient};
+use crate::observability::{client_operation, infallible_client_operation, operation};
use crate::options::batch_append::BatchAppendOptions;
use crate::options::persistent_subscription::PersistentSubscriptionOptions;
use crate::options::read_all::ReadAllOptions;
@@ -78,7 +79,12 @@ impl Client {
where
Events: ToEvents,
{
- commands::append_to_stream(&self.client, stream_name, options, events.into_events()).await
+ let stream_name = stream_name.into_stream_name();
+ client_operation(
+ operation::APPEND_TO_STREAM.on_stream(&stream_name),
+ commands::append_to_stream(&self.client, stream_name, options, events.into_events()),
+ )
+ .await
}
// Sets a stream metadata.
@@ -88,11 +94,18 @@ impl Client {
options: &AppendToStreamOptions,
metadata: &StreamMetadata,
) -> crate::Result {
- let event = EventData::json("$metadata", metadata)
- .map_err(|e| crate::Error::InternalParsingError(e.to_string()))?;
+ let stream_name = name.into_metadata_stream_name();
+ client_operation(
+ operation::SET_STREAM_METADATA.on_stream(&stream_name),
+ async {
+ let event = EventData::json("$metadata", metadata)
+ .map_err(|e| crate::Error::InternalParsingError(e.to_string()))?;
- self.append_to_stream(name.into_metadata_stream_name(), options, event)
- .await
+ commands::append_to_stream(&self.client, stream_name, options, event.into_events())
+ .await
+ },
+ )
+ .await
}
// Creates a batch-append client.
@@ -100,7 +113,11 @@ impl Client {
&self,
options: &BatchAppendOptions,
) -> crate::Result {
- commands::batch_append(&self.client, options).await
+ client_operation(
+ operation::BATCH_APPEND,
+ commands::batch_append(&self.client, options),
+ )
+ .await
}
/// Reads events from a given stream. The reading can be done forward and
@@ -110,11 +127,15 @@ impl Client {
stream_name: impl StreamName,
options: &ReadStreamOptions,
) -> crate::Result {
- commands::read_stream(
- self.client.clone(),
- options,
- stream_name,
- options.max_count as u64,
+ let stream_name = stream_name.into_stream_name();
+ client_operation(
+ operation::READ_STREAM.on_stream(&stream_name),
+ commands::read_stream(
+ self.client.clone(),
+ options,
+ stream_name,
+ options.max_count as u64,
+ ),
)
.await
}
@@ -122,7 +143,11 @@ impl Client {
/// Reads events for the system stream `$all`. The reading can be done
/// forward and backward.
pub async fn read_all(&self, options: &ReadAllOptions) -> crate::Result {
- commands::read_all(self.client.clone(), options, options.max_count as u64).await
+ client_operation(
+ operation::READ_ALL.on_collection("$all"),
+ commands::read_all(self.client.clone(), options, options.max_count as u64),
+ )
+ .await
}
/// Reads a stream metadata.
@@ -131,32 +156,43 @@ impl Client {
name: impl MetadataStreamName,
options: &ReadStreamOptions,
) -> crate::Result {
- let mut stream = self
- .read_stream(name.into_metadata_stream_name(), options)
- .await?;
-
- match stream.next().await {
- Ok(event) => {
- let event = event.expect("to be defined");
- let metadata = event
- .get_original_event()
- .as_json::()
- .map_err(|e| crate::Error::InternalParsingError(e.to_string()))?;
-
- let metadata = VersionedMetadata {
- stream: event.get_original_stream_id().to_string(),
- version: event.get_original_event().revision,
- metadata,
- };
-
- Ok(StreamMetadataResult::Success(Box::new(metadata)))
- }
- Err(e) => match e {
- crate::Error::ResourceNotFound => Ok(StreamMetadataResult::NotFound),
- crate::Error::ResourceDeleted => Ok(StreamMetadataResult::Deleted),
- other => Err(other),
+ let stream_name = name.into_metadata_stream_name();
+ client_operation(
+ operation::GET_STREAM_METADATA.on_stream(&stream_name),
+ async {
+ let mut stream = commands::read_stream(
+ self.client.clone(),
+ options,
+ stream_name,
+ options.max_count as u64,
+ )
+ .await?;
+
+ match stream.next().await {
+ Ok(event) => {
+ let event = event.expect("to be defined");
+ let metadata = event
+ .get_original_event()
+ .as_json::()
+ .map_err(|e| crate::Error::InternalParsingError(e.to_string()))?;
+
+ let metadata = VersionedMetadata {
+ stream: event.get_original_stream_id().to_string(),
+ version: event.get_original_event().revision,
+ metadata,
+ };
+
+ Ok(StreamMetadataResult::Success(Box::new(metadata)))
+ }
+ Err(e) => match e {
+ crate::Error::ResourceNotFound => Ok(StreamMetadataResult::NotFound),
+ crate::Error::ResourceDeleted => Ok(StreamMetadataResult::Deleted),
+ other => Err(other),
+ },
+ }
},
- }
+ )
+ .await
}
/// Soft deletes a given stream.
@@ -170,7 +206,12 @@ impl Client {
stream_name: impl StreamName,
options: &DeleteStreamOptions,
) -> crate::Result