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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/spanner/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ _experimental-builtin-metrics = [
"dep:google-cloud-monitoring-v3",
"dep:opentelemetry",
"dep:opentelemetry_sdk",
"dep:uuid",
]

[dependencies]
Expand Down Expand Up @@ -71,6 +72,7 @@ google-cloud-api = { workspace = true, optional = true }
google-cloud-monitoring-v3 = { workspace = true, optional = true }
opentelemetry = { workspace = true, optional = true, features = ["metrics"] }
opentelemetry_sdk = { workspace = true, optional = true, features = ["metrics", "rt-tokio"] }
uuid = { workspace = true, optional = true }

[dev-dependencies]
anyhow.workspace = true
Expand Down
11 changes: 1 addition & 10 deletions src/spanner/src/database_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,13 +371,12 @@ impl DatabaseClientBuilder {
self.database_name
};

let project_id = parse_project_id(&database_name);
let o11y = Arc::new(
Observability::init(
&self.spanner.config,
self.spanner.instance_type(),
&database_name,
self.spanner.is_emulator(),
project_id,
)
.await,
);
Expand All @@ -399,14 +398,6 @@ impl DatabaseClientBuilder {
}
}

fn parse_project_id(database_name: &str) -> Option<&str> {
let mut parts = database_name.split('/');
if parts.next() != Some("projects") {
return None;
}
parts.next()
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
222 changes: 212 additions & 10 deletions src/spanner/src/observability/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,72 @@ impl SpannerMetrics {
}
}

/// Parses `projects/{project}/instances/{instance}/databases/{database}` into its
/// `(project_id, instance_id, database_id)` components.
#[cfg(feature = "_experimental-builtin-metrics")]
#[allow(dead_code)]
pub(crate) fn parse_database_name(database_name: &str) -> Option<(&str, &str, &str)> {
let mut parts = database_name.split('/');
if parts.next() != Some("projects") {
return None;
}
let project = parts.next()?;
if parts.next() != Some("instances") {
return None;
}
let instance = parts.next()?;
if parts.next() != Some("databases") {
return None;
}
let database = parts.next()?;
if parts.next().is_some() || project.is_empty() || instance.is_empty() || database.is_empty() {
return None;
}
Some((project, instance, database))
}

/// Generates a unique identifier for the `client_uid` metric attribute in the format
/// `UUID@PID@hostname`.
#[cfg(feature = "_experimental-builtin-metrics")]
#[allow(dead_code)]
pub(crate) fn generate_client_uid() -> String {
let uuid = uuid::Uuid::new_v4().to_string();
let pid = std::process::id();
let hostname = std::env::var("HOSTNAME")
.or_else(|_| std::env::var("COMPUTERNAME"))
.unwrap_or_else(|_| "localhost".to_string());
format!("{uuid}@{pid}@{hostname}")
}

/// Generates a 6-character zero-padded lowercase hexadecimal hash for the `client_hash`
/// resource label using the 24 least significant bits of an FNV-1a 64-bit hash of `client_uid`.
#[cfg(feature = "_experimental-builtin-metrics")]
#[allow(dead_code)]
pub(crate) fn generate_client_hash(client_uid: &str) -> String {
if client_uid.is_empty() {
return "000000".to_string();
}
let mut hash: u64 = 0xcbf29ce484222325;
for &byte in client_uid.as_bytes() {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
let hash_24 = hash & 0xff_ffff;
format!("{hash_24:06x}")
}
Comment thread
olavloite marked this conversation as resolved.

/// Returns the library client identification string (`"spanner-rust/<VERSION>"`).
#[cfg(feature = "_experimental-builtin-metrics")]
#[allow(dead_code)]
pub(crate) fn client_name() -> &'static str {
concat!("spanner-rust/", env!("CARGO_PKG_VERSION"))
}
Comment thread
olavloite marked this conversation as resolved.

#[cfg(feature = "_experimental-builtin-metrics")]
#[derive(Clone, Debug)]
pub(crate) struct Observability {
pub(crate) metrics: Option<Arc<SpannerMetrics>>,
common_attributes: [opentelemetry::KeyValue; 3],
meter_provider: Option<Arc<SdkMeterProvider>>,
}

