Skip to content

Commit f7a8c85

Browse files
feat: Add Bun OTel auto-instrumentation loader and EE dependencies
- Add otel_bun_loader.js for automatic fetch span tracing in Bun scripts - Inject OTel loader via -r flag when WINDMILL_OTEL_AUTO_INSTRUMENTATION is set - Add prost and axum as optional enterprise dependencies for OTLP parsing - Fix ansible_executor.rs compilation warnings for OSS build 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e38b316 commit f7a8c85

File tree

6 files changed

+251
-19
lines changed

6 files changed

+251
-19
lines changed

backend/Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,3 +450,4 @@ oracle = { version = "0.6.3", features = ["chrono"] }
450450
rumqttc = { version = "0.24.0", features = ["use-native-tls"]}
451451
strum = { version = "0.27", features = ["derive"] }
452452
strum_macros = "^0"
453+
prost = "0.13.5"

backend/windmill-worker/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ path = "src/lib.rs"
1212
default = []
1313
private = []
1414
prometheus = ["dep:prometheus", "windmill-common/prometheus"]
15-
enterprise = ["windmill-queue/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "dep:pem", "dep:tokio-util"]
15+
enterprise = ["windmill-queue/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "dep:pem", "dep:tokio-util", "dep:prost", "dep:axum"]
1616
mssql = ["dep:tiberius"]
1717
bigquery = ["dep:gcp_auth"]
1818
benchmark = ["windmill-queue/benchmark", "windmill-common/benchmark"]
@@ -138,6 +138,8 @@ libloading = { workspace = true, optional = true }
138138
opentelemetry = { workspace = true, optional = true }
139139
bollard = { workspace = true, optional = true }
140140
oracle = { workspace = true, optional = true }
141+
prost = { workspace = true, optional = true }
142+
axum = { workspace = true, optional = true }
141143

142144
[build-dependencies]
143145
deno_fetch = { workspace = true, optional = true }
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// OpenTelemetry Auto-Instrumentation Loader for Bun/Node.js
2+
// This file is loaded via -r flag when WINDMILL_OTEL_AUTO_INSTRUMENTATION=true
3+
// It wraps the global fetch to automatically create OTel spans
4+
5+
const OTEL_ENDPOINT = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT;
6+
const JOB_ID = process.env.WINDMILL_JOB_ID;
7+
const WORKSPACE_ID = process.env.WINDMILL_WORKSPACE_ID;
8+
const SERVICE_NAME = process.env.OTEL_SERVICE_NAME || 'windmill-script';
9+
10+
if (!OTEL_ENDPOINT || !JOB_ID || !WORKSPACE_ID) {
11+
// Skip instrumentation if env vars not set
12+
console.log('[OTel] Missing environment variables, skipping instrumentation');
13+
} else {
14+
console.log(`[OTel] Auto-instrumentation enabled, sending traces to ${OTEL_ENDPOINT}`);
15+
16+
// Simple trace context generator
17+
function generateTraceId() {
18+
const bytes = new Uint8Array(16);
19+
crypto.getRandomValues(bytes);
20+
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
21+
}
22+
23+
function generateSpanId() {
24+
const bytes = new Uint8Array(8);
25+
crypto.getRandomValues(bytes);
26+
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
27+
}
28+
29+
// Store for pending spans
30+
const pendingSpans = [];
31+
let flushTimeout = null;
32+
33+
// Encode spans to OTLP protobuf format (simplified JSON export for now)
34+
async function flushSpans() {
35+
if (pendingSpans.length === 0) return;
36+
37+
const spansToSend = pendingSpans.splice(0, pendingSpans.length);
38+
39+
// Build OTLP JSON format (will be converted to protobuf by collector or use JSON endpoint)
40+
const jsonEndpoint = OTEL_ENDPOINT.replace('/v1/traces', '/v1/traces');
41+
42+
const exportRequest = {
43+
resourceSpans: [{
44+
resource: {
45+
attributes: [
46+
{ key: 'service.name', value: { stringValue: SERVICE_NAME } },
47+
{ key: 'windmill.job_id', value: { stringValue: JOB_ID } },
48+
{ key: 'windmill.workspace_id', value: { stringValue: WORKSPACE_ID } }
49+
]
50+
},
51+
scopeSpans: [{
52+
scope: {
53+
name: 'windmill-otel-bun-loader',
54+
version: '1.0.0'
55+
},
56+
spans: spansToSend
57+
}]
58+
}]
59+
};
60+
61+
try {
62+
// Use the original fetch to avoid recursion
63+
await originalFetch(jsonEndpoint, {
64+
method: 'POST',
65+
headers: {
66+
'Content-Type': 'application/json'
67+
},
68+
body: JSON.stringify(exportRequest)
69+
});
70+
} catch (e) {
71+
// Silently ignore export errors to not affect the script
72+
console.error('[OTel] Failed to export spans:', e.message);
73+
}
74+
}
75+
76+
function scheduleFlush() {
77+
if (flushTimeout) return;
78+
flushTimeout = setTimeout(() => {
79+
flushTimeout = null;
80+
flushSpans();
81+
}, 100);
82+
}
83+
84+
// Wrap the global fetch
85+
const originalFetch = globalThis.fetch;
86+
87+
globalThis.fetch = async function instrumentedFetch(input, init) {
88+
const url = typeof input === 'string' ? input : input.url;
89+
const method = init?.method || (typeof input === 'object' ? input.method : 'GET') || 'GET';
90+
91+
// Don't instrument calls to the OTel endpoint to avoid recursion
92+
if (url.includes(OTEL_ENDPOINT) || url.includes('/v1/traces')) {
93+
return originalFetch(input, init);
94+
}
95+
96+
const traceId = generateTraceId();
97+
const spanId = generateSpanId();
98+
const startTimeNano = BigInt(Date.now()) * 1000000n;
99+
100+
let statusCode = 0; // UNSET
101+
let statusMessage = '';
102+
let responseStatus = 0;
103+
let error = null;
104+
105+
try {
106+
const response = await originalFetch(input, init);
107+
responseStatus = response.status;
108+
109+
if (response.ok) {
110+
statusCode = 1; // OK
111+
} else {
112+
statusCode = 2; // ERROR
113+
statusMessage = `HTTP ${response.status}`;
114+
}
115+
116+
return response;
117+
} catch (e) {
118+
statusCode = 2; // ERROR
119+
statusMessage = e.message;
120+
error = e;
121+
throw e;
122+
} finally {
123+
const endTimeNano = BigInt(Date.now()) * 1000000n;
124+
125+
// Parse URL for attributes
126+
let parsedUrl;
127+
try {
128+
parsedUrl = new URL(url);
129+
} catch {
130+
parsedUrl = { hostname: '', pathname: url, protocol: '' };
131+
}
132+
133+
const span = {
134+
traceId: hexToBase64(traceId),
135+
spanId: hexToBase64(spanId),
136+
name: `HTTP ${method}`,
137+
kind: 3, // CLIENT
138+
startTimeUnixNano: startTimeNano.toString(),
139+
endTimeUnixNano: endTimeNano.toString(),
140+
attributes: [
141+
{ key: 'http.method', value: { stringValue: method } },
142+
{ key: 'http.url', value: { stringValue: url } },
143+
{ key: 'http.host', value: { stringValue: parsedUrl.hostname } },
144+
{ key: 'http.target', value: { stringValue: parsedUrl.pathname } },
145+
{ key: 'http.scheme', value: { stringValue: parsedUrl.protocol?.replace(':', '') || 'https' } }
146+
],
147+
status: {
148+
code: statusCode,
149+
message: statusMessage
150+
}
151+
};
152+
153+
if (responseStatus) {
154+
span.attributes.push({ key: 'http.status_code', value: { intValue: responseStatus.toString() } });
155+
}
156+
157+
if (error) {
158+
span.events = [{
159+
timeUnixNano: endTimeNano.toString(),
160+
name: 'exception',
161+
attributes: [
162+
{ key: 'exception.message', value: { stringValue: error.message } },
163+
{ key: 'exception.type', value: { stringValue: error.name } }
164+
]
165+
}];
166+
}
167+
168+
pendingSpans.push(span);
169+
scheduleFlush();
170+
}
171+
};
172+
173+
// Helper to convert hex to base64 for OTLP JSON format
174+
function hexToBase64(hex) {
175+
const bytes = new Uint8Array(hex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
176+
return btoa(String.fromCharCode(...bytes));
177+
}
178+
179+
// Ensure spans are flushed before process exits
180+
process.on('beforeExit', async () => {
181+
if (flushTimeout) {
182+
clearTimeout(flushTimeout);
183+
flushTimeout = null;
184+
}
185+
await flushSpans();
186+
});
187+
188+
process.on('exit', () => {
189+
// Synchronous - can't do async here, but beforeExit should have handled it
190+
});
191+
}

backend/windmill-worker/src/ansible_executor.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@ use tokio::process::Command;
1313
use uuid::Uuid;
1414
use windmill_common::{
1515
error,
16-
git_sync_oss::prepend_token_to_github_url,
1716
worker::{
1817
is_allowed_file_location, split_python_requirements, to_raw_value, write_file,
1918
write_file_at_user_defined_location, Connection, PyVAlias, WORKER_CONFIG,
2019
},
2120
};
21+
#[cfg(feature = "enterprise")]
22+
use windmill_common::git_sync_oss::prepend_token_to_github_url;
2223
use windmill_queue::MiniPulledJob;
2324

2425
use windmill_parser_yaml::{
@@ -948,8 +949,12 @@ pub async fn handle_ansible_job(
948949
));
949950
};
950951

952+
#[cfg(feature = "enterprise")]
951953
let mut secret_url = git_repo_resource.get("url").and_then(|s| s.as_str()).map(|s| s.to_string())
952954
.ok_or(anyhow!("Failed to get url from git repo resource, please check that the resource has the correct type (git_repository)"))?;
955+
#[cfg(not(feature = "enterprise"))]
956+
let secret_url = git_repo_resource.get("url").and_then(|s| s.as_str()).map(|s| s.to_string())
957+
.ok_or(anyhow!("Failed to get url from git repo resource, please check that the resource has the correct type (git_repository)"))?;
953958

954959
#[cfg(feature = "enterprise")]
955960
let is_github_app = git_repo_resource.get("is_github_app").and_then(|s| s.as_bool())

backend/windmill-worker/src/bun_executor.rs

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ const RELATIVE_BUN_LOADER: &str = include_str!("../loader.bun.js");
6464

6565
const RELATIVE_BUN_BUILDER: &str = include_str!("../loader_builder.bun.js");
6666

67+
const OTEL_BUN_LOADER: &str = include_str!("../otel_bun_loader.js");
68+
6769
const NSJAIL_CONFIG_RUN_BUN_CONTENT: &str = include_str!("../nsjail/run.bun.config.proto");
6870

6971
pub const BUN_LOCK_SPLIT: &str = "\n//bun.lock\n";
@@ -1377,6 +1379,12 @@ try {{
13771379
vec![]
13781380
};
13791381

1382+
// Write OTel loader file if auto-instrumentation is enabled
1383+
let otel_enabled = !otel_envs.is_empty();
1384+
if otel_enabled {
1385+
write_file(job_dir, "otel_bun_loader.js", OTEL_BUN_LOADER)?;
1386+
}
1387+
13801388
//do not cache local dependencies
13811389
let child = if !*DISABLE_NSJAIL {
13821390
let _ = write_file(
@@ -1409,17 +1417,22 @@ try {{
14091417
"/tmp/nodejs/wrapper.mjs",
14101418
]
14111419
} else if codebase.is_some() || has_bundle_cache {
1412-
vec![
1420+
let mut base_args = vec![
14131421
"--config",
14141422
"run.config.proto",
14151423
"--",
14161424
&BUN_PATH,
14171425
"run",
14181426
"--preserve-symlinks",
1419-
"/tmp/bun/wrapper.mjs",
1420-
]
1427+
];
1428+
// Add OTel loader if enabled
1429+
if otel_enabled {
1430+
base_args.extend_from_slice(&["-r", "/tmp/bun/otel_bun_loader.js"]);
1431+
}
1432+
base_args.push("/tmp/bun/wrapper.mjs");
1433+
base_args
14211434
} else {
1422-
vec![
1435+
let mut base_args = vec![
14231436
"--config",
14241437
"run.config.proto",
14251438
"--",
@@ -1429,8 +1442,13 @@ try {{
14291442
"--prefer-offline",
14301443
"-r",
14311444
"/tmp/bun/loader.bun.js",
1432-
"/tmp/bun/wrapper.mjs",
1433-
]
1445+
];
1446+
// Add OTel loader if enabled
1447+
if otel_enabled {
1448+
base_args.extend_from_slice(&["-r", "/tmp/bun/otel_bun_loader.js"]);
1449+
}
1450+
base_args.push("/tmp/bun/wrapper.mjs");
1451+
base_args
14341452
};
14351453
nsjail_cmd
14361454
.current_dir(job_dir)
@@ -1467,20 +1485,33 @@ try {{
14671485
bun_cmd
14681486
} else {
14691487
let script_path = format!("{job_dir}/wrapper.mjs");
1488+
let otel_loader_path = format!("{job_dir}/otel_bun_loader.js");
14701489

1471-
let args: Vec<&str> = if codebase.is_some() || has_bundle_cache {
1472-
vec!["run", &script_path]
1490+
let args: Vec<String> = if codebase.is_some() || has_bundle_cache {
1491+
let mut base_args = vec!["run".to_string()];
1492+
// Add OTel loader if enabled
1493+
if otel_enabled {
1494+
base_args.extend_from_slice(&["-r".to_string(), otel_loader_path.clone()]);
1495+
}
1496+
base_args.push(script_path);
1497+
base_args
14731498
} else {
1474-
vec![
1475-
"run",
1476-
"-i",
1477-
"--prefer-offline",
1478-
"-r",
1479-
"./loader.bun.js",
1480-
&script_path,
1481-
]
1499+
let mut base_args = vec![
1500+
"run".to_string(),
1501+
"-i".to_string(),
1502+
"--prefer-offline".to_string(),
1503+
"-r".to_string(),
1504+
"./loader.bun.js".to_string(),
1505+
];
1506+
// Add OTel loader if enabled
1507+
if otel_enabled {
1508+
base_args.extend_from_slice(&["-r".to_string(), otel_loader_path.clone()]);
1509+
}
1510+
base_args.push(script_path);
1511+
base_args
14821512
};
1483-
let mut bun_cmd = build_command_with_isolation(&*BUN_PATH, &args);
1513+
let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1514+
let mut bun_cmd = build_command_with_isolation(&*BUN_PATH, &args_refs);
14841515
bun_cmd
14851516
.current_dir(job_dir)
14861517
.env_clear()

0 commit comments

Comments
 (0)