diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..51300b4f58 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# A .ignore file for Docker +target/ diff --git a/Cargo.lock b/Cargo.lock index b95feb285a..4e4bd91183 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -620,6 +620,35 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "demo-cloud-run-o11y" +version = "0.0.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "clap", + "google-cloud-aiplatform-v1", + "google-cloud-auth", + "google-cloud-gax", + "google-cloud-storage", + "integration-tests-o11y", + "markdown", + "opentelemetry", + "opentelemetry-http", + "opentelemetry_sdk", + "rand 0.10.0", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-opentelemetry", + "tracing-serde", + "tracing-subscriber", + "uuid", +] + [[package]] name = "der" version = "0.7.10" @@ -6083,6 +6112,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "markdown" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" +dependencies = [ + "unicode-id", +] + [[package]] name = "matchers" version = "0.2.0" @@ -8444,8 +8482,10 @@ checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc" dependencies = [ "js-sys", "opentelemetry", + "smallvec", "tracing", "tracing-core", + "tracing-log", "tracing-subscriber", "web-time", ] @@ -8499,6 +8539,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-id" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index d26fe67bdf..e0b6d165c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ default-members = [ ] members = [ + "demos/cloud-run-o11y", "guide/samples", "src/auth", "src/bigquery", @@ -374,9 +375,11 @@ http-body = { default-features = false, version = "1" } humantime = { default-features = false, version = "2" } hyper = { default-features = false, version = "1.6" } jsonwebtoken = { default-features = false, version = "10.2" } +markdown = { default-features = false, version = "1.0" } opentelemetry = { default-features = false, version = "0.31" } opentelemetry-proto = { default-features = false, version = "0.31" } opentelemetry_sdk = { default-features = false, version = "0.31" } +opentelemetry-http = { default-features = false, version = "0.31" } opentelemetry-otlp = { default-features = false, version = "0.31" } parse-size = { default-features = false, version = "1.1" } percent-encoding = { default-features = false, version = "2.3" } diff --git a/demos/cloud-run-o11y/Cargo.toml b/demos/cloud-run-o11y/Cargo.toml new file mode 100644 index 0000000000..03b6bb5020 --- /dev/null +++ b/demos/cloud-run-o11y/Cargo.toml @@ -0,0 +1,54 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[package] +name = "demo-cloud-run-o11y" +version = "0.0.0" +publish = false +description = "Use Rust on Cloud Run with observability." +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +keywords.workspace = true +categories.workspace = true +rust-version.workspace = true + +[dependencies] +anyhow.workspace = true +axum = { workspace = true, features = ["http1", "tokio"] } +chrono = { workspace = true, features = ["std"] } +clap = { workspace = true, features = ["env", "std"] } +google-cloud-aiplatform-v1 = { workspace = true, features = ["default-rustls-provider", "prediction-service"] } +google-cloud-auth = { workspace = true, features = ["default", "idtoken"] } +google-cloud-gax = { workspace = true } +google-cloud-storage = { workspace = true, features = ["default"] } +integration-tests-o11y = { path = "../../tests/o11y" } +markdown.workspace = true +opentelemetry = { workspace = true, features = ["trace"] } +opentelemetry-http = { workspace = true } +opentelemetry_sdk = { workspace = true } +rand.workspace = true +serde = { workspace = true } +serde_json.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["full", "macros"] } +tracing-opentelemetry = { workspace = true, default-features = true } +tracing-serde = { workspace = true } +tracing-subscriber = { workspace = true, features = ["json", "std"] } +tracing.workspace = true +uuid = { workspace = true, features = ["v4"] } + +[lints] +workspace = true diff --git a/demos/cloud-run-o11y/Dockerfile b/demos/cloud-run-o11y/Dockerfile new file mode 100644 index 0000000000..f2da398527 --- /dev/null +++ b/demos/cloud-run-o11y/Dockerfile @@ -0,0 +1,34 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FROM rust:1.93-slim-bookworm AS builder + +WORKDIR /usr/src/workspace +COPY . . +RUN env RUSTFLAGS="--cfg google_cloud_unstable_tracing" cargo build -p demo-cloud-run-o11y --release + +# Use a lightweight Debian image for the runtime +FROM debian:bookworm-slim + +# Install CA certificates needed for HTTPS/GCP API calls +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +# Copy the compiled binary from the builder stage +COPY --from=builder /usr/src/workspace/target/release/demo-cloud-run-o11y /usr/local/bin/demo-cloud-run-o11y + +# Expose standard Cloud Run port +EXPOSE 8080 + +# Run the application +ENTRYPOINT ["/usr/local/bin/demo-cloud-run-o11y"] diff --git a/demos/cloud-run-o11y/README.md b/demos/cloud-run-o11y/README.md new file mode 100644 index 0000000000..209fe49546 --- /dev/null +++ b/demos/cloud-run-o11y/README.md @@ -0,0 +1,54 @@ +# Highlights the observability features in the Rust SDK + +This directory contains a demo application demonstrating how to deploy Rust +applications to Cloud Run and monitor them with Google Cloud AppHub. + +## Building and Deploying + +Because this application relies on other crates in the Rust workspace, you must +build the Docker image from the root of the workspace. + +1. Ensure you are authenticated with Google Cloud: + + ```bash + gcloud auth login + gcloud config set project YOUR_PROJECT_ID + GOOGLE_CLOUD_PROJECT="$(gcloud config get project)" + ``` + +1. Create an Artifact Registry repository (if you don't already have one): + + ```bash + gcloud artifacts repositories create cloud-run-apps \ + --repository-format=docker \ + --location=us-central1 \ + --description="Docker repository for Cloud Run apps" + ``` + +1. Grant Cloud Run permission to read from the repository (using the default + Compute Engine service account): + + ```bash + PROJECT_NUMBER=$(gcloud projects describe ${GOOGLE_CLOUD_PROJECT} --format="value(projectNumber)") + gcloud artifacts repositories add-iam-policy-binding cloud-run-apps \ + --location=us-central1 \ + --member="serviceAccount:${PROJECT_NUMBER}-compute@developer.gserviceaccount.com" \ + --role="roles/artifactregistry.reader" + ``` + +1. Build the Docker image using Google Cloud Build (run from the workspace + root): + + ```bash + gcloud builds submit . --config demos/cloud-run-o11y/cloudbuild.yaml + ``` + +1. Deploy the built image to Cloud Run: + + ```bash + gcloud run deploy cloud-run-o11y \ + --image us-central1-docker.pkg.dev/${GOOGLE_CLOUD_PROJECT}/cloud-run-apps/demo-cloud-run-o11y \ + --allow-unauthenticated \ + --region us-central1 \ + --set-env-vars=GOOGLE_CLOUD_PROJECT=${GOOGLE_CLOUD_PROJECT} + ``` diff --git a/demos/cloud-run-o11y/cloudbuild.yaml b/demos/cloud-run-o11y/cloudbuild.yaml new file mode 100644 index 0000000000..d9e9a24b60 --- /dev/null +++ b/demos/cloud-run-o11y/cloudbuild.yaml @@ -0,0 +1,24 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +steps: + - name: 'gcr.io/cloud-builders/docker' + args: ['build', '-t', 'us-central1-docker.pkg.dev/${PROJECT_ID}/cloud-run-apps/demo-cloud-run-o11y', '-f', 'demos/cloud-run-o11y/Dockerfile', '.'] + automapSubstitutions: true + +images: + - 'us-central1-docker.pkg.dev/${PROJECT_ID}/cloud-run-apps/demo-cloud-run-o11y' + +options: + machineType: 'E2_HIGHCPU_32' diff --git a/demos/cloud-run-o11y/src/args.rs b/demos/cloud-run-o11y/src/args.rs new file mode 100644 index 0000000000..0b01c635f7 --- /dev/null +++ b/demos/cloud-run-o11y/src/args.rs @@ -0,0 +1,36 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use clap::Parser; + +/// Command-line arguments. +#[derive(Clone, Debug, Parser)] +#[command(version, about, long_about = super::DESCRIPTION)] +pub struct Args { + /// The default project name, if not found via resource discovery. + #[arg(long, env = "GOOGLE_CLOUD_PROJECT", default_value = "")] + pub project_id: String, + + /// The default project name, if not found via resource discovery. + #[arg(long, env = "K_SERVICE", default_value = "")] + pub service_name: String, + + /// The default port. + #[arg(long, env = "PORT", default_value = "8080")] + pub port: String, + + /// Use the regional version of Vertex AI. + #[arg(long)] + pub regional: Option, +} diff --git a/demos/cloud-run-o11y/src/error.rs b/demos/cloud-run-o11y/src/error.rs new file mode 100644 index 0000000000..e7c1bdc8c6 --- /dev/null +++ b/demos/cloud-run-o11y/src/error.rs @@ -0,0 +1,69 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use google_cloud_auth::build_errors::Error as BuildError; +use google_cloud_gax::client_builder::Error as ClientBuilderError; +use google_cloud_gax::error::Error; + +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("the backend reported an error: {0}")] + Backend(#[source] Error), + #[error("the backend response had an unexpected format: {0}")] + BadResponseFormat(String), + #[error("there was a problem contacting the backend: {0}")] + Request(#[source] Error), + #[error("cannot initialize the service credentials: {0}")] + Credentials(#[source] BuildError), + #[error("cannot initialize a client: {0}")] + Client(#[source] ClientBuilderError), +} + +impl From for AppError { + fn from(value: Error) -> Self { + if value.status().is_some() { + Self::Backend(value) + } else { + Self::Request(value) + } + } +} + +impl From for AppError { + fn from(value: BuildError) -> Self { + Self::Credentials(value) + } +} + +impl From for AppError { + fn from(value: ClientBuilderError) -> Self { + Self::Client(value) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + tracing::error!("internal service error: {self:?}"); + let (status, message) = match self { + Self::Backend(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")), + Self::BadResponseFormat(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")), + Self::Request(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#?}")), + Self::Credentials(e) => (StatusCode::UNAUTHORIZED, format!("{e:#?}")), + Self::Client(e) => (StatusCode::SERVICE_UNAVAILABLE, format!("{e:#?}")), + }; + (status, message).into_response() + } +} diff --git a/demos/cloud-run-o11y/src/logs.rs b/demos/cloud-run-o11y/src/logs.rs new file mode 100644 index 0000000000..33e9cc40aa --- /dev/null +++ b/demos/cloud-run-o11y/src/logs.rs @@ -0,0 +1,15 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +pub use integration_tests_o11y::otlp::logs::EventFormatter; diff --git a/demos/cloud-run-o11y/src/main.rs b/demos/cloud-run-o11y/src/main.rs new file mode 100644 index 0000000000..618b9aad20 --- /dev/null +++ b/demos/cloud-run-o11y/src/main.rs @@ -0,0 +1,210 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Highlights the observability features in the Rust SDK. + +const DESCRIPTION: &str = concat!( + "The demo highlights the observability features in the Rust SDK.", + "\n\n", + "This application is a web service that picks an image at random and asks Gemini to describe it.", + "\n\n", + "To pick the image the application uses Cloud Storage.", + " To describe the image the application uses Vertex AI.", + " The application converts the markdown output from Gemini to HTML.", + "\n\n", + "Each request to Cloud Storage and Vertex AI are traced, its latency is measured, and any", + " errors are logged in a format that Cloud Logging can consume.", + "\n" +); + +// A public bucket. +// +// The images/ prefix includes a number of images that can be sent to Gemini. +const BUCKET: &str = "generativeai-downloads"; + +mod args; +mod error; +mod logs; +mod observability; +mod state; + +use args::Args; +use axum::Router; +use axum::extract::State; +use axum::http::HeaderMap; +use axum::response::Html; +use axum::routing; +use clap::Parser; +use error::AppError; +use google_cloud_aiplatform_v1::client::PredictionService; +use google_cloud_aiplatform_v1::model::part::Data; +use google_cloud_aiplatform_v1::model::{Content, FileData, Part}; +use google_cloud_auth::credentials::Builder as CredentialsBuilder; +use google_cloud_gax::options::RequestOptionsBuilder; +use google_cloud_gax::paginator::ItemPaginator as _; +use google_cloud_storage::client::StorageControl; +use google_cloud_storage::model::Object; +use opentelemetry_http::HeaderExtractor; +use state::AppState; +use std::time::Duration; +use tokio::net::TcpListener; +use tracing::Instrument; +use tracing_opentelemetry::OpenTelemetrySpanExt; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + let credentials = CredentialsBuilder::default() + .build() + .inspect_err(|e| tracing::error!("Cannot initialize credentials: {e:#?}"))?; + observability::exporters(&args, credentials.clone()).await?; + tracing::info!("configuration: {args:?}"); + + let state = AppState::new(args.clone(), credentials.clone()).await?; + let app = Router::new() + .route("/", routing::get(handler)) + .route("/ok", routing::get(ok)) + .route("/predict", routing::get(predict)) + .with_state(state); + let addr = format!("0.0.0.0:{}", args.port); + let listener = TcpListener::bind(&addr).await?; + axum::serve(listener, app).await?; + Ok(()) +} + +async fn ok() -> &'static str { + "OK\n" +} + +async fn handler( + State(state): State, + headers: HeaderMap, +) -> Result, AppError> { + let extractor = HeaderExtractor(&headers); + let remote_context = + opentelemetry::global::get_text_map_propagator(|propagator| propagator.extract(&extractor)); + let span = tracing::info_span!( + "handling / request", + "otel.status_code" = tracing::field::Empty + ); + let _ = span + .set_parent(remote_context) + .inspect_err(|e| tracing::warn!("cannot set context: {e:?}")); + + let object = random_image(state.storage_control()) + .instrument(span.clone()) + .await?; + let prediction = call_model(state.prediction_service(), state.model(), &object) + .instrument(span.clone()) + .await?; + let _enter = span.entered(); + let description = markdown::to_html(&prediction); + let path = format!("{BUCKET}/{}", object.name); + let body = format!( + r#" + +

