diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a4f728b..5bdc7558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `libcnb`: + - An optional `trace` feature has been added that emits OpenTelemetry tracing + data to a [File Export](https://opentelemetry.io/docs/specs/otel/protocol/file-exporter/). ([#723](https://github.com/heroku/libcnb.rs/pull/723)) ## [0.16.0] - 2023-11-17 diff --git a/Cargo.toml b/Cargo.toml index fed493e9..562a1e05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "test-buildpacks/readonly-layer-files", "test-buildpacks/sbom", "test-buildpacks/store", + "test-buildpacks/tracing", ] [workspace.package] diff --git a/clippy.toml b/clippy.toml index 154626ef..4ee7a54a 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1 +1,2 @@ allow-unwrap-in-tests = true +doc-valid-idents = ["OpenTelemetry", ".."] diff --git a/libcnb/Cargo.toml b/libcnb/Cargo.toml index 9d50d9f8..2394b8fe 100644 --- a/libcnb/Cargo.toml +++ b/libcnb/Cargo.toml @@ -14,12 +14,18 @@ include = ["src/**/*", "LICENSE", "README.md"] [lints] workspace = true +[features] +trace = ["dep:opentelemetry", "dep:opentelemetry_sdk", "dep:opentelemetry-stdout"] + [dependencies] anyhow = { version = "1.0.75", optional = true } cyclonedx-bom = { version = "0.4.3", optional = true } libcnb-common.workspace = true libcnb-data.workspace = true libcnb-proc-macros.workspace = true +opentelemetry = { version = "0.21.0", optional = true } +opentelemetry_sdk = { version = "0.21.0", optional = true } +opentelemetry-stdout = { version = "0.2.0", optional = true, features = ["trace"] } serde = { version = "1.0.192", features = ["derive"] } thiserror = "1.0.50" toml.workspace = true @@ -27,3 +33,4 @@ toml.workspace = true [dev-dependencies] fastrand = "2.0.1" tempfile = "3.8.1" +serde_json = "1.0.108" diff --git a/libcnb/src/lib.rs b/libcnb/src/lib.rs index a65de479..1c3fa7ac 100644 --- a/libcnb/src/lib.rs +++ b/libcnb/src/lib.rs @@ -18,6 +18,8 @@ mod error; mod exit_code; mod platform; mod runtime; +#[cfg(feature = "trace")] +mod tracing; mod util; pub use buildpack::Buildpack; @@ -27,6 +29,9 @@ pub use libcnb_common::toml_file::*; pub use platform::*; pub use runtime::*; +#[cfg(all(test, not(feature = "trace")))] +use serde_json as _; + /// Provides types for CNB data formats. Is a re-export of the `libcnb-data` crate. #[doc(inline)] pub use libcnb_data as data; diff --git a/libcnb/src/runtime.rs b/libcnb/src/runtime.rs index d878b83e..7b4c85a2 100644 --- a/libcnb/src/runtime.rs +++ b/libcnb/src/runtime.rs @@ -5,9 +5,12 @@ use crate::detect::{DetectContext, InnerDetectResult}; use crate::error::Error; use crate::platform::Platform; use crate::sbom::cnb_sbom_path; +#[cfg(feature = "trace")] +use crate::tracing::start_trace; use crate::util::is_not_found_error_kind; use crate::{exit_code, TomlFileError, LIBCNB_SUPPORTED_BUILDPACK_API}; use libcnb_common::toml_file::{read_toml_file, write_toml_file}; +use libcnb_data::buildpack::ComponentBuildpackDescriptor; use libcnb_data::store::Store; use serde::de::DeserializeOwned; use serde::Deserialize; @@ -121,31 +124,63 @@ pub fn libcnb_runtime_detect( ) -> crate::Result { let app_dir = env::current_dir().map_err(Error::CannotDetermineAppDirectory)?; + let buildpack_dir = read_buildpack_dir()?; + + let buildpack_descriptor: ComponentBuildpackDescriptor<::Metadata> = + read_buildpack_descriptor()?; + + #[cfg(feature = "trace")] + let mut trace = start_trace(&buildpack_descriptor.buildpack, "detect"); + + let mut trace_error = |err: &dyn std::error::Error| { + #[cfg(feature = "trace")] + trace.set_error(err); + }; let stack_id: StackId = env::var("CNB_STACK_ID") .map_err(Error::CannotDetermineStackId) - .and_then(|stack_id_string| stack_id_string.parse().map_err(Error::StackIdError))?; - - let platform = B::Platform::from_path(&args.platform_dir_path) - .map_err(Error::CannotCreatePlatformFromPath)?; + .and_then(|stack_id_string| stack_id_string.parse().map_err(Error::StackIdError)) + .map_err(|err| { + trace_error(&err); + err + })?; + + let platform = B::Platform::from_path(&args.platform_dir_path).map_err(|inner_err| { + let err = Error::CannotCreatePlatformFromPath(inner_err); + trace_error(&err); + err + })?; let build_plan_path = args.build_plan_path; let detect_context = DetectContext { app_dir, + buildpack_dir, stack_id, platform, - buildpack_dir: read_buildpack_dir()?, - buildpack_descriptor: read_buildpack_descriptor()?, + buildpack_descriptor, }; - match buildpack.detect(detect_context)?.0 { - InnerDetectResult::Fail => Ok(exit_code::DETECT_DETECTION_FAILED), + let detect_result = buildpack.detect(detect_context).map_err(|err| { + trace_error(&err); + err + })?; + + match detect_result.0 { + InnerDetectResult::Fail => { + #[cfg(feature = "trace")] + trace.add_event("detect-failed"); + Ok(exit_code::DETECT_DETECTION_FAILED) + } InnerDetectResult::Pass { build_plan } => { if let Some(build_plan) = build_plan { - write_toml_file(&build_plan, build_plan_path) - .map_err(Error::CannotWriteBuildPlan)?; + write_toml_file(&build_plan, build_plan_path).map_err(|inner_err| { + let err = Error::CannotWriteBuildPlan(inner_err); + trace_error(&err); + err + })?; } - + #[cfg(feature = "trace")] + trace.add_event("detect-passed"); Ok(exit_code::DETECT_DETECTION_PASSED) } } @@ -163,31 +198,63 @@ pub fn libcnb_runtime_build( let app_dir = env::current_dir().map_err(Error::CannotDetermineAppDirectory)?; + let buildpack_dir = read_buildpack_dir()?; + + let buildpack_descriptor: ComponentBuildpackDescriptor<::Metadata> = + read_buildpack_descriptor()?; + + #[cfg(feature = "trace")] + let mut trace = start_trace(&buildpack_descriptor.buildpack, "build"); + + let mut trace_error = |err: &dyn std::error::Error| { + #[cfg(feature = "trace")] + trace.set_error(err); + }; + let stack_id: StackId = env::var("CNB_STACK_ID") .map_err(Error::CannotDetermineStackId) - .and_then(|stack_id_string| stack_id_string.parse().map_err(Error::StackIdError))?; - - let platform = Platform::from_path(&args.platform_dir_path) - .map_err(Error::CannotCreatePlatformFromPath)?; + .and_then(|stack_id_string| stack_id_string.parse().map_err(Error::StackIdError)) + .map_err(|err| { + trace_error(&err); + err + })?; + + let platform = Platform::from_path(&args.platform_dir_path).map_err(|inner_err| { + let err = Error::CannotCreatePlatformFromPath(inner_err); + trace_error(&err); + err + })?; - let buildpack_plan = - read_toml_file(&args.buildpack_plan_path).map_err(Error::CannotReadBuildpackPlan)?; + let buildpack_plan = read_toml_file(&args.buildpack_plan_path).map_err(|inner_err| { + let err = Error::CannotReadBuildpackPlan(inner_err); + trace_error(&err); + err + })?; let store = match read_toml_file::(layers_dir.join("store.toml")) { Err(TomlFileError::IoError(io_error)) if is_not_found_error_kind(&io_error) => Ok(None), other => other.map(Some), } - .map_err(Error::CannotReadStore)?; + .map_err(Error::CannotReadStore) + .map_err(|err| { + trace_error(&err); + err + })?; - let build_result = buildpack.build(BuildContext { + let build_context = BuildContext { layers_dir: layers_dir.clone(), app_dir, stack_id, platform, buildpack_plan, - buildpack_dir: read_buildpack_dir()?, - buildpack_descriptor: read_buildpack_descriptor()?, + buildpack_dir, + buildpack_descriptor, store, + }; + + let build_result = buildpack.build(build_context).map_err(|err| { + trace_error(&err); + err })?; match build_result.0 { @@ -198,13 +265,19 @@ pub fn libcnb_runtime_build( launch_sboms, } => { if let Some(launch) = launch { - write_toml_file(&launch, layers_dir.join("launch.toml")) - .map_err(Error::CannotWriteLaunch)?; + write_toml_file(&launch, layers_dir.join("launch.toml")).map_err(|inner_err| { + let err = Error::CannotWriteLaunch(inner_err); + trace_error(&err); + err + })?; }; if let Some(store) = store { - write_toml_file(&store, layers_dir.join("store.toml")) - .map_err(Error::CannotWriteStore)?; + write_toml_file(&store, layers_dir.join("store.toml")).map_err(|inner_err| { + let err = Error::CannotWriteStore(inner_err); + trace_error(&err); + err + })?; }; for build_sbom in build_sboms { @@ -212,7 +285,11 @@ pub fn libcnb_runtime_build( cnb_sbom_path(&build_sbom.format, &layers_dir, "build"), &build_sbom.data, ) - .map_err(Error::CannotWriteBuildSbom)?; + .map_err(Error::CannotWriteBuildSbom) + .map_err(|err| { + trace_error(&err); + err + })?; } for launch_sbom in launch_sboms { @@ -220,9 +297,15 @@ pub fn libcnb_runtime_build( cnb_sbom_path(&launch_sbom.format, &layers_dir, "launch"), &launch_sbom.data, ) - .map_err(Error::CannotWriteLaunchSbom)?; + .map_err(Error::CannotWriteLaunchSbom) + .map_err(|err| { + trace_error(&err); + err + })?; } + #[cfg(feature = "trace")] + trace.add_event("build-success"); Ok(exit_code::GENERIC_SUCCESS) } } diff --git a/libcnb/src/tracing.rs b/libcnb/src/tracing.rs new file mode 100644 index 00000000..2d0eae80 --- /dev/null +++ b/libcnb/src/tracing.rs @@ -0,0 +1,195 @@ +use libcnb_data::buildpack::Buildpack; +use opentelemetry::{ + global, + trace::{Span as SpanTrait, Status, Tracer, TracerProvider as TracerProviderTrait}, + KeyValue, +}; +use opentelemetry_sdk::{ + trace::{Config, Span, TracerProvider}, + Resource, +}; +use std::{io::BufWriter, path::Path}; + +// This is the directory in which `BuildpackTrace` stores OpenTelemetry File +// Exports. Services which intend to export the tracing data from libcnb.rs +// (such as https://github.com/heroku/cnb-otel-collector) +// should look for `.jsonl` file exports in this directory. This path was chosen +// to prevent conflicts with the CNB spec and /tmp is commonly available and +// writable on base images. +#[cfg(target_family = "unix")] +const TELEMETRY_EXPORT_ROOT: &str = "/tmp/libcnb-telemetry"; + +/// Represents an OpenTelemetry tracer provider and single span tracing +/// a single CNB build or detect phase. +pub(crate) struct BuildpackTrace { + provider: TracerProvider, + span: Span, +} + +/// Start an OpenTelemetry trace and span that exports to an +/// OpenTelemetry file export. The resulting trace provider and span are +/// enriched with data from the buildpack and the rust environment. +pub(crate) fn start_trace(buildpack: &Buildpack, phase_name: &'static str) -> BuildpackTrace { + let trace_name = format!( + "{}-{phase_name}", + buildpack.id.replace(['/', '.', '-'], "_") + ); + let tracing_file_path = Path::new(TELEMETRY_EXPORT_ROOT).join(format!("{trace_name}.jsonl")); + + // Ensure tracing file path parent exists by creating it. + if let Some(parent_dir) = tracing_file_path.parent() { + let _ = std::fs::create_dir_all(parent_dir); + } + let exporter = match std::fs::File::options() + .create(true) + .append(true) + .open(&tracing_file_path) + { + // Write tracing data to a file, which may be read by other + // services. Wrap with a BufWriter to prevent serde from sending each + // JSON token to IO, and instead send entire JSON objects to IO. + Ok(file) => opentelemetry_stdout::SpanExporter::builder() + .with_writer(BufWriter::new(file)) + .build(), + // Failed tracing shouldn't fail a build, and any logging here would + // likely confuse the user, so send telemetry to /dev/null on errors. + Err(_) => opentelemetry_stdout::SpanExporter::builder() + .with_writer(std::io::sink()) + .build(), + }; + + let provider = TracerProvider::builder() + .with_simple_exporter(exporter) + .with_config(Config::default().with_resource(Resource::new([ + // Associate the tracer provider with service attributes. The buildpack + // name/version seems to map well to the suggestion here + // https://opentelemetry.io/docs/specs/semconv/resource/#service. + KeyValue::new("service.name", buildpack.id.to_string()), + KeyValue::new("service.version", buildpack.version.to_string()), + ]))) + .build(); + + // Set the global tracer provider so that buildpacks may use it. + global::set_tracer_provider(provider.clone()); + + // Get a tracer identified by the instrumentation scope/library. The libcnb + // crate name/version seems to map well to the suggestion here: + // https://opentelemetry.io/docs/specs/otel/trace/api/#get-a-tracer. + let tracer = provider.versioned_tracer( + env!("CARGO_PKG_NAME"), + Some(env!("CARGO_PKG_VERSION")), + None as Option<&str>, + None, + ); + + let mut span = tracer.start(trace_name); + span.set_attributes([ + KeyValue::new("buildpack_id", buildpack.id.to_string()), + KeyValue::new("buildpack_name", buildpack.name.clone().unwrap_or_default()), + KeyValue::new("buildpack_version", buildpack.version.to_string()), + KeyValue::new( + "buildpack_homepage", + buildpack.homepage.clone().unwrap_or_default(), + ), + ]); + BuildpackTrace { provider, span } +} + +impl BuildpackTrace { + /// Set the status for the underlying span to error, and record + /// an exception on the span. + pub(crate) fn set_error(&mut self, err: &dyn std::error::Error) { + self.span.set_status(Status::error(format!("{err:?}"))); + self.span.record_error(err); + } + /// Add a named event to the underlying span. + pub(crate) fn add_event(&mut self, name: &'static str) { + self.span.add_event(name, vec![]); + } +} + +impl Drop for BuildpackTrace { + fn drop(&mut self) { + self.span.end(); + self.provider.force_flush(); + global::shutdown_tracer_provider(); + } +} + +#[cfg(test)] +mod tests { + use super::start_trace; + use libcnb_data::{ + buildpack::{Buildpack, BuildpackVersion}, + buildpack_id, + }; + use serde_json::Value; + use std::{ + collections::HashSet, + fs, + io::{Error, ErrorKind}, + }; + + #[test] + fn test_tracing() { + let buildpack = Buildpack { + id: buildpack_id!("company.com/foo"), + version: BuildpackVersion::new(0, 0, 99), + name: Some("Foo buildpack for company.com".to_string()), + homepage: None, + clear_env: false, + description: None, + keywords: vec![], + licenses: vec![], + sbom_formats: HashSet::new(), + }; + let telemetry_path = "/tmp/libcnb-telemetry/company_com_foo-bar.jsonl"; + _ = fs::remove_file(telemetry_path); + + { + let mut trace = start_trace(&buildpack, "bar"); + trace.add_event("baz-event"); + trace.set_error(&Error::new(ErrorKind::Other, "it's broken")); + } + let tracing_contents = fs::read_to_string(telemetry_path) + .expect("Expected telemetry file to exist, but couldn't read it"); + + println!("tracing_contents: {tracing_contents}"); + let _tracing_data: Value = serde_json::from_str(&tracing_contents) + .expect("Expected tracing export file contents to be valid json"); + + // Check resource attributes + assert!(tracing_contents.contains( + "{\"key\":\"service.name\",\"value\":{\"stringValue\":\"company.com/foo\"}}" + )); + assert!(tracing_contents + .contains("{\"key\":\"service.version\",\"value\":{\"stringValue\":\"0.0.99\"}}")); + + // Check span name + assert!(tracing_contents.contains("\"name\":\"company_com_foo-bar\"")); + + // Check span attributes + assert!(tracing_contents.contains( + "{\"key\":\"buildpack_id\",\"value\":{\"stringValue\":\"company.com/foo\"}}" + )); + assert!(tracing_contents + .contains("{\"key\":\"buildpack_version\",\"value\":{\"stringValue\":\"0.0.99\"}}")); + assert!(tracing_contents.contains( + "{\"key\":\"buildpack_name\",\"value\":{\"stringValue\":\"Foo buildpack for company.com\"}}" + )); + + // Check event name + assert!(tracing_contents.contains("\"name\":\"baz-event\"")); + + // Check exception event + assert!(tracing_contents.contains("\"name\":\"exception\"")); + assert!(tracing_contents.contains( + "{\"key\":\"exception.message\",\"value\":{\"stringValue\":\"it's broken\"}}" + )); + + // Check error status + assert!(tracing_contents + .contains("\"message\":\"Custom { kind: Other, error: \\\"it's broken\\\" }")); + assert!(tracing_contents.contains("\"code\":1")); + } +} diff --git a/test-buildpacks/tracing/Cargo.toml b/test-buildpacks/tracing/Cargo.toml new file mode 100644 index 00000000..cd7edb81 --- /dev/null +++ b/test-buildpacks/tracing/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "tracing" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +libcnb = { workspace = true, features = ["trace"] } + +[dev-dependencies] +libcnb-test.workspace = true diff --git a/test-buildpacks/tracing/buildpack.toml b/test-buildpacks/tracing/buildpack.toml new file mode 100644 index 00000000..3820284a --- /dev/null +++ b/test-buildpacks/tracing/buildpack.toml @@ -0,0 +1,9 @@ +api = "0.9" + +[buildpack] +id = "libcnb-test-buildpacks/tracing" +version = "0.1.0" +name = "libcnb test buildpack: tracing" + +[[stacks]] +id = "*" diff --git a/test-buildpacks/tracing/src/main.rs b/test-buildpacks/tracing/src/main.rs new file mode 100644 index 00000000..85b0cdb4 --- /dev/null +++ b/test-buildpacks/tracing/src/main.rs @@ -0,0 +1,29 @@ +use libcnb::build::{BuildContext, BuildResult, BuildResultBuilder}; +use libcnb::detect::{DetectContext, DetectResult, DetectResultBuilder}; +use libcnb::generic::{GenericError, GenericMetadata, GenericPlatform}; +use libcnb::{buildpack_main, Buildpack}; + +// Suppress warnings due to the `unused_crate_dependencies` lint not handling integration tests well. +#[cfg(test)] +use libcnb_test as _; + +/// `TestTracingBuildpack` is a basic buildpack compiled with the libcnb.rs +/// `trace` flag, which should emit opentelemetry file exports to the build +/// file system (but not the final image). +pub struct TestTracingBuildpack; + +impl Buildpack for TestTracingBuildpack { + type Platform = GenericPlatform; + type Metadata = GenericMetadata; + type Error = GenericError; + + fn detect(&self, _context: DetectContext) -> libcnb::Result { + DetectResultBuilder::pass().build() + } + + fn build(&self, _context: BuildContext) -> libcnb::Result { + BuildResultBuilder::new().build() + } +} + +buildpack_main!(TestTracingBuildpack); diff --git a/test-buildpacks/tracing/tests/fixtures/buildpacks/tracing-reader/bin/build b/test-buildpacks/tracing/tests/fixtures/buildpacks/tracing-reader/bin/build new file mode 100755 index 00000000..e09860e8 --- /dev/null +++ b/test-buildpacks/tracing/tests/fixtures/buildpacks/tracing-reader/bin/build @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "---> Tracing Reader Buildpack" + +# Report the contents of previous buildpack tracing file exports. +# Useful for testing the contents of tracing file contents, which aren't +# available in the resulting image of a CNB build. +for tracing_file in /tmp/libcnb-telemetry/*.jsonl; do + cat $tracing_file +done + +exit 0 diff --git a/test-buildpacks/tracing/tests/fixtures/buildpacks/tracing-reader/bin/detect b/test-buildpacks/tracing/tests/fixtures/buildpacks/tracing-reader/bin/detect new file mode 100755 index 00000000..6e877466 --- /dev/null +++ b/test-buildpacks/tracing/tests/fixtures/buildpacks/tracing-reader/bin/detect @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +set -euo pipefail diff --git a/test-buildpacks/tracing/tests/fixtures/buildpacks/tracing-reader/buildpack.toml b/test-buildpacks/tracing/tests/fixtures/buildpacks/tracing-reader/buildpack.toml new file mode 100644 index 00000000..3b35a264 --- /dev/null +++ b/test-buildpacks/tracing/tests/fixtures/buildpacks/tracing-reader/buildpack.toml @@ -0,0 +1,9 @@ +api = "0.9" + +[buildpack] +id = "libcnb-test-buildpacks/tracing-reader" +version = "0.1.0" +name = "libcnb test buildpack fixture: tracing-reader" + +[[stacks]] +id = "*" diff --git a/test-buildpacks/tracing/tests/integration_test.rs b/test-buildpacks/tracing/tests/integration_test.rs new file mode 100644 index 00000000..4ad6cbf7 --- /dev/null +++ b/test-buildpacks/tracing/tests/integration_test.rs @@ -0,0 +1,33 @@ +// Required due to: https://github.com/rust-lang/rust/issues/95513 +#![allow(unused_crate_dependencies)] + +use libcnb_test::{assert_contains, BuildConfig, BuildpackReference, TestRunner}; +use std::env::temp_dir; +use std::fs; + +#[test] +#[ignore = "integration test"] +fn test_tracing_export_file() { + let empty_app_dir = temp_dir().join("empty-app-dir"); + fs::create_dir_all(&empty_app_dir).unwrap(); + + let mut build_config = BuildConfig::new("heroku/builder:22", &empty_app_dir); + + // Telemetry file exports are not persisted to the build's resulting image, + // so to test that contents are emitted, a second buildpack is used to read + // the contents during the build. + build_config.buildpacks([ + BuildpackReference::CurrentCrate, + BuildpackReference::Other(format!( + "file://{}/tests/fixtures/buildpacks/tracing-reader", + env!("CARGO_MANIFEST_DIR") + )), + ]); + + TestRunner::default().build(&build_config, |context| { + // Ensure expected span names for detect and build phases are present + // in the file export contents. + assert_contains!(context.pack_stdout, "libcnb_test_buildpacks_tracing-detect"); + assert_contains!(context.pack_stdout, "libcnb_test_buildpacks_tracing-build"); + }); +}