Expand All @@ -104,15 +166,20 @@ impl Observability {
pub(crate) fn disabled() -> Self {
Self {
metrics: None,
common_attributes: [
opentelemetry::KeyValue::new("client_uid", ""),
opentelemetry::KeyValue::new("client_name", ""),
opentelemetry::KeyValue::new("database", ""),
],
meter_provider: None,
}
}

pub(crate) async fn init(
config: &ClientConfig,
instance_type: InstanceType,
database_name: &str,
is_emulator: bool,
project_id: Option<&str>,
) -> Self {
let disable_builtin_metrics = std::env::var("SPANNER_DISABLE_BUILTIN_METRICS")
.map(|s| s.eq_ignore_ascii_case("true") || s == "1")
Expand All @@ -121,9 +188,9 @@ impl Observability {
return Self::disabled();
}

let project_id = match project_id {
Some(id) if !id.is_empty() => id,
_ => return Self::disabled(),
let (project_id, instance_id, database_id) = match parse_database_name(database_name) {
Some(parts) => parts,
None => return Self::disabled(),
};

// Create the Google Cloud Monitoring client using the same config
Expand All @@ -149,18 +216,42 @@ impl Observability {

let exporter = GcpMonitoringExporter::new(monitoring_client, project_id);

let client_uid = generate_client_uid();
let client_hash = generate_client_hash(&client_uid);
let client_name = client_name();

let resource = opentelemetry_sdk::Resource::builder()
.with_attributes([
opentelemetry::KeyValue::new("project_id", project_id.to_string()),
opentelemetry::KeyValue::new("instance_id", instance_id.to_string()),
opentelemetry::KeyValue::new("location", "global"),
opentelemetry::KeyValue::new("instance_config", "unknown"),
opentelemetry::KeyValue::new("client_hash", client_hash),
])
.build();

// Set up PeriodicReader with a 60-second export interval.
let reader = PeriodicReader::builder(exporter)
.with_interval(DEFAULT_EXPORT_INTERVAL)
.build();

let meter_provider = SdkMeterProvider::builder().with_reader(reader).build();
let meter_provider = SdkMeterProvider::builder()
.with_reader(reader)
.with_resource(resource)
.build();

let meter = meter_provider.meter("cloud.google.com/rust");
let metrics = SpannerMetrics::new(meter);

let common_attributes = [
opentelemetry::KeyValue::new("client_uid", client_uid),
opentelemetry::KeyValue::new("client_name", client_name),
opentelemetry::KeyValue::new("database", database_id.to_string()),
];

Self {
metrics: Some(Arc::new(metrics)),
common_attributes,
meter_provider: Some(Arc::new(meter_provider)),
}
}
Expand Down Expand Up @@ -217,6 +308,9 @@ impl Observability {
let attributes = [
opentelemetry::KeyValue::new("method", method),
opentelemetry::KeyValue::new("status", status),
self.common_attributes[0].clone(),
self.common_attributes[1].clone(),
self.common_attributes[2].clone(),
];

metrics
Expand Down Expand Up @@ -246,6 +340,9 @@ impl Observability {
let attributes = [
opentelemetry::KeyValue::new("method", method),
opentelemetry::KeyValue::new("status", status),
self.common_attributes[0].clone(),
self.common_attributes[1].clone(),
self.common_attributes[2].clone(),
];

metrics
Expand Down Expand Up @@ -343,8 +440,8 @@ impl Observability {
pub(crate) async fn init(
_config: &ClientConfig,
_instance_type: InstanceType,
_database_name: &str,
_is_emulator: bool,
_project_id: Option<&str>,
) -> Self {
Self
}
Expand Down Expand Up @@ -395,6 +492,11 @@ mod tests {
let metrics = SpannerMetrics::new(meter);
let o11y = Observability {
metrics: Some(Arc::new(metrics)),
common_attributes: [
opentelemetry::KeyValue::new("client_uid", ""),
opentelemetry::KeyValue::new("client_name", ""),
opentelemetry::KeyValue::new("database", ""),
],
meter_provider: Some(Arc::new(provider.clone())),
};

Expand Down Expand Up @@ -425,6 +527,86 @@ mod tests {
assert_eq!(result.expect("trace_operation result"), 42);
}

#[test]
fn parse_database_name_valid() {
let parsed = parse_database_name("projects/proj-123/instances/inst-456/databases/db-789");
assert_eq!(parsed, Some(("proj-123", "inst-456", "db-789")));
}

#[test]
fn parse_database_name_invalid() {
assert_eq!(parse_database_name("projects/proj/instances/inst"), None);
assert_eq!(
parse_database_name("projects/proj/instances/inst/databases"),
None
);
assert_eq!(
parse_database_name("projects/proj/instances/inst/databases/db/extra"),
None
);
assert_eq!(
parse_database_name("projects//instances/inst/databases/db"),
None
);
assert_eq!(
parse_database_name("projects/proj/instances//databases/db"),
None
);
assert_eq!(
parse_database_name("projects/proj/instances/inst/databases/"),
None
);
assert_eq!(parse_database_name("invalid/string"), None);
}

#[test]
fn generate_client_hash_known_values() {
assert_eq!(generate_client_hash(""), "000000");

let hash1 = generate_client_hash("test-client-uid");
assert_eq!(hash1, "416874");
assert_eq!(hash1.len(), 6);
assert!(
hash1
.chars()
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
"hash must be 6 lowercase hex characters, got {hash1}"
);

let hash2 = generate_client_hash("test-client-uid");
assert_eq!(hash1, hash2, "client hash must be deterministic");

assert_eq!(generate_client_hash("spanner"), "727a8e");
}

#[test]
fn generate_client_uid_format() {
let uid1 = generate_client_uid();
let parts: Vec<&str> = uid1.split('@').collect();
assert_eq!(
parts.len(),
3,
"expected UUID@PID@hostname format, got {uid1}"
);
assert_eq!(parts[0].len(), 36, "expected 36-char UUID prefix");

let uid2 = generate_client_uid();
assert_ne!(
uid1, uid2,
"each generated client_uid must have a unique UUID"
);
}

#[test]
fn client_name_format() {
let name = client_name();
assert!(
name.starts_with("spanner-rust/"),
"expected prefix 'spanner-rust/', got {name}"
);
assert!(name.len() > "spanner-rust/".len());
}

#[test]
fn parse_server_timing() {
assert_eq!(
Expand Down Expand Up @@ -537,15 +719,25 @@ mod tests {
);

let config = ClientConfig::default();
let o11y_emulator =
Observability::init(&config, InstanceType::Cloud, true, Some("my-project")).await;
let o11y_emulator = Observability::init(
&config,
InstanceType::Cloud,
"projects/proj/instances/inst/databases/db",
true,
)
.await;
assert!(
o11y_emulator.metrics.is_none(),
"emulator client should have disabled metrics"
);

let o11y_omni =
Observability::init(&config, InstanceType::Omni, false, Some("my-project")).await;
let o11y_omni = Observability::init(
&config,
InstanceType::Omni,
"projects/proj/instances/inst/databases/db",
false,
)
.await;
assert!(
o11y_omni.metrics.is_none(),
"omni client should have disabled metrics"
Expand Down Expand Up @@ -632,6 +824,11 @@ mod tests {

let o11y = Observability {
metrics: Some(Arc::new(metrics)),
common_attributes: [
opentelemetry::KeyValue::new("client_uid", ""),
opentelemetry::KeyValue::new("client_name", ""),
opentelemetry::KeyValue::new("database", ""),
],
meter_provider: Some(Arc::new(provider.clone())),
};

Expand Down Expand Up @@ -671,6 +868,11 @@ mod tests {
let meter_provider = SdkMeterProvider::builder().build();
let o11y = Observability {
metrics: None,
common_attributes: [
opentelemetry::KeyValue::new("client_uid", ""),
opentelemetry::KeyValue::new("client_name", ""),
opentelemetry::KeyValue::new("database", ""),
],
meter_provider: Some(Arc::new(meter_provider)),
};
// Calling shutdown twice should cleanly handle AlreadyShutdown on the second call.
Expand Down
Loading