From e38b316aad856eee0c0983315e672842f19e13e4 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 04:23:42 +0000 Subject: [PATCH] feat: Add OTel auto-instrumentation for Python and TypeScript scripts (EE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add OpenTelemetry auto-instrumentation as an enterprise feature that automatically instruments Python and TypeScript scripts to collect traces. Backend changes: - Add database migration for job_otel_traces table to store trace spans - Add otel_auto_instrumentation_oss.rs with OSS stub implementation - Inject OTel environment variables in Python, Bun, and Deno executors - Add API endpoint GET /api/w/{workspace}/jobs/get_otel_traces/{id} - Add OTEL_AUTO_INSTRUMENTATION_SETTING constant Frontend changes: - Add OTel auto-instrumentation settings in instance settings (OTEL/Prom tab) - Add JobOtelTraces.svelte component for viewing traces - Add "Traces" tab to job details page When enabled, scripts using OpenTelemetry libraries will automatically send traces to a built-in collector. Traces are stored in the database and can be viewed in the job details. Closes #7512 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../20260107000000_job_otel_traces.down.sql | 9 + .../20260107000000_job_otel_traces.up.sql | 28 ++ backend/windmill-api/src/jobs.rs | 81 ++++++ .../windmill-common/src/global_settings.rs | 1 + backend/windmill-worker/src/bun_executor.rs | 24 ++ backend/windmill-worker/src/deno_executor.rs | 22 ++ backend/windmill-worker/src/lib.rs | 3 + .../src/otel_auto_instrumentation_oss.rs | 108 ++++++++ .../windmill-worker/src/python_executor.rs | 23 ++ .../src/lib/components/InstanceSetting.svelte | 49 ++++ .../src/lib/components/JobOtelTraces.svelte | 254 ++++++++++++++++++ .../src/lib/components/instanceSettings.ts | 11 +- .../(root)/(logged)/run/[...run]/+page.svelte | 8 +- 13 files changed, 619 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/20260107000000_job_otel_traces.down.sql create mode 100644 backend/migrations/20260107000000_job_otel_traces.up.sql create mode 100644 backend/windmill-worker/src/otel_auto_instrumentation_oss.rs create mode 100644 frontend/src/lib/components/JobOtelTraces.svelte diff --git a/backend/migrations/20260107000000_job_otel_traces.down.sql b/backend/migrations/20260107000000_job_otel_traces.down.sql new file mode 100644 index 0000000000..e6a9f97dce --- /dev/null +++ b/backend/migrations/20260107000000_job_otel_traces.down.sql @@ -0,0 +1,9 @@ +-- Drop indexes +DROP INDEX IF EXISTS idx_job_otel_traces_job_workspace; +DROP INDEX IF EXISTS idx_job_otel_traces_created_at; +DROP INDEX IF EXISTS idx_job_otel_traces_trace_id; +DROP INDEX IF EXISTS idx_job_otel_traces_workspace_id; +DROP INDEX IF EXISTS idx_job_otel_traces_job_id; + +-- Drop table +DROP TABLE IF EXISTS job_otel_traces; diff --git a/backend/migrations/20260107000000_job_otel_traces.up.sql b/backend/migrations/20260107000000_job_otel_traces.up.sql new file mode 100644 index 0000000000..c20fc55053 --- /dev/null +++ b/backend/migrations/20260107000000_job_otel_traces.up.sql @@ -0,0 +1,28 @@ +-- Add table to store OTel traces from auto-instrumented scripts +CREATE TABLE IF NOT EXISTS job_otel_traces ( + id BIGSERIAL PRIMARY KEY, + job_id UUID NOT NULL, + workspace_id VARCHAR(50) NOT NULL, + trace_id VARCHAR(32) NOT NULL, + span_id VARCHAR(16) NOT NULL, + parent_span_id VARCHAR(16), + operation_name VARCHAR(255) NOT NULL, + service_name VARCHAR(255), + start_time_unix_nano BIGINT NOT NULL, + end_time_unix_nano BIGINT NOT NULL, + duration_ns BIGINT GENERATED ALWAYS AS (end_time_unix_nano - start_time_unix_nano) STORED, + status_code SMALLINT DEFAULT 0, + status_message TEXT, + attributes JSONB DEFAULT '{}', + events JSONB DEFAULT '[]', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Create indexes for efficient querying +CREATE INDEX IF NOT EXISTS idx_job_otel_traces_job_id ON job_otel_traces(job_id); +CREATE INDEX IF NOT EXISTS idx_job_otel_traces_workspace_id ON job_otel_traces(workspace_id); +CREATE INDEX IF NOT EXISTS idx_job_otel_traces_trace_id ON job_otel_traces(trace_id); +CREATE INDEX IF NOT EXISTS idx_job_otel_traces_created_at ON job_otel_traces(created_at); + +-- Composite index for common query patterns +CREATE INDEX IF NOT EXISTS idx_job_otel_traces_job_workspace ON job_otel_traces(job_id, workspace_id); diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index c9950c3c89..782db9f75a 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -373,6 +373,7 @@ pub fn workspace_unauthed_service() -> Router { get(get_completed_job_logs_tail), ) .route("/get_args/:id", get(get_args)) + .route("/get_otel_traces/:id", get(get_job_otel_traces)) .route("/queue/get_started_at_by_ids", post(get_started_at_by_ids)) .route("/get_flow_debug_info/:id", get(get_flow_job_debug_info)) .route("/completed/get/:id", get(get_completed_job)) @@ -1694,6 +1695,86 @@ async fn get_args( } } +/// OTel trace span returned from the API +#[derive(Debug, Serialize, Deserialize)] +pub struct OtelTraceSpan { + pub trace_id: String, + pub span_id: String, + pub parent_span_id: Option, + pub operation_name: String, + pub service_name: Option, + pub start_time_unix_nano: i64, + pub end_time_unix_nano: i64, + pub duration_ns: i64, + pub status_code: i16, + pub status_message: Option, + pub attributes: serde_json::Value, + pub events: serde_json::Value, +} + +async fn get_job_otel_traces( + OptAuthed(opt_authed): OptAuthed, + Extension(db): Extension, + Path((w_id, id)): Path<(String, Uuid)>, +) -> JsonResult> { + // Check if user has access to view job (similar to get_args) + // Use raw SQL query since this is a simple check + let job_record: Option<(String,)> = sqlx::query_as( + "SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2", + ) + .bind(id) + .bind(&w_id) + .fetch_optional(&db) + .await?; + + if let Some(record) = job_record { + if opt_authed.is_none() && record.0 != "anonymous" { + return Err(Error::BadRequest( + "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), + )); + } + } else { + return Err(Error::NotFound(format!("Job {} not found", id))); + } + + // Fetch OTel traces for this job using raw SQL (table is new, not in sqlx cache) + let traces: Vec<(String, String, Option, String, Option, i64, i64, Option, Option, Option, Option, Option)> = sqlx::query_as( + r#" + SELECT + trace_id, span_id, parent_span_id, operation_name, service_name, + start_time_unix_nano, end_time_unix_nano, duration_ns, + status_code, status_message, attributes, events + FROM job_otel_traces + WHERE job_id = $1 AND workspace_id = $2 + ORDER BY start_time_unix_nano ASC + "#, + ) + .bind(id) + .bind(&w_id) + .fetch_all(&db) + .await?; + + let spans: Vec = traces + .into_iter() + .map(|row| OtelTraceSpan { + trace_id: row.0, + span_id: row.1, + parent_span_id: row.2, + operation_name: row.3, + service_name: row.4, + start_time_unix_nano: row.5, + end_time_unix_nano: row.6, + duration_ns: row.7.unwrap_or(0), + status_code: row.8.unwrap_or(0) as i16, + status_message: row.9, + attributes: row.10.unwrap_or(serde_json::json!({})), + events: row.11.unwrap_or(serde_json::json!([])), + }) + .collect(); + + Ok(Json(spans)) +} + async fn get_started_at_by_ids( Extension(db): Extension, Json(mut ids): Json>, diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index a73f488aae..2f58ab0b9e 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -46,6 +46,7 @@ pub const DEV_INSTANCE_SETTING: &str = "dev_instance"; pub const JWT_SECRET_SETTING: &str = "jwt_secret"; pub const EMAIL_DOMAIN_SETTING: &str = "email_domain"; pub const OTEL_SETTING: &str = "otel"; +pub const OTEL_AUTO_INSTRUMENTATION_SETTING: &str = "otel_auto_instrumentation"; pub const APP_WORKSPACED_ROUTE_SETTING: &str = "app_workspaced_route"; pub const ENV_SETTINGS: &[&str] = &[ diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 404e221486..229101a683 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -51,6 +51,15 @@ use windmill_common::s3_helpers::attempt_fetch_bytes; use windmill_parser::Typ; +#[cfg(feature = "enterprise")] +use crate::otel_auto_instrumentation_ee::{ + get_otel_auto_instrumentation_config, get_otel_typescript_env_vars, +}; +#[cfg(not(feature = "enterprise"))] +use crate::otel_auto_instrumentation_oss::{ + get_otel_auto_instrumentation_config, get_otel_typescript_env_vars, +}; + const RELATIVE_BUN_LOADER: &str = include_str!("../loader.bun.js"); const RELATIVE_BUN_BUILDER: &str = include_str!("../loader_builder.bun.js"); @@ -1356,6 +1365,18 @@ try {{ } append_logs(&job.id, &job.workspace_id, init_logs, conn).await; + // Get OTel auto-instrumentation env vars (EE feature) + let otel_envs: Vec<(String, String)> = if let Connection::Sql(db) = conn { + let otel_config = get_otel_auto_instrumentation_config(db).await; + if otel_config.enabled && otel_config.typescript_enabled { + get_otel_typescript_env_vars(&job.id, &job.workspace_id, job.runnable_path(), &otel_config) + } else { + vec![] + } + } else { + vec![] + }; + //do not cache local dependencies let child = if !*DISABLE_NSJAIL { let _ = write_file( @@ -1417,6 +1438,7 @@ try {{ .envs(envs) .envs(reserved_variables) .envs(common_bun_proc_envs) + .envs(otel_envs.clone()) .env("PATH", PATH_ENV.as_str()) .args(args) .stdout(Stdio::piped()) @@ -1434,6 +1456,7 @@ try {{ .envs(envs) .envs(reserved_variables) .envs(common_bun_proc_envs) + .envs(otel_envs.clone()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -1464,6 +1487,7 @@ try {{ .envs(envs) .envs(reserved_variables) .envs(common_bun_proc_envs) + .envs(otel_envs) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); diff --git a/backend/windmill-worker/src/deno_executor.rs b/backend/windmill-worker/src/deno_executor.rs index e80eec265d..f7116d8c66 100644 --- a/backend/windmill-worker/src/deno_executor.rs +++ b/backend/windmill-worker/src/deno_executor.rs @@ -23,6 +23,15 @@ use windmill_common::{ }; use windmill_parser::Typ; +#[cfg(feature = "enterprise")] +use crate::otel_auto_instrumentation_ee::{ + get_otel_auto_instrumentation_config, get_otel_typescript_env_vars, +}; +#[cfg(not(feature = "enterprise"))] +use crate::otel_auto_instrumentation_oss::{ + get_otel_auto_instrumentation_config, get_otel_typescript_env_vars, +}; + lazy_static::lazy_static! { static ref DENO_FLAGS: Option> = std::env::var("DENO_FLAGS") @@ -357,6 +366,18 @@ try {{ common_deno_proc_envs.insert("HOME".to_string(), job_dir.to_string()); } + // Get OTel auto-instrumentation env vars (EE feature) + let otel_envs: Vec<(String, String)> = if let Connection::Sql(db) = conn { + let otel_config = get_otel_auto_instrumentation_config(db).await; + if otel_config.enabled && otel_config.typescript_enabled { + get_otel_typescript_env_vars(&job.id, &job.workspace_id, job.runnable_path(), &otel_config) + } else { + vec![] + } + } else { + vec![] + }; + //do not cache local dependencies let child = { let reload = format!("--reload={base_internal_url}"); @@ -417,6 +438,7 @@ try {{ .envs(envs) .envs(reserved_variables) .envs(common_deno_proc_envs) + .envs(otel_envs) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); diff --git a/backend/windmill-worker/src/lib.rs b/backend/windmill-worker/src/lib.rs index d211695091..6fa464c842 100644 --- a/backend/windmill-worker/src/lib.rs +++ b/backend/windmill-worker/src/lib.rs @@ -52,6 +52,9 @@ mod oracledb_executor; #[cfg(feature = "private")] pub mod otel_ee; mod otel_oss; +#[cfg(feature = "enterprise")] +pub mod otel_auto_instrumentation_ee; +pub mod otel_auto_instrumentation_oss; mod pg_executor; #[cfg(feature = "php")] mod php_executor; diff --git a/backend/windmill-worker/src/otel_auto_instrumentation_oss.rs b/backend/windmill-worker/src/otel_auto_instrumentation_oss.rs new file mode 100644 index 0000000000..d4df98d537 --- /dev/null +++ b/backend/windmill-worker/src/otel_auto_instrumentation_oss.rs @@ -0,0 +1,108 @@ +//! OTel Auto-Instrumentation Collector - OSS Stub +//! +//! This module provides stub implementations for the OTel auto-instrumentation +//! collector. The actual implementation is in the EE version. + +#[cfg(feature = "enterprise")] +#[allow(unused)] +pub use crate::otel_auto_instrumentation_ee::*; + +use serde::{Deserialize, Serialize}; + +#[cfg(not(feature = "enterprise"))] +use uuid::Uuid; + +/// Configuration for OTel auto-instrumentation +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct OtelAutoInstrumentationConfig { + pub enabled: bool, + pub python_enabled: bool, + pub typescript_enabled: bool, + pub collector_port: u16, +} + +impl OtelAutoInstrumentationConfig { + pub fn default_config() -> Self { + Self { + enabled: false, + python_enabled: true, + typescript_enabled: true, + collector_port: 4318, + } + } +} + +/// OTel span received from auto-instrumented scripts +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OtelSpan { + pub trace_id: String, + pub span_id: String, + pub parent_span_id: Option, + pub operation_name: String, + pub service_name: Option, + pub start_time_unix_nano: i64, + pub end_time_unix_nano: i64, + pub status_code: i16, + pub status_message: Option, + pub attributes: serde_json::Value, + pub events: serde_json::Value, +} + +/// OSS stub: Check if OTel auto-instrumentation is enabled +#[cfg(not(feature = "enterprise"))] +pub async fn is_otel_auto_instrumentation_enabled( + _db: &sqlx::Pool, +) -> bool { + false +} + +/// OSS stub: Get OTel auto-instrumentation config +#[cfg(not(feature = "enterprise"))] +pub async fn get_otel_auto_instrumentation_config( + _db: &sqlx::Pool, +) -> OtelAutoInstrumentationConfig { + OtelAutoInstrumentationConfig::default_config() +} + +/// OSS stub: Get OTel environment variables for Python scripts +#[cfg(not(feature = "enterprise"))] +pub fn get_otel_python_env_vars( + _job_id: &Uuid, + _workspace_id: &str, + _script_path: &str, + _config: &OtelAutoInstrumentationConfig, +) -> Vec<(String, String)> { + vec![] +} + +/// OSS stub: Get OTel environment variables for TypeScript scripts (Bun/Deno) +#[cfg(not(feature = "enterprise"))] +pub fn get_otel_typescript_env_vars( + _job_id: &Uuid, + _workspace_id: &str, + _script_path: &str, + _config: &OtelAutoInstrumentationConfig, +) -> Vec<(String, String)> { + vec![] +} + +/// OSS stub: Store OTel spans in the database +#[cfg(not(feature = "enterprise"))] +pub async fn store_otel_spans( + _db: &sqlx::Pool, + _job_id: &Uuid, + _workspace_id: &str, + _spans: Vec, +) -> anyhow::Result<()> { + Ok(()) +} + +/// OSS stub: Start the built-in OTel collector HTTP server +#[cfg(not(feature = "enterprise"))] +pub async fn start_otel_collector_server( + _db: sqlx::Pool, + _config: OtelAutoInstrumentationConfig, +) -> anyhow::Result<()> { + // No-op in OSS + Ok(()) +} diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 3f64641813..b07937e714 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -41,6 +41,15 @@ use windmill_common::variables::get_secret_value_as_admin; use std::env::var; use windmill_queue::{append_logs, CanceledBy, PrecomputedAgentInfo}; +#[cfg(feature = "enterprise")] +use crate::otel_auto_instrumentation_ee::{ + get_otel_auto_instrumentation_config, get_otel_python_env_vars, +}; +#[cfg(not(feature = "enterprise"))] +use crate::otel_auto_instrumentation_oss::{ + get_otel_auto_instrumentation_config, get_otel_python_env_vars, +}; + use process_wrap::tokio::TokioChildWrapper; lazy_static::lazy_static! { @@ -812,6 +821,18 @@ mount {{ job.id ); + // Get OTel auto-instrumentation env vars (EE feature) + let otel_envs: Vec<(String, String)> = if let Connection::Sql(db) = conn { + let otel_config = get_otel_auto_instrumentation_config(db).await; + if otel_config.enabled && otel_config.python_enabled { + get_otel_python_env_vars(&job.id, &job.workspace_id, &script_path, &otel_config) + } else { + vec![] + } + } else { + vec![] + }; + let child = if !*DISABLE_NSJAIL { let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); nsjail_cmd @@ -820,6 +841,7 @@ mount {{ // inject PYTHONPATH here - for some reason I had to do it in nsjail conf .envs(reserved_variables) .envs(PROXY_ENVS.clone()) + .envs(otel_envs.clone()) .env("PATH", PATH_ENV.as_str()) .env("TZ", TZ_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) @@ -845,6 +867,7 @@ mount {{ .env_clear() .envs(envs) .envs(reserved_variables) + .envs(otel_envs) .env("PATH", PATH_ENV.as_str()) .env("TZ", TZ_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index b1920a04ff..cd9ee051bc 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -830,6 +830,55 @@ --> {/if} + {:else if setting.fieldType == 'otel_auto_instrumentation'} +
+ {#if $values[setting.key]} +
+ +
+ {#if $values[setting.key].enabled} +
+ + +
+
+ + + Port for the built-in OTel collector (default: 4318) +
+ {/if} + {:else} +
+ Click Save to initialize OTel auto-instrumentation settings +
+ {/if} +
{:else if setting.fieldType == 'object_store_config'}
diff --git a/frontend/src/lib/components/JobOtelTraces.svelte b/frontend/src/lib/components/JobOtelTraces.svelte new file mode 100644 index 0000000000..e3b208b5b6 --- /dev/null +++ b/frontend/src/lib/components/JobOtelTraces.svelte @@ -0,0 +1,254 @@ + + +
+ {#if loading} + + {:else if error} + {error} + {:else if traces.length === 0} +
+ +

No traces found

+

+ This job did not generate any OTel traces. Make sure OTel auto-instrumentation is enabled + and the script uses OTel libraries. +

+
+ {:else} +
+
+

Traces ({traces.length} spans)

+ +
+ + +
+
+
+
Operation
+
Status
+
Timeline
+
Duration
+
+
+ +
+ {#each traces as span (span.span_id)} + {@const startOffset = + ((span.start_time_unix_nano - timelineMetrics.minTime) / + timelineMetrics.totalDuration) * + 100} + {@const width = (span.duration_ns / timelineMetrics.totalDuration) * 100} + +
+ + + {#if expandedSpans.has(span.span_id)} +
+
+
+

Trace ID

+

{span.trace_id}

+
+
+

Span ID

+

{span.span_id}

+
+ {#if span.parent_span_id} +
+

Parent Span ID

+

{span.parent_span_id}

+
+ {/if} + {#if span.service_name} +
+

Service

+

{span.service_name}

+
+ {/if} +
+

Start Time

+

{formatTimestamp(span.start_time_unix_nano)}

+
+
+

End Time

+

{formatTimestamp(span.end_time_unix_nano)}

+
+ {#if span.status_message} +
+

Status Message

+

{span.status_message}

+
+ {/if} + {#if Object.keys(span.attributes).length > 0} +
+

Attributes

+
{JSON.stringify(span.attributes, null, 2)}
+
+ {/if} + {#if span.events && span.events.length > 0} +
+

Events ({span.events.length})

+
{JSON.stringify(span.events, null, 2)}
+
+ {/if} +
+
+ {/if} +
+ {/each} +
+
+
+ {/if} +
diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index e8c5bff667..545d122814 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -34,6 +34,7 @@ export interface Setting { | 'smtp_connect' | 'indexer_rates' | 'otel' + | 'otel_auto_instrumentation' storage: SettingStorage advancedToggle?: { label: string @@ -445,7 +446,15 @@ export const settings: Record = { storage: 'setting', ee_only: '' }, - + { + label: 'OTel Auto-Instrumentation', + description: + 'Enable automatic OpenTelemetry instrumentation for Python and TypeScript scripts. When enabled, scripts using OTel libraries will automatically send traces to a built-in collector that stores them for viewing in job details.', + key: 'otel_auto_instrumentation', + fieldType: 'otel_auto_instrumentation', + storage: 'setting', + ee_only: 'OTel auto-instrumentation is an EE feature' + }, { label: 'Prometheus', description: diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index be8639db74..68d7da78b9 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -91,13 +91,14 @@ import RunBadges from '$lib/components/runs/RunBadges.svelte' import { twMerge } from 'tailwind-merge' import FlowRestartButton from '$lib/components/FlowRestartButton.svelte' + import JobOtelTraces from '$lib/components/JobOtelTraces.svelte' let job: (Job & { result?: any; result_stream?: string }) | undefined = $state() let jobUpdateLastFetch: Date | undefined = $state() let scriptProgress: number | undefined = $state(undefined) let currentJobIsLongRunning: boolean = $state(false) - let viewTab: 'result' | 'logs' | 'code' | 'stats' | 'assets' = $state('result') + let viewTab: 'result' | 'logs' | 'code' | 'stats' | 'assets' | 'traces' = $state('result') let selectedJobStep: string | undefined = $state(undefined) let selectedJobStepIsTopLevel: boolean | undefined = $state(undefined) @@ -769,6 +770,7 @@ + {#if isScriptPreview(job?.job_kind)} @@ -798,6 +800,10 @@
+ {:else if viewTab == 'traces'} +
+ +
{:else if viewTab == 'code'} {#if job && 'raw_code' in job && job.raw_code}