Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions sdk/cosmos/azure_data_cosmos/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
- Added `FeedOptions::max_fan_out` (and `FeedOptions::with_max_fan_out`) to cap how many physical partitions a cross-partition query or change feed may fan out to. Applies to `ContainerClient::query_items` and `ContainerClient::query_change_feed`. The cap is enforced only at initial query setup; a partition that splits mid-execution and pushes the fan-out higher does not abort the operation. ([#4855](https://github.com/Azure/azure-sdk-for-rust/pull/4855))
- Added a pluggable client-side diagnostics emission layer — the `DiagnosticsHandler` trait and ordered `DiagnosticsHandlerChain` (registered via `CosmosClientBuilder::with_diagnostics_handler`) — invoked once per operation (singleton and paginated, on success and failure) with the completed `DiagnosticsContext` plus an SDK-supplied `CosmosOperationContext`; the empty default chain is a zero-overhead no-op. ([#4789](https://github.com/Azure/azure-sdk-for-rust/pull/4789))
- Added the `metrics`-gated `CosmosMetricsHandler` (with `MetricsOptions`), emitting the stable `db.client.operation.duration` histogram plus per-signal opt-in metrics (`with_request_charge_metric`, `with_returned_rows_metric`) and an opt-in extended attribute set (`with_extended_attributes`); a no-op when no meter provider is registered. ([#4789](https://github.com/Azure/azure-sdk-for-rust/pull/4789))
- Added `MetricsOptions::with_active_instance_metric`, an opt-in `azure.cosmosdb.client.active_instance.count` up-down counter reporting the number of live `CosmosClient` instances per account endpoint, keyed on `server.address` (plus `server.port` for a non-default port). ([#4874](https://github.com/Azure/azure-sdk-for-rust/pull/4874))
- Added `DiagnosticsHandler::on_client_created`, a defaulted hook that lets a handler observe client construction (`CosmosClientInfo`) and return a `ClientLifetimeToken` dropped with the client, for handlers that need to track client lifetime. ([#4874](https://github.com/Azure/azure-sdk-for-rust/pull/4874))
- Added composable tail-sampled emission handlers — a `TracingLogHandler` leaf that writes a compact `tracing` line and a `SamplingLogHandler` wrapper (holding an `Arc<dyn DiagnosticsHandler>`) that applies the sampling gate plus a shared per-window rate limit, defaulting to wrap a `TracingLogHandler` — and the `distributed_tracing`-gated `CosmosTracingHandler` (backdated span tree), also rate-limited so an error storm can't overwhelm exporters. All emit only for operations which fail or breach a configurable `DiagnosticsThresholds`, and stamp *why* they were sampled (a failure, or which threshold) on the emitted line and span. ([#4789](https://github.com/Azure/azure-sdk-for-rust/pull/4789))
- Added the non-default `control_plane` feature that gates the control-plane APIs (database and container CRUD, and throughput/offer management). It is intentionally independent of `key_auth` so these APIs are not tied to key-based authentication. ([#4854](https://github.com/Azure/azure-sdk-for-rust/pull/4854))

Expand All @@ -19,6 +21,8 @@

### Bugs Fixed

- The Cosmos tracing span's operation label now prefers the caller-facing `CosmosOperationContext` identity over the driver-recorded name, matching how the `db.operation.name` metric attribute is resolved. Previously an aggregate whose surfaced sub-operation differed from the caller's operation — such as a PATCH that fails during its internal read — could label the span `read_item` while the metric reported `patch_item`. Attempt spans now carry the operation that issued them, so a PATCH's attempts report `db.operation.name` of `patch_read_item` / `patch_replace_item` while its operation span and metric stay `patch_item`; attempts of every other operation continue to inherit the operation's own name. ([#4874](https://github.com/Azure/azure-sdk-for-rust/pull/4874))

### Other Changes

- Existing `ResponseHeaders` accessors now return Gateway 2.0 backend duration, quota, item-count, and local-LSN response metadata. ([#4797](https://github.com/Azure/azure-sdk-for-rust/pull/4797))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ impl ContainerClient {
self.container_ref.account(),
self.container_ref.rid(),
options.operation,
self.operation_context("read_throughput"),
self.operation_context("read_container_throughput"),
)
.await
}
Expand Down Expand Up @@ -231,7 +231,7 @@ impl ContainerClient {
self.container_ref.rid(),
throughput,
options.operation,
self.operation_context("replace_throughput"),
self.operation_context("replace_container_throughput"),
)
.await
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::sync::Arc;

use crate::{
clients::{resolve_binary_encoding, ClientContext},
diagnostics::DiagnosticsHandler,
diagnostics::{CosmosClientInfo, DiagnosticsHandler},
options::{
BinaryEncodingOptions, CosmosClientOptions, OperationOptions, PartitionFailoverOptions,
ThroughputControlGroupOptions, UserAgentSuffix,
Expand Down Expand Up @@ -298,6 +298,10 @@ impl CosmosClientBuilder {
let (account_endpoint, credential) = account.into_parts();
let endpoint = account_endpoint.into_url();

// Capture the account coordinates for client-scoped diagnostics before
// the endpoint is moved into the driver account.
let client_info = CosmosClientInfo::from_endpoint(&endpoint);

// Clone credential for the driver before the SDK consumes it for auth policy.
let driver_credential = credential.clone();

Expand All @@ -321,11 +325,12 @@ impl CosmosClientBuilder {
let driver = runtime.into_inner().create_driver(driver_options).await?;

Ok(CosmosClient {
context: ClientContext {
context: ClientContext::new(
driver,
binary_encoding: resolve_binary_encoding(self.options.binary_encoding),
diagnostics_handlers: self.options.diagnostics_handlers,
},
resolve_binary_encoding(self.options.binary_encoding),
self.options.diagnostics_handlers,
&client_info,
),
})
}
}
Expand Down
4 changes: 2 additions & 2 deletions sdk/cosmos/azure_data_cosmos/src/clients/database_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ impl DatabaseClient {
self.context.driver.account(),
&resource_id,
options.operation,
self.operation_context("read_throughput"),
self.operation_context("read_database_throughput"),
)
.await
}
Expand Down Expand Up @@ -314,7 +314,7 @@ impl DatabaseClient {
&resource_id,
throughput,
options.operation,
self.operation_context("replace_throughput"),
self.operation_context("replace_database_throughput"),
)
.await
}
Expand Down
32 changes: 31 additions & 1 deletion sdk/cosmos/azure_data_cosmos/src/clients/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ use std::sync::Arc;

use azure_data_cosmos_driver::CosmosDriver;

use crate::diagnostics::{CosmosOperationContext, DiagnosticsContext, DiagnosticsHandlerChain};
use crate::diagnostics::{
ClientLifetimeToken, CosmosClientInfo, CosmosOperationContext, DiagnosticsContext,
DiagnosticsHandlerChain,
};
use crate::models::CosmosResponse;
use crate::options::BinaryEncodingOptions;

Expand All @@ -62,9 +65,36 @@ pub(crate) struct ClientContext {
/// Empty by default, in which case the completion path does nothing beyond
/// checking whether a handler is present.
pub(crate) diagnostics_handlers: DiagnosticsHandlerChain,
/// Lifetime tokens handed back by the handlers when this client was built.
///
/// Never read; held solely so the tokens' `Drop` runs when the last client
/// derived from this context goes away. Because the context is cloned down
/// into every `DatabaseClient`/`ContainerClient`, the shared `Arc` keeps the
/// tokens alive for as long as *any* of those clients is reachable, which is
/// the lifetime handlers are meant to observe.
_client_tokens: Arc<[ClientLifetimeToken]>,
}

impl ClientContext {
/// Builds the shared context for a newly constructed
/// [`CosmosClient`](super::CosmosClient), notifying every registered handler
/// that a client came online and taking ownership of the lifetime tokens
/// they hand back.
pub(crate) fn new(
driver: Arc<CosmosDriver>,
binary_encoding: BinaryEncodingOptions,
diagnostics_handlers: DiagnosticsHandlerChain,
client_info: &CosmosClientInfo,
) -> Self {
let client_tokens = diagnostics_handlers.dispatch_client_created(client_info);
Self {
driver,
binary_encoding,
diagnostics_handlers,
_client_tokens: client_tokens,
}
}

/// Converts a completed driver response into the SDK
/// [`CosmosResponse`](crate::models::CosmosResponse) and invokes the
/// diagnostics handler chain for the operation.
Expand Down
6 changes: 6 additions & 0 deletions sdk/cosmos/azure_data_cosmos/src/diagnostics/attributes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ pub(crate) const DB_RESPONSE_STATUS_CODE: &str = "db.response.status_code";
/// `server.address` — the host contacted for the request.
pub(crate) const SERVER_ADDRESS: &str = "server.address";

/// `server.port` — the port contacted for the request.
///
/// Per semantic conventions this is emitted only when the port differs from the
/// scheme's default (443 for HTTPS).
pub(crate) const SERVER_PORT: &str = "server.port";

/// `error.type` — a low-cardinality identifier of the error (the status code).
pub(crate) const ERROR_TYPE: &str = "error.type";

Expand Down
213 changes: 213 additions & 0 deletions sdk/cosmos/azure_data_cosmos/src/diagnostics/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,91 @@ use azure_core::http::Context;

use crate::diagnostics::DiagnosticsContext;

/// Identity of a Cosmos client instance, handed to
/// [`DiagnosticsHandler::on_client_created`] when a
/// [`CosmosClient`](crate::CosmosClient) is constructed.
///
/// Carries only the account-level coordinates a handler needs to key
/// client-scoped telemetry; it deliberately exposes no credential material.
#[derive(Clone, Debug)]
pub struct CosmosClientInfo {
server_address: Option<String>,
server_port: Option<u16>,
}

impl CosmosClientInfo {
/// Builds the client identity from the account endpoint.
///
/// `server_port` is populated only when the endpoint specifies a port other
/// than the scheme default, matching the `server.port` semantic convention.
pub(crate) fn from_endpoint(endpoint: &azure_core::http::Url) -> Self {
Self {
server_address: endpoint.host_str().map(str::to_owned),
server_port: endpoint.port(),
}
}

/// The account endpoint's host, if the endpoint had one.
///
/// Maps to the `server.address` semantic-convention attribute.
pub fn server_address(&self) -> Option<&str> {
self.server_address.as_deref()
}

/// The account endpoint's port, when it is not the scheme default.
///
/// Maps to the `server.port` semantic-convention attribute, which is only
/// emitted for non-default ports.
pub fn server_port(&self) -> Option<u16> {
self.server_port
}
}

/// An opaque handle that represents one live client's registration with a
/// [`DiagnosticsHandler`].
///
/// A handler returns a token from
/// [`on_client_created`](DiagnosticsHandler::on_client_created) when it needs to
/// observe the end of that client's lifetime. The SDK stores the token in the
/// client's shared state, so it is dropped once the [`CosmosClient`](crate::CosmosClient)
/// and every client derived from it (database, container) have been dropped.
///
/// Handlers use this to keep client-scoped state — such as the
/// `azure.cosmosdb.client.active_instance.count` up-down counter — balanced
/// without tying that state to the handler object's own lifetime (a single
/// handler may be registered on many clients, or on none).
pub struct ClientLifetimeToken {
on_drop: Option<Box<dyn FnOnce() + Send + Sync>>,
}

impl ClientLifetimeToken {
/// Creates a token that runs `on_drop` when the client it is attached to is
/// released.
///
/// `on_drop` runs on whichever thread drops the last client handle, so it
/// must be cheap and non-blocking, and it must not panic.
pub fn new(on_drop: impl FnOnce() + Send + Sync + 'static) -> Self {
Self {
on_drop: Some(Box::new(on_drop)),
}
}
}

impl Drop for ClientLifetimeToken {
fn drop(&mut self) {
if let Some(on_drop) = self.on_drop.take() {
on_drop();
}
}
}

impl fmt::Debug for ClientLifetimeToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ClientLifetimeToken")
.finish_non_exhaustive()
}
}

/// A sink that consumes a completed [`DiagnosticsContext`] for a single Cosmos
/// operation.
///
Expand Down Expand Up @@ -67,6 +152,25 @@ pub trait DiagnosticsHandler: Send + Sync {
/// the caller's pipeline/trace context, so read it for operation metadata
/// rather than for trace-context correlation.
fn handle(&self, diagnostics: &DiagnosticsContext, cx: &Context<'_>);

/// Notifies the handler that a new [`CosmosClient`](crate::CosmosClient) was
/// constructed with this handler registered.
///
/// Called exactly once per client, at construction — including when the same
/// handler was registered on that client's chain more than once. Return a
/// [`ClientLifetimeToken`] to be notified when that client — and every
/// database/container client derived from it — has been dropped; return
/// `None` (the default) when the handler does not track client lifetimes.
///
/// This is the seam for client-scoped telemetry. A handler object may be
/// shared across several clients or registered on none, so its own lifetime
/// is not a proxy for a live client; this hook and the returned token are.
///
/// * `client` - Account-level identity of the newly created client.
fn on_client_created(&self, client: &CosmosClientInfo) -> Option<ClientLifetimeToken> {
let _ = client;
None
}
}

/// An ordered, cheaply cloneable chain of [`DiagnosticsHandler`]s.
Expand Down Expand Up @@ -143,6 +247,34 @@ impl DiagnosticsHandlerChain {
handler.handle(diagnostics, cx);
}
}

/// Notifies every handler that a client was created, collecting the lifetime
/// tokens they hand back.
///
/// A handler that appears in the chain more than once — the chain is
/// additive, so the same `Arc` can be registered twice — is notified only
/// once, since this is a per-client lifecycle event rather than a per-call
/// dispatch. Distinct handler objects are always notified independently.
///
/// The returned tokens must be stored for the client's lifetime; dropping
/// them is what signals client teardown to the handlers.
pub(crate) fn dispatch_client_created(
&self,
client: &CosmosClientInfo,
) -> Arc<[ClientLifetimeToken]> {
let mut notified: Vec<&Arc<dyn DiagnosticsHandler>> = Vec::new();
let mut tokens = Vec::new();
for handler in self.handlers.iter() {
if notified.iter().any(|seen| Arc::ptr_eq(seen, handler)) {
continue;
}
notified.push(handler);
if let Some(token) = handler.on_client_created(client) {
tokens.push(token);
}
}
tokens.into()
}
}

impl Default for DiagnosticsHandlerChain {
Expand Down Expand Up @@ -257,4 +389,85 @@ mod tests {
let recorded = log.lock().unwrap().clone();
assert_eq!(recorded, vec![("first", op.clone()), ("second", op)]);
}

/// A handler that counts `on_client_created` calls and hands back a token
/// which records the matching teardown.
struct LifecycleHandler {
created: Arc<Mutex<usize>>,
dropped: Arc<Mutex<usize>>,
}

impl DiagnosticsHandler for LifecycleHandler {
fn handle(&self, _diagnostics: &DiagnosticsContext, _cx: &Context<'_>) {}

fn on_client_created(&self, _client: &CosmosClientInfo) -> Option<ClientLifetimeToken> {
*self.created.lock().unwrap() += 1;
let dropped = Arc::clone(&self.dropped);
Some(ClientLifetimeToken::new(move || {
*dropped.lock().unwrap() += 1;
}))
}
}

fn client_info() -> CosmosClientInfo {
CosmosClientInfo::from_endpoint(
&azure_core::http::Url::parse("https://account.documents.azure.com/")
.expect("valid test endpoint"),
)
}

#[test]
fn client_created_notifies_a_repeated_handler_once() {
let created = Arc::new(Mutex::new(0));
let dropped = Arc::new(Mutex::new(0));
let handler: Arc<dyn DiagnosticsHandler> = Arc::new(LifecycleHandler {
created: Arc::clone(&created),
dropped: Arc::clone(&dropped),
});

// The chain is additive, so the same handler can land on it twice. That
// is one registration for client-lifecycle purposes: a client must not
// be counted twice just because a handler was added twice.
let chain = DiagnosticsHandlerChain::new()
.with_handler(Arc::clone(&handler))
.with_handler(Arc::clone(&handler));
assert_eq!(chain.len(), 2);

let tokens = chain.dispatch_client_created(&client_info());
assert_eq!(*created.lock().unwrap(), 1);
assert_eq!(tokens.len(), 1);

drop(tokens);
assert_eq!(*dropped.lock().unwrap(), 1);
}

#[test]
fn client_created_notifies_distinct_handlers_independently() {
let created = Arc::new(Mutex::new(0));
let dropped = Arc::new(Mutex::new(0));
let make = || -> Arc<dyn DiagnosticsHandler> {
Arc::new(LifecycleHandler {
created: Arc::clone(&created),
dropped: Arc::clone(&dropped),
})
};

// Distinct handler objects are separate sinks, so each is notified.
let chain = DiagnosticsHandlerChain::from_handlers(vec![make(), make()]);
let tokens = chain.dispatch_client_created(&client_info());
assert_eq!(*created.lock().unwrap(), 2);
assert_eq!(tokens.len(), 2);

drop(tokens);
assert_eq!(*dropped.lock().unwrap(), 2);
}

#[test]
fn client_created_is_noop_for_handlers_that_do_not_track_lifetime() {
let log = Arc::new(Mutex::new(Vec::new()));
// `RecordingHandler` does not override `on_client_created`, so the
// default returns `None` and no token is retained.
let chain = DiagnosticsHandlerChain::from_handlers(vec![recording("a", &log)]);
assert!(chain.dispatch_client_created(&client_info()).is_empty());
}
}
Loading
Loading