Tests for the log's fields and the metrics Grafana reads (#403)

Three tests for what the dashboard and the alert rules read and nothing
checked: every ipx_ metric named in grafana/dashboard.py and alerts.py
(read out of them at compile time) is served by render, with the ones
written only once known (counts, the last scan, a job under way) set
first; ipx's JSON log lines for a scan's events carry ev, feed, new,
bytes, msg and error.type, and inside a span trace_id and span_id; the
directory's snapshot line carries instance, accepted and the refused_
counts.

The JSON layer was built the same way in both services' setup; it is
otel::json_layer now, so the tests run the layer production does.

The log tests set the global subscriber, as production does: under a
scoped one WithTrace finds no current span (#408).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 04:02:15 +00:00
parent a92e81aba2
commit b62182e38e
5 changed files with 185 additions and 30 deletions

View File

@@ -393,6 +393,46 @@ mod tests {
assert_eq!(call(&app, "DELETE", &format!("/feed/{id}"), None, None).await.0, 405);
}
/// What the log layer wrote, kept for the test to read.
struct Lines(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for Lines {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[tokio::test]
async fn a_snapshots_log_line_has_the_fields_grafana_reads() {
use tracing_subscriber::layer::SubscriberExt;
let buf = std::sync::Arc::new(std::sync::Mutex::new(vec![]));
let b = buf.clone();
// Global, as the directory's own is; other tests' snapshots land here too, and each of
// their lines must carry the same fields.
tracing::subscriber::set_global_default(
tracing_subscriber::registry().with(crate::otel::json_layer(move || Lines(b.clone()), false)),
)
.unwrap();
let (app, _dir) = app().await;
let snap = serde_json::json!([{ "url": "https://x.test/log.xml", "subscribers": 1 }]);
assert_eq!(call(&app, "POST", "/v1/snapshot", Some("rw"), Some(snap)).await.0, 200);
let text = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
let lines: Vec<serde_json::Value> = text.lines().map(|l| serde_json::from_str(l).expect(l)).collect();
let snaps: Vec<&serde_json::Value> = lines.iter().filter(|l| l["message"] == "snapshot").collect();
assert!(!snaps.is_empty(), "no snapshot line in {text}");
for l in snaps {
assert!(l["instance"].is_string(), "{l}");
for n in ["accepted", "refused_private", "refused_blocked", "refused_invalid"] {
assert!(l[n].is_u64(), "{n} in {l}");
}
}
}
#[tokio::test]
async fn each_access_level_does_what_it_may_and_no_more() {
let (app, dir) = app().await;

View File

@@ -85,21 +85,9 @@ async fn serve(path: &Path) -> Result<()> {
let otel = ipx_directory::otel::provider("ipx-directory")?;
tracing_subscriber::registry()
.with((!json).then(|| tracing_subscriber::fmt::layer().with_writer(std::io::stderr).with_filter(filter())))
.with(json.then(|| {
tracing_subscriber::fmt::layer()
.fmt_fields(tracing_subscriber::fmt::format::JsonFields::new())
// Each line's own fields say all its span did, and the list said it again (#363);
// the trace and span ids lead to the trace instead, as ipx's do (#375).
.event_format(ipx_directory::otel::WithTrace(
tracing_subscriber::fmt::format()
.json()
.flatten_event(true)
.with_current_span(false)
.with_span_list(false),
))
.with_writer(std::io::stderr)
.with_filter(filter())
}))
// Each line's own fields say all its span did, and the span said it again (#363); the trace
// and span ids lead to the trace instead, as ipx's do (#375).
.with(json.then(|| ipx_directory::otel::json_layer(std::io::stderr, false).with_filter(filter())))
.with(otel.as_ref().map(|p| {
use opentelemetry::trace::TracerProvider;
tracing_opentelemetry::layer()

View File

@@ -95,6 +95,27 @@ pub fn with_ids<R>(
/// The JSON log line with the trace and span it belongs to (#91), so a line in Loki leads to its
/// trace in Tempo. The JSON formatter cannot take a field of its own, so the ids go on the end of
/// the object it writes; the directory's lines carry them too (#375). A line outside any traced span, or with no trace exporter, is unchanged.
/// One JSON object a line for Loki, each event's fields its own and the trace and span ids beside
/// them, for ipx and the directory both: the dashboard and the alert rules read these fields.
/// `current_span` adds the span's own fields under `span`, which ipx keeps and the directory
/// dropped (#363).
pub fn json_layer<S, W>(writer: W, current_span: bool) -> impl tracing_subscriber::Layer<S>
where
S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
W: for<'w> tracing_subscriber::fmt::MakeWriter<'w> + Send + Sync + 'static,
{
tracing_subscriber::fmt::layer()
.fmt_fields(tracing_subscriber::fmt::format::JsonFields::new())
.event_format(WithTrace(
tracing_subscriber::fmt::format()
.json()
.flatten_event(true)
.with_current_span(current_span)
.with_span_list(false),
))
.with_writer(writer)
}
pub struct WithTrace<F>(pub F);
impl<S, N, F> tracing_subscriber::fmt::FormatEvent<S, N> for WithTrace<F>