Skip to content

Commit f5a0c9c

Browse files
committed
feat: add tracing instrumentation for command execution
Wraps `DockerCommand::execute`, `StreamableCommand::stream`, and the compose, buildx, and swarm paths (all of which funnel through `CommandExecutor::execute_command`) in a top-level `docker.command` info span carrying `command`, `args_count`, `platform`, `runtime`, and `timeout_secs` fields. Successful exits emit `info!` with `duration_ms` + `exit_code`; non-zero exits emit `warn!` with the same plus a 512-byte truncated stderr snippet. Streaming variants run under a `docker.stream` span and emit `debug!` per stdout/stderr line so noisy paths can be filtered at the subscriber level. Instrumentation is gated behind a new `tracing` cargo feature, enabled by default. Building with `--no-default-features` drops the `tracing` dependency entirely; the existing `use tracing::*;` sites now go through a small internal `tracing_compat` shim that re-exports the real macros when the feature is on and expands to no-ops otherwise. Closes #241
1 parent 9320166 commit f5a0c9c

13 files changed

Lines changed: 455 additions & 56 deletions

Cargo.toml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@ all-features = true
2121
rustdoc-args = ["--cfg", "docsrs"]
2222

2323
[features]
24-
default = ["compose", "swarm", "manifest"]
24+
default = ["compose", "swarm", "manifest", "tracing"]
2525
compose = []
2626
swarm = []
2727
manifest = []
28+
# Emit `tracing` spans/events from command execution paths.
29+
# Enabled by default; disable with --no-default-features to compile out.
30+
tracing = ["dep:tracing"]
2831

