fix(trace): write diagnostics to stderr so they don't corrupt captured stdout#2375
Conversation
juan-fernandez
left a comment
There was a problem hiding this comment.
Leaving one inline review comment for the stream lifetime issue.
| if (res !== 0) { | ||
| if (this.noFail) { | ||
| console.log('note: Not failing since --no-fail provided') | ||
| this.context.stderr.write('note: Not failing since --no-fail provided\n') |
There was a problem hiding this comment.
[P2] This can write to an ended stderr stream. childProcess.stderr.pipe(this.context.stderr) uses the default {end: true}, so once the wrapped command exits it ends the destination stream. process.stderr tolerates a later write, but regular Writable streams used by programmatic Clipanion callers or tests will emit write after end when this diagnostic is written afterward. The test currently masks that by overriding stderr.end; the command should instead pipe with {end: false} before moving post-child diagnostics onto stderr.
There was a problem hiding this comment.
Good catch. I believe this is now fixed and the test was adjusted accordingly.
There was a problem hiding this comment.
thanks for the contribution! This makes a lot of sense.
A few non-blocking suggestions that could make this safer and easier to maintain:
-
Add a CLI-level regression test for the banner stream. The new tests cover
printVersion(logger), but the customer-facing behavior lives inpackages/datadog-ci/src/cli.ts: the automatic banner should go tostderr, explicitdatadog-ci version/--versionshould still go tostdout, and command stdout should stay clean for captures/pipes. A small integration-style test would protect the exact bug class this PR is fixing. -
Consider using the existing
toBoolean()helper forDD_CI_SKIP_VERSION_BANNER. Right now the new env var accepts exactly1or lowercasetrue; most env parsing in this repo usestoBoolean(), which handles casing consistently. -
Consider preserving
printVersionAPI compatibility.printVersionis exported from@datadog/datadog-ci-base/versionand re-exported by@datadog/datadog-ci; changing it fromprintVersion()toprintVersion(logger)could break external callers that import it directly. If that API surface matters, making the logger optional would reduce the blast radius. -
If
DD_CI_SKIP_VERSION_BANNERis intended for customers rather than just as an escape hatch, it may be worth documenting it somewhere discoverable.
🎉 All green!🧪 All tests passed 🔗 Commit SHA: 2d6217b | Docs | Datadog PR Page | Give us feedback! |
This comment was marked as outdated.
This comment was marked as outdated.
rodrigo-roca
left a comment
There was a problem hiding this comment.
there is also the span subcommand which reuses most of the logic, and still use context.stdout.write if I recall correctly. I think we are missing changes in that command, can you take a look at span.ts?
| // while mirroring the resolved command's `--log-format` so it stays a JSON line in JSON mode. | ||
| // Builtin commands (e.g. --help) and command line parse errors fall back to text format. | ||
| const jsonOutput = command instanceof BaseCommand ? command.logger.isJsonOutput() : false | ||
| const versionLogger = new Logger((s) => context.stderr.write(s), LogLevel.INFO, {jsonOutput}) |
There was a problem hiding this comment.
is there unit tests for this piece of new logic? If not can we add some?
There was a problem hiding this comment.
Added packages/datadog-ci/src/__tests__/version-banner.test.ts — it spawns the real CLI entry point and asserts this logic end-to-end: the banner goes to stderr (never stdout, which is the bug class this fixes), --log-format json is mirrored as a JSON line, version/--version still print to stdout, and DD_CI_SKIP_VERSION_BANNER suppresses it.
An in-process cli.run() test wouldn't cover it, since the banner is emitted under require.main === module.
-- Claudelie
9cae238 to
a55a308
Compare
|
Most of the concerns in Juan's comments have been addressed in the latest version. I'll work through the rest in short order.
The printVersion(logger) signature landed on master with the --log-format json feature (426c548) — this PR doesn't change it. The only change here is an internal early-return for DD_CI_SKIP_VERSION_BANNER, so there's no API-surface change to guard against. |
a55a308 to
276856f
Compare
|
Thanks for running the CI! 🙏 Fixed the standalone smoke test: Also rebased onto the latest -- Claudelie |
…d stdout
`datadog-ci trace -- <cmd>` runs the wrapped command with
`stdio: ['inherit', 'inherit', 'pipe']`, so the child's stdout is fd 1 itself
and its bytes are captured verbatim by `VAR=$(datadog-ci trace -- cmd)`.
But `trace` also wrote some of its own diagnostics to fd 1 (`console.log` /
`context.stdout.write`), so those lines got mixed into the capture. When the
wrapped command emits a secret (e.g. `TOKEN=$(... vault write -field=token ...)`),
the captured value gains a newline + diagnostic text and is rejected downstream
("configured Vault token contains non-printable characters"). The corruption is
intermittent and load-correlated: the `--no-fail` note only fires when the span
report fails (e.g. under intake throttling).
Route these diagnostics to stderr instead, consistent with the adjacent error
paths that already use `context.stderr.write`:
- the `--no-fail` note (trace.ts)
- the unsupported-CI-provider message (helper.ts, shared CustomSpanCommand)
- the missing-API-key message (helper.ts, shared CustomSpanCommand)
The helper.ts changes also harden the `span` command, which shares the base.
Adds a regression test asserting the `--no-fail` note lands on stderr (not
stdout), and updates the shared CI-provider test assertion accordingly.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The `datadog-ci v<version>` banner was the one remaining diagnostic still written to stdout, so it could corrupt captured output the same way the trace diagnostics did (`VAR=$(datadog-ci -- cmd)`). Route it to stderr instead, mirroring the resolved command's `--log-format` so it stays a JSON line in JSON mode. Builtin commands and parse errors fall back to text format. Also add `DD_CI_SKIP_VERSION_BANNER` (`1`/`true`) to suppress the per-invocation banner entirely for callers that find it too noisy. Co-Authored-By: Claude Opus 4.8 <[email protected]>
276856f to
d6257f8
Compare
|
One more consistency fix for the The inherited diagnostics (unsupported-CI-provider, missing-API-key) were already moved to stderr in this PR. But Moved those four to -- Claudelie |
dda497f to
2d6217b
Compare
What and why?
datadog-ci trace -- <cmd>runs the wrapped command withstdio: ['inherit', 'inherit', 'pipe'], so the child's stdout is fd 1 and its bytes are captured verbatim byVAR=$(datadog-ci trace -- cmd). Buttracealso wrote some of its own diagnostics to fd 1 (console.log/context.stdout.write), so those lines got mixed into the capture.When the wrapped command emits a secret — e.g.
TOKEN=$(… vault write -field=token …)— the captured value gains a newline + diagnostic text and is rejected downstream (configured Vault token contains non-printable characters). It is intermittent and load-correlated: the--no-failnote only fires when the span report fails (e.g. under intake throttling).These diagnostics belong on stderr, consistent with the adjacent error paths in the same files that already use
this.context.stderr.write. Thedatadog-ci v<version>banner has the same problem — it's printed to stdout on every invocation, so it can corrupt the same captures — and on top of that it's noisy in CI where the CLI runs many times.Resolves #2381.
How?
Route three trace diagnostics from stdout → stderr:
--no-failnote (trace.ts) — wasconsole.log, nowthis.context.stderr.write(with the trailing\nthatconsole.logimplied)helper.ts, sharedCustomSpanCommand)helper.ts, sharedCustomSpanCommand)The
helper.tschanges also harden thespancommand, which shares theCustomSpanCommandbase. No change to stdio inheritance, thechildProcess.stderrpipe, or the span/reporting logic.Also route the version banner to stderr (
cli.ts), mirroring the resolved command's--log-formatso it stays a JSON line in JSON mode; builtin commands and parse errors fall back to text format. And addDD_CI_SKIP_VERSION_BANNER(1/true) to suppress the banner entirely, following the existingDD_CI_-prefixed toggle convention (e.g.DD_CI_BYPASS_SITE_VALIDATION).Adds regression tests: the
--no-failnote lands on stderr (not stdout), the shared CI-provider assertion is updated to match, and the banner is suppressed whenDD_CI_SKIP_VERSION_BANNERis1/true.Judgment call left out (happy to add if preferred): the
--dry-runpayload write inhelper.tsis also capturable, but moving it would changespan --dry-run | jqbehaviour and is lower risk (only under--dry-run), so it's left on stdout.Review checklist