use crate::runtime::ExecutionPhase;
use libcnb_data::buildpack::Buildpack;
use opentelemetry::{
InstrumentationScope, KeyValue,
global::{self},
trace::TracerProvider as TracerProviderTrait,
};
use opentelemetry_proto::transform::trace::tonic::group_spans_by_resource_and_scope;
use opentelemetry_proto::{
tonic::trace::v1::TracesData, transform::common::tonic::ResourceAttributesWithSchema,
};
use opentelemetry_sdk::{
Resource,
error::{OTelSdkError, OTelSdkResult},
trace::SdkTracerProvider,
trace::SpanExporter,
};
use std::{
fmt::Debug,
io::{LineWriter, Write},
path::Path,
sync::{Arc, Mutex},
};
use tracing::Level;
use tracing_opentelemetry::OpenTelemetryLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[cfg(target_family = "unix")]
const TELEMETRY_EXPORT_ROOT: &str = "/tmp/libcnb-telemetry";
pub(crate) struct BuildpackTrace {
provider: SdkTracerProvider,
}
pub(crate) fn init_tracing(
buildpack: &Buildpack,
execution_phase: &ExecutionPhase,
) -> BuildpackTrace {
let phase_name = match execution_phase {
ExecutionPhase::Detect(_) => "detect",
ExecutionPhase::Build(_) => "build",
};
let trace_name = format!(
"{}-{phase_name}",
buildpack.id.replace(['/', '.', '-'], "_")
);
let tracing_file_path = Path::new(TELEMETRY_EXPORT_ROOT).join(format!("{trace_name}.jsonl"));
if let Some(parent_dir) = tracing_file_path.parent() {
let _ = std::fs::create_dir_all(parent_dir);
}
let bp_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(),
),
];
let resource = Resource::builder()
.with_attributes([
KeyValue::new("service.name", buildpack.id.to_string()),
KeyValue::new("service.version", buildpack.version.to_string()),
])
.with_attributes(bp_attributes.clone())
.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))
{
Ok(exporter) => provider_builder.with_batch_exporter(exporter),
Err(_) => provider_builder,
}
.build();
global::set_tracer_provider(provider.clone());
let tracer = provider.tracer_with_scope(
InstrumentationScope::builder(env!("CARGO_PKG_NAME"))
.with_version(env!("CARGO_PKG_VERSION"))
.with_attributes(bp_attributes)
.build(),
);
tracing_subscriber::registry()
.with(OpenTelemetryLayer::new(tracer))
.with(tracing_subscriber::filter::LevelFilter::from_level(
Level::INFO,
))
.init();
BuildpackTrace { provider }
}
impl Drop for BuildpackTrace {
fn drop(&mut self) {
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> {
async fn export(&self, batch: Vec<opentelemetry_sdk::trace::SpanData>) -> OTelSdkResult {
let resource = ResourceAttributesWithSchema::from(&self.resource);
let resource_spans = group_spans_by_resource_and_scope(batch, &resource);
let data = TracesData { resource_spans };
let mut writer = match self.writer.lock() {
Ok(f) => f,
Err(e) => {
return Err(OTelSdkError::InternalFailure(e.to_string()));
}
};
serde_json::to_writer(writer.get_mut(), &data)
.map_err(|e| OTelSdkError::InternalFailure(e.to_string()))
.and(writeln!(writer).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();
}
}
#[cfg(test)]
mod tests {
use super::init_tracing;
use crate::BuildArgs;
use crate::runtime::ExecutionPhase;
use libcnb_data::{
buildpack::{Buildpack, BuildpackVersion},
buildpack_id,
};
use serde_json::Value;
use std::path::PathBuf;
use std::{collections::HashSet, fs, io::ErrorKind};
use tracing::Level;
#[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::new(),
licenses: Vec::new(),
sbom_formats: HashSet::new(),
};
let telemetry_path = "/tmp/libcnb-telemetry/company_com_foo-build.jsonl";
_ = fs::remove_file(telemetry_path);
{
let _trace_guard = init_tracing(
&buildpack,
&ExecutionPhase::Build(BuildArgs {
layers_dir_path: PathBuf::from("./layers/dir"),
platform_dir_path: PathBuf::from("./platform/dir"),
buildpack_plan_path: PathBuf::from("./buildpack_plan"),
}),
);
let _span_guard = tracing::span!(Level::INFO, "span-name").entered();
tracing::event!(Level::INFO, "baz-event");
let err = std::io::Error::new(ErrorKind::Unsupported, "oh no!");
tracing::error!(
error = &err as &(dyn std::error::Error + 'static),
"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");
assert!(tracing_contents.contains("{\"resourceSpans\":[{\"resource\":"));
assert!(tracing_contents.contains(
"{\"key\":\"service.name\",\"value\":{\"stringValue\":\"company.com/foo\"}}"
));
assert!(
tracing_contents
.contains("{\"key\":\"service.version\",\"value\":{\"stringValue\":\"0.0.99\"}}")
);
assert!(tracing_contents.contains("\"name\":\"span-name\""));
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\"}}"
));
assert!(tracing_contents.contains("\"name\":\"baz-event\""));
assert!(tracing_contents.contains("\"name\":\"it's broken\""));
assert!(
tracing_contents
.contains("{\"key\":\"exception.message\",\"value\":{\"stringValue\":\"oh no!\"}}")
);
assert!(tracing_contents.contains("\"code\":2"));
assert!(tracing_contents.ends_with('\n'));
}
}