2932
# Template features
3033
templates = [
@@ -66,7 +69,7 @@ tokio = { version = "1.46", features = [
6669
serde = { version = "1.0", features = ["derive"] }
6770
serde_json = "1.0"
6871
thiserror = "2.0"
69-
tracing = "0.1"
72+
tracing = { version = "0.1", optional = true }
7073
async-trait = "0.1"
7174

7275
# Optional dependencies for templates
@@ -77,7 +80,9 @@ which = "8.0"
7780

7881
[dev-dependencies]
7982
tokio-test = "0.4"
83+
tracing = "0.1"
8084
tracing-subscriber = "0.3"
85+
tracing-test = { version = "0.2", features = ["no-env-filter"] }
8186
uuid = { version = "1.0", features = ["v4"] }
8287
futures = "0.3"
8388
tempfile = "3.0"

README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,67 @@ let id = redis.start().await?;
7979
redis.stop().await?;
8080
```
8181

82+
## Tracing
83+
84+
Every command invocation emits [`tracing`](https://docs.rs/tracing) spans and
85+
events so you can observe Docker CLI activity in production. Instrumentation is
86+
enabled by default via the `tracing` cargo feature; compile with
87+
`--no-default-features` to drop the dependency entirely.
88+
89+
```toml
90+
# Default: tracing instrumentation is on
91+
docker-wrapper = "0.10"
92+
93+
# Opt out
94+
docker-wrapper = { version = "0.10", default-features = false, features = ["compose"] }
95+
```
96+
97+
### What gets emitted
98+
99+
Each call to `DockerCommand::execute` and `StreamableCommand::stream` is
100+
wrapped in a span:
101+
102+
| Span | Entered by | Fields |
103+
|---------------------|------------------------------------|-------------------------------------------------------------------|
104+
| `docker.command` | `CommandExecutor::execute_command` | `command`, `args_count`, `platform`, `runtime`, `timeout_secs` |
105+
| `docker.process` | process spawn | `full_command` |
106+
| `docker.timeout` | timeout-wrapped execution | `timeout_secs` |
107+
| `docker.stream` | streaming execution | `command`, `mode` (`handler` or `channel`) |
108+
109+
Within the `docker.command` span, events are emitted as:
110+
111+
- `info` on success: `exit_code`, `duration_ms`, `stdout_len`, `stderr_len`.
112+
- `warn` on non-zero exit / spawn failure: `exit_code`, `duration_ms`,
113+
`stderr_snippet` (truncated to ~512 bytes), plus the error message.
114+
- `trace` for raw stdout / stderr payloads.
115+
116+
Streaming variants additionally emit `debug!` for every stdout/stderr line
117+
(set `RUST_LOG=docker_wrapper=debug` to see them), so noisy builds can be
118+
filtered down by level.
119+
120+
### Subscribing
121+
122+
```rust,no_run
123+
use tracing_subscriber::EnvFilter;
124+
125+
tracing_subscriber::fmt()
126+
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "docker_wrapper=info".into()))
127+
.init();
128+
```
129+
130+
Useful `RUST_LOG` filters:
131+
132+
```bash
133+
# All docker-wrapper activity
134+
RUST_LOG=docker_wrapper=trace cargo run
135+
136+
# Just the top-level execute spans + completion events
137+
RUST_LOG=docker_wrapper=info cargo run
138+
139+
# Stream commands with per-line output
140+
RUST_LOG=docker_wrapper::stream=debug cargo run
141+
```
142+
82143
## Why docker-wrapper?
83144

84145
This crate wraps the Docker CLI rather than calling the Docker API directly (like [bollard](https://crates.io/crates/bollard)).

src/command.rs

Lines changed: 87 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,14 @@
4646
4747
use crate::error::{Error, Result};
4848
use crate::platform::PlatformInfo;
49+
use crate::tracing_compat::{debug, error, info, trace, warn};
4950
use async_trait::async_trait;
5051
use std::collections::HashMap;
5152
use std::ffi::OsStr;
5253
use std::path::PathBuf;
5354
use std::process::Stdio;
5455
use std::time::Duration;
5556
use tokio::process::Command as TokioCommand;
56-
use tracing::{debug, error, instrument, trace, warn};
5757

5858
// Re-export all command modules
5959
pub mod attach;
@@ -471,6 +471,28 @@ pub trait ComposeCommand: DockerCommand {
471471
/// Default timeout for command execution (30 seconds)
472472
pub const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
473473

474+
/// Maximum length (in bytes) of stderr snippets attached to tracing events.
475+
/// Bounded so that log sinks don't drown in large error payloads.
476+
const STDERR_LOG_SNIPPET_BYTES: usize = 512;
477+
478+
/// Truncate `s` to at most `STDERR_LOG_SNIPPET_BYTES`, respecting UTF-8 char
479+
/// boundaries and appending an ellipsis marker when truncation occurs.
480+
#[cfg_attr(not(feature = "tracing"), allow(dead_code))]
481+
fn truncate_for_log(s: &str) -> String {
482+
if s.len() <= STDERR_LOG_SNIPPET_BYTES {
483+
return s.to_string();
484+
}
485+
// Walk back to a char boundary <= limit.
486+
let mut end = STDERR_LOG_SNIPPET_BYTES;
487+
while end > 0 && !s.is_char_boundary(end) {
488+
end -= 1;
489+
}
490+
let mut out = String::with_capacity(end + 4);
491+
out.push_str(&s[..end]);
492+
out.push_str("...");
493+
out
494+
}
495+
474496
/// Common functionality for executing Docker commands
475497
#[derive(Debug, Clone)]
476498
pub struct CommandExecutor {
@@ -540,20 +562,41 @@ impl CommandExecutor {
540562
}
541563
}
542564

565+
/// Get the runtime label suitable for a tracing field (e.g. "docker",
566+
/// "podman"). Returns `None` when no platform has been detected.
567+
#[cfg_attr(not(feature = "tracing"), allow(dead_code))]
568+
fn tracing_platform(&self) -> Option<&'static str> {
569+
use crate::platform::Runtime;
570+
let runtime = self.platform_info.as_ref().map(|p| &p.runtime)?;
571+
Some(match runtime {
572+
Runtime::Docker | Runtime::DockerDesktop => "docker",
573+
Runtime::Podman => "podman",
574+
Runtime::Colima => "colima",
575+
Runtime::RancherDesktop => "rancher-desktop",
576+
Runtime::OrbStack => "orbstack",
577+
})
578+
}
579+
543580
/// Execute a Docker command with the given arguments
544581
///
545582
/// # Errors
546583
/// Returns an error if the Docker command fails to execute, returns a non-zero exit code,
547584
/// or times out (if a timeout is configured)
548-
#[instrument(
549-
name = "docker.command",
550-
skip(self, args),
551-
fields(
552-
command = %command_name,
553-
runtime = %self.get_runtime_command(),
554-
timeout_secs = self.timeout.map(|t| t.as_secs()),
585+
#[cfg_attr(
586+
feature = "tracing",
587+
tracing::instrument(
588+
name = "docker.command",
589+
skip(self, args),
590+
fields(
591+
command = %command_name,
592+
args_count = args.len(),
593+
platform = self.tracing_platform(),
594+
runtime = %self.get_runtime_command(),
595+
timeout_secs = self.timeout.map(|t| t.as_secs()),
596+
)
555597
)
556598
)]
599+
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
557600
pub async fn execute_command(
558601
&self,
559602
command_name: &str,
@@ -570,6 +613,8 @@ impl CommandExecutor {
570613

571614
trace!(args = ?all_args, "executing docker command");
572615

616+
let started_at = std::time::Instant::now();
617+
573618
// Execute with or without timeout
574619
let result = if let Some(timeout_duration) = self.timeout {
575620
self.execute_with_timeout(&runtime_command, &all_args, timeout_duration)
@@ -578,33 +623,52 @@ impl CommandExecutor {
578623
self.execute_internal(&runtime_command, &all_args).await
579624
};
580625

626+
let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
627+
581628
match &result {
582629
Ok(output) => {
583-
debug!(
630+
info!(
584631
exit_code = output.exit_code,
632+
duration_ms = duration_ms,
585633
stdout_len = output.stdout.len(),
586634
stderr_len = output.stderr.len(),
587-
"command completed successfully"
635+
"command completed"
588636
);
589637
trace!(stdout = %output.stdout, "command stdout");
590638
if !output.stderr.is_empty() {
591639
trace!(stderr = %output.stderr, "command stderr");
592640
}
593641
}
594642
Err(e) => {
595-
error!(error = %e, "command failed");
643+
let (exit_code, stderr_snippet) = match e {
644+
Error::CommandFailed {
645+
exit_code, stderr, ..
646+
} => (Some(*exit_code), Some(truncate_for_log(stderr))),
647+
_ => (None, None),
648+
};
649+
warn!(
650+
command = %command_name,
651+
exit_code = exit_code,
652+
duration_ms = duration_ms,
653+
stderr_snippet = stderr_snippet.as_deref(),
654+
error = %e,
655+
"command failed"
656+
);
596657
}
597658
}
598659

599660
result
600661
}
601662

602663
/// Internal method to execute a command without timeout
603-
#[instrument(
604-
name = "docker.process",
605-
skip(self, all_args),
606-
fields(
607-
full_command = %format!("{} {}", runtime_command, all_args.join(" ")),
664+
#[cfg_attr(
665+
feature = "tracing",
666+
tracing::instrument(
667+
name = "docker.process",
668+
skip(self, all_args),
669+
fields(
670+
full_command = %format!("{} {}", runtime_command, all_args.join(" ")),
671+
)
608672
)
609673
)]
610674
async fn execute_internal(
@@ -675,10 +739,13 @@ impl CommandExecutor {
675739
}
676740

677741
/// Execute a command with a timeout
678-
#[instrument(
679-
name = "docker.timeout",
680-
skip(self, all_args),
681-
fields(timeout_secs = timeout_duration.as_secs())
742+
#[cfg_attr(
743+
feature = "tracing",
744+
tracing::instrument(
745+
name = "docker.timeout",
746+
skip(self, all_args),
747+
fields(timeout_secs = timeout_duration.as_secs())
748+
)
682749
)]
683750
async fn execute_with_timeout(
684751
&self,

src/command/build.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1361,7 +1361,7 @@ impl StreamableCommand for BuildCommand {
13611361
cmd.arg(arg);
13621362
}
13631363

1364-
crate::stream::stream_command(cmd, handler).await
1364+
crate::stream::stream_command(cmd, handler, "build").await
13651365
}
13661366

13671367
async fn stream_channel(&self) -> Result<(mpsc::Receiver<OutputLine>, StreamResult)> {
@@ -1371,7 +1371,7 @@ impl StreamableCommand for BuildCommand {
13711371
cmd.arg(arg);
13721372
}
13731373

1374-
crate::stream::stream_command_channel(cmd).await
1374+
crate::stream::stream_command_channel(cmd, "build").await
13751375
}
13761376
}
13771377

src/command/logs.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ impl StreamableCommand for LogsCommand {
178178
cmd.arg(arg);
179179
}
180180

181-
crate::stream::stream_command(cmd, handler).await
181+
crate::stream::stream_command(cmd, handler, "logs").await
182182
}
183183

184184
async fn stream_channel(&self) -> Result<(mpsc::Receiver<OutputLine>, StreamResult)> {
@@ -189,7 +189,7 @@ impl StreamableCommand for LogsCommand {
189189
cmd.arg(arg);
190190
}
191191

192-
crate::stream::stream_command_channel(cmd).await
192+
crate::stream::stream_command_channel(cmd, "logs").await
193193
}
194194
}
195195

src/command/run.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1833,7 +1833,7 @@ impl StreamableCommand for RunCommand {
18331833
cmd.arg(arg);
18341834
}
18351835

1836-
crate::stream::stream_command(cmd, handler).await
1836+
crate::stream::stream_command(cmd, handler, "run").await
18371837
}
18381838

18391839
async fn stream_channel(&self) -> Result<(mpsc::Receiver<OutputLine>, StreamResult)> {
@@ -1851,7 +1851,7 @@ impl StreamableCommand for RunCommand {
18511851
cmd.arg(arg);
18521852
}
18531853

1854-
crate::stream::stream_command_channel(cmd).await
1854+
crate::stream::stream_command_channel(cmd, "run").await
18551855
}
18561856
}
18571857

0 commit comments

Comments
 (0)