Tracing: root spans for background jobs, retire raw OTel spans (#142)

Background work has no request parent, so each job now opens a named
tracing root and carries it into its fan-out:

- watcher: `watcher.tick` (scan=quick|full) entered per tick on the
  watcher's std thread, `watcher.library` per library, and
  `watcher.playlist_cleanup` per orphaned-HLS pass. face_watch /
  clip_watch carry the ambient span into every per-candidate
  tokio::spawn and give each call its own `face_watch.detect` /
  `clip_watch.encode` span.
- thumbnails: `creating thumbnails` via #[instrument]; per-video spans on
  rayon workers name their parent explicitly.
- video actors: `playlistgenerator.generate_playlist` and
  `preview_clip.generate` are tracing roots that instrument their futures.
- reels: `reel.job` (created before the spawn, so on-demand jobs stay in
  the request trace), `reel.pregen_batch`, `reel.cache_sweep`.
- RefreshThumbnailsMessage and the dead-code GIF job move to tracing.

No raw OpenTelemetry span creation is left outside otel.rs;
`global_tracer` is removed.

tests/otel_background.rs pins the std-thread tick -> block_on -> spawn
shape and the rayon explicit-parent shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-09-29 17:02:40 -04:00
1 parent b6deca2a52
commit 26115b3378
11 files changed
+330 -179

No files matched your search

+13 -4
View File
@@ -29,6 +29,7 @@ use log::{debug, info, warn};
use std::path::Path; use std::path::Path;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
use tracing::Instrument;
/// One file the watcher would like to CLIP-encode. Built from the DAO /// One file the watcher would like to CLIP-encode. Built from the DAO
/// `list_clip_unencoded_candidates` result — needs the `content_hash` /// `list_clip_unencoded_candidates` result — needs the `content_hash`
@@ -90,6 +91,9 @@ pub fn run_clip_encoding_pass(
let library_id = library.id; let library_id = library.id;
let library_root = library.root_path.clone(); let library_root = library.root_path.clone();
// The detect tasks run on this runtime's worker threads; carry the
// watcher's span into each so their spans stay in the tick's trace.
let parent = tracing::Span::current();
rt.block_on(async move { rt.block_on(async move {
let sem = Arc::new(Semaphore::new(concurrency)); let sem = Arc::new(Semaphore::new(concurrency));
let mut handles = Vec::with_capacity(filtered.len()); let mut handles = Vec::with_capacity(filtered.len());
@@ -98,10 +102,14 @@ pub fn run_clip_encoding_pass(
let clip_client = clip_client.clone(); let clip_client = clip_client.clone();
let exif_dao = exif_dao.clone(); let exif_dao = exif_dao.clone();
let library_root = library_root.clone(); let library_root = library_root.clone();
handles.push(tokio::spawn(async move { let task_span = parent.clone();
let _permit = permit_sem.acquire().await.expect("clip semaphore"); handles.push(tokio::spawn(
process_one(library_id, &library_root, cand, &clip_client, exif_dao).await; async move {
})); let _permit = permit_sem.acquire().await.expect("clip semaphore");
process_one(library_id, &library_root, cand, &clip_client, exif_dao).await;
}
.instrument(task_span),
));
} }
for h in handles { for h in handles {
let _ = h.await; let _ = h.await;
@@ -109,6 +117,7 @@ pub fn run_clip_encoding_pass(
}); });
} }
#[tracing::instrument(name = "clip_watch.encode", skip_all, fields(rel_path = %cand.rel_path))]
async fn process_one( async fn process_one(
library_id: i32, library_id: i32,
library_root: &str, library_root: &str,
+24 -15
View File
@@ -25,6 +25,7 @@ use log::{debug, info, warn};
use std::path::Path; use std::path::Path;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
use tracing::Instrument;
/// One file the watcher would like to face-scan. Built by the caller from /// One file the watcher would like to face-scan. Built by the caller from
/// the EXIF batch (we need `content_hash` to key everything against). /// the EXIF batch (we need `content_hash` to key everything against).
@@ -89,6 +90,9 @@ pub fn run_face_detection_pass(
let library_id = library.id; let library_id = library.id;
let library_root = library.root_path.clone(); let library_root = library.root_path.clone();
// The detect tasks run on this runtime's worker threads; carry the
// watcher's span into each so their spans stay in the tick's trace.
let parent = tracing::Span::current();
rt.block_on(async move { rt.block_on(async move {
let sem = Arc::new(Semaphore::new(concurrency)); let sem = Arc::new(Semaphore::new(concurrency));
let mut handles = Vec::with_capacity(filtered.len()); let mut handles = Vec::with_capacity(filtered.len());
@@ -98,21 +102,25 @@ pub fn run_face_detection_pass(
let face_dao = face_dao.clone(); let face_dao = face_dao.clone();
let tag_dao = tag_dao.clone(); let tag_dao = tag_dao.clone();
let library_root = library_root.clone(); let library_root = library_root.clone();
handles.push(tokio::spawn(async move { let task_span = parent.clone();
// acquire_owned would let us drop the permit explicitly handles.push(tokio::spawn(
// before await points; for a one-shot call into Apollo async move {
// the simpler bounded acquire is enough. // acquire_owned would let us drop the permit explicitly
let _permit = permit_sem.acquire().await.expect("face semaphore"); // before await points; for a one-shot call into Apollo
process_one( // the simpler bounded acquire is enough.
library_id, let _permit = permit_sem.acquire().await.expect("face semaphore");
&library_root, process_one(
cand, library_id,
&face_client, &library_root,
face_dao, cand,
tag_dao, &face_client,
) face_dao,
.await; tag_dao,
})); )
.await;
}
.instrument(task_span),
));
} }
for h in handles { for h in handles {
// join; per-task panics are logged inside process_one before // join; per-task panics are logged inside process_one before
@@ -122,6 +130,7 @@ pub fn run_face_detection_pass(
}); });
} }
#[tracing::instrument(name = "face_watch.detect", skip_all, fields(rel_path = %cand.rel_path))]
async fn process_one( async fn process_one(
library_id: i32, library_id: i32,
library_root: &str, library_root: &str,
+2 -4
View File
@@ -30,11 +30,10 @@ use actix_web::{
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
use opentelemetry::KeyValue; use opentelemetry::KeyValue;
use opentelemetry::trace::{Status, Tracer}; use opentelemetry::trace::Status;
use crate::data::SortType::NameAsc; use crate::data::SortType::NameAsc;
use crate::error::IntoHttpError; use crate::error::IntoHttpError;
use crate::otel::global_tracer;
use crate::tags::{FileWithTagCount, TagDao}; use crate::tags::{FileWithTagCount, TagDao};
use crate::video::actors::StreamActor; use crate::video::actors::StreamActor;
use path_absolutize::*; use path_absolutize::*;
@@ -1373,9 +1372,8 @@ impl Message for RefreshThumbnailsMessage {
impl Handler<RefreshThumbnailsMessage> for StreamActor { impl Handler<RefreshThumbnailsMessage> for StreamActor {
type Result = (); type Result = ();
#[tracing::instrument(name = "RefreshThumbnailsMessage", skip_all)]
fn handle(&mut self, _msg: RefreshThumbnailsMessage, _ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, _msg: RefreshThumbnailsMessage, _ctx: &mut Self::Context) -> Self::Result {
let tracer = global_tracer();
let _ = tracer.start("RefreshThumbnailsMessage");
info!("Refreshing thumbnails after upload"); info!("Refreshing thumbnails after upload");
// The stub in lib.rs is a no-op; the real generation is driven by // The stub in lib.rs is a no-op; the real generation is driven by
// the file watcher tick in main.rs, which has access to the // the file watcher tick in main.rs, which has access to the
+3 -8
View File
@@ -1,4 +1,3 @@
use opentelemetry::global::BoxedTracer;
use opentelemetry::propagation::TextMapPropagator; use opentelemetry::propagation::TextMapPropagator;
use opentelemetry::trace::{Status, TraceContextExt, TracerProvider}; use opentelemetry::trace::{Status, TraceContextExt, TracerProvider};
use opentelemetry::{Context, KeyValue, global}; use opentelemetry::{Context, KeyValue, global};
@@ -10,10 +9,6 @@ use opentelemetry_sdk::propagation::TraceContextPropagator;
use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::util::SubscriberInitExt;
pub fn global_tracer() -> BoxedTracer {
global::tracer("image-server")
}
/// Install the process-wide `tracing` subscriber plus the OTel span pipeline. /// Install the process-wide `tracing` subscriber plus the OTel span pipeline.
/// ///
/// Called in BOTH debug and release builds (the old split made spans a no-op /// Called in BOTH debug and release builds (the old split made spans a no-op
@@ -54,9 +49,9 @@ pub fn init_tracing() {
} }
let tracer_provider = provider_builder.build(); let tracer_provider = provider_builder.build();
// The rest of the codebase resolves spans through the global provider // Install the provider globally too (flush/shutdown and any direct
// (`global_tracer()`, `global::tracer("db")`), so install it there; the // `global::tracer` users); the subscriber's OTel layer uses the same
// subscriber's OTel layer uses the same provider's tracer. // provider's tracer.
let tracer = tracer_provider.tracer("image-server"); let tracer = tracer_provider.tracer("image-server");
global::set_tracer_provider(tracer_provider); global::set_tracer_provider(tracer_provider);
+41 -28
View File
@@ -28,6 +28,7 @@ use anyhow::{Context, anyhow};
use chrono::{DateTime, Datelike, Timelike}; use chrono::{DateTime, Datelike, Timelike};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use tracing::Instrument;
use uuid::Uuid; use uuid::Uuid;
use crate::data::Claims; use crate::data::Claims;
@@ -557,34 +558,41 @@ pub async fn create_reel_handler(
let state = app_state.clone(); let state = app_state.clone();
let insight_dao = insight_dao.clone(); let insight_dao = insight_dao.clone();
let exif_dao = exif_dao.clone(); let exif_dao = exif_dao.clone();
let handle = tokio::spawn(async move { // Created before the spawn so the job parents on the dispatching request
match run_reel_job( // (or runs as its own root from the pre-gen scheduler), then carried into
&state, // the task — tracing context doesn't cross `tokio::spawn` on its own.
&insight_dao, let job_span = tracing::info_span!("reel.job", job_id = %job_id);
&exif_dao, let handle = tokio::spawn(
job_id, async move {
planned, match run_reel_job(
meta, &state,
voice, &insight_dao,
&key, &exif_dao,
) job_id,
.await planned,
{ meta,
Ok((title, path)) => { voice,
finish_job(job_id, ReelJobStatus::Done, Some(title), Some(path), None) &key,
} )
Err(e) => { .await
log::error!("reel job {job_id} failed: {e:?}"); {
finish_job( Ok((title, path)) => {
job_id, finish_job(job_id, ReelJobStatus::Done, Some(title), Some(path), None)
ReelJobStatus::Error, }
None, Err(e) => {
None, log::error!("reel job {job_id} failed: {e:?}");
Some(format!("{e}")), finish_job(
) job_id,
ReelJobStatus::Error,
None,
None,
Some(format!("{e}")),
)
}
} }
} }
}); .instrument(job_span),
);
with_job(job_id, |job| job.abort = Some(handle.abort_handle())); with_job(job_id, |job| job.abort = Some(handle.abort_handle()));
// Capture params for passive prefs mirror (best-effort, never fails). // Capture params for passive prefs mirror (best-effort, never fails).
@@ -1056,7 +1064,11 @@ pub(crate) async fn spawn_pregen_scheduler(app_state: web::Data<AppState>) {
log::debug!("Next pre-gen run in {}s", sleep_secs); log::debug!("Next pre-gen run in {}s", sleep_secs);
tokio::time::sleep(std::time::Duration::from_secs(sleep_secs)).await; tokio::time::sleep(std::time::Duration::from_secs(sleep_secs)).await;
if let Err(e) = run_pregen_batch(&app_state).await { // One root span per nightly batch.
if let Err(e) = run_pregen_batch(&app_state)
.instrument(tracing::info_span!("reel.pregen_batch"))
.await
{
log::error!("Reel pre-generation batch failed: {}", e); log::error!("Reel pre-generation batch failed: {}", e);
} }
} }
@@ -1267,7 +1279,8 @@ pub(crate) async fn spawn_reel_cache_sweeper(app_state: web::Data<AppState>) {
// Settle after startup, then sweep on a fixed cadence. // Settle after startup, then sweep on a fixed cadence.
tokio::time::sleep(Duration::from_secs(300)).await; tokio::time::sleep(Duration::from_secs(300)).await;
loop { loop {
let removed = sweep_reel_cache(&app_state); let removed =
tracing::info_span!("reel.cache_sweep").in_scope(|| sweep_reel_cache(&app_state));
if removed > 0 { if removed > 0 {
log::info!("Reel cache sweep removed {removed} stale file(s)"); log::info!("Reel cache sweep removed {removed} stale file(s)");
} }
+10 -18
View File
@@ -14,19 +14,15 @@ use image::GenericImageView;
use image::codecs::jpeg::JpegEncoder; use image::codecs::jpeg::JpegEncoder;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use opentelemetry::{
KeyValue,
trace::{Span, TraceContextExt, Tracer},
};
use prometheus::IntGauge; use prometheus::IntGauge;
use rayon::prelude::*; use rayon::prelude::*;
use tracing_opentelemetry::OpenTelemetrySpanExt;
use walkdir::DirEntry; use walkdir::DirEntry;
use crate::content_hash; use crate::content_hash;
use crate::exif; use crate::exif;
use crate::file_types; use crate::file_types;
use crate::libraries; use crate::libraries;
use crate::otel::global_tracer;
use crate::video::actors::{generate_image_thumbnail_ffmpeg, generate_video_thumbnail}; use crate::video::actors::{generate_image_thumbnail_ffmpeg, generate_video_thumbnail};
/// Maximum long-edge size (px) for the large preview tier. Tuned to look /// Maximum long-edge size (px) for the large preview tier. Tuned to look
@@ -327,9 +323,9 @@ fn generate_xlarge_preview_ffmpeg(
Ok(()) Ok(())
} }
#[tracing::instrument(name = "creating thumbnails", skip_all)]
pub fn create_thumbnails(libs: &[libraries::Library], excluded_dirs: &[String]) { pub fn create_thumbnails(libs: &[libraries::Library], excluded_dirs: &[String]) {
let tracer = global_tracer(); let span = tracing::Span::current();
let span = tracer.start("creating thumbnails");
let thumbs = &dotenv::var("THUMBNAILS").expect("THUMBNAILS not defined"); let thumbs = &dotenv::var("THUMBNAILS").expect("THUMBNAILS not defined");
let thumbnail_directory: &Path = Path::new(thumbs); let thumbnail_directory: &Path = Path::new(thumbs);
@@ -387,16 +383,12 @@ pub fn create_thumbnails(libs: &[libraries::Library], excluded_dirs: &[String])
} }
if is_video(&entry) { if is_video(&entry) {
let mut video_span = tracer.start_with_context( // Explicit parent: this closure runs on rayon worker
"generate_video_thumbnail", // threads, where the ambient span isn't current.
&opentelemetry::Context::new() let video_span = tracing::info_span!(parent: &span, "generate_video_thumbnail");
.with_remote_span_context(span.span_context().clone()), video_span.set_attribute("type", "video");
); video_span.set_attribute("file-name", thumb_path.display().to_string());
video_span.set_attributes(vec![ video_span.set_attribute("library", lib.name.clone());
KeyValue::new("type", "video"),
KeyValue::new("file-name", thumb_path.display().to_string()),
KeyValue::new("library", lib.name.clone()),
]);
debug!("Generating video thumbnail: {:?}", thumb_path); debug!("Generating video thumbnail: {:?}", thumb_path);
if let Err(e) = generate_video_thumbnail(src, &thumb_path) { if let Err(e) = generate_video_thumbnail(src, &thumb_path) {
@@ -409,7 +401,7 @@ pub fn create_thumbnails(libs: &[libraries::Library], excluded_dirs: &[String])
warn!("Failed to write sentinel {:?}: {}", sentinel, se); warn!("Failed to write sentinel {:?}: {}", sentinel, se);
} }
} }
video_span.end(); drop(video_span);
} else if is_image(&entry) { } else if is_image(&entry) {
match generate_image_thumbnail(src, &thumb_path) { match generate_image_thumbnail(src, &thumb_path) {
Ok(_) => info!("Saved thumbnail: {:?}", thumb_path), Ok(_) => info!("Saved thumbnail: {:?}", thumb_path),
+53 -43
View File
@@ -1,18 +1,19 @@
use crate::content_hash; use crate::content_hash;
use crate::database::PreviewDao; use crate::database::PreviewDao;
use crate::libraries::Library; use crate::libraries::Library;
use crate::otel::global_tracer;
use crate::video::ffmpeg::{generate_preview_clip, get_duration_seconds_blocking}; use crate::video::ffmpeg::{generate_preview_clip, get_duration_seconds_blocking};
use crate::video::hls_paths; use crate::video::hls_paths;
use actix::prelude::*; use actix::prelude::*;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use opentelemetry::KeyValue; use opentelemetry::KeyValue;
use opentelemetry::trace::{Span, Status, Tracer}; use opentelemetry::trace::Status;
use std::io::Result; use std::io::Result;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
use tracing::Instrument;
use tracing_opentelemetry::OpenTelemetrySpanExt;
// ffmpeg -i test.mp4 -c:v h264 -flags +cgop -g 30 -hls_time 3 out.m3u8 // ffmpeg -i test.mp4 -c:v h264 -flags +cgop -g 30 -hls_time 3 out.m3u8
// ffmpeg -i "filename.mp4" -preset veryfast -c:v libx264 -f hls -hls_list_size 100 -hls_time 2 -crf 24 -vf scale=1080:-2,setsar=1:1 attempt/vid_out.m3u8 // ffmpeg -i "filename.mp4" -preset veryfast -c:v libx264 -f hls -hls_list_size 100 -hls_time 2 -crf 24 -vf scale=1080:-2,setsar=1:1 attempt/vid_out.m3u8
@@ -448,15 +449,16 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
let playlist_file = playlist_path.to_string_lossy().to_string(); let playlist_file = playlist_path.to_string_lossy().to_string();
let segment_pattern = segment_template.to_string_lossy().to_string(); let segment_pattern = segment_template.to_string_lossy().to_string();
let tracer = global_tracer(); // Job root: actix messages don't carry the sender's span, so each
let mut span = tracer // playlist generation is its own trace. The future is instrumented
.span_builder("playlistgenerator.generate_playlist") // so any DB / child spans inside nest under it.
.with_attributes(vec![ let span = tracing::info_span!(
KeyValue::new("video_file", video_file.clone()), "playlistgenerator.generate_playlist",
KeyValue::new("content_hash", content_hash_str.clone()), video_file = %video_file,
KeyValue::new("playlist_file", playlist_file.clone()), content_hash = %content_hash_str,
]) playlist_file = %playlist_file,
.start(&tracer); );
let fut_span = span.clone();
Box::pin(async move { Box::pin(async move {
let wait_start = std::time::Instant::now(); let wait_start = std::time::Instant::now();
@@ -691,7 +693,8 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
span.set_status(Status::error(detail.clone())); span.set_status(Status::error(detail.clone()));
Err(std::io::Error::other(detail)) Err(std::io::Error::other(detail))
} }
}) }
.instrument(fut_span))
} }
} }
@@ -818,39 +821,46 @@ impl Handler<GeneratePreviewClipMessage> for PreviewClipGenerator {
let video_path_owned = video_path.clone(); let video_path_owned = video_path.clone();
let relative_path_owned = relative_path.clone(); let relative_path_owned = relative_path.clone();
tokio::spawn(async move { let clip_span = tracing::info_span!(
match generate_preview_clip(&video_path_owned, &output_str).await { "preview_clip.generate",
Ok((duration, size)) => { rel_path = %relative_path_owned,
info!( );
"Preview clip complete for '{}' ({:.1}s, {} bytes)", tokio::spawn(
relative_path_owned, duration, size async move {
); match generate_preview_clip(&video_path_owned, &output_str).await {
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao"); Ok((duration, size)) => {
let _ = dao.update_status( info!(
&relative_path_owned, "Preview clip complete for '{}' ({:.1}s, {} bytes)",
"complete", relative_path_owned, duration, size
Some(duration as f32), );
Some(size as i32), let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
None, let _ = dao.update_status(
); &relative_path_owned,
} "complete",
Err(e) => { Some(duration as f32),
error!( Some(size as i32),
"Failed to generate preview clip for '{}': {}", None,
relative_path_owned, e );
); }
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao"); Err(e) => {
let _ = dao.update_status( error!(
&relative_path_owned, "Failed to generate preview clip for '{}': {}",
"failed", relative_path_owned, e
None, );
None, let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
Some(&e.to_string()), let _ = dao.update_status(
); &relative_path_owned,
"failed",
None,
None,
Some(&e.to_string()),
);
}
} }
drop(permit);
} }
drop(permit); .instrument(clip_span),
}); );
}) })
} }
} }
+57 -57
View File
@@ -1,10 +1,9 @@
use crate::otel::global_tracer;
use crate::thumbnails::{is_video, update_media_counts}; use crate::thumbnails::{is_video, update_media_counts};
use crate::video::ffmpeg::{Ffmpeg, GifType}; use crate::video::ffmpeg::{Ffmpeg, GifType};
use log::info; use log::info;
use opentelemetry::trace::Tracer;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tracing::Instrument;
use walkdir::WalkDir; use walkdir::WalkDir;
pub mod actors; pub mod actors;
@@ -14,67 +13,68 @@ pub mod legacy_migration;
#[allow(dead_code)] #[allow(dead_code)]
pub async fn generate_video_gifs() { pub async fn generate_video_gifs() {
tokio::spawn(async { tokio::spawn(
info!("Starting to make video gifs"); async {
info!("Starting to make video gifs");
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let tracer = global_tracer();
tracer.start("creating video gifs");
let gif_base_path = &dotenv::var("GIFS_DIRECTORY").unwrap_or(String::from("gifs")); let gif_base_path = &dotenv::var("GIFS_DIRECTORY").unwrap_or(String::from("gifs"));
let gif_directory: &Path = Path::new(gif_base_path); let gif_directory: &Path = Path::new(gif_base_path);
fs::create_dir_all(gif_base_path).expect("There was an issue creating directory"); fs::create_dir_all(gif_base_path).expect("There was an issue creating directory");
let files = PathBuf::from(dotenv::var("BASE_PATH").unwrap()); let files = PathBuf::from(dotenv::var("BASE_PATH").unwrap());
// EXCLUDED_DIRS read here for the update_media_counts call below. // EXCLUDED_DIRS read here for the update_media_counts call below.
// The WalkDir walk itself is left raw — this function is currently // The WalkDir walk itself is left raw — this function is currently
// dead code (`#[allow(dead_code)]`) and not reachable from main. // dead code (`#[allow(dead_code)]`) and not reachable from main.
// If revived, swap to file_scan::walk_library_files (dual lib/bin // If revived, swap to file_scan::walk_library_files (dual lib/bin
// module path makes that non-trivial here). // module path makes that non-trivial here).
let excluded_dirs: Vec<String> = std::env::var("EXCLUDED_DIRS") let excluded_dirs: Vec<String> = std::env::var("EXCLUDED_DIRS")
.unwrap_or_default() .unwrap_or_default()
.split(',') .split(',')
.filter(|s| !s.trim().is_empty()) .filter(|s| !s.trim().is_empty())
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
.collect(); .collect();
let ffmpeg = Ffmpeg; let ffmpeg = Ffmpeg;
for file in WalkDir::new(&files) for file in WalkDir::new(&files)
.into_iter() .into_iter()
.filter_map(|entry| entry.ok()) .filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().is_file()) .filter(|entry| entry.file_type().is_file())
.filter(is_video) .filter(is_video)
.filter(|entry| { .filter(|entry| {
let path = entry.path(); let path = entry.path();
let relative_path = &path.strip_prefix(&files).unwrap();
let thumb_path = Path::new(gif_directory).join(relative_path);
let gif_path = thumb_path.with_extension("gif");
!gif_path.exists()
})
{
let path = file.path();
let relative_path = &path.strip_prefix(&files).unwrap(); let relative_path = &path.strip_prefix(&files).unwrap();
let thumb_path = Path::new(gif_directory).join(relative_path); let gif_path = Path::new(gif_directory).join(relative_path);
let gif_path = thumb_path.with_extension("gif"); let gif_path = gif_path.with_extension("gif");
!gif_path.exists() if let Some(parent_dir) = gif_path.parent() {
}) fs::create_dir_all(parent_dir).unwrap_or_else(|_| {
{ panic!("There was an issue creating gif directory {:?}", gif_path)
let path = file.path(); });
let relative_path = &path.strip_prefix(&files).unwrap(); }
let gif_path = Path::new(gif_directory).join(relative_path); info!("Generating gif for {:?}", path);
let gif_path = gif_path.with_extension("gif");
if let Some(parent_dir) = gif_path.parent() { ffmpeg
fs::create_dir_all(parent_dir).unwrap_or_else(|_| { .generate_video_gif(
panic!("There was an issue creating gif directory {:?}", gif_path) path.to_str().unwrap(),
}); gif_path.to_str().unwrap(),
GifType::Overview,
)
.await
.expect("There was an issue generating the gif");
} }
info!("Generating gif for {:?}", path);
ffmpeg info!("Finished making video gifs in {:?}", start.elapsed());
.generate_video_gif(
path.to_str().unwrap(), update_media_counts(&files, &excluded_dirs);
gif_path.to_str().unwrap(),
GifType::Overview,
)
.await
.expect("There was an issue generating the gif");
} }
.instrument(tracing::info_span!("video.create_gifs")),
info!("Finished making video gifs in {:?}", start.elapsed()); );
update_media_counts(&files, &excluded_dirs);
});
} }
+16
View File
@@ -125,6 +125,7 @@ pub fn cleanup_orphaned_playlists(
} }
info!("Running orphaned HLS cleanup"); info!("Running orphaned HLS cleanup");
let _pass = tracing::info_span!("watcher.playlist_cleanup").entered();
let start = std::time::Instant::now(); let start = std::time::Instant::now();
// Snapshot every live content_hash currently in image_exif. // Snapshot every live content_hash currently in image_exif.
@@ -418,6 +419,15 @@ pub fn watch_files(
let is_full_scan = since_last_full.as_secs() >= full_interval_secs; let is_full_scan = since_last_full.as_secs() >= full_interval_secs;
// Root span per tick: this loop runs on a plain std thread, so a
// synchronous `entered()` guard is the right shape. Everything the
// tick does (probes, drains, ingest, maintenance) nests under it.
let _tick = tracing::info_span!(
"watcher.tick",
scan = if is_full_scan { "full" } else { "quick" },
)
.entered();
// Fresh snapshot per tick — picks up PATCH /libraries/{id} // Fresh snapshot per tick — picks up PATCH /libraries/{id}
// mutations to `enabled` / `excluded_dirs` without restart. // mutations to `enabled` / `excluded_dirs` without restart.
let libs: Vec<libraries::Library> = let libs: Vec<libraries::Library> =
@@ -436,6 +446,12 @@ pub fn watch_files(
); );
continue; continue;
} }
let _lib_span = tracing::info_span!(
"watcher.library",
library = %lib.name,
library_id = lib.id,
)
.entered();
// Availability probe: every tick checks that the // Availability probe: every tick checks that the
// library's mount is reachable, is a directory, is // library's mount is reachable, is a directory, is
+106
View File
@@ -0,0 +1,106 @@
//! Verifies background-job root spans (#142 / map #135).
//!
//! Background work has no request parent, so each job opens its own root
//! span and must carry it into whatever concurrency it fans out to. Two
//! production shapes are pinned here:
//!
//! 1. **Watcher tick → per-pass runtime.** `watcher::watch_files` runs on a
//! plain std thread and enters a `watcher.tick` span; `face_watch` /
//! `clip_watch` then build a multi-thread runtime, `block_on` it, and
//! `tokio::spawn` one task per candidate. Each task must carry the
//! tick's span via `.instrument(parent.clone())`.
//! 2. **Rayon fan-out.** `thumbnails::create_thumbnails` walks files on
//! rayon workers, where the ambient span isn't current, so child spans
//! name their parent explicitly (`info_span!(parent: &span, ...)`).
//!
//! One process-global subscriber per test binary, so this file holds a
//! single test.
use opentelemetry::trace::{SpanId, TracerProvider};
use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider, SpanData};
use rayon::prelude::*;
use tracing::Instrument;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::Registry;
use tracing_subscriber::util::SubscriberInitExt;
fn find<'a>(spans: &'a [SpanData], name: &str) -> &'a SpanData {
spans
.iter()
.find(|s| s.name == name)
.unwrap_or_else(|| panic!("span {name} not exported"))
}
#[tracing::instrument(name = "test.detect_one", skip_all)]
async fn detect_one() {
tokio::task::yield_now().await;
}
#[test]
fn background_work_nests_under_job_root() {
let exporter = InMemorySpanExporter::default();
let provider = SdkTracerProvider::builder()
.with_simple_exporter(exporter.clone())
.build();
Registry::default()
.with(tracing_opentelemetry::OpenTelemetryLayer::new(
provider.tracer("background-test"),
))
.try_init()
.expect("global subscriber not yet set");
// Shape 1: std-thread tick with an entered root, per-pass runtime, spawns.
std::thread::spawn(|| {
let _tick = tracing::info_span!("test.tick").entered();
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.unwrap();
let parent = tracing::Span::current();
rt.block_on(async move {
let handles: Vec<_> = (0..3)
.map(|_| tokio::spawn(detect_one().instrument(parent.clone())))
.collect();
for h in handles {
h.await.unwrap();
}
});
})
.join()
.unwrap();
// Shape 2: rayon fan-out with an explicit parent.
{
let root = tracing::info_span!("test.thumbnails");
(0..4).into_par_iter().for_each(|_| {
let _child = tracing::info_span!(parent: &root, "test.thumbnail_one");
});
}
let _ = provider.force_flush();
let spans = exporter.get_finished_spans().unwrap();
let tick = find(&spans, "test.tick");
assert_eq!(tick.parent_span_id, SpanId::INVALID, "tick is a root span");
let detects: Vec<_> = spans
.iter()
.filter(|s| s.name == "test.detect_one")
.collect();
assert_eq!(detects.len(), 3);
for d in detects {
assert_eq!(d.span_context.trace_id(), tick.span_context.trace_id());
assert_eq!(d.parent_span_id, tick.span_context.span_id());
}
let root = find(&spans, "test.thumbnails");
let children: Vec<_> = spans
.iter()
.filter(|s| s.name == "test.thumbnail_one")
.collect();
assert_eq!(children.len(), 4);
for c in children {
assert_eq!(c.span_context.trace_id(), root.span_context.trace_id());
assert_eq!(c.parent_span_id, root.span_context.span_id());
}
}
+5 -2
View File
@@ -14,8 +14,8 @@
//! One process-global subscriber per test binary, so this file holds a //! One process-global subscriber per test binary, so this file holds a
//! single test. //! single test.
use opentelemetry::trace::{Status, TracerProvider};
use opentelemetry::Value; use opentelemetry::Value;
use opentelemetry::trace::{Status, TracerProvider};
use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider, SpanData}; use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider, SpanData};
use tracing::Instrument; use tracing::Instrument;
use tracing_opentelemetry::OpenTelemetrySpanExt; use tracing_opentelemetry::OpenTelemetrySpanExt;
@@ -85,7 +85,10 @@ fn db_spans_nest_under_ambient_span() {
let del = find(&spans, "db.delete.remove_tag"); let del = find(&spans, "db.delete.remove_tag");
for (span, qt, op) in [(get, "query", "get_tags"), (del, "delete", "remove_tag")] { for (span, qt, op) in [(get, "query", "get_tags"), (del, "delete", "remove_tag")] {
assert_eq!(span.span_context.trace_id(), handler.span_context.trace_id()); assert_eq!(
span.span_context.trace_id(),
handler.span_context.trace_id()
);
assert_eq!( assert_eq!(
span.parent_span_id, span.parent_span_id,
handler.span_context.span_id(), handler.span_context.span_id(),