From 9f26551a22e3473367945ce4e8c1d9d3d3a52a90 Mon Sep 17 00:00:00 2001 From: Caleb Schoepp Date: Fri, 22 Mar 2024 12:22:48 -0600 Subject: [PATCH] feat(wasi-observe): A WASI Observe host component Signed-off-by: Caleb Schoepp --- Cargo.lock | 146 +++++- Cargo.toml | 1 + crates/factor-observe/Cargo.toml | 37 ++ crates/factor-observe/src/host.rs | 131 +++++ crates/factor-observe/src/lib.rs | 113 ++++ crates/factor-outbound-http/Cargo.toml | 1 + crates/factor-outbound-http/src/lib.rs | 4 + crates/factor-outbound-http/src/spin.rs | 2 + crates/factor-outbound-http/src/wasi.rs | 2 + crates/runtime-config/Cargo.toml | 1 + crates/runtime-config/src/lib.rs | 7 + crates/telemetry/src/lib.rs | 2 +- crates/telemetry/src/traces.rs | 12 +- crates/trigger-http/Cargo.toml | 1 + crates/trigger-http/src/handler.rs | 483 ++++++++++++++++++ crates/trigger-http/src/wasi.rs | 8 +- crates/trigger/Cargo.toml | 1 + crates/trigger/src/factors.rs | 3 + crates/world/Cargo.toml | 1 + crates/world/src/conversions.rs | 34 ++ tests/integration.rs | 419 ++++++++++++--- tests/test-components/components/Cargo.lock | 241 ++++++++- .../components/otel-smoke-test/Cargo.toml | 12 + .../components/otel-smoke-test/src/lib.rs | 22 + .../wasi-observe-tracing/Cargo.toml | 13 + .../wasi-observe-tracing/src/lib.rs | 72 +++ tests/testcases/otel-smoke-test/spin.toml | 11 +- .../testcases/wasi-observe-tracing/spin.toml | 19 + wit/deps/observe/traces.wit | 108 ++++ wit/deps/observe/world.wit | 5 + wit/world.wit | 1 + 31 files changed, 1809 insertions(+), 104 deletions(-) create mode 100644 crates/factor-observe/Cargo.toml create mode 100644 crates/factor-observe/src/host.rs create mode 100644 crates/factor-observe/src/lib.rs create mode 100644 crates/trigger-http/src/handler.rs create mode 100644 tests/test-components/components/otel-smoke-test/Cargo.toml create mode 100644 tests/test-components/components/otel-smoke-test/src/lib.rs create mode 100644 tests/test-components/components/wasi-observe-tracing/Cargo.toml create mode 100644 tests/test-components/components/wasi-observe-tracing/src/lib.rs create mode 100644 tests/testcases/wasi-observe-tracing/spin.toml create mode 100644 wit/deps/observe/traces.wit create mode 100644 wit/deps/observe/world.wit diff --git a/Cargo.lock b/Cargo.lock index 8d29228be9..275f577560 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2323,6 +2323,25 @@ dependencies = [ "syn 2.0.58", ] +[[package]] +name = "fake-opentelemetry-collector" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beaa700fac52a0d51fe90eaa63f91ec313e8970e5e6103f8f022c0d3322d7aa2" +dependencies = [ + "futures", + "hex", + "opentelemetry 0.23.0", + "opentelemetry-otlp 0.16.0", + "opentelemetry-proto 0.6.0", + "opentelemetry_sdk 0.23.0", + "serde 1.0.197", + "tokio", + "tokio-stream", + "tonic", + "tracing", +] + [[package]] name = "fallible-iterator" version = "0.2.0" @@ -5027,6 +5046,20 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "opentelemetry" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b69a91d4893e713e06f724597ad630f1fa76057a5e1026c0ca67054a9032a76" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "once_cell", + "pin-project-lite", + "thiserror", +] + [[package]] name = "opentelemetry-http" version = "0.11.1" @@ -5036,7 +5069,7 @@ dependencies = [ "async-trait", "bytes", "http 0.2.12", - "opentelemetry", + "opentelemetry 0.22.0", "reqwest 0.11.27", ] @@ -5049,11 +5082,11 @@ dependencies = [ "async-trait", "futures-core", "http 0.2.12", - "opentelemetry", + "opentelemetry 0.22.0", "opentelemetry-http", - "opentelemetry-proto", + "opentelemetry-proto 0.5.0", "opentelemetry-semantic-conventions", - "opentelemetry_sdk", + "opentelemetry_sdk 0.22.1", "prost", "reqwest 0.11.27", "thiserror", @@ -5061,14 +5094,44 @@ dependencies = [ "tonic", ] +[[package]] +name = "opentelemetry-otlp" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94c69209c05319cdf7460c6d4c055ed102be242a0a6245835d7bc42c6ec7f54" +dependencies = [ + "async-trait", + "futures-core", + "http 0.2.12", + "opentelemetry 0.23.0", + "opentelemetry-proto 0.6.0", + "opentelemetry_sdk 0.23.0", + "prost", + "thiserror", + "tokio", + "tonic", +] + [[package]] name = "opentelemetry-proto" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a8fddc9b68f5b80dae9d6f510b88e02396f006ad48cac349411fbecc80caae4" dependencies = [ - "opentelemetry", - "opentelemetry_sdk", + "opentelemetry 0.22.0", + "opentelemetry_sdk 0.22.1", + "prost", + "tonic", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "984806e6cf27f2b49282e2a05e288f30594f3dbc74eb7a6e99422bc48ed78162" +dependencies = [ + "opentelemetry 0.23.0", + "opentelemetry_sdk 0.23.0", "prost", "tonic", ] @@ -5092,7 +5155,31 @@ dependencies = [ "futures-util", "glob", "once_cell", - "opentelemetry", + "opentelemetry 0.22.0", + "ordered-float 4.2.0", + "percent-encoding", + "rand 0.8.5", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae312d58eaa90a82d2e627fd86e075cf5230b3f11794e2ed74199ebbe572d4fd" +dependencies = [ + "async-std", + "async-trait", + "futures-channel", + "futures-executor", + "futures-util", + "glob", + "lazy_static 1.4.0", + "once_cell", + "opentelemetry 0.23.0", "ordered-float 4.2.0", "percent-encoding", "rand 0.8.5", @@ -7100,6 +7187,7 @@ dependencies = [ "ctrlc", "dialoguer 0.10.4", "dirs 4.0.0", + "fake-opentelemetry-collector", "futures", "glob", "hex", @@ -7335,6 +7423,35 @@ dependencies = [ "url", ] +[[package]] +name = "spin-factor-observe" +version = "2.8.0-pre0" +dependencies = [ + "anyhow", + "async-trait", + "dotenvy", + "futures-executor", + "indexmap 2.2.6", + "once_cell", + "opentelemetry 0.22.0", + "opentelemetry-otlp 0.15.0", + "opentelemetry_sdk 0.22.1", + "serde 1.0.197", + "spin-app", + "spin-core", + "spin-expressions", + "spin-factors", + "spin-telemetry", + "spin-world", + "table", + "thiserror", + "tokio", + "toml 0.5.11", + "tracing", + "tracing-opentelemetry", + "vaultrs", +] + [[package]] name = "spin-factor-outbound-http" version = "2.8.0-pre0" @@ -7345,6 +7462,7 @@ dependencies = [ "hyper 1.4.1", "reqwest 0.11.27", "rustls 0.23.7", + "spin-factor-observe", "spin-factor-outbound-networking", "spin-factor-variables", "spin-factors", @@ -7825,6 +7943,7 @@ dependencies = [ "spin-factor-key-value-redis", "spin-factor-key-value-spin", "spin-factor-llm", + "spin-factor-observe", "spin-factor-outbound-http", "spin-factor-outbound-mqtt", "spin-factor-outbound-mysql", @@ -7903,10 +8022,10 @@ dependencies = [ "anyhow", "http 0.2.12", "http 1.1.0", - "opentelemetry", - "opentelemetry-otlp", + "opentelemetry 0.22.0", + "opentelemetry-otlp 0.15.0", "opentelemetry-semantic-conventions", - "opentelemetry_sdk", + "opentelemetry_sdk 0.22.1", "terminal", "tracing", "tracing-appender", @@ -7967,6 +8086,7 @@ dependencies = [ "spin-core", "spin-factor-key-value", "spin-factor-llm", + "spin-factor-observe", "spin-factor-outbound-http", "spin-factor-outbound-mqtt", "spin-factor-outbound-mysql", @@ -8009,6 +8129,7 @@ dependencies = [ "serde_json", "spin-app", "spin-core", + "spin-factor-observe", "spin-factor-outbound-http", "spin-factor-wasi", "spin-http", @@ -8050,6 +8171,7 @@ name = "spin-world" version = "2.8.0-pre0" dependencies = [ "async-trait", + "opentelemetry 0.22.0", "wasmtime", ] @@ -8929,8 +9051,8 @@ checksum = "a9be14ba1bbe4ab79e9229f7f89fab8d120b865859f10527f31c033e599d2284" dependencies = [ "js-sys", "once_cell", - "opentelemetry", - "opentelemetry_sdk", + "opentelemetry 0.22.0", + "opentelemetry_sdk 0.22.1", "smallvec", "tracing", "tracing-core", diff --git a/Cargo.toml b/Cargo.toml index c656a30777..09b6328f13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,6 +97,7 @@ test-components = { path = "tests/test-components" } test-environment = { workspace = true } testing-framework = { path = "tests/testing-framework" } which = "4.2.5" +fake-opentelemetry-collector = "0.19.0" [build-dependencies] cargo-target-dep = { git = "https://github.com/fermyon/cargo-target-dep", rev = "482f269eceb7b1a7e8fc618bf8c082dd24979cf1" } diff --git a/crates/factor-observe/Cargo.toml b/crates/factor-observe/Cargo.toml new file mode 100644 index 0000000000..3a519ed189 --- /dev/null +++ b/crates/factor-observe/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "spin-factor-observe" +version = { workspace = true } +authors = { workspace = true } +edition = { workspace = true } + +[dependencies] +anyhow = "1.0" +async-trait = "0.1" +dotenvy = "0.15" +futures-executor = "0.3" +indexmap = "2.2.6" +once_cell = "1" +opentelemetry = { version = "0.22.0", features = [ "metrics", "trace"] } +opentelemetry_sdk = { version = "0.22.1", features = ["rt-tokio"] } +opentelemetry-otlp = { version = "0.15.0", default-features=false, features = ["http-proto", "trace", "http", "reqwest-client", "metrics", "grpc-tonic"] } +serde = "1.0.188" +spin-app = { path = "../app" } +spin-core = { path = "../core" } +spin-expressions = { path = "../expressions" } +spin-factors = { path = "../factors" } +spin-telemetry = { path = "../telemetry" } +spin-world = { path = "../world" } +table = { path = "../table" } +thiserror = "1" +tokio = { version = "1", features = ["rt-multi-thread"] } +tracing = "0.1.40" +tracing-opentelemetry = "0.23.0" +vaultrs = "0.6.2" + +[dev-dependencies] +toml = "0.5" + +[lints] +workspace = true + +# TODO(Caleb): Cleanup these dependencies, use workspace, remove not needed diff --git a/crates/factor-observe/src/host.rs b/crates/factor-observe/src/host.rs new file mode 100644 index 0000000000..f5be718617 --- /dev/null +++ b/crates/factor-observe/src/host.rs @@ -0,0 +1,131 @@ +use anyhow::Result; +use opentelemetry::global::ObjectSafeSpan; +use opentelemetry::trace::TraceContextExt; +use opentelemetry::trace::Tracer; +use opentelemetry::Context; +use spin_core::async_trait; +use spin_core::wasmtime::component::Resource; +use spin_telemetry::traces::WASI_OBSERVE_TRACER; +use spin_world::wasi::clocks0_2_0::wall_clock::Datetime; +use spin_world::wasi::observe::traces::{self, KeyValue, Span as WitSpan}; +use tracing_opentelemetry::OpenTelemetrySpanExt; + +use crate::{GuestSpan, InstanceState}; + +#[async_trait] +impl traces::Host for InstanceState {} + +#[async_trait] +impl traces::HostSpan for InstanceState { + // TODO(Caleb): Make this implicit logic make more sense (the indexmap seems wrong) + async fn start(&mut self, name: String) -> Result> { + let mut state = self.state.write().unwrap(); + + // TODO(Caleb): Make this cleaner + let parent_context = match state.active_spans.is_empty() { + true => tracing::Span::current().context(), + false => Context::new().with_remote_span_context( + state + .guest_spans + .get(*state.active_spans.last().unwrap().1) + .unwrap() + .inner + .span_context() + .clone(), + ), + }; + + // Create the underlying opentelemetry span + let otel_span = WASI_OBSERVE_TRACER + .lock() + .unwrap() + .clone() + .unwrap() + .start_with_context(name, &parent_context); + + let span_id = otel_span.span_context().span_id().to_string(); + + // Wrap it in a GuestSpan for our own bookkeeping purposes + let guest_span = GuestSpan { inner: otel_span }; + + // Put the GuestSpan in our resource table and push it to our stack of active spans + let resource_id = state.guest_spans.push(guest_span).unwrap(); + state.active_spans.insert(span_id, resource_id); + + Ok(Resource::new_own(resource_id)) + } + + async fn set_attribute( + &mut self, + resource: Resource, + attribute: KeyValue, + ) -> Result<()> { + if let Some(guest_span) = self + .state + .write() + .unwrap() + .guest_spans + .get_mut(resource.rep()) + { + guest_span.inner.set_attribute(attribute.into()) + } else { + tracing::debug!("can't find guest span to set attribute on") + } + Ok(()) + } + + async fn set_attributes( + &mut self, + resource: Resource, + attributes: Vec, + ) -> Result<()> { + if let Some(guest_span) = self + .state + .write() + .unwrap() + .guest_spans + .get_mut(resource.rep()) + { + for attribute in attributes { + guest_span.inner.set_attribute(attribute.into()); + } + } else { + tracing::debug!("can't find guest span to set attributes on") + } + Ok(()) + } + + async fn add_event( + &mut self, + _resource: Resource, + _name: String, + _timestamp: Option, + _attributes: Option>, + ) -> Result<()> { + // TODO: Implement + Ok(()) + } + + async fn end(&mut self, resource: Resource) -> Result<()> { + if let Some(guest_span) = self + .state + .write() + .unwrap() + .guest_spans + .get_mut(resource.rep()) + { + guest_span.inner.end() + } else { + tracing::debug!("can't find guest span to end") + } + Ok(()) + } + + fn drop(&mut self, _resource: Resource) -> Result<()> { + // TODO(Caleb): What do we want the dropping behavior to be? + // TODO(Caleb): How is the drop semantics test passing? + Ok(()) + } +} + +// TODO(Caleb): Improve debug tracing in failure cases diff --git a/crates/factor-observe/src/lib.rs b/crates/factor-observe/src/lib.rs new file mode 100644 index 0000000000..fb179ad5b6 --- /dev/null +++ b/crates/factor-observe/src/lib.rs @@ -0,0 +1,113 @@ +mod host; + +use std::sync::{Arc, RwLock}; + +use indexmap::IndexMap; +use opentelemetry::{global::ObjectSafeSpan, trace::TraceContextExt, Context}; +use spin_factors::{Factor, SelfInstanceBuilder}; +use tracing_opentelemetry::OpenTelemetrySpanExt; + +#[derive(Default)] +pub struct ObserveFactor {} + +impl Factor for ObserveFactor { + type RuntimeConfig = (); + type AppState = (); + type InstanceBuilder = InstanceState; + + fn init( + &mut self, + mut ctx: spin_factors::InitContext, + ) -> anyhow::Result<()> { + ctx.link_bindings(spin_world::wasi::observe::traces::add_to_linker)?; + Ok(()) + } + + fn configure_app( + &self, + _ctx: spin_factors::ConfigureAppContext, + ) -> anyhow::Result { + Ok(()) + } + + fn prepare( + &self, + _ctx: spin_factors::PrepareContext, + _builders: &mut spin_factors::InstanceBuilders, + ) -> anyhow::Result { + Ok(InstanceState { + state: Arc::new(RwLock::new(State { + guest_spans: table::Table::new(1024), + active_spans: Default::default(), + })), + }) + } +} + +impl ObserveFactor { + pub fn new() -> Self { + Self::default() + } +} + +pub struct InstanceState { + pub(crate) state: Arc>, +} + +impl SelfInstanceBuilder for InstanceState {} + +impl InstanceState { + pub fn get_observe_context(&self) -> ObserveContext { + ObserveContext { + state: self.state.clone(), + } + } +} + +/// Internal state of the observe host component. +pub(crate) struct State { + /// A resource table that holds the guest spans. + pub guest_spans: table::Table, + + /// A LIFO stack of guest spans that are currently active. + /// + /// Only a reference ID to the guest span is held here. The actual guest span must be looked up + /// in the `guest_spans` table using the reference ID. + /// TODO: Fix comment + pub active_spans: IndexMap, +} + +/// The WIT resource Span. Effectively wraps an [opentelemetry_sdk::trace::Span]. +pub struct GuestSpan { + /// The [opentelemetry_sdk::trace::Span] we use to do the actual tracing work. + pub inner: opentelemetry_sdk::trace::Span, +} + +pub struct ObserveContext { + pub(crate) state: Arc>, +} + +impl ObserveContext { + /// TODO comment + pub fn oh_dear_i_better_get_renamed(&self) { + // TODO: Move this duplicate logic into its own impl + let state = self.state.read().unwrap(); + if state.active_spans.is_empty() { + return; + } + + let parent_context = Context::new().with_remote_span_context( + state + .guest_spans + .get(*state.active_spans.last().unwrap().1) + .unwrap() + .inner + .span_context() + .clone(), + ); + tracing::Span::current().set_parent(parent_context); + } +} + +// TODO(Caleb): Reorder things +// TODO(Caleb): Make otel a workspace dependency diff --git a/crates/factor-outbound-http/Cargo.toml b/crates/factor-outbound-http/Cargo.toml index 9d22b59084..38e2c73f89 100644 --- a/crates/factor-outbound-http/Cargo.toml +++ b/crates/factor-outbound-http/Cargo.toml @@ -11,6 +11,7 @@ http-body-util = "0.1" hyper = "1.4.1" reqwest = { version = "0.11", features = ["gzip"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +spin-factor-observe = { path = "../factor-observe" } spin-factor-outbound-networking = { path = "../factor-outbound-networking" } spin-factors = { path = "../factors" } spin-telemetry = { path = "../telemetry" } diff --git a/crates/factor-outbound-http/src/lib.rs b/crates/factor-outbound-http/src/lib.rs index 739be2ab9a..c0b59fae76 100644 --- a/crates/factor-outbound-http/src/lib.rs +++ b/crates/factor-outbound-http/src/lib.rs @@ -10,6 +10,7 @@ use http::{ uri::{Authority, Parts, PathAndQuery, Scheme}, HeaderValue, Uri, }; +use spin_factor_observe::{ObserveContext, ObserveFactor}; use spin_factor_outbound_networking::{ ComponentTlsConfigs, OutboundAllowedHosts, OutboundNetworkingFactor, }; @@ -65,6 +66,7 @@ impl Factor for OutboundHttpFactor { let outbound_networking = builders.get_mut::()?; let allowed_hosts = outbound_networking.allowed_hosts(); let component_tls_configs = outbound_networking.component_tls_configs().clone(); + let observe_context = builders.get_mut::()?.get_observe_context(); Ok(InstanceState { wasi_http_ctx: WasiHttpCtx::new(), allowed_hosts, @@ -72,6 +74,7 @@ impl Factor for OutboundHttpFactor { self_request_origin: None, request_interceptor: None, spin_http_client: None, + observe_context, }) } } @@ -84,6 +87,7 @@ pub struct InstanceState { request_interceptor: Option>, // Connection-pooling client for 'fermyon:spin/http' interface spin_http_client: Option, + observe_context: ObserveContext, } impl InstanceState { diff --git a/crates/factor-outbound-http/src/spin.rs b/crates/factor-outbound-http/src/spin.rs index 633df727d9..08691932be 100644 --- a/crates/factor-outbound-http/src/spin.rs +++ b/crates/factor-outbound-http/src/spin.rs @@ -13,6 +13,8 @@ impl spin_http::Host for crate::InstanceState { fields(otel.kind = "client", url.full = Empty, http.request.method = Empty, http.response.status_code = Empty, otel.name = Empty, server.address = Empty, server.port = Empty))] async fn send_request(&mut self, req: Request) -> Result { + self.observe_context.oh_dear_i_better_get_renamed(); + let span = Span::current(); record_request_fields(&span, &req); diff --git a/crates/factor-outbound-http/src/wasi.rs b/crates/factor-outbound-http/src/wasi.rs index 8d49bad2ab..6965b9982f 100644 --- a/crates/factor-outbound-http/src/wasi.rs +++ b/crates/factor-outbound-http/src/wasi.rs @@ -86,6 +86,8 @@ impl<'a> WasiHttpView for WasiHttpImplInner<'a> { mut request: Request, mut config: wasmtime_wasi_http::types::OutgoingRequestConfig, ) -> wasmtime_wasi_http::HttpResult { + self.state.observe_context.oh_dear_i_better_get_renamed(); + // wasmtime-wasi-http fills in scheme and authority for relative URLs // (e.g. https://:443/), which makes them hard to reason about. // Undo that here. diff --git a/crates/runtime-config/Cargo.toml b/crates/runtime-config/Cargo.toml index 6efac61b5a..623adadf53 100644 --- a/crates/runtime-config/Cargo.toml +++ b/crates/runtime-config/Cargo.toml @@ -15,6 +15,7 @@ spin-factor-key-value-azure = { path = "../factor-key-value-azure" } spin-factor-key-value-redis = { path = "../factor-key-value-redis" } spin-factor-key-value-spin = { path = "../factor-key-value-spin" } spin-factor-llm = { path = "../factor-llm" } +spin-factor-observe = { path = "../factor-observe" } spin-factor-outbound-http = { path = "../factor-outbound-http" } spin-factor-outbound-mqtt = { path = "../factor-outbound-mqtt" } spin-factor-outbound-mysql = { path = "../factor-outbound-mysql" } diff --git a/crates/runtime-config/src/lib.rs b/crates/runtime-config/src/lib.rs index 2691ec4aa7..8999ca9eaf 100644 --- a/crates/runtime-config/src/lib.rs +++ b/crates/runtime-config/src/lib.rs @@ -5,6 +5,7 @@ use spin_factor_key_value::runtime_config::spin::{self as key_value}; use spin_factor_key_value::{DefaultLabelResolver as _, KeyValueFactor}; use spin_factor_key_value_spin::{SpinKeyValueRuntimeConfig, SpinKeyValueStore}; use spin_factor_llm::{spin as llm, LlmFactor}; +use spin_factor_observe::ObserveFactor; use spin_factor_outbound_http::OutboundHttpFactor; use spin_factor_outbound_mqtt::OutboundMqttFactor; use spin_factor_outbound_mysql::OutboundMysqlFactor; @@ -382,6 +383,12 @@ impl FactorRuntimeConfigSource for TomlRuntimeConfigSource<'_, '_> } } +impl FactorRuntimeConfigSource for TomlRuntimeConfigSource<'_, '_> { + fn get_runtime_config(&mut self) -> anyhow::Result> { + Ok(None) + } +} + impl RuntimeConfigSourceFinalizer for TomlRuntimeConfigSource<'_, '_> { fn finalize(&mut self) -> anyhow::Result<()> { Ok(self.toml.validate_all_keys_used()?) diff --git a/crates/telemetry/src/lib.rs b/crates/telemetry/src/lib.rs index 6c54318592..8484ce7d98 100644 --- a/crates/telemetry/src/lib.rs +++ b/crates/telemetry/src/lib.rs @@ -14,7 +14,7 @@ mod env; pub mod logs; pub mod metrics; mod propagation; -mod traces; +pub mod traces; pub use propagation::extract_trace_context; pub use propagation::inject_trace_context; diff --git a/crates/telemetry/src/traces.rs b/crates/telemetry/src/traces.rs index 43fe600f3a..4049761c5c 100644 --- a/crates/telemetry/src/traces.rs +++ b/crates/telemetry/src/traces.rs @@ -1,9 +1,10 @@ -use std::time::Duration; +use std::{sync::Mutex, time::Duration}; use anyhow::bail; use opentelemetry_otlp::SpanExporterBuilder; use opentelemetry_sdk::{ resource::{EnvResourceDetector, TelemetryResourceDetector}, + trace::Tracer, Resource, }; use tracing::Subscriber; @@ -12,6 +13,9 @@ use tracing_subscriber::{registry::LookupSpan, EnvFilter, Layer}; use crate::detector::SpinResourceDetector; use crate::env::OtlpProtocol; +// TODO(Caleb): I think there is probably a better way to do this than just a global tracer. Maybe I should make telemetry a factor? +pub static WASI_OBSERVE_TRACER: Mutex> = Mutex::new(None); + /// Constructs a layer for the tracing subscriber that sends spans to an OTEL collector. /// /// It pulls OTEL configuration from the environment based on the variables defined @@ -37,7 +41,7 @@ pub(crate) fn otel_tracing_layer LookupSpan<'span>>( // currently default to using the HTTP exporter but in the future we could select off of the // combination of OTEL_EXPORTER_OTLP_PROTOCOL and OTEL_EXPORTER_OTLP_TRACES_PROTOCOL to // determine whether we should use http/protobuf or grpc. - let exporter: SpanExporterBuilder = match OtlpProtocol::traces_protocol_from_env() { + let exporter_builder: SpanExporterBuilder = match OtlpProtocol::traces_protocol_from_env() { OtlpProtocol::Grpc => opentelemetry_otlp::new_exporter().tonic().into(), OtlpProtocol::HttpProtobuf => opentelemetry_otlp::new_exporter().http().into(), OtlpProtocol::HttpJson => bail!("http/json OTLP protocol is not supported"), @@ -45,10 +49,12 @@ pub(crate) fn otel_tracing_layer LookupSpan<'span>>( let tracer = opentelemetry_otlp::new_pipeline() .tracing() - .with_exporter(exporter) + .with_exporter(exporter_builder) .with_trace_config(opentelemetry_sdk::trace::config().with_resource(resource)) .install_batch(opentelemetry_sdk::runtime::Tokio)?; + *WASI_OBSERVE_TRACER.lock().unwrap() = Some(tracer.clone()); + let env_filter = match EnvFilter::try_from_env("SPIN_OTEL_TRACING_LEVEL") { Ok(filter) => filter, // If it isn't set or it fails to parse default to info diff --git a/crates/trigger-http/Cargo.toml b/crates/trigger-http/Cargo.toml index c273b1db22..d652030185 100644 --- a/crates/trigger-http/Cargo.toml +++ b/crates/trigger-http/Cargo.toml @@ -38,6 +38,7 @@ spin-outbound-networking = { path = "../outbound-networking" } spin-telemetry = { path = "../telemetry" } spin-trigger = { path = "../trigger" } spin-world = { path = "../world" } +spin-factor-observe = { path = "../factor-observe" } terminal = { path = "../terminal" } tls-listener = { version = "0.10.0", features = ["rustls"] } tokio = { version = "1.23", features = ["full"] } diff --git a/crates/trigger-http/src/handler.rs b/crates/trigger-http/src/handler.rs new file mode 100644 index 0000000000..a93af2ce02 --- /dev/null +++ b/crates/trigger-http/src/handler.rs @@ -0,0 +1,483 @@ +use std::{net::SocketAddr, str, str::FromStr}; + +use crate::{Body, ChainedRequestHandler, HttpExecutor, HttpInstance, HttpTrigger, Store}; +use anyhow::{anyhow, Context, Result}; +use futures::TryFutureExt; +use http::{HeaderName, HeaderValue}; +use http_body_util::BodyExt; +use hyper::{Request, Response}; +use outbound_http::OutboundHttpComponent; +use spin_core::async_trait; +use spin_core::wasi_2023_10_18::exports::wasi::http::incoming_handler::Guest as IncomingHandler2023_10_18; +use spin_core::wasi_2023_11_10::exports::wasi::http::incoming_handler::Guest as IncomingHandler2023_11_10; +use spin_core::{Component, Engine, Instance}; +use spin_http::body; +use spin_http::routes::RouteMatch; +use spin_observe::future::{FutureExt, ObserveContext}; +use spin_trigger::TriggerAppEngine; +use spin_world::v1::http_types; +use std::sync::Arc; +use tokio::{sync::oneshot, task}; +use tracing::{instrument, Instrument, Level}; +use wasmtime_wasi_http::{proxy::Proxy, WasiHttpView}; + +#[derive(Clone)] +pub struct HttpHandlerExecutor; + +#[async_trait] +impl HttpExecutor for HttpHandlerExecutor { + #[instrument(name = "spin_trigger_http.execute_wasm", skip_all, err(level = Level::INFO), fields(otel.name = format!("execute_wasm_component {}", route_match.component_id())))] + async fn execute( + &self, + engine: Arc>, + base: &str, + route_match: &RouteMatch, + req: Request, + client_addr: SocketAddr, + ) -> Result> { + let component_id = route_match.component_id(); + + tracing::trace!( + "Executing request using the Spin executor for component {}", + component_id + ); + + let (instance, mut store) = engine.prepare_instance(component_id).await?; + let HttpInstance::Component(instance, ty) = instance else { + unreachable!() + }; + + set_http_origin_from_request(&mut store, engine.clone(), self, &req); + + // set the client tls options for the current component_id. + // The OutboundWasiHttpHandler in this file is only used + // when making http-request from a http-trigger component. + // The outbound http requests from other triggers such as Redis + // uses OutboundWasiHttpHandler defined in spin_core crate. + store.as_mut().data_mut().as_mut().client_tls_opts = + engine.get_client_tls_opts(component_id); + + let observe_context = ObserveContext::new(&mut store, &engine.engine)?; + + let resp = match ty { + HandlerType::Spin => Self::execute_spin( + store, + observe_context, + instance, + base, + route_match, + req, + client_addr, + ) + .await + .map_err(contextualise_err)?, + _ => { + Self::execute_wasi( + store, + observe_context, + instance, + ty, + base, + route_match, + req, + client_addr, + ) + .await? + } + }; + + tracing::info!( + "Request finished, sending response with status code {}", + resp.status() + ); + Ok(resp) + } +} + +impl HttpHandlerExecutor { + pub async fn execute_spin( + mut store: Store, + observe_context: ObserveContext, + instance: Instance, + base: &str, + route_match: &RouteMatch, + req: Request, + client_addr: SocketAddr, + ) -> Result> { + let headers = Self::headers(&req, base, route_match, client_addr)?; + let func = instance + .exports(&mut store) + .instance("fermyon:spin/inbound-http") + // Safe since we have already checked that this instance exists + .expect("no fermyon:spin/inbound-http found") + .typed_func::<(http_types::Request,), (http_types::Response,)>("handle-request")?; + + let (parts, body) = req.into_parts(); + let bytes = body.collect().await?.to_bytes().to_vec(); + + let method = if let Some(method) = Self::method(&parts.method) { + method + } else { + return Ok(Response::builder() + .status(http::StatusCode::METHOD_NOT_ALLOWED) + .body(body::empty())?); + }; + + // Preparing to remove the params field. We are leaving it in place for now + // to avoid breaking the ABI, but no longer pass or accept values in it. + // https://github.com/fermyon/spin/issues/663 + let params = vec![]; + + let uri = match parts.uri.path_and_query() { + Some(u) => u.to_string(), + None => parts.uri.to_string(), + }; + + let req = http_types::Request { + method, + uri, + headers, + params, + body: Some(bytes), + }; + + let (resp,) = func + .call_async(&mut store, (req,)) + .manage_guest_spans(observe_context)? + .await?; + + if resp.status < 100 || resp.status > 600 { + tracing::error!("malformed HTTP status code"); + return Ok(Response::builder() + .status(http::StatusCode::INTERNAL_SERVER_ERROR) + .body(body::empty())?); + }; + + let mut response = http::Response::builder().status(resp.status); + if let Some(headers) = response.headers_mut() { + Self::append_headers(headers, resp.headers)?; + } + + let body = match resp.body { + Some(b) => body::full(b.into()), + None => body::empty(), + }; + + Ok(response.body(body)?) + } + + fn method(m: &http::Method) -> Option { + Some(match *m { + http::Method::GET => http_types::Method::Get, + http::Method::POST => http_types::Method::Post, + http::Method::PUT => http_types::Method::Put, + http::Method::DELETE => http_types::Method::Delete, + http::Method::PATCH => http_types::Method::Patch, + http::Method::HEAD => http_types::Method::Head, + http::Method::OPTIONS => http_types::Method::Options, + _ => return None, + }) + } + + async fn execute_wasi( + mut store: Store, + observe_context: ObserveContext, + instance: Instance, + ty: HandlerType, + base: &str, + route_match: &RouteMatch, + mut req: Request, + client_addr: SocketAddr, + ) -> anyhow::Result> { + let headers = Self::headers(&req, base, route_match, client_addr)?; + req.headers_mut().clear(); + req.headers_mut() + .extend(headers.into_iter().filter_map(|(n, v)| { + let Ok(name) = n.parse::() else { + return None; + }; + let Ok(value) = HeaderValue::from_bytes(v.as_bytes()) else { + return None; + }; + Some((name, value)) + })); + let request = store.as_mut().data_mut().new_incoming_request(req)?; + + let (response_tx, response_rx) = oneshot::channel(); + let response = store + .as_mut() + .data_mut() + .new_response_outparam(response_tx)?; + + enum Handler { + Latest(Proxy), + Handler2023_11_10(IncomingHandler2023_11_10), + Handler2023_10_18(IncomingHandler2023_10_18), + } + + let handler = + { + let mut exports = instance.exports(&mut store); + match ty { + HandlerType::Wasi2023_10_18 => { + let mut instance = exports + .instance(WASI_HTTP_EXPORT_2023_10_18) + .ok_or_else(|| { + anyhow!("export of `{WASI_HTTP_EXPORT_2023_10_18}` not an instance") + })?; + Handler::Handler2023_10_18(IncomingHandler2023_10_18::new(&mut instance)?) + } + HandlerType::Wasi2023_11_10 => { + let mut instance = exports + .instance(WASI_HTTP_EXPORT_2023_11_10) + .ok_or_else(|| { + anyhow!("export of `{WASI_HTTP_EXPORT_2023_11_10}` not an instance") + })?; + Handler::Handler2023_11_10(IncomingHandler2023_11_10::new(&mut instance)?) + } + HandlerType::Wasi0_2 => { + drop(exports); + Handler::Latest(Proxy::new(&mut store, &instance)?) + } + HandlerType::Spin => panic!("should have used execute_spin instead"), + } + }; + + let span = tracing::debug_span!("execute_wasi"); + let handle = task::spawn( + async move { + let result = match handler { + Handler::Latest(proxy) => { + proxy + .wasi_http_incoming_handler() + .call_handle(&mut store, request, response) + .manage_guest_spans(observe_context)? + .instrument(span) + .await + } + Handler::Handler2023_10_18(proxy) => { + proxy + .call_handle(&mut store, request, response) + .manage_guest_spans(observe_context)? + .instrument(span) + .await + } + Handler::Handler2023_11_10(proxy) => { + proxy + .call_handle(&mut store, request, response) + .manage_guest_spans(observe_context)? + .instrument(span) + .await + } + }; + + tracing::trace!( + "wasi-http memory consumed: {}", + store.as_ref().data().memory_consumed() + ); + + result + } + .in_current_span(), + ); + + match response_rx.await { + Ok(response) => { + task::spawn( + async move { + handle + .await + .context("guest invocation panicked")? + .context("guest invocation failed")?; + + Ok(()) + } + .map_err(|e: anyhow::Error| { + tracing::warn!("component error after response: {e:?}"); + }), + ); + + Ok(response.context("guest failed to produce a response")?) + } + + Err(_) => { + handle + .await + .context("guest invocation panicked")? + .context("guest invocation failed")?; + + Err(anyhow!( + "guest failed to produce a response prior to returning" + )) + } + } + } + + fn headers( + req: &Request, + base: &str, + route_match: &RouteMatch, + client_addr: SocketAddr, + ) -> Result> { + let mut res = Vec::new(); + for (name, value) in req + .headers() + .iter() + .map(|(name, value)| (name.to_string(), std::str::from_utf8(value.as_bytes()))) + { + let value = value?.to_string(); + res.push((name, value)); + } + + let default_host = http::HeaderValue::from_str("localhost")?; + let host = std::str::from_utf8( + req.headers() + .get("host") + .unwrap_or(&default_host) + .as_bytes(), + )?; + + // Set the environment information (path info, base path, etc) as headers. + // In the future, we might want to have this information in a context + // object as opposed to headers. + for (keys, val) in + crate::compute_default_headers(req.uri(), base, host, route_match, client_addr)? + { + res.push((Self::prepare_header_key(&keys[0]), val)); + } + + Ok(res) + } + + fn prepare_header_key(key: &str) -> String { + key.replace('_', "-").to_ascii_lowercase() + } + + fn append_headers(res: &mut http::HeaderMap, src: Option>) -> Result<()> { + if let Some(src) = src { + for (k, v) in src.iter() { + res.insert( + http::header::HeaderName::from_str(k)?, + http::header::HeaderValue::from_str(v)?, + ); + } + }; + + Ok(()) + } +} + +/// Whether this handler uses the custom Spin http handler interface for wasi-http +#[derive(Copy, Clone)] +pub enum HandlerType { + Spin, + Wasi0_2, + Wasi2023_11_10, + Wasi2023_10_18, +} + +const WASI_HTTP_EXPORT_2023_10_18: &str = "wasi:http/incoming-handler@0.2.0-rc-2023-10-18"; +const WASI_HTTP_EXPORT_2023_11_10: &str = "wasi:http/incoming-handler@0.2.0-rc-2023-11-10"; +const WASI_HTTP_EXPORT_0_2_0: &str = "wasi:http/incoming-handler@0.2.0"; + +impl HandlerType { + /// Determine the handler type from the exports of a component + pub fn from_component(engine: &Engine, component: &Component) -> Result { + let mut handler_ty = None; + + let mut set = |ty: HandlerType| { + if handler_ty.is_none() { + handler_ty = Some(ty); + Ok(()) + } else { + Err(anyhow!( + "component exports multiple different handlers but \ + it's expected to export only one" + )) + } + }; + let ty = component.component_type(); + for (name, _) in ty.exports(engine.as_ref()) { + match name { + WASI_HTTP_EXPORT_2023_10_18 => set(HandlerType::Wasi2023_10_18)?, + WASI_HTTP_EXPORT_2023_11_10 => set(HandlerType::Wasi2023_11_10)?, + WASI_HTTP_EXPORT_0_2_0 => set(HandlerType::Wasi0_2)?, + "fermyon:spin/inbound-http" => set(HandlerType::Spin)?, + _ => {} + } + } + + handler_ty.ok_or_else(|| { + anyhow!( + "Expected component to either export `{WASI_HTTP_EXPORT_2023_10_18}`, \ + `{WASI_HTTP_EXPORT_2023_11_10}`, `{WASI_HTTP_EXPORT_0_2_0}`, \ + or `fermyon:spin/inbound-http` but it exported none of those" + ) + }) + } +} + +fn set_http_origin_from_request( + store: &mut Store, + engine: Arc>, + handler: &HttpHandlerExecutor, + req: &Request, +) { + if let Some(authority) = req.uri().authority() { + if let Some(scheme) = req.uri().scheme_str() { + let origin = format!("{}://{}", scheme, authority); + if let Some(outbound_http_handle) = engine + .engine + .find_host_component_handle::>() + { + let outbound_http_data = store + .host_components_data() + .get_or_insert(outbound_http_handle); + + outbound_http_data.origin.clone_from(&origin); + store.as_mut().data_mut().as_mut().allowed_hosts = + outbound_http_data.allowed_hosts.clone(); + } + + let chained_request_handler = ChainedRequestHandler { + engine: engine.clone(), + executor: handler.clone(), + }; + store.as_mut().data_mut().as_mut().origin = Some(origin); + store.as_mut().data_mut().as_mut().chained_handler = Some(chained_request_handler); + } + } +} + +fn contextualise_err(e: anyhow::Error) -> anyhow::Error { + if e.to_string() + .contains("failed to find function export `canonical_abi_free`") + { + e.context( + "component is not compatible with Spin executor - should this use the Wagi executor?", + ) + } else { + e + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_spin_header_keys() { + assert_eq!( + HttpHandlerExecutor::prepare_header_key("SPIN_FULL_URL"), + "spin-full-url".to_string() + ); + assert_eq!( + HttpHandlerExecutor::prepare_header_key("SPIN_PATH_INFO"), + "spin-path-info".to_string() + ); + assert_eq!( + HttpHandlerExecutor::prepare_header_key("SPIN_RAW_COMPONENT_ROUTE"), + "spin-raw-component-route".to_string() + ); + } +} diff --git a/crates/trigger-http/src/wasi.rs b/crates/trigger-http/src/wasi.rs index d61f3bdb21..1c21411c26 100644 --- a/crates/trigger-http/src/wasi.rs +++ b/crates/trigger-http/src/wasi.rs @@ -98,7 +98,7 @@ impl HttpExecutor for WasiHttpExecutor { } }; - let span = tracing::debug_span!("execute_wasi"); + // TODO(Caleb): Verify that using tracing::Span::current() does not cause regressions let handle = task::spawn( async move { let result = match handler { @@ -106,19 +106,19 @@ impl HttpExecutor for WasiHttpExecutor { proxy .wasi_http_incoming_handler() .call_handle(&mut store, request, response) - .instrument(span) + .instrument(tracing::Span::current()) .await } Handler::Handler2023_10_18(handler) => { handler .call_handle(&mut store, request, response) - .instrument(span) + .instrument(tracing::Span::current()) .await } Handler::Handler2023_11_10(handler) => { handler .call_handle(&mut store, request, response) - .instrument(span) + .instrument(tracing::Span::current()) .await } }; diff --git a/crates/trigger/Cargo.toml b/crates/trigger/Cargo.toml index 9f92388555..d9d4df299f 100644 --- a/crates/trigger/Cargo.toml +++ b/crates/trigger/Cargo.toml @@ -28,6 +28,7 @@ spin-compose = { path = "../compose" } spin-core = { path = "../core" } spin-factor-key-value = { path = "../factor-key-value" } spin-factor-llm = { path = "../factor-llm" } +spin-factor-observe = { path = "../factor-observe" } spin-factor-outbound-http = { path = "../factor-outbound-http" } spin-factor-outbound-mqtt = { path = "../factor-outbound-mqtt" } spin-factor-outbound-mysql = { path = "../factor-outbound-mysql" } diff --git a/crates/trigger/src/factors.rs b/crates/trigger/src/factors.rs index 3a1b0993a9..a26734013d 100644 --- a/crates/trigger/src/factors.rs +++ b/crates/trigger/src/factors.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use anyhow::Context as _; use spin_factor_key_value::KeyValueFactor; use spin_factor_llm::LlmFactor; +use spin_factor_observe::ObserveFactor; use spin_factor_outbound_http::OutboundHttpFactor; use spin_factor_outbound_mqtt::{NetworkedMqttClient, OutboundMqttFactor}; use spin_factor_outbound_mysql::OutboundMysqlFactor; @@ -17,6 +18,7 @@ use spin_runtime_config::TomlRuntimeConfigSource; #[derive(RuntimeFactors)] pub struct TriggerFactors { + pub observe: ObserveFactor, pub wasi: WasiFactor, pub variables: VariablesFactor, pub key_value: KeyValueFactor, @@ -40,6 +42,7 @@ impl TriggerFactors { use_gpu: bool, ) -> anyhow::Result { Ok(Self { + observe: ObserveFactor::new(), wasi: wasi_factor(working_dir, allow_transient_writes), variables: VariablesFactor::default(), key_value: KeyValueFactor::new(default_key_value_label_resolver), diff --git a/crates/world/Cargo.toml b/crates/world/Cargo.toml index 270b46a174..5c6317a629 100644 --- a/crates/world/Cargo.toml +++ b/crates/world/Cargo.toml @@ -7,3 +7,4 @@ edition = { workspace = true } [dependencies] async-trait = "0.1" wasmtime = { workspace = true } +opentelemetry = { version = "0.22.0", features = [ "metrics", "trace"] } diff --git a/crates/world/src/conversions.rs b/crates/world/src/conversions.rs index 29b21f408a..1cd4bafb8c 100644 --- a/crates/world/src/conversions.rs +++ b/crates/world/src/conversions.rs @@ -218,3 +218,37 @@ mod llm { } } } + +mod observe { + use opentelemetry::StringValue; + + use super::*; + + impl From for opentelemetry::Value { + fn from(value: wasi::observe::traces::Value) -> Self { + match value { + wasi::observe::traces::Value::String(v) => v.into(), + wasi::observe::traces::Value::Bool(v) => v.into(), + wasi::observe::traces::Value::Float64(v) => v.into(), + wasi::observe::traces::Value::S64(v) => v.into(), + wasi::observe::traces::Value::StringArray(v) => opentelemetry::Value::Array( + v.into_iter() + .map(StringValue::from) + .collect::>() + .into(), + ), + wasi::observe::traces::Value::BoolArray(v) => opentelemetry::Value::Array(v.into()), + wasi::observe::traces::Value::Float64Array(v) => { + opentelemetry::Value::Array(v.into()) + } + wasi::observe::traces::Value::S64Array(v) => opentelemetry::Value::Array(v.into()), + } + } + } + + impl From for opentelemetry::KeyValue { + fn from(kv: wasi::observe::traces::KeyValue) -> Self { + opentelemetry::KeyValue::new(kv.key, kv.value) + } + } +} diff --git a/tests/integration.rs b/tests/integration.rs index c09e241d3f..041fb011e6 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,6 +1,8 @@ mod testcases; mod integration_tests { + use anyhow::Context; + use fake_opentelemetry_collector::{ExportedSpan, FakeCollectorServer}; use sha2::Digest; use std::collections::HashMap; use test_environment::{ @@ -9,13 +11,13 @@ mod integration_tests { }; use testing_framework::runtimes::{spin_cli::SpinConfig, SpinAppType}; + use crate::testcases::run_test_inited; + use super::testcases::{ assert_spin_request, bootstap_env, http_smoke_test_template, run_test, spin_binary, }; - use anyhow::Context; /// Helper macro to assert that a condition is true eventually - #[cfg(feature = "extern-dependencies-tests")] macro_rules! assert_eventually { ($e:expr, $t:expr) => { let mut i = 0; @@ -153,72 +155,365 @@ mod integration_tests { Ok(()) } - #[test] - #[cfg(feature = "extern-dependencies-tests")] - /// Test that basic otel tracing works - fn otel_smoke_test() -> anyhow::Result<()> { - use anyhow::Context; + // TODO: Cleanup the fake collectors somehow - use crate::testcases::run_test_inited; - run_test_inited( - "otel-smoke-test", - SpinConfig { - binary_path: spin_binary(), - spin_up_args: Vec::new(), - app_type: SpinAppType::Http, - }, - ServicesConfig::new(vec!["jaeger"])?, - |env| { - let otel_port = env - .services_mut() - .get_port(4318)? - .context("expected a port for Jaeger")?; - env.set_env_var( - "OTEL_EXPORTER_OTLP_ENDPOINT", - format!("http://localhost:{}", otel_port), - ); - Ok(()) - }, - move |env| { - let spin = env.runtime_mut(); - assert_spin_request( - spin, - Request::new(Method::Get, "/hello"), - Response::new_with_body(200, "Hello, Fermyon!\n"), - )?; + #[tokio::test] + // Test that basic otel tracing and context propagation works + async fn otel_smoke_test() -> anyhow::Result<()> { + let collector = FakeCollectorServer::start() + .await + .expect("fake collector server should start"); + let collector_endpoint = collector.endpoint().clone(); + + tokio::task::spawn_blocking(|| { + run_test_inited( + "otel-smoke-test", + SpinConfig { + binary_path: spin_binary(), + spin_up_args: Vec::new(), + app_type: SpinAppType::Http, + }, + ServicesConfig::none(), + |env| { + env.set_env_var("OTEL_EXPORTER_OTLP_ENDPOINT", collector_endpoint); + env.set_env_var("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "grpc"); + env.set_env_var("OTEL_BSP_SCHEDULE_DELAY", "5"); + Ok(()) + }, + move |env| { + let spin = env.runtime_mut(); + assert_spin_request( + spin, + Request::new(Method::Get, "/one"), + Response::new(200), + )?; - assert_eventually!( - { - let jaeger_port = env - .services_mut() - .get_port(16686)? - .context("no jaeger port was exposed by test services")?; - let url = format!("http://localhost:{jaeger_port}/api/traces?service=spin"); - match reqwest::blocking::get(&url).context("failed to get jaeger traces")? { - resp if resp.status().is_success() => { - let traces: serde_json::Value = - resp.json().context("failed to parse jaeger traces")?; - let traces = - traces.get("data").context("jaeger traces has no data")?; - let traces = - traces.as_array().context("jaeger traces is not an array")?; - !traces.is_empty() - } - _resp => { - eprintln!("failed to get jaeger traces:"); - false - } - } - }, - 20 - ); - Ok(()) - }, - )?; + let mut spans: Vec; + assert_eventually!( + { + spans = collector.exported_spans(); + !spans.is_empty() + }, + 5 + ); + + assert_eq!(spans.len(), 5); + + // They're all part of the same trace which implies context propagation is working + assert!(spans + .iter() + .map(|s| s.trace_id.clone()) + .all(|t| t == spans[0].trace_id)); + + Ok(()) + }, + ) + }) + .await??; + + Ok(()) + } + + #[tokio::test] + async fn wasi_observe_nested_spans() -> anyhow::Result<()> { + let collector = FakeCollectorServer::start() + .await + .expect("fake collector server should start"); + let collector_endpoint = collector.endpoint().clone(); + + tokio::task::spawn_blocking(|| { + run_test_inited( + "wasi-observe-tracing", + SpinConfig { + binary_path: spin_binary(), + spin_up_args: Vec::new(), + app_type: SpinAppType::Http, + }, + ServicesConfig::none(), + |env| { + env.set_env_var("OTEL_EXPORTER_OTLP_ENDPOINT", collector_endpoint); + env.set_env_var("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "grpc"); + env.set_env_var("OTEL_BSP_SCHEDULE_DELAY", "5"); + Ok(()) + }, + move |env| { + let spin = env.runtime_mut(); + assert_spin_request( + spin, + Request::new(Method::Get, "/nested-spans"), + Response::new(200), + )?; + + let mut spans: Vec; + assert_eventually!( + { + spans = collector.exported_spans(); + !spans.is_empty() + }, + 5 + ); + + assert_eq!(spans.len(), 4); + + let handle_request_span = spans + .iter() + .find(|s| s.name == "GET /...") + .expect("'GET /...' span should exist"); + let exec_component_span = spans + .iter() + .find(|s| s.name == "execute_wasm_component wasi-observe-tracing") + .expect("'execute_wasm_component wasi-observe-tracing' span should exist"); + let outer_span = spans + .iter() + .find(|s| s.name == "outer_func") + .expect("'outer_func' span should exist"); + let inner_span = spans + .iter() + .find(|s| s.name == "inner_func") + .expect("'inner_func' span should exist"); + + assert!( + handle_request_span.trace_id == exec_component_span.trace_id + && exec_component_span.trace_id == outer_span.trace_id + && outer_span.trace_id == inner_span.trace_id + ); + assert_eq!( + exec_component_span.parent_span_id, + handle_request_span.span_id + ); + assert_eq!(outer_span.parent_span_id, exec_component_span.span_id); + assert_eq!(inner_span.parent_span_id, outer_span.span_id); + + Ok(()) + }, + ) + }) + .await??; Ok(()) } + #[tokio::test] + async fn wasi_observe_drop_semantics() -> anyhow::Result<()> { + let collector = FakeCollectorServer::start() + .await + .expect("fake collector server should start"); + let collector_endpoint = collector.endpoint().clone(); + + tokio::task::spawn_blocking(|| { + run_test_inited( + "wasi-observe-tracing", + SpinConfig { + binary_path: spin_binary(), + spin_up_args: Vec::new(), + app_type: SpinAppType::Http, + }, + ServicesConfig::none(), + |env| { + env.set_env_var("OTEL_EXPORTER_OTLP_ENDPOINT", collector_endpoint); + env.set_env_var("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "grpc"); + env.set_env_var("OTEL_BSP_SCHEDULE_DELAY", "5"); + Ok(()) + }, + move |env| { + let spin = env.runtime_mut(); + assert_spin_request( + spin, + Request::new(Method::Get, "/drop-semantics"), + Response::new(200), + )?; + + let mut spans: Vec; + assert_eventually!( + { + spans = collector.exported_spans(); + !spans.is_empty() + }, + 5 + ); + + assert_eq!(spans.len(), 3); + + let handle_request_span = spans + .iter() + .find(|s| s.name == "GET /...") + .expect("'GET /...' span should exist"); + let exec_component_span = spans + .iter() + .find(|s| s.name == "execute_wasm_component wasi-observe-tracing") + .expect("'execute_wasm_component wasi-observe-tracing' span should exist"); + let drop_span = spans + .iter() + .find(|s| s.name == "drop_semantics") + .expect("'drop_semantics' span should exist"); + + assert!( + handle_request_span.trace_id == exec_component_span.trace_id + && exec_component_span.trace_id == drop_span.trace_id + ); + assert_eq!( + exec_component_span.parent_span_id, + handle_request_span.span_id + ); + assert_eq!(drop_span.parent_span_id, exec_component_span.span_id); + assert!(drop_span.end_time_unix_nano < exec_component_span.end_time_unix_nano); + + Ok(()) + }, + ) + }) + .await??; + + Ok(()) + } + + #[tokio::test] + async fn wasi_observe_setting_attributes() -> anyhow::Result<()> { + let collector = FakeCollectorServer::start() + .await + .expect("fake collector server should start"); + let collector_endpoint = collector.endpoint().clone(); + + tokio::task::spawn_blocking(|| { + run_test_inited( + "wasi-observe-tracing", + SpinConfig { + binary_path: spin_binary(), + spin_up_args: Vec::new(), + app_type: SpinAppType::Http, + }, + ServicesConfig::none(), + |env| { + env.set_env_var("OTEL_EXPORTER_OTLP_ENDPOINT", collector_endpoint); + env.set_env_var("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "grpc"); + env.set_env_var("OTEL_BSP_SCHEDULE_DELAY", "5"); + Ok(()) + }, + move |env| { + let spin = env.runtime_mut(); + assert_spin_request( + spin, + Request::new(Method::Get, "/setting-attributes"), + Response::new(200), + )?; + + let mut spans: Vec; + assert_eventually!( + { + spans = collector.exported_spans(); + !spans.is_empty() + }, + 5 + ); + + assert_eq!(spans.len(), 3); + + let attr_span = spans + .iter() + .find(|s| s.name == "setting_attributes") + .expect("'setting_attributes' span should exist"); + + // There are some other attributes already set on the span + assert_eq!(attr_span.attributes.len(), 2); + + assert_eq!( + attr_span.attributes.get("foo").expect("'foo' attribute should exist"), + "Some(AnyValue { value: Some(StringValue(\"baz\")) })" + ); + assert_eq!( + attr_span.attributes.get("qux").expect("'qux' attribute should exist"), + "Some(AnyValue { value: Some(ArrayValue(ArrayValue { values: [AnyValue { value: Some(StringValue(\"qaz\")) }, AnyValue { value: Some(StringValue(\"thud\")) }] })) })" + ); + + Ok(()) + }, + ) + }) + .await??; + + Ok(()) + } + + #[tokio::test] + async fn wasi_observe_host_guest_host() -> anyhow::Result<()> { + let collector = FakeCollectorServer::start() + .await + .expect("fake collector server should start"); + let collector_endpoint = collector.endpoint().clone(); + + tokio::task::spawn_blocking(|| { + run_test_inited( + "wasi-observe-tracing", + SpinConfig { + binary_path: spin_binary(), + spin_up_args: Vec::new(), + app_type: SpinAppType::Http, + }, + ServicesConfig::none(), + |env| { + env.set_env_var("OTEL_EXPORTER_OTLP_ENDPOINT", collector_endpoint); + env.set_env_var("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "grpc"); + env.set_env_var("OTEL_BSP_SCHEDULE_DELAY", "5"); + Ok(()) + }, + move |env| { + let spin = env.runtime_mut(); + assert_spin_request( + spin, + Request::new(Method::Get, "/host-guest-host"), + Response::new(200), + )?; + + let mut spans: Vec; + assert_eventually!( + { + spans = collector.exported_spans(); + !spans.is_empty() + }, + 5 + ); + + assert_eq!(spans.len(), 4); + + assert!(spans + .iter() + .map(|s| s.trace_id.clone()) + .all(|t| t == spans[0].trace_id)); + + let exec_component_span = spans + .iter() + .find(|s| s.name == "execute_wasm_component wasi-observe-tracing") + .expect("'execute_wasm_component wasi-observe-tracing' span should exist"); + let guest_span = spans + .iter() + .find(|s| s.name == "guest") + .expect("'guest' span should exist"); + let get_span = spans + .iter() + .find(|s| s.name == "GET") + .expect("'GET' span should exist"); + + assert_eq!(guest_span.parent_span_id, exec_component_span.span_id); + assert_eq!(get_span.parent_span_id, guest_span.span_id); + + Ok(()) + }, + ) + }) + .await??; + + Ok(()) + } + + // TODO: wasi_observe_set_event + // TODO: wasi_observe_update_name + // TODO: wasi_observe_set_link + // TODO: wasi_observe_host_guest_host + // TODO: semantics of closing a parent doesn't close child + // TODO: inbound trace propagation + // TODO: outbound trace propagation + // TODO: Weird edge cases where a span is closed before we try to load it implicitly as parent or as observecontext thing. + #[test] /// Test dynamic environment variables fn dynamic_env_test() -> anyhow::Result<()> { diff --git a/tests/test-components/components/Cargo.lock b/tests/test-components/components/Cargo.lock index 928df83f2d..684b337088 100644 --- a/tests/test-components/components/Cargo.lock +++ b/tests/test-components/components/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "ai" version = "0.1.0" @@ -24,7 +36,7 @@ checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.74", ] [[package]] @@ -166,7 +178,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.74", ] [[package]] @@ -214,6 +226,9 @@ name = "hashbrown" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +dependencies = [ + "ahash", +] [[package]] name = "head-rust-sdk-redis" @@ -242,6 +257,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hello-world" version = "0.1.0" @@ -464,7 +485,7 @@ checksum = "51c55587ac25c2d63a75e4171221b2803a9b9e83aeb7bdfde5833c4cd578b50d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.74", ] [[package]] @@ -484,6 +505,15 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "otel-smoke-test" +version = "0.1.0" +dependencies = [ + "anyhow", + "http 0.2.11", + "spin-sdk 2.2.0", +] + [[package]] name = "outbound-http-component" version = "0.1.0" @@ -543,11 +573,21 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "prettyplease" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f12335488a2f3b0a83b14edad48dca9879ce89b2edd10e80237e4e852dd645e" +dependencies = [ + "proc-macro2", + "syn 2.0.74", +] + [[package]] name = "proc-macro2" -version = "1.0.76" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95fc56cda0b5c3325f5fbbd7ff9fda9e02bb00bb3dac51252d2f1bfa1cb8cc8c" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" dependencies = [ "unicode-ident", ] @@ -600,7 +640,7 @@ checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.74", ] [[package]] @@ -788,9 +828,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.48" +version = "2.0.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" +checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7" dependencies = [ "proc-macro2", "quote", @@ -822,7 +862,7 @@ checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.74", ] [[package]] @@ -938,6 +978,16 @@ dependencies = [ "wit-bindgen 0.16.0", ] +[[package]] +name = "wasi-observe-tracing" +version = "0.1.0" +dependencies = [ + "anyhow", + "http 0.2.11", + "spin-sdk 2.2.0", + "wit-bindgen 0.30.0", +] + [[package]] name = "wasm-encoder" version = "0.36.2" @@ -965,6 +1015,16 @@ dependencies = [ "leb128", ] +[[package]] +name = "wasm-encoder" +version = "0.215.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb56df3e06b8e6b77e37d2969a50ba51281029a9aeb3855e76b7f49b6418847" +dependencies = [ + "leb128", + "wasmparser 0.215.0", +] + [[package]] name = "wasm-metadata" version = "0.10.20" @@ -981,6 +1041,22 @@ dependencies = [ "wasmparser 0.121.2", ] +[[package]] +name = "wasm-metadata" +version = "0.215.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6bb07c5576b608f7a2a9baa2294c1a3584a249965d695a9814a496cb6d232f" +dependencies = [ + "anyhow", + "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "wasm-encoder 0.215.0", + "wasmparser 0.215.0", +] + [[package]] name = "wasmparser" version = "0.116.1" @@ -1012,6 +1088,19 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmparser" +version = "0.215.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fbde0881f24199b81cf49b6ff8f9c145ac8eb1b7fc439adb5c099734f7d90e" +dependencies = [ + "ahash", + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + [[package]] name = "wit-bindgen" version = "0.13.1" @@ -1032,6 +1121,16 @@ dependencies = [ "wit-bindgen-rust-macro 0.16.0", ] +[[package]] +name = "wit-bindgen" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4bac478334a647374ff24a74b66737a4cb586dc8288bc3080a93252cd1105c" +dependencies = [ + "wit-bindgen-rt", + "wit-bindgen-rust-macro 0.30.0", +] + [[package]] name = "wit-bindgen-core" version = "0.13.1" @@ -1054,6 +1153,26 @@ dependencies = [ "wit-parser 0.13.0", ] +[[package]] +name = "wit-bindgen-core" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb7e3df01cd43cfa1cb52602e4fc05cb2b62217655f6705639b6953eb0a3fed2" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser 0.215.0", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2de7a3b06b9725d129b5cbd1beca968feed919c433305a23da46843185ecdd6" +dependencies = [ + "bitflags", +] + [[package]] name = "wit-bindgen-rust" version = "0.13.2" @@ -1061,8 +1180,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7200e565124801e01b7b5ddafc559e1da1b2e1bed5364d669cd1d96fb88722" dependencies = [ "anyhow", - "heck", - "wasm-metadata", + "heck 0.4.1", + "wasm-metadata 0.10.20", "wit-bindgen-core 0.13.1", "wit-component 0.17.0", ] @@ -1074,12 +1193,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a01ff9cae7bf5736750d94d91eb8a49f5e3a04aff1d1a3218287d9b2964510f8" dependencies = [ "anyhow", - "heck", - "wasm-metadata", + "heck 0.4.1", + "wasm-metadata 0.10.20", "wit-bindgen-core 0.16.0", "wit-component 0.18.2", ] +[[package]] +name = "wit-bindgen-rust" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61a767d1a8eb4e908bfc53febc48b87ada545703b16fe0148ee7736a29a01417" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.74", + "wasm-metadata 0.215.0", + "wit-bindgen-core 0.30.0", + "wit-component 0.215.0", +] + [[package]] name = "wit-bindgen-rust-macro" version = "0.13.1" @@ -1089,7 +1224,7 @@ dependencies = [ "anyhow", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.74", "wit-bindgen-core 0.13.1", "wit-bindgen-rust 0.13.2", "wit-component 0.17.0", @@ -1104,12 +1239,27 @@ dependencies = [ "anyhow", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.74", "wit-bindgen-core 0.16.0", "wit-bindgen-rust 0.16.0", "wit-component 0.18.2", ] +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b185c342d0d27bd83d4080f5a66cf3b4f247fa49d679bceb66e11cc7eb58b99" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.74", + "wit-bindgen-core 0.30.0", + "wit-bindgen-rust 0.30.0", +] + [[package]] name = "wit-component" version = "0.17.0" @@ -1124,7 +1274,7 @@ dependencies = [ "serde_derive", "serde_json", "wasm-encoder 0.36.2", - "wasm-metadata", + "wasm-metadata 0.10.20", "wasmparser 0.116.1", "wit-parser 0.12.2", ] @@ -1143,11 +1293,30 @@ dependencies = [ "serde_derive", "serde_json", "wasm-encoder 0.38.1", - "wasm-metadata", + "wasm-metadata 0.10.20", "wasmparser 0.118.1", "wit-parser 0.13.0", ] +[[package]] +name = "wit-component" +version = "0.215.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f725e3885fc5890648be5c5cbc1353b755dc932aa5f1aa7de968b912a3280743" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.215.0", + "wasm-metadata 0.215.0", + "wasmparser 0.215.0", + "wit-parser 0.215.0", +] + [[package]] name = "wit-parser" version = "0.12.2" @@ -1181,3 +1350,41 @@ dependencies = [ "serde_json", "unicode-xid", ] + +[[package]] +name = "wit-parser" +version = "0.215.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935a97eaffd57c3b413aa510f8f0b550a4a9fe7d59e79cd8b89a83dcb860321f" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.215.0", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.74", +] diff --git a/tests/test-components/components/otel-smoke-test/Cargo.toml b/tests/test-components/components/otel-smoke-test/Cargo.toml new file mode 100644 index 0000000000..521468b790 --- /dev/null +++ b/tests/test-components/components/otel-smoke-test/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "otel-smoke-test" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +anyhow = "1" +http = "0.2" +spin-sdk = "2.2.0" diff --git a/tests/test-components/components/otel-smoke-test/src/lib.rs b/tests/test-components/components/otel-smoke-test/src/lib.rs new file mode 100644 index 0000000000..85c4326102 --- /dev/null +++ b/tests/test-components/components/otel-smoke-test/src/lib.rs @@ -0,0 +1,22 @@ +use spin_sdk::{ + http::{Method, Params, Request, Response, Router}, + http_component, +}; + +#[http_component] +fn handle(req: http::Request<()>) -> Response { + let mut router = Router::new(); + router.get_async("/one", one); + router.get_async("/two", two); + router.handle(req) +} + +async fn one(_req: Request, _params: Params) -> Response { + let req = Request::builder().method(Method::Get).uri("/two").build(); + let _res: Response = spin_sdk::http::send(req).await.unwrap(); + Response::new(200, "") +} + +async fn two(_req: Request, _params: Params) -> Response { + Response::new(201, "") +} diff --git a/tests/test-components/components/wasi-observe-tracing/Cargo.toml b/tests/test-components/components/wasi-observe-tracing/Cargo.toml new file mode 100644 index 0000000000..3b4eb0c449 --- /dev/null +++ b/tests/test-components/components/wasi-observe-tracing/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "wasi-observe-tracing" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +anyhow = "1" +http = "0.2" +spin-sdk = "2.2.0" +wit-bindgen = "0.30.0" diff --git a/tests/test-components/components/wasi-observe-tracing/src/lib.rs b/tests/test-components/components/wasi-observe-tracing/src/lib.rs new file mode 100644 index 0000000000..b1fa359a4a --- /dev/null +++ b/tests/test-components/components/wasi-observe-tracing/src/lib.rs @@ -0,0 +1,72 @@ +wit_bindgen::generate!({ + path: "../../../../wit", + world: "wasi:observe/imports@0.2.0-draft", + generate_all, +}); + +use spin_sdk::{ + http::{Method, Params, Request, Response, Router}, + http_component, +}; +use wasi::observe::traces::{KeyValue, Span, Value}; + +#[http_component] +fn handle(req: http::Request<()>) -> Response { + let mut router = Router::new(); + router.get("/nested-spans", nested_spans); + router.get("/drop-semantics", drop_semantics); + router.get("/setting-attributes", setting_attributes); + router.get_async("/host-guest-host", host_guest_host); + router.handle(req) +} + +fn nested_spans(_req: Request, _params: Params) -> Response { + let span = Span::start("outer_func"); + inner_func(); + span.end(); + Response::new(200, "") +} + +fn inner_func() { + let span = Span::start("inner_func"); + span.end(); +} + +fn drop_semantics(_req: Request, _params: Params) -> Response { + let _span = Span::start("drop_semantics"); + Response::new(200, "") + // _span will drop here and should be ended +} + +fn setting_attributes(_req: Request, _params: Params) -> Response { + let span = Span::start("setting_attributes"); + span.set_attribute(&KeyValue { + key: "foo".to_string(), + value: Value::String("bar".to_string()), + }); + span.set_attributes(&[ + KeyValue { + key: "foo".to_string(), + value: Value::String("baz".to_string()), + }, + KeyValue { + key: "qux".to_string(), + value: Value::StringArray(vec!["qaz".to_string(), "thud".to_string()]), + }, + ]); + span.end(); + Response::new(200, "") +} + +async fn host_guest_host(_req: Request, _params: Params) -> Response { + let span = Span::start("guest"); + + let req = Request::builder() + .method(Method::Get) + .uri("https://asdf.com") + .build(); + let _res: Response = spin_sdk::http::send(req).await.unwrap(); + span.end(); + + Response::new(200, "") +} diff --git a/tests/testcases/otel-smoke-test/spin.toml b/tests/testcases/otel-smoke-test/spin.toml index a4eb09f671..909149421f 100644 --- a/tests/testcases/otel-smoke-test/spin.toml +++ b/tests/testcases/otel-smoke-test/spin.toml @@ -1,12 +1,13 @@ spin_version = "1" authors = ["Fermyon Engineering "] -description = "A simple application that returns hello and goodbye." -name = "head-rust-sdk-http" +description = "A simple application that tests otel." +name = "otel-smoke-test" trigger = { type = "http" } version = "1.0.0" [[component]] -id = "hello" -source = "%{source=hello-world}" +id = "otel" +source = "%{source=otel-smoke-test}" +allowed_outbound_hosts = ["http://self"] [component.trigger] -route = "/hello/..." +route = "/..." diff --git a/tests/testcases/wasi-observe-tracing/spin.toml b/tests/testcases/wasi-observe-tracing/spin.toml new file mode 100644 index 0000000000..cc4698b5f9 --- /dev/null +++ b/tests/testcases/wasi-observe-tracing/spin.toml @@ -0,0 +1,19 @@ +spin_manifest_version = 2 + +[application] +authors = ["Fermyon Engineering "] +description = "An application to exercise wasi-observe tracing functionality." +name = "wasi-observe-tracing" +version = "1.0.0" + +[[trigger.http]] +route = "/..." +component = "wasi-observe-tracing" + +[component.wasi-observe-tracing] +source = "%{source=wasi-observe-tracing}" +key_value_stores = ["default"] +allowed_outbound_hosts = ["http://self", "https://asdf.com"] +[component.wasi-observe-tracing.build] +command = "cargo build --target wasm32-wasi --release" +watch = ["src/**/*.rs", "Cargo.toml"] diff --git a/wit/deps/observe/traces.wit b/wit/deps/observe/traces.wit new file mode 100644 index 0000000000..94011e7967 --- /dev/null +++ b/wit/deps/observe/traces.wit @@ -0,0 +1,108 @@ +interface traces { + use wasi:clocks/wall-clock@0.2.0.{datetime}; + + /// Represents a unit of work or operation. + resource span { + /// Starts a new span with the given name. + /// + /// By default the currently active `span` is set as the nex `span`'s parent. + start: static func(name: string) -> span; + + // TODO: Start with timestamp, attrs, links, newroot, spankind, stacktrace?, context? is this possible? + + /// Set an attribute of this span. + /// + /// If the key already exists for an attribute of the Span it will be overwritten with the new value. + set-attribute: func(attribute: key-value); + + /// Set multiple attributes of this span. + /// + /// If one of the keys already exists for an attribute of the Span it will be overwritten with the corresponding new value. + set-attributes: func(attributes: list); + + // TODO: Get span context? + + // TODO: Is recording? + + /// Adds an event with the provided name at the curent timestamp. + /// + /// Optionally an alternative timestamp may be provided. You may also provide attributes of this event. + add-event: func(name: string, timestamp: option, attributes: option>); + + /// Adds a link from the current span to another span, identified by its `span-context`. + /// + /// Links can be used to connect spans from different traces or within the same trace. Attributes can be attached to the link to provide additional context or metadata. + // add-link: func(span-context: span-context, attributes: option>) + + // TODO: Set status + + // TODO: Set name, is this possible? + // update-name: func(name: string) + + /// Signals that the operation described by this span has now ended. + end: func(); + + // TODO: Is this possible? + // end_with_timestamp + } + + /// A key-value pair describing an attribute. + record key-value { + /// The attribute name. + key: key, + /// The attribute value. + value: value, + } + + /// The key part of attribute `key-value` pairs. + type key = string; + + /// The value part of attribute `key-value` pairs. + variant value { + %string(string), + %bool(bool), + %float64(float64), + %s64(s64), + string-array(list), + bool-array(list), + float64-array(list), + s64-array(list), + } + + /// Identifying trace information about a span. + // TODO: Make types for the trace-id's and such? + record span-context { + /// Hexidecimal representation of the trace id. + trace-id: string, + /// Hexidecimal representation of the span id. + span-id: string, + /// Hexidecimal representation of the trace flags + trace-flags: string, + /// Span remoteness + is-remote: bool, + /// Entirety of tracestate + trace-state: string, + } + + // ???????????????????? + // // TODO: Document this and children. + // enum span-kind { + // client, + // server, + // producer, + // consumer, + // internal + // } + + // ?????????????????????? + // // An immutable representation of the entity producing telemetry as attributes. + // record otel-resource { + // // Resource attributes. + // attrs: list>, + + // // Resource schema url. + // schema-url: option, + // } +} + +// TODO: Do we want set-attribute in addition to set-attributes? I'm leaning towards no \ No newline at end of file diff --git a/wit/deps/observe/world.wit b/wit/deps/observe/world.wit new file mode 100644 index 0000000000..f86540e0d5 --- /dev/null +++ b/wit/deps/observe/world.wit @@ -0,0 +1,5 @@ +package wasi:observe@0.2.0-draft; + +world imports { + import traces; +} diff --git a/wit/world.wit b/wit/world.wit index b16e429054..585eb59770 100644 --- a/wit/world.wit +++ b/wit/world.wit @@ -24,6 +24,7 @@ world platform { import sqlite; import key-value; import variables; + include wasi:observe/imports@0.2.0-draft; } /// Like `platform`, but using WASI 0.2.0-rc-2023-10-18