Rust SDK Demo: Vertex AI Prediction

+

+a stock image +

+

+Gemini Response:
+{description} +

+ +"#, + ); + Ok(Html::from(body)) +} + +async fn predict(State(state): State, headers: HeaderMap) -> Result { + let extractor = HeaderExtractor(&headers); + let remote_context = + opentelemetry::global::get_text_map_propagator(|propagator| propagator.extract(&extractor)); + let span = tracing::info_span!( + "handling /predict request", + "otel.status_code" = tracing::field::Empty + ); + let _ = span + .set_parent(remote_context) + .inspect_err(|e| tracing::warn!("cannot set context: {e:?}")); + + let object = random_image(state.storage_control()) + .instrument(span.clone()) + .await?; + let prediction = call_model(state.prediction_service(), state.model(), &object) + .instrument(span.clone()) + .await?; + Ok(prediction) +} + +async fn random_image(storage_control: &StorageControl) -> Result { + let bucket = format!("projects/_/buckets/{BUCKET}"); + let mut items = storage_control + .list_objects() + .set_parent(&bucket) + .set_prefix("images/") + .by_item(); + // Implements Jeffrey Vitter's "Algorithm R" for a reservoir of size 1: + // https://en.wikipedia.org/wiki/Reservoir_sampling + let mut object = None; + let mut count = 0_usize; + while let Some(o) = items.next().await.transpose().map_err(AppError::Backend)? { + count += 1; + if rand::random_range(0..count) == 0 { + object = Some(o); + } + } + object.ok_or_else(|| AppError::BadResponseFormat(format!("cannot find image in {bucket}"))) +} + +async fn call_model( + prediction_service: &PredictionService, + model: &str, + object: &Object, +) -> Result { + let response = prediction_service + .generate_content() + .set_model(model) + .set_contents([Content::new().set_role("user").set_parts([ + Part::new().set_file_data( + FileData::new() + .set_mime_type(&object.content_type) + .set_file_uri(format!("gs://{BUCKET}/{}", object.name)), + ), + Part::new().set_text("Describe this picture."), + ])]) + .with_attempt_timeout(Duration::from_secs(15)) + .send() + .await; + + let response = response + .inspect_err(|e| { + tracing::error!("response error: {e:?}"); + }) + .map_err(AppError::Backend)?; + let Some(Data::Text(data)) = response + .candidates + .into_iter() + .filter_map(|candidate| candidate.content) + .flat_map(|content| content.parts.into_iter()) + .filter_map(|part| part.data) + .next() + else { + return Err(AppError::BadResponseFormat( + "missing Data::Text element".into(), + )); + }; + Ok(data) +} diff --git a/demos/cloud-run-o11y/src/observability.rs b/demos/cloud-run-o11y/src/observability.rs new file mode 100644 index 0000000000..38f8ee461f --- /dev/null +++ b/demos/cloud-run-o11y/src/observability.rs @@ -0,0 +1,106 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::Args; +use super::logs::EventFormatter; +use google_cloud_auth::credentials::Credentials; +use integration_tests_o11y::detector::GoogleCloudResourceDetector; +use integration_tests_o11y::otlp::metrics::Builder as MeterProviderBuilder; +use integration_tests_o11y::otlp::trace::Builder as TracerProviderBuilder; +use integration_tests_o11y::tracing::trace_layer; +use opentelemetry::KeyValue; +use opentelemetry_sdk::Resource; +use opentelemetry_sdk::propagation::TraceContextPropagator; +use opentelemetry_sdk::resource::ResourceDetector; +use tracing_subscriber::fmt::format::FmtSpan; +use tracing_subscriber::{EnvFilter, Registry}; +use uuid::Uuid; + +/// Configure exporters for traces, logs, and metrics. +pub async fn exporters(args: &Args, credentials: Credentials) -> anyhow::Result<()> { + use tracing_subscriber::prelude::*; + + let logging_layer = tracing_subscriber::fmt::layer() + .with_span_events(FmtSpan::NONE) + .with_level(true) + .with_thread_ids(true) + .event_format(EventFormatter::new(args.project_id.clone())); + + let node = GenericNodeDetector::new(); + let detector = GoogleCloudResourceDetector::builder() + .with_fallback(node.detect()) + .build() + .await?; + if args.project_id.is_empty() || args.service_name.is_empty() { + tracing::subscriber::set_global_default( + Registry::default().with(logging_layer.with_filter(EnvFilter::from_default_env())), + )?; + tracing::warn!("observability disabled"); + return Ok(()); + } + let project_id = &args.project_id; + let service = &args.service_name; + let tracer_provider = TracerProviderBuilder::new(project_id, service) + .with_credentials(credentials.clone()) + .with_detector(detector.clone()) + .build() + .await?; + let meter_provider = MeterProviderBuilder::new(project_id, service) + .with_credentials(credentials.clone()) + .with_detector(node.clone()) + .build() + .await?; + + tracing::subscriber::set_global_default( + Registry::default() + .with(logging_layer.with_filter(EnvFilter::from_default_env())) + .with(trace_layer(tracer_provider.clone())), // Also log to stdout, + )?; + opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new()); + opentelemetry::global::set_meter_provider(meter_provider.clone()); + + tracing::info!("Detected resource: {:?}", detector.detect()); + tracing::info!("Detected node: {:?}", node.detect()); + Ok(()) +} + +#[derive(Clone, Debug)] +struct GenericNodeDetector { + id: String, + location: String, + namespace: String, +} + +impl GenericNodeDetector { + pub fn new() -> Self { + let id = Uuid::new_v4().to_string(); + Self { + id, + location: "us-central1".to_string(), + namespace: "google-cloud-rust".to_string(), + } + } +} + +impl ResourceDetector for GenericNodeDetector { + fn detect(&self) -> Resource { + Resource::builder_empty() + .with_attributes([ + KeyValue::new("location", self.location.clone()), + KeyValue::new("namespace", self.namespace.clone()), + KeyValue::new("node_id", self.id.clone()), + ]) + .build() + } +} diff --git a/demos/cloud-run-o11y/src/state.rs b/demos/cloud-run-o11y/src/state.rs new file mode 100644 index 0000000000..12222a2880 --- /dev/null +++ b/demos/cloud-run-o11y/src/state.rs @@ -0,0 +1,82 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::time::Duration; + +use super::args::Args; +use google_cloud_aiplatform_v1::client::PredictionService; +use google_cloud_auth::credentials::Credentials; +use google_cloud_gax::retry_policy::{Aip194Strict, RetryPolicyExt}; +use google_cloud_storage::client::StorageControl; + +const MODEL: &str = "gemini-2.5-flash"; + +#[derive(Clone, Debug)] +pub struct AppState { + prediction_service: PredictionService, + model: String, + storage_control: StorageControl, +} + +impl AppState { + pub async fn new(args: Args, credentials: Credentials) -> anyhow::Result { + let builder = PredictionService::builder() + .with_credentials(credentials.clone()) + .with_retry_policy( + Aip194Strict + .continue_on_too_many_requests() + .with_time_limit(Duration::from_secs(60)), + ) + .with_tracing(); + let (builder, model) = if let Some(region) = args.regional.as_ref() { + let model = format!( + "projects/{}/locations/{region}/publishers/google/models/{MODEL}", + args.project_id + ); + let builder = + builder.with_endpoint(format!("https://{region}-aiplatform.googleapis.com")); + (builder, model) + } else { + let model = format!( + "projects/{}/locations/global/publishers/google/models/{MODEL}", + args.project_id + ); + (builder, model) + }; + + let prediction_service = builder.build().await?; + let storage_control = StorageControl::builder() + .with_credentials(credentials.clone()) + .with_tracing() + .build() + .await?; + Ok(Self { + prediction_service, + model, + storage_control, + }) + } + + pub fn prediction_service(&self) -> &PredictionService { + &self.prediction_service + } + + pub fn storage_control(&self) -> &StorageControl { + &self.storage_control + } + + pub fn model(&self) -> &str { + self.model.as_str() + } +}