Emit per-diagnostic tracing events from pipeline::compile
The outer PipelineError's Display is just "ci.fnl has errors" — the
actual problem messages live in the #[related] Vec<Diagnostic>. With
sentry-tracing using Display to populate the exception value, every
compile failure surfaced in Sentry as an opaque "ci.fnl has errors"
title regardless of how many or which diagnostics fired. Pushing the
detail through extra["diagnostic"] worked locally but ran into Sentry
data scrubbing on the rendered source snippets.
Have compile() iterate the PipelineError's inner diagnostics and emit
one tracing::error! per Diagnostic instead. Each event's `error` field
carries the inner Display ("Job 'a' has empty inputs…", "Pipeline
image declared more than once.", "Cycle detected among jobs: build,
test"), which becomes the Sentry exception value directly — visible,
unfiltered, one Sentry issue per distinct problem. FennelError
failures still emit a single event since they're not aggregated.
Drop the per-call-site tracing::error in quire-ci's run_pipeline now
that pipeline::compile owns the reporting. Add tracing-capture unit
tests that assert the number of events matches the number of inner
diagnostics and that the outer "ci.fnl has errors" string is not what
reaches a subscriber.
Assisted-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 0e0c253..69c1897 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -336,18 +336,7 @@ fn run_pipeline(
meta: RunMeta,
secrets: HashMap<String, quire_core::secret::SecretString>,
) -> miette::Result<()> {
- let path = workspace.join(".quire").join("ci.fnl");
- let source = fs_err::read_to_string(&path).into_diagnostic()?;
- let pipeline = match pipeline::compile(&source, &path.display().to_string()) {
- Ok(p) => p,
- Err(e) => {
- tracing::error!(
- error = &e as &(dyn std::error::Error + 'static),
- "ci.fnl compilation failed",
- );
- return Err(e.into());
- }
- };
+ let pipeline = compile_at(&workspace)?;
let job_ids: Vec<String> = pipeline
.topo_order()
diff --git a/quire-core/src/ci/pipeline.rs b/quire-core/src/ci/pipeline.rs
index f1e0aa4..889772b 100644
--- a/quire-core/src/ci/pipeline.rs
+++ b/quire-core/src/ci/pipeline.rs
@@ -389,7 +389,44 @@ pub type CompileResult<T> = std::result::Result<T, CompileError>;
/// [`validate_post_graph`] checks the dependency graph. Errors from a
/// phase are wrapped in a [`PipelineError`] for miette to render with
/// inline labels.
+///
+/// Failures are emitted via `tracing::error!` so any caller running
+/// under a tracing subscriber (and a configured Sentry layer) gets the
+/// diagnostic reported. A [`PipelineError`] holds a vector of inner
+/// diagnostics — those are emitted one event each so the exception
+/// value in Sentry carries the actual `Display` message of each
+/// definition/structure error, not just the outer "ci.fnl has errors"
+/// summary.
pub fn compile(source: &str, name: &str) -> CompileResult<Pipeline> {
+ let result = compile_inner(source, name);
+ if let Err(e) = &result {
+ report_compile_error(name, e);
+ }
+ result
+}
+
+fn report_compile_error(name: &str, err: &CompileError) {
+ match err {
+ CompileError::Pipeline(pe) => {
+ for diag in &pe.diagnostics {
+ tracing::error!(
+ ci_fnl = %name,
+ error = diag as &(dyn std::error::Error + 'static),
+ "ci.fnl diagnostic",
+ );
+ }
+ }
+ CompileError::Fennel(_) => {
+ tracing::error!(
+ ci_fnl = %name,
+ error = err as &(dyn std::error::Error + 'static),
+ "ci.fnl compilation failed",
+ );
+ }
+ }
+}
+
+fn compile_inner(source: &str, name: &str) -> CompileResult<Pipeline> {
let fennel = Fennel::new()?;
let Registrations { jobs, image } = registration::register(&fennel, source, name)?;
@@ -890,4 +927,108 @@ mod tests {
"expected pipeline error: {msg}"
);
}
+
+ /// Captures the `Display` text of each `error` field recorded on a
+ /// tracing event. Mirrors what sentry-tracing's `record_error` does
+ /// to build the exception value, so the test asserts on the same
+ /// thing Sentry would surface as the event title.
+ mod tracing_capture {
+ use std::sync::{Arc, Mutex};
+ use tracing_subscriber::layer::SubscriberExt;
+
+ pub struct ErrorCapture {
+ messages: Arc<Mutex<Vec<String>>>,
+ }
+
+ struct CaptureVisitor<'a>(&'a Arc<Mutex<Vec<String>>>);
+
+ impl tracing::field::Visit for CaptureVisitor<'_> {
+ fn record_error(
+ &mut self,
+ field: &tracing::field::Field,
+ value: &(dyn std::error::Error + 'static),
+ ) {
+ if field.name() == "error" {
+ self.0.lock().unwrap().push(format!("{value}"));
+ }
+ }
+
+ fn record_debug(&mut self, _: &tracing::field::Field, _: &dyn std::fmt::Debug) {}
+ }
+
+ impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for ErrorCapture {
+ fn on_event(
+ &self,
+ event: &tracing::Event<'_>,
+ _ctx: tracing_subscriber::layer::Context<'_, S>,
+ ) {
+ if *event.metadata().level() != tracing::Level::ERROR {
+ return;
+ }
+ let mut visitor = CaptureVisitor(&self.messages);
+ event.record(&mut visitor);
+ }
+ }
+
+ /// Run `f` under a subscriber that records the `Display` of every
+ /// `error` field on an ERROR-level event, and return the captured
+ /// strings.
+ pub fn capture(f: impl FnOnce()) -> Vec<String> {
+ let messages = Arc::new(Mutex::new(Vec::new()));
+ let layer = ErrorCapture {
+ messages: messages.clone(),
+ };
+ let subscriber = tracing_subscriber::registry().with(layer);
+ tracing::subscriber::with_default(subscriber, f);
+ let out = messages.lock().unwrap().clone();
+ out
+ }
+ }
+
+ #[test]
+ fn compile_emits_one_tracing_error_per_inner_diagnostic() {
+ let source = r#"(local ci (require :quire.ci))
+(ci.image "alpine")
+(ci.image "ubuntu")
+(ci.job :a [] (fn [] nil))"#;
+
+ let errors = tracing_capture::capture(|| {
+ let _ = compile(source, "ci.fnl");
+ });
+
+ // Two definition errors expected: duplicate image, empty inputs.
+ assert_eq!(
+ errors.len(),
+ 2,
+ "one event per inner diagnostic, got: {errors:?}"
+ );
+ assert!(
+ errors
+ .iter()
+ .any(|m| m.contains("image declared more than once")),
+ "expected duplicate-image diagnostic in: {errors:?}"
+ );
+ assert!(
+ errors.iter().any(|m| m.contains("empty inputs")),
+ "expected empty-inputs diagnostic in: {errors:?}"
+ );
+ // Crucially, the bare outer "ci.fnl has errors" message must NOT be
+ // what reaches Sentry — that's the bug this fixes.
+ assert!(
+ !errors.iter().any(|m| m == "ci.fnl has errors"),
+ "outer wrapper message should not be emitted as an error event: {errors:?}"
+ );
+ }
+
+ #[test]
+ fn compile_emits_one_tracing_error_for_fennel_failure() {
+ let errors = tracing_capture::capture(|| {
+ let _ = compile("{:bad {:}", "ci.fnl");
+ });
+ assert_eq!(
+ errors.len(),
+ 1,
+ "fennel parse failure should produce a single event, got: {errors:?}"
+ );
+ }
}