collective

Browse / github.com/storytold/artcraft-services / patch

Investigate Sora latency by plan (#1069 metrics)

Investigate Sora latency by plan (#1069 metrics) ## Summary - Add Datadog metrics `artcraft.sora.enqueue` and `artcraft.sora.job_duration_ms` tagged by `plan`, `model`, `resolution`, `duration_s`, and `outcome` for `sora_2` / `sora_2_pro`. - Cover omni-gen Fal enqueue plus legacy multi-function Sora handlers, and Fal webhook success/failure completion. - Document suggested Datadog queries in `_docs/sora_latency_investigation_1069.md`. **No behavior change.** Sora models remain `is_disabled`. Fix PR only after admin reviews the metrics (artcraft [#1069](https://github.com/storytold/artcraft/issues/1069)). Companion desktop PR: opened agains

Status
open
Score
0.00
Adopters
0 other operators
Author
giorgioabbruscato (imported from pull request #139, open)
Base
3048db996de98af0188d1e3d2db6af8f473faf3f
Size
+456 −3 in 12 files
Review
pending
Shared
2026-10-28
Id
114fb0e018c7292ac41b0d20

How it went

No other operator has reported using it yet.

Diff

raw · _docs/sora_latency_investigation_1069.md crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/sora_2_multi_function_video_gen_handler.rs crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/sora_2_pro_multi_function_video_gen_handler.rs crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/shared_video_generation.rs crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/support.rs crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/mod.rs crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/sora_latency_metrics.rs crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_failure/handle_failed_fal_webhook.rs crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/handle_successful_fal_webhook.rs crates/service/web/storyteller_web/src/main.rs crates/service/web/storyteller_web/src/startup/build_dependencies.rs crates/service/web/storyteller_web/src/state/server_state.rs

diff --git a/_docs/sora_latency_investigation_1069.md b/_docs/sora_latency_investigation_1069.mdnew file mode 100644--- /dev/null+++ b/_docs/sora_latency_investigation_1069.md@@ -0,0 +1,67 @@+# Sora latency investigation (#1069)++Investigation-only instrumentation for+[storytold/artcraft#1069](https://github.com/storytold/artcraft/issues/1069):+users reporting longer Sora times after upgrading to Basic or Pro.++**No product behavior change.** Sora models remain `is_disabled` until a+separate fix PR is approved after reviewing these metrics.++## Metrics (Datadog)++| Metric                         | Type         | When                                      |+|--------------------------------|--------------|-------------------------------------------|+| `artcraft.sora.enqueue`        | count        | Successful Fal/DB enqueue for Sora jobs   |+| `artcraft.sora.job_duration_ms`| distribution | Fal webhook success/failure for Sora jobs |++### Tags (low cardinality)++| Tag           | Values                                                              |+|---------------|---------------------------------------------------------------------|+| `model`       | `sora_2`, `sora_2_pro`                                              |+| `plan`        | `free`, `artcraft_basic`, `artcraft_pro`, `artcraft_max`, `unknown` |+| `resolution`  | `720p`, `1080p`, `other`                                            |+| `duration_s`  | `4`, `8`, `12`, `other`                                             |+| `outcome`     | `success`, `failure` (duration metric only)                         |++`plan` comes from the user's Artcraft subscription row (`free` when none).+Completion events may use `resolution:other` / `duration_s:other` when those+fields are not cheaply available on the Fal job row.++## Suggested Datadog queries++Compare paid vs free wall-clock:++```text+p50:artcraft.sora.job_duration_ms{outcome:success} by {plan,model}+p90:artcraft.sora.job_duration_ms{outcome:success} by {plan,model}+```++Enqueue volume after upgrades (are paid users submitting more?):++```text+sum:artcraft.sora.enqueue{*}.as_count() by {plan,model}+```++Filter to a single tier:++```text+p90:artcraft.sora.job_duration_ms{plan:artcraft_basic,outcome:success} by {model,resolution,duration_s}+p90:artcraft.sora.job_duration_ms{plan:free,outcome:success} by {model,resolution,duration_s}+```++## Code touchpoints++- Tag helpers / recorders:+  `crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/sora_latency_metrics.rs`+- Omni enqueue: `shared_video_generation.rs` (Fal branch)+- Legacy multi-function enqueue: `sora_2_*_multi_function_video_gen_handler.rs`+- Completion: Fal webhook success/failure handlers++## Follow-up++1. Deploy with `ENABLE_METRICS=true` and a Datadog API key.+2. Collect enough Sora samples (may require temporary staging enablement while+   catalog `is_disabled` remains true in production).+3. Summarize p50/p90 by `plan` for an admin.+4. Open a **separate** fix PR only after admin approval.diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/sora_2_multi_function_video_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/sora_2_multi_function_video_gen_handler.rs--- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/sora_2_multi_function_video_gen_handler.rs+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/sora_2_multi_function_video_gen_handler.rs@@ -382,6 +382,29 @@ pub async fn sora_2_multi_function_video_gen_handler(       return Err(CommonWebError::from_error(err));     }   };++  // Issue #1069 investigation: legacy multi-function Sora enqueue (no behavior change).+  {+    use crate::http_server::endpoints::omni_gen::shared_utils::sora_latency_metrics::{+      duration_tag, record_sora_enqueue, resolution_tag, resolve_plan_tag_for_user_in_transaction,+    };+    use enums::common::generation::common_resolution::CommonResolution;++    let plan = resolve_plan_tag_for_user_in_transaction(user_token, &mut transaction).await;+    let duration_seconds = request.duration.as_ref().map(|d| match d {+      Sora2MultiFunctionVideoGenDuration::FourSeconds => 4u16,+      Sora2MultiFunctionVideoGenDuration::EightSeconds => 8,+      Sora2MultiFunctionVideoGenDuration::TwelveSeconds => 12,+    });+    let resolution = Some(CommonResolution::SevenTwentyP);+    record_sora_enqueue(+      &server_state.metrics,+      "sora_2",+      plan,+      resolution_tag(resolution),+      duration_tag(duration_seconds),+    );+  }      let _r = transaction       .commit()diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/sora_2_pro_multi_function_video_gen_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/sora_2_pro_multi_function_video_gen_handler.rs--- a/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/sora_2_pro_multi_function_video_gen_handler.rs+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/generate/video/multi_function/sora_2_pro_multi_function_video_gen_handler.rs@@ -384,6 +384,34 @@ pub async fn sora_2_pro_multi_function_video_gen_handler(       return Err(CommonWebError::from_error(err));     }   };++  // Issue #1069 investigation: legacy multi-function Sora Pro enqueue (no behavior change).+  {+    use crate::http_server::endpoints::omni_gen::shared_utils::sora_latency_metrics::{+      duration_tag, record_sora_enqueue, resolution_tag, resolve_plan_tag_for_user_in_transaction,+    };+    use enums::common::generation::common_resolution::CommonResolution;++    let plan = resolve_plan_tag_for_user_in_transaction(user_token, &mut transaction).await;+    let duration_seconds = request.duration.as_ref().map(|d| match d {+      Sora2ProMultiFunctionVideoGenDuration::FourSeconds => 4u16,+      Sora2ProMultiFunctionVideoGenDuration::EightSeconds => 8,+      Sora2ProMultiFunctionVideoGenDuration::TwelveSeconds => 12,+    });+    let resolution = match request.resolution {+      Some(Sora2ProMultiFunctionVideoGenResolution::TenEightyP) => Some(CommonResolution::TenEightyP),+      Some(Sora2ProMultiFunctionVideoGenResolution::SevenTwentyP)+      | Some(Sora2ProMultiFunctionVideoGenResolution::Auto)+      | None => Some(CommonResolution::SevenTwentyP),+    };+    record_sora_enqueue(+      &server_state.metrics,+      "sora_2_pro",+      plan,+      resolution_tag(resolution),+      duration_tag(duration_seconds),+    );+  }      let _r = transaction       .commit()diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/shared_video_generation.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/shared_video_generation.rs--- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/shared_video_generation.rs+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/shared_video_generation.rs@@ -58,6 +58,10 @@ use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::ins use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs; use crate::http_server::endpoints::omni_gen::generate::video::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args}; use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;+use crate::http_server::endpoints::omni_gen::shared_utils::sora_latency_metrics::{+  duration_tag, model_tag_from_video_model, record_sora_enqueue, resolution_tag,+  resolve_plan_tag_for_user_in_transaction,+}; use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; use crate::state::server_state::ServerState; use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map;@@ -394,6 +398,18 @@ server_state.maybe_media_cdn_override_url.as_deref(),         },       }).await?; +      // Issue #1069 investigation: Sora enqueue volume by plan (no behavior change).+      if let Some(model) = request.model.and_then(model_tag_from_video_model) {+        let plan = resolve_plan_tag_for_user_in_transaction(user_token, &mut transaction).await;+        record_sora_enqueue(+          &server_state.metrics,+          model,+          plan,+          resolution_tag(request.resolution),+          duration_tag(request.duration_seconds),+        );+      }+       (         token.clone(),          vec![token],diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/support.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/support.rs--- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/support.rs+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/tests/support.rs@@ -735,6 +735,7 @@ pub(crate) fn build_test_server_state(pool: MySqlPool, media_cdn_override_url: S         daily_generations_id: None,       },     },+    metrics: metrics::collector::MetricsCollector::noop(),   } } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/mod.rs--- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/mod.rs+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/mod.rs@@ -3,5 +3,6 @@ pub mod kinovi_account; pub mod map_router_cost_error; pub mod map_kinovi_web_router_error; pub mod mesh;+pub mod sora_latency_metrics; pub mod splat; pub mod video;diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/sora_latency_metrics.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/sora_latency_metrics.rsnew file mode 100644--- /dev/null+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/shared_utils/sora_latency_metrics.rs@@ -0,0 +1,283 @@+//! Investigation metrics for GitHub issue #1069 (Sora latency by plan).+//!+//! Emits Datadog counters/distributions only — no behavior change.+//! See `_docs/sora_latency_investigation_1069.md`.++use chrono::{DateTime, Utc};+use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug;+use enums::common::generation::common_model_type::CommonModelType;+use enums::common::generation::common_resolution::CommonResolution;+use enums::common::generation::common_video_model::CommonVideoModel;+use enums::common::payments_namespace::PaymentsNamespace;+use log::warn;+use metrics::collector::MetricsCollector;+use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::{+  find_subscription_for_owner_user_using_connection,+  find_subscription_for_owner_user_using_transaction,+};+use sqlx::pool::PoolConnection;+use sqlx::MySql;+use tokens::tokens::generic_inference_jobs::InferenceJobToken;+use tokens::tokens::users::UserToken;++pub const METRIC_ENQUEUE: &str = "artcraft.sora.enqueue";+pub const METRIC_JOB_DURATION_MS: &str = "artcraft.sora.job_duration_ms";++/// Bucketed plan tag for Datadog (low cardinality).+pub fn plan_tag_from_product_slug(maybe_slug: Option<&str>) -> &'static str {+  match maybe_slug {+    None => "free",+    Some(slug) => match ArtcraftSubscriptionSlug::from_str(slug) {+      Ok(ArtcraftSubscriptionSlug::ArtcraftBasic) => "artcraft_basic",+      Ok(ArtcraftSubscriptionSlug::ArtcraftPro) => "artcraft_pro",+      Ok(ArtcraftSubscriptionSlug::ArtcraftMax) => "artcraft_max",+      Err(_) => "unknown",+    },+  }+}++pub fn model_tag_from_video_model(model: CommonVideoModel) -> Option<&'static str> {+  match model {+    CommonVideoModel::Sora2 => Some("sora_2"),+    CommonVideoModel::Sora2Pro => Some("sora_2_pro"),+    _ => None,+  }+}++pub fn model_tag_from_model_type_str(maybe_model_type: Option<&str>) -> Option<&'static str> {+  match maybe_model_type {+    Some(s) => match CommonModelType::from_str(s) {+      Ok(CommonModelType::Sora2) => Some("sora_2"),+      Ok(CommonModelType::Sora2Pro) => Some("sora_2_pro"),+      _ => None,+    },+    None => None,+  }+}++pub fn resolution_tag(maybe_resolution: Option<CommonResolution>) -> &'static str {+  match maybe_resolution {+    Some(CommonResolution::SevenTwentyP) => "720p",+    Some(CommonResolution::TenEightyP) => "1080p",+    Some(_) | None => "other",+  }+}++pub fn duration_tag(maybe_duration_seconds: Option<u16>) -> &'static str {+  match maybe_duration_seconds {+    Some(4) => "4",+    Some(8) => "8",+    Some(12) => "12",+    _ => "other",+  }+}++fn build_tags(+  model: &str,+  plan: &str,+  resolution: &str,+  duration_s: &str,+  maybe_outcome: Option<&str>,+) -> Vec<String> {+  let mut tags = vec![+    format!("model:{}", model),+    format!("plan:{}", plan),+    format!("resolution:{}", resolution),+    format!("duration_s:{}", duration_s),+  ];+  if let Some(outcome) = maybe_outcome {+    tags.push(format!("outcome:{}", outcome));+  }+  tags+}++pub fn record_sora_enqueue(+  collector: &MetricsCollector,+  model: &str,+  plan: &str,+  resolution: &str,+  duration_s: &str,+) {+  collector.increment(+    METRIC_ENQUEUE,+    build_tags(model, plan, resolution, duration_s, None),+  );+}++pub fn record_sora_job_duration_ms(+  collector: &MetricsCollector,+  model: &str,+  plan: &str,+  resolution: &str,+  duration_s: &str,+  outcome: &str,+  duration_ms: f64,+) {+  collector.observe(+    METRIC_JOB_DURATION_MS,+    duration_ms,+    build_tags(model, plan, resolution, duration_s, Some(outcome)),+  );+}++fn plan_tag_from_lookup_result(+  user_token: &UserToken,+  result: Result<+    Option<mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::UserSubscription>,+    mysql_queries::errors::select_optional_record_error::SelectOptionalRecordError,+  >,+) -> &'static str {+  match result {+    Ok(Some(sub)) => plan_tag_from_product_slug(Some(sub.subscription_product_slug.as_str())),+    Ok(None) => "free",+    Err(err) => {+      warn!(+        "Sora latency metrics: failed to look up subscription for {}: {:?}",+        user_token.as_str(),+        err+      );+      "unknown"+    }+  }+}++/// Resolve Artcraft plan slug for metrics (`free` when no active subscription).+pub async fn resolve_plan_tag_for_user(+  user_token: &UserToken,+  mysql_connection: &mut PoolConnection<MySql>,+) -> &'static str {+  plan_tag_from_lookup_result(+    user_token,+    find_subscription_for_owner_user_using_connection(+      user_token,+      PaymentsNamespace::Artcraft,+      mysql_connection,+    )+    .await,+  )+}++/// Same as [`resolve_plan_tag_for_user`] but on an open transaction (enqueue path).+pub async fn resolve_plan_tag_for_user_in_transaction(+  user_token: &UserToken,+  transaction: &mut sqlx::Transaction<'_, MySql>,+) -> &'static str {+  plan_tag_from_lookup_result(+    user_token,+    find_subscription_for_owner_user_using_transaction(+      user_token,+      PaymentsNamespace::Artcraft,+      transaction,+    )+    .await,+  )+}++/// Runtime (non-macro) lookup so we do not need a new SQLx prepare entry.+pub async fn fetch_maybe_model_type_for_job(+  job_token: &InferenceJobToken,+  mysql_connection: &mut PoolConnection<MySql>,+) -> Option<String> {+  let result: Result<Option<String>, sqlx::Error> = sqlx::query_scalar(+    "SELECT maybe_model_type FROM generic_inference_jobs WHERE token = ?",+  )+  .bind(job_token.as_str())+  .fetch_optional(&mut **mysql_connection)+  .await;++  match result {+    Ok(value) => value,+    Err(err) => {+      warn!(+        "Sora latency metrics: failed to load maybe_model_type for {}: {:?}",+        job_token.as_str(),+        err+      );+      None+    }+  }+}++pub fn job_duration_ms_since(created_at: DateTime<Utc>) -> f64 {+  let now = Utc::now();+  let millis = (now - created_at).num_milliseconds();+  if millis < 0 {+    0.0+  } else {+    millis as f64+  }+}++/// Best-effort completion metric for Fal webhooks on Sora jobs.+pub async fn maybe_record_sora_fal_job_completion(+  collector: &MetricsCollector,+  job_token: &InferenceJobToken,+  maybe_creator_user_token: Option<&UserToken>,+  created_at: DateTime<Utc>,+  outcome: &str,+  mysql_connection: &mut PoolConnection<MySql>,+) {+  let maybe_model_type = fetch_maybe_model_type_for_job(job_token, mysql_connection).await;+  let Some(model) = model_tag_from_model_type_str(maybe_model_type.as_deref()) else {+    return;+  };++  let plan = match maybe_creator_user_token {+    Some(user_token) => resolve_plan_tag_for_user(user_token, mysql_connection).await,+    None => "unknown",+  };++  record_sora_job_duration_ms(+    collector,+    model,+    plan,+    "other",+    "other",+    outcome,+    job_duration_ms_since(created_at),+  );+}++#[cfg(test)]+mod tests {+  use super::*;++  #[test]+  fn plan_tag_maps_known_slugs_and_defaults() {+    assert_eq!(plan_tag_from_product_slug(None), "free");+    assert_eq!(plan_tag_from_product_slug(Some("artcraft_basic")), "artcraft_basic");+    assert_eq!(plan_tag_from_product_slug(Some("artcraft_pro")), "artcraft_pro");+    assert_eq!(plan_tag_from_product_slug(Some("artcraft_max")), "artcraft_max");+    assert_eq!(plan_tag_from_product_slug(Some("fakeyou_plus")), "unknown");+  }++  #[test]+  fn model_tag_only_for_sora() {+    assert_eq!(model_tag_from_video_model(CommonVideoModel::Sora2), Some("sora_2"));+    assert_eq!(model_tag_from_video_model(CommonVideoModel::Sora2Pro), Some("sora_2_pro"));+    assert_eq!(model_tag_from_video_model(CommonVideoModel::Kling2p6Pro), None);+    assert_eq!(model_tag_from_model_type_str(Some("sora_2")), Some("sora_2"));+    assert_eq!(model_tag_from_model_type_str(Some("sora_2_pro")), Some("sora_2_pro"));+    assert_eq!(model_tag_from_model_type_str(Some("kling_2p6_pro")), None);+    assert_eq!(model_tag_from_model_type_str(None), None);+  }++  #[test]+  fn resolution_and_duration_buckets() {+    assert_eq!(resolution_tag(Some(CommonResolution::SevenTwentyP)), "720p");+    assert_eq!(resolution_tag(Some(CommonResolution::TenEightyP)), "1080p");+    assert_eq!(resolution_tag(Some(CommonResolution::FourEightyP)), "other");+    assert_eq!(resolution_tag(None), "other");+    assert_eq!(duration_tag(Some(4)), "4");+    assert_eq!(duration_tag(Some(8)), "8");+    assert_eq!(duration_tag(Some(12)), "12");+    assert_eq!(duration_tag(Some(5)), "other");+    assert_eq!(duration_tag(None), "other");+  }++  #[test]+  fn job_duration_non_negative() {+    let past = Utc::now() - chrono::Duration::seconds(3);+    assert!(job_duration_ms_since(past) >= 0.0);+  }+}diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_failure/handle_failed_fal_webhook.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_failure/handle_failed_fal_webhook.rs--- a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_failure/handle_failed_fal_webhook.rs+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_failure/handle_failed_fal_webhook.rs@@ -1,4 +1,5 @@ use crate::http_server::common_responses::common_web_error::CommonWebError;+use crate::http_server::endpoints::omni_gen::shared_utils::sora_latency_metrics::maybe_record_sora_fal_job_completion; use crate::state::server_state::ServerState; use actix_web::web::Json; use enums::by_table::debug_logs::debug_log_level::DebugLogLevel;@@ -116,6 +117,16 @@ pub async fn handle_failed_fal_webhook(     request_id,   ); +  // Issue #1069 investigation: Sora wall-clock by plan (no behavior change).+  maybe_record_sora_fal_job_completion(+    &server_state.metrics,+    &job.job_token,+    job.maybe_creator_user_token.as_ref(),+    job.created_at,+    "failure",+    mysql_connection,+  ).await;+   Ok(SimpleGenericJsonSuccess::wrapped(true)) } diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/handle_successful_fal_webhook.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/handle_successful_fal_webhook.rs--- a/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/handle_successful_fal_webhook.rs+++ b/crates/service/web/storyteller_web/src/http_server/endpoints/webhooks/fal/process_success/handle_successful_fal_webhook.rs@@ -11,6 +11,7 @@ use pager::client::pager::Pager; use sqlx::pool::PoolConnection; use sqlx::MySql; use crate::http_server::common_responses::common_web_error::CommonWebError;+use crate::http_server::endpoints::omni_gen::shared_utils::sora_latency_metrics::maybe_record_sora_fal_job_completion; use crate::state::server_state::ServerState;  use super::process_audio_payload::process_audio_payload;@@ -177,5 +178,15 @@ pub async fn handle_successful_fal_webhook(     warn!("No media token found in payload for request_id {} / job {:?}", request_id, job.job_token);   } +  // Issue #1069 investigation: Sora wall-clock by plan (no behavior change).+  maybe_record_sora_fal_job_completion(+    &server_state.metrics,+    &job.job_token,+    job.maybe_creator_user_token.as_ref(),+    job.created_at,+    "success",+    mysql_connection,+  ).await;+   Ok(SimpleGenericJsonSuccess::wrapped(true)) }diff --git a/crates/service/web/storyteller_web/src/main.rs b/crates/service/web/storyteller_web/src/main.rs--- a/crates/service/web/storyteller_web/src/main.rs+++ b/crates/service/web/storyteller_web/src/main.rs@@ -53,7 +53,6 @@ use crate::http_server::routes::add_routes::add_routes; use crate::http_server::web_utils::handle_multipart_error::handle_multipart_error; use crate::startup::build_dependencies::setup_dependencies; use crate::startup::setup_disabled_endpoints::read_disabled_endpoints;-use crate::startup::setup_metrics::build_metrics; use crate::state::server_state::ServerState; use crate::threads::db_health_checker_thread::db_health_checker_thread::db_health_checker_thread; use crate::threads::expire_login_challenges::expire_login_challenges;@@ -109,6 +108,7 @@ async fn main() -> AnyhowResult<()> {   let server_state = setup.server_state;   let health_check_interval = setup.health_check_interval;   let pager_worker = setup.pager_worker;+  let maybe_metrics_worker = setup.maybe_metrics_worker;    // ==================== Background threads ==================== // @@ -168,8 +168,7 @@ async fn main() -> AnyhowResult<()> {    // ==================== Metrics worker ==================== // -  let (metrics_collector, maybe_metrics_worker) =-    build_metrics(server_state.server_environment, &server_state.hostname);+  let metrics_collector = server_state.metrics.clone();    let maybe_metrics_shutdown = maybe_metrics_worker.map(|worker| {     info!("Spawning metrics worker thread.");diff --git a/crates/service/web/storyteller_web/src/startup/build_dependencies.rs b/crates/service/web/storyteller_web/src/startup/build_dependencies.rs--- a/crates/service/web/storyteller_web/src/startup/build_dependencies.rs+++ b/crates/service/web/storyteller_web/src/startup/build_dependencies.rs@@ -15,6 +15,7 @@ use memory_caching::single_item_ttl_cache::SingleItemTtlCache; use mysql_queries::mediators::badge_granter::BadgeGranter; use mysql_queries::mediators::firehose_publisher::FirehosePublisher; use crate::http_server::web_utils::web_opaque_cursor_encoder_v2::WebOpaqueCursorEncoderV2;+use metrics::worker::MetricsWorker; use pager::worker::pager_worker::PagerWorker; use redis::Client; use redis_caching::redis_ttl_cache::RedisTtlCache;@@ -36,6 +37,7 @@ use crate::startup::setup_bans::{ use crate::startup::setup_inference_providers::setup_inference_providers; use crate::startup::setup_internal_api_keys::setup_internal_api_keys; use crate::startup::setup_dashboards::setup_dashboards;+use crate::startup::setup_metrics::build_metrics; use crate::startup::setup_seedance_video_bucket::setup_seedance_video_bucket; use crate::startup::setup_static_feature_flags::setup_static_feature_flags; use crate::startup::setup_stripe_artcraft::setup_stripe_artcraft;@@ -65,6 +67,8 @@ pub struct SetupResult {   pub server_state: ServerState,   pub pager_worker: PagerWorker,   pub health_check_interval: Duration,+  /// Present when Datadog metrics are enabled; spawn and drain on shutdown.+  pub maybe_metrics_worker: Option<MetricsWorker>, }  pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult<SetupResult> {@@ -199,6 +203,9 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult<SetupResu    let startup_time = Utc::now(); +  let (metrics_collector, maybe_metrics_worker) =+    build_metrics(server_environment, server_hostname);+   let server_state = ServerState {     env_config: EnvConfig {       num_workers,@@ -269,12 +276,14 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult<SetupResu     temp_dir_creator: ScopedTempDirCreator::auto_setup(),     google_sign_in_cert: GoogleSignInCert::new(),     dashboards: setup_dashboards(),+    metrics: metrics_collector,   };    Ok(SetupResult {     server_state,     pager_worker,     health_check_interval,+    maybe_metrics_worker,   }) } diff --git a/crates/service/web/storyteller_web/src/state/server_state.rs b/crates/service/web/storyteller_web/src/state/server_state.rs--- a/crates/service/web/storyteller_web/src/state/server_state.rs+++ b/crates/service/web/storyteller_web/src/state/server_state.rs@@ -36,6 +36,7 @@ use mysql_queries::queries::generic_inference::web::get_pending_inference_job_co use mysql_queries::queries::media_files::list::list_featured_media_files::FeaturedMediaFileListPage; use mysql_queries::queries::model_categories::list_categories_query_builder::CategoryList; use crate::http_server::web_utils::web_opaque_cursor_encoder_v2::WebOpaqueCursorEncoderV2;+use metrics::collector::MetricsCollector; use pager::client::pager::Pager; use redis::Client; use redis_caching::redis_ttl_cache::RedisTtlCache;@@ -135,6 +136,9 @@ pub struct ServerState {   /// Embedded metrics dashboards for the admin dashboard, configured from   /// optional env vars at startup.   pub dashboards: Dashboards,++  /// Datadog metrics collector (noop when metrics are disabled).+  pub metrics: MetricsCollector, }  #[derive(Clone)]