Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom OTLP File Exporter + opentelemetry updates #909

Merged
merged 12 commits into from
Feb 19, 2025
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- `libcnb`:
- Implemented custom OTLP File Exporter instead of `opentelemetry-stdout` and updated `opentelemetry` libraries to `0.28`. ([#909](https://github.com/heroku/libcnb.rs/pull/909/))

## [0.26.1] - 2024-12-10

Expand Down
21 changes: 14 additions & 7 deletions libcnb/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,16 @@ workspace = true

[features]
trace = [
"dep:futures-core",
"dep:opentelemetry",
"opentelemetry/trace",
"dep:opentelemetry_sdk",
"dep:opentelemetry-stdout",
"opentelemetry_sdk/trace",
"dep:opentelemetry-proto",
"opentelemetry-proto/trace",
"opentelemetry-proto/gen-tonic-messages",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's probably not much we can/should do about it, but it seems the gen-tonic-messages feature pulls in a whole load of additional deps (tonic, tower, prost etc), eg:
https://github.com/heroku/buildpacks-procfile/pull/262/files#diff-13ee4b2252c9e516a0547f2891aa2105c3ca71c6d7a1e682c69be97998dfc87eR1078

(I was hoping after open-telemetry/opentelemetry-rust#1569 updating to newer OTel might see a saving in buildpack crates, but the dependency count has increased).

Anyway, we need this functionality (and writing the transformation implementation ourselves would be worse), so there's not much we can do...

"opentelemetry-proto/with-serde",
"dep:serde_json",
]

[dependencies]
Expand All @@ -27,15 +34,15 @@ cyclonedx-bom = { version = "0.8.0", optional = true }
libcnb-common.workspace = true
libcnb-data.workspace = true
libcnb-proc-macros.workspace = true
opentelemetry = { version = "0.24", optional = true }
opentelemetry_sdk = { version = "0.24", optional = true }
opentelemetry-stdout = { version = "0.5", optional = true, features = [
"trace",
] }
futures-core = { version = "0.3", optional = true }
opentelemetry = { version = "0.28.0", optional = true, default-features = false }
opentelemetry_sdk = { version = "0.28.0", optional = true, default-features = false }
opentelemetry-proto = { version = "0.28.0", optional = true, default-features = false }
serde = { version = "1.0.215", features = ["derive"] }
serde_json = { version = "1.0.133", optional = true }
thiserror = "2.0.6"
toml.workspace = true

[dev-dependencies]
serde_json = "1.0.133"
tempfile = "3.14.0"
serde_json = "1.0.133"
133 changes: 97 additions & 36 deletions libcnb/src/tracing.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
use futures_core::future::BoxFuture;
use libcnb_data::buildpack::Buildpack;
use opentelemetry::{
global,
global::{self, BoxedSpan},
trace::{Span as SpanTrait, Status, Tracer, TracerProvider as TracerProviderTrait},
KeyValue,
InstrumentationScope, KeyValue,
};
use opentelemetry_proto::transform::common::tonic::ResourceAttributesWithSchema;
use opentelemetry_proto::transform::trace::tonic::group_spans_by_resource_and_scope;
use opentelemetry_sdk::{
trace::{Config, Span, TracerProvider},
error::{OTelSdkError, OTelSdkResult},
trace::SdkTracerProvider,
trace::SpanExporter,
Resource,
};
use std::{io::BufWriter, path::Path};
use std::{
fmt::Debug,
io::{LineWriter, Write},
path::Path,
sync::{Arc, Mutex},
};

// This is the directory in which `BuildpackTrace` stores OpenTelemetry File
// Exports. Services which intend to export the tracing data from libcnb.rs
Expand All @@ -22,8 +32,8 @@ 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,
provider: SdkTracerProvider,
span: BoxedSpan,
}

/// Start an OpenTelemetry trace and span that exports to an
Expand All @@ -40,45 +50,44 @@ pub(crate) fn start_trace(buildpack: &Buildpack, phase_name: &'static str) -> Bu
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.
let resource = Resource::builder()
// Define a resource that defines the trace provider.
// The buildpack name/version seems to map well to the suggestion here
// https://opentelemetry.io/docs/specs/semconv/resource/#service.
.with_attributes([
KeyValue::new("service.name", buildpack.id.to_string()),
KeyValue::new("service.version", buildpack.version.to_string()),
])))
])
.build();

let provider_builder = SdkTracerProvider::builder().with_resource(resource.clone());

let provider = match std::fs::File::options()
.create(true)
.append(true)
.open(&tracing_file_path)
.map(|file| FileExporter::new(file, resource))
{
// Write tracing data to a file, which may be read by other services
Ok(exporter) => provider_builder.with_batch_exporter(exporter),
// Failed tracing shouldn't fail a build, and any export logging here
// would likely confuse the user; don't export when the file has IO errors
Err(_) => provider_builder,
}
.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
.tracer_builder(env!("CARGO_PKG_NAME"))
.with_version(env!("CARGO_PKG_VERSION"))
.build();
let tracer = global::tracer_provider().tracer_with_scope(
InstrumentationScope::builder(env!("CARGO_PKG_NAME"))
.with_version(env!("CARGO_PKG_VERSION"))
.build(),
);

let mut span = tracer.start(trace_name);
span.set_attributes([
Expand Down Expand Up @@ -109,8 +118,60 @@ impl BuildpackTrace {
impl Drop for BuildpackTrace {
fn drop(&mut self) {
self.span.end();
self.provider.force_flush();
global::shutdown_tracer_provider();
self.provider.force_flush().ok();
self.provider.shutdown().ok();
}
}

#[derive(Debug)]
struct FileExporter<W: Write + Send + Debug> {
writer: Arc<Mutex<LineWriter<W>>>,
resource: Resource,
}

impl<W: Write + Send + Debug> FileExporter<W> {
fn new(writer: W, resource: Resource) -> Self {
Self {
writer: Arc::new(Mutex::new(LineWriter::new(writer))),
resource,
}
}
}

impl<W: Write + Send + Debug> SpanExporter for FileExporter<W> {
fn export(
&mut self,
batch: Vec<opentelemetry_sdk::trace::SpanData>,
) -> BoxFuture<'static, OTelSdkResult> {
let resource = ResourceAttributesWithSchema::from(&self.resource);
let data = group_spans_by_resource_and_scope(batch, &resource);
let mut writer = match self.writer.lock() {
Ok(f) => f,
Err(e) => {
return Box::pin(std::future::ready(Err(OTelSdkError::InternalFailure(
e.to_string(),
))));
}
};
Box::pin(std::future::ready(
serde_json::to_writer(writer.get_mut(), &data)
.map_err(|e| OTelSdkError::InternalFailure(e.to_string())),
))
}

fn force_flush(&mut self) -> OTelSdkResult {
let mut writer = self
.writer
.lock()
.map_err(|e| OTelSdkError::InternalFailure(e.to_string()))?;

writer
.flush()
.map_err(|e| OTelSdkError::InternalFailure(e.to_string()))
}

fn set_resource(&mut self, res: &opentelemetry_sdk::Resource) {
self.resource = res.clone();
}
}

Expand Down
Loading