From 7b3b3c77f806965290f86693ab5410801f061f19 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 31 Mar 2026 18:44:53 -0400 Subject: [PATCH 01/19] Run IT suite with coveraged on a scheduled basis --- .github/workflows/coverage.yml | 73 ++++++++++++++++++------ .github/workflows/integration.yml | 43 +++++++++++--- .github/workflows/test.yml | 27 +-------- .github/workflows/unit-tests.yml | 67 ++++++++++++++++++++++ Makefile | 17 +++++- scripts/run-integration-test.sh | 19 +++++- tests/e2e/Dockerfile | 2 +- vdev/src/commands/compose_tests/start.rs | 2 +- vdev/src/commands/compose_tests/stop.rs | 2 +- vdev/src/commands/compose_tests/test.rs | 3 +- vdev/src/commands/e2e/test.rs | 5 ++ vdev/src/commands/integration/test.rs | 5 ++ vdev/src/commands/test.rs | 1 + vdev/src/testing/integration.rs | 5 ++ vdev/src/testing/runner.rs | 43 +++++++++++++- 15 files changed, 255 insertions(+), 59 deletions(-) create mode 100644 .github/workflows/unit-tests.yml diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index a4a4c39d4f760..9669a48e56506 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,39 +1,78 @@ -name: Code Coverage +# Weekly Coverage +# +# Runs all test suites (unit, component validation, integration, e2e) with code coverage +# instrumentation on a single commit. Results are merged into one lcov report and uploaded +# to Datadog. Scheduled weekly so coverage reflects the full test suite without impacting +# regular CI latency. + +name: Weekly Coverage on: - push: - branches: - - master - workflow_dispatch: # Allow manual trigger from GitHub UI + schedule: + - cron: "0 2 * * 0" # 2 AM UTC every Sunday + workflow_dispatch: permissions: contents: read env: + CARGO_INCREMENTAL: "0" CI: true - CARGO_INCREMENTAL: '0' # Disable incremental compilation for coverage jobs: - coverage: + unit-coverage: + uses: ./.github/workflows/unit-tests.yml + with: + coverage: true + + integration-tests: + permissions: + contents: read + packages: write + uses: ./.github/workflows/integration.yml + with: + coverage: true + secrets: inherit + + coverage-upload: + name: Merge and Upload Coverage runs-on: ubuntu-24.04 + needs: + - unit-coverage + - integration-tests + # Run even if some integration jobs failed — partial coverage is still valuable. + if: always() && needs.unit-coverage.result == 'success' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Download all coverage artifacts + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + pattern: coverage-* + path: coverage-artifacts + merge-multiple: false + + - name: Merge coverage reports + run: | + sudo apt-get install -y --no-install-recommends lcov + + mapfile -t FILES < <(find coverage-artifacts -name '*.info') + echo "Merging ${#FILES[@]} coverage file(s)" + + ARGS=() + for f in "${FILES[@]}"; do + ARGS+=("--add-tracefile" "$f") + done + lcov "${ARGS[@]}" --output-file merged-lcov.info + - uses: ./.github/actions/setup with: - rust: true - libsasl2: true - protoc: true - cargo-llvm-cov: true - cargo-nextest: true + vdev: false datadog-ci: true - - name: "Generate code coverage" - run: cargo llvm-cov nextest --workspace --lcov --output-path lcov.info - - - name: "Upload coverage to Datadog" + - name: Upload to Datadog env: DD_API_KEY: ${{ secrets.DD_API_KEY }} DD_SITE: datadoghq.com DD_ENV: ci - run: datadog-ci coverage upload lcov.info + run: datadog-ci coverage upload merged-lcov.info diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 20fae17be16ae..300ee806a1501 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -7,14 +7,22 @@ name: Integration Test Suite on: + workflow_call: + inputs: + coverage: + description: "Collect code coverage (outputs target/coverage/lcov.info per service)" + required: false + type: boolean + default: false workflow_dispatch: pull_request: merge_group: types: [ checks_requested ] concurrency: - # `github.event.number` exists for pull requests, otherwise fall back to SHA for merge queue - group: ${{ github.workflow }}-${{ github.event.number || github.event.merge_group.head_sha }} + # `github.event.number` exists for pull requests, otherwise fall back to SHA for merge queue. + # For workflow_call (e.g. weekly coverage), use run_id so each caller gets its own group. + group: ${{ github.workflow }}-${{ github.event.number || github.event.merge_group.head_sha || github.run_id }} cancel-in-progress: true permissions: @@ -52,6 +60,7 @@ jobs: if: ${{ always() && (github.event_name == 'workflow_dispatch' || + github.event_name == 'workflow_call' || (github.event_name == 'merge_group' && needs.changes.result == 'success' && (needs.changes.outputs.dependencies == 'true' || @@ -70,8 +79,9 @@ jobs: - build-test-runner if: ${{ always() && !failure() && !cancelled() && needs.build-test-runner.result == 'success' && - (github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch') }} + (github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') }} strategy: + fail-fast: false matrix: # TODO: Add "splunk" back once https://github.com/vectordotdev/vector/issues/23474 is fixed. # If you modify this list, please also update the `int_tests` job in changes.yml. @@ -127,17 +137,26 @@ jobs: # Check if any of the three conditions is true if [[ "${{ github.event_name }}" == "workflow_dispatch" || \ + "${{ github.event_name }}" == "workflow_call" || \ "${{ needs.changes.outputs.dependencies }}" == "true" || \ "${{ needs.changes.outputs.integration-yml }}" == "true" || \ "$should_run" == "true" ]]; then # Only install dep if test runs bash scripts/environment/prepare.sh --modules=datadog-ci echo "Running test for ${{ matrix.service }}" - bash scripts/run-integration-test.sh int ${{ matrix.service }} + bash scripts/run-integration-test.sh ${{ inputs.coverage && '-c' || '' }} int ${{ matrix.service }} else echo "Skipping ${{ matrix.service }} test as the value is false or conditions not met." fi + - name: Upload coverage artifact + if: inputs.coverage && always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: coverage-int-${{ matrix.service }} + path: target/coverage/lcov.info + if-no-files-found: ignore + e2e-tests: runs-on: ubuntu-24.04-8core @@ -145,8 +164,9 @@ jobs: - changes - build-test-runner if: ${{ always() && !failure() && !cancelled() && needs.build-test-runner.result == 'success' && - (github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch') }} + (github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') }} strategy: + fail-fast: false matrix: service: [ "datadog-logs", "datadog-metrics", "opentelemetry-logs", "opentelemetry-metrics" ] timeout-minutes: 90 @@ -195,17 +215,26 @@ jobs: # Check if any of the three conditions is true if [[ "${{ github.event_name }}" == "workflow_dispatch" || \ + "${{ github.event_name }}" == "workflow_call" || \ "${{ needs.changes.outputs.dependencies }}" == "true" || \ - "${{ needs.changes.outputs.integration-yml }}" == "true" ]] || \ + "${{ needs.changes.outputs.integration-yml }}" == "true" || \ "$should_run" == "true" ]]; then # Only install dep if test runs bash scripts/environment/prepare.sh --modules=datadog-ci echo "Running test for ${{ matrix.service }}" - bash scripts/run-integration-test.sh e2e ${{ matrix.service }} + bash scripts/run-integration-test.sh ${{ inputs.coverage && '-c' || '' }} e2e ${{ matrix.service }} else echo "Skipping ${{ matrix.service }} test as the value is false or conditions not met." fi + - name: Upload coverage artifact + if: inputs.coverage && always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: coverage-e2e-${{ matrix.service }} + path: target/coverage/lcov.info + if-no-files-found: ignore + integration-test-suite: name: Integration Test Suite runs-on: ubuntu-24.04 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5572b2d3f8d9d..b8500b196b66f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,31 +55,10 @@ jobs: - run: make check-clippy test: - name: Unit and Component Validation tests - x86_64-unknown-linux-gnu - runs-on: ubuntu-24.04-8core - if: ${{ needs.changes.outputs.source == 'true' || needs.changes.outputs.test-yml == 'true' }} needs: changes - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/setup - with: - rust: true - cargo-nextest: true - datadog-ci: true - protoc: true - libsasl2: true - - name: Unit Test - run: make test - env: - CARGO_BUILD_JOBS: 5 - - # Validates components for adherence to the Component Specification - - name: Check Component Spec - run: make test-component-validation - - - name: Upload test results - run: scripts/upload-test-results.sh - if: always() + if: ${{ needs.changes.outputs.source == 'true' || needs.changes.outputs.test-yml == 'true' }} + uses: ./.github/workflows/unit-tests.yml + secrets: inherit check-scripts: name: Check scripts diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000000000..803577f1aa567 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,67 @@ +# Reusable Workflow: Unit and Component Validation Tests +# +# Runs unit tests and component validation tests via the Makefile. +# With coverage: false (default), plain cargo-nextest; uploads test results to Datadog. +# With coverage: true, cargo-llvm-cov (COVERAGE=1); uploads an lcov artifact named "coverage-unit". + +name: Unit and Component Validation Tests + +on: + workflow_call: + inputs: + coverage: + description: "Collect code coverage (sets COVERAGE=1 for make targets)" + required: false + type: boolean + default: false + +permissions: + contents: read + +env: + CARGO_INCREMENTAL: "0" + CI: true + +jobs: + unit-tests: + name: Unit and Component Validation Tests + runs-on: ubuntu-24.04-8core + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: ./.github/actions/setup + with: + rust: true + cargo-nextest: true + cargo-llvm-cov: ${{ inputs.coverage }} + datadog-ci: ${{ !inputs.coverage }} + protoc: true + libsasl2: true + + - name: Unit tests + run: make test + env: + COVERAGE: ${{ inputs.coverage }} + + - name: Component validation tests + run: make test-component-validation + env: + COVERAGE: ${{ inputs.coverage }} + + - name: Upload test results to Datadog + if: "!inputs.coverage && always()" + run: scripts/upload-test-results.sh + + - name: Generate lcov report + if: inputs.coverage + run: | + make coverage-report + # Normalize absolute source paths to relative so they merge cleanly with + # coverage from the containerized integration tests (which use SF:src/...). + sed -i "s|SF:$(pwd)/|SF:|g" lcov.info + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: inputs.coverage + with: + name: coverage-unit + path: lcov.info diff --git a/Makefile b/Makefile index 8b1b856647b9c..fc02206333a5f 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,15 @@ else export DNSTAP_BENCHES := dnstap-benches endif +# When COVERAGE=true, swap cargo-nextest for cargo-llvm-cov so test targets collect +# coverage data. Run `make coverage-report` afterwards to emit the lcov file. +export COVERAGE ?= false +ifeq ($(COVERAGE), true) +TEST_RUNNER := cargo llvm-cov nextest --no-report +else +TEST_RUNNER := cargo nextest run +endif + # Override this with any scopes for testing/benching. export SCOPE ?= # Override this with any extra flags for cargo bench @@ -349,7 +358,7 @@ target/%/vector.tar.gz: target/%/vector CARGO_HANDLES_FRESHNESS # https://github.com/rust-lang/cargo/issues/6454 .PHONY: test test: ## Run the unit test suite - ${MAYBE_ENVIRONMENT_EXEC} cargo nextest run --workspace --no-fail-fast --no-default-features --features "${FEATURES}" ${SCOPE} + ${MAYBE_ENVIRONMENT_EXEC} ${TEST_RUNNER} --workspace --no-fail-fast --no-default-features --features "${FEATURES}" ${SCOPE} .PHONY: test-docs test-docs: ## Run the docs test suite @@ -420,7 +429,11 @@ test-vector-api: ## Runs vector API tests (top and tap) .PHONY: test-component-validation test-component-validation: ## Runs component validation tests - ${MAYBE_ENVIRONMENT_EXEC} cargo nextest run --no-fail-fast --no-default-features --features component-validation-tests --status-level pass --test-threads 4 --lib components::validation::tests + ${MAYBE_ENVIRONMENT_EXEC} ${TEST_RUNNER} --no-fail-fast --no-default-features --features component-validation-tests --status-level pass --test-threads 4 --lib components::validation::tests + +.PHONY: coverage-report +coverage-report: ## Generate lcov report after running tests with COVERAGE=1 (outputs lcov.info) + cargo llvm-cov report --lcov --output-path lcov.info ##@ Benching (Supports `ENVIRONMENT=true`) diff --git a/scripts/run-integration-test.sh b/scripts/run-integration-test.sh index a1b22ce4d6c4c..2c6e71a61c5c5 100755 --- a/scripts/run-integration-test.sh +++ b/scripts/run-integration-test.sh @@ -34,6 +34,7 @@ Options: -v Increase verbosity; repeat for more (e.g. -vv or -vvv) -e One or more environments to run (repeatable or comma-separated). If provided, these are used as TEST_ENVIRONMENTS instead of auto-discovery. + -c Collect code coverage (outputs target/coverage/lcov.info) Notes: - All existing two-argument invocations remain compatible: @@ -45,7 +46,8 @@ USAGE # Parse options # Note: options must come before positional args (standard getopts behavior) TEST_ENV="" -while getopts ":hr:v:e:" opt; do +COVERAGE=false +while getopts ":hr:v:e:c" opt; do case "$opt" in h) usage @@ -64,6 +66,9 @@ while getopts ":hr:v:e:" opt; do e) TEST_ENV="$OPTARG" ;; + c) + COVERAGE=true + ;; \?) echo "ERROR: unknown option: -$OPTARG" >&2 usage @@ -134,10 +139,20 @@ for TEST_ENV in "${TEST_ENVIRONMENTS[@]}"; do print_compose_logs_on_failure "$START_RET" if [[ "$START_RET" -eq 0 ]]; then - $vdev_cmd "${VERBOSITY}" "${TEST_TYPE}" test --retries "$RETRIES" "${TEST_NAME}" "${TEST_ENV}" + COVERAGE_FLAG="" + [[ "$COVERAGE" == "true" ]] && COVERAGE_FLAG="--coverage" + + $vdev_cmd "${VERBOSITY}" "${TEST_TYPE}" test --retries "$RETRIES" ${COVERAGE_FLAG} "${TEST_NAME}" "${TEST_ENV}" RET=$? print_compose_logs_on_failure "$RET" + # Normalize source paths in coverage report so they are relative to the repo root. + # The test runner container mounts source at /home/vector; strip that prefix so + # Datadog can resolve files against the repository root (e.g. SF:src/foo.rs). + if [[ "$COVERAGE" == "true" && "$RET" -eq 0 && -f target/coverage/lcov.info ]]; then + sed -i 's|SF:/home/vector/|SF:|g' target/coverage/lcov.info + fi + # Upload test results only if the vdev test step ran ./scripts/upload-test-results.sh else diff --git a/tests/e2e/Dockerfile b/tests/e2e/Dockerfile index dd564544e480b..1e7cda7fc696b 100644 --- a/tests/e2e/Dockerfile +++ b/tests/e2e/Dockerfile @@ -30,7 +30,7 @@ COPY scripts/environment/release-flags.sh / WORKDIR /vector COPY rust-toolchain.toml . -RUN bash /prepare.sh --modules=cargo-nextest +RUN bash /prepare.sh --modules=cargo-nextest,cargo-llvm-cov RUN bash /install-protoc.sh COPY . . diff --git a/vdev/src/commands/compose_tests/start.rs b/vdev/src/commands/compose_tests/start.rs index 51a4a3c4b2237..b96df10255921 100644 --- a/vdev/src/commands/compose_tests/start.rs +++ b/vdev/src/commands/compose_tests/start.rs @@ -20,5 +20,5 @@ pub(crate) fn exec( env.clone() }; debug!("Selected environment: {environment:#?}"); - ComposeTest::generate(local_config, integration, environment, 0)?.start() + ComposeTest::generate(local_config, integration, environment, 0, false)?.start() } diff --git a/vdev/src/commands/compose_tests/stop.rs b/vdev/src/commands/compose_tests/stop.rs index 188b428a71019..ee6af325cdc5e 100644 --- a/vdev/src/commands/compose_tests/stop.rs +++ b/vdev/src/commands/compose_tests/stop.rs @@ -13,7 +13,7 @@ pub(crate) fn exec(local_config: ComposeTestLocalConfig, test_name: &str) -> Res find_active_environment_for_integration(local_config.directory, test_name, &config)?; if let Some(environment) = active_environment { - ComposeTest::generate(local_config, test_name, environment, 0)?.stop() + ComposeTest::generate(local_config, test_name, environment, 0, false)?.stop() } else { println!("No environment for {test_name} is active."); Ok(()) diff --git a/vdev/src/commands/compose_tests/test.rs b/vdev/src/commands/compose_tests/test.rs index e2fb51d006c23..ab4f36693c8e6 100644 --- a/vdev/src/commands/compose_tests/test.rs +++ b/vdev/src/commands/compose_tests/test.rs @@ -15,6 +15,7 @@ pub fn exec( environment: Option<&String>, retries: u8, args: &[String], + coverage: bool, ) -> Result<()> { let (_test_dir, config) = ComposeTestConfig::load(local_config.directory, integration)?; let envs = config.environments(); @@ -33,7 +34,7 @@ pub fn exec( }; for environment in environments { - ComposeTest::generate(local_config, integration, environment, retries)? + ComposeTest::generate(local_config, integration, environment, retries, coverage)? .test(args.to_owned())?; } Ok(()) diff --git a/vdev/src/commands/e2e/test.rs b/vdev/src/commands/e2e/test.rs index b9d0149afbe65..e83c70e5f6518 100644 --- a/vdev/src/commands/e2e/test.rs +++ b/vdev/src/commands/e2e/test.rs @@ -24,6 +24,10 @@ pub struct Cli { #[arg(short = 'r', long)] retries: Option, + /// Collect code coverage using cargo-llvm-cov (outputs target/coverage/lcov.info) + #[arg(long)] + coverage: bool, + /// Extra test command arguments args: Vec, } @@ -36,6 +40,7 @@ impl Cli { self.environment.as_ref(), self.retries.unwrap_or_default(), &self.args, + self.coverage, ) } } diff --git a/vdev/src/commands/integration/test.rs b/vdev/src/commands/integration/test.rs index 05ac2d4df1347..66da5c009b262 100644 --- a/vdev/src/commands/integration/test.rs +++ b/vdev/src/commands/integration/test.rs @@ -24,6 +24,10 @@ pub struct Cli { #[arg(short = 'r', long)] retries: Option, + /// Collect code coverage using cargo-llvm-cov (outputs target/coverage/lcov.info) + #[arg(long)] + coverage: bool, + /// Extra test command arguments args: Vec, } @@ -36,6 +40,7 @@ impl Cli { self.environment.as_ref(), self.retries.unwrap_or_default(), &self.args, + self.coverage, ) } } diff --git a/vdev/src/commands/test.rs b/vdev/src/commands/test.rs index c00274cfcfccf..d3f6cba96b654 100644 --- a/vdev/src/commands/test.rs +++ b/vdev/src/commands/test.rs @@ -53,6 +53,7 @@ impl Cli { None, &args, false, // Don't pre-build Vector for direct test runs + false, ) } } diff --git a/vdev/src/testing/integration.rs b/vdev/src/testing/integration.rs index 127346cb3206f..6f2ed7e3f53d4 100644 --- a/vdev/src/testing/integration.rs +++ b/vdev/src/testing/integration.rs @@ -76,6 +76,7 @@ pub(crate) struct ComposeTest { compose: Option, env_config: Environment, retries: u8, + coverage: bool, } impl ComposeTest { @@ -84,6 +85,7 @@ impl ComposeTest { test_name: impl Into, environment: impl Into, retries: u8, + coverage: bool, ) -> Result { let test_name: String = test_name.into(); let environment = environment.into(); @@ -114,6 +116,7 @@ impl ComposeTest { runner_name, &config.runner, compose.is_some().then_some(network_name), + coverage, )?; env_config.insert("VECTOR_IMAGE".to_string(), Some(runner.image_name())); @@ -127,6 +130,7 @@ impl ComposeTest { compose, env_config: rename_environment_keys(&env_config), retries, + coverage, }; trace!("Generated {compose_test:#?}"); Ok(compose_test) @@ -230,6 +234,7 @@ impl ComposeTest { Some(&self.config.features), &args, self.local_config.kind == ComposeTestKind::E2E, + self.coverage, )?; Ok(()) diff --git a/vdev/src/testing/runner.rs b/vdev/src/testing/runner.rs index cce1c28d2d251..306ab3d50ca60 100644 --- a/vdev/src/testing/runner.rs +++ b/vdev/src/testing/runner.rs @@ -29,6 +29,17 @@ const TEST_COMMAND: &[&str] = &[ "--no-fail-fast", "--no-default-features", ]; +const COVERAGE_COMMAND: &[&str] = &[ + "cargo", + "llvm-cov", + "nextest", + "--no-fail-fast", + "--no-default-features", +]; +/// Coverage output path inside the test runner container. +const COVERAGE_OUTPUT_DIR: &str = "/coverage"; +/// Coverage output path on the host (relative to project root). +const LOCAL_COVERAGE_OUTPUT_DIR: &str = "target/coverage"; // The upstream container we publish artifacts to on a successful master build. const UPSTREAM_IMAGE: &str = "docker.io/timberio/vector-dev:latest"; @@ -59,6 +70,7 @@ pub trait TestRunner { features: Option<&[String]>, args: &[String], build: bool, + coverage: bool, ) -> Result<()>; } @@ -237,6 +249,7 @@ where features: Option<&[String]>, args: &[String], build: bool, + coverage: bool, ) -> Result<()> { self.ensure_running(features, config_environment_variables, build)?; @@ -256,8 +269,15 @@ where append_environment_variables(&mut command, config_environment_variables); command.arg(self.container_name()); - command.args(TEST_COMMAND); + command.args(if coverage { COVERAGE_COMMAND } else { TEST_COMMAND }); command.args(args); + if coverage { + command.args([ + "--lcov", + "--output-path", + &format!("{COVERAGE_OUTPUT_DIR}/lcov.info"), + ]); + } command.check_run() } @@ -277,6 +297,7 @@ impl IntegrationTestRunner { integration: Option, config: &IntegrationRunnerConfig, network: Option, + coverage: bool, ) -> Result { let mut volumes: Vec = config .volumes @@ -286,6 +307,12 @@ impl IntegrationTestRunner { volumes.push(format!("{VOLUME_TARGET}:/output")); + if coverage { + let coverage_dir = std::path::Path::new(app::path()).join(LOCAL_COVERAGE_OUTPUT_DIR); + std::fs::create_dir_all(&coverage_dir)?; + volumes.push(format!("{}:{COVERAGE_OUTPUT_DIR}", coverage_dir.display())); + } + Ok(Self { integration, needs_docker_socket: config.needs_docker_socket, @@ -402,10 +429,20 @@ impl TestRunner for LocalTestRunner { _features: Option<&[String]>, args: &[String], _build: bool, + coverage: bool, ) -> Result<()> { - let mut command = Command::new(TEST_COMMAND[0]); - command.args(&TEST_COMMAND[1..]); + let test_cmd = if coverage { COVERAGE_COMMAND } else { TEST_COMMAND }; + let mut command = Command::new(test_cmd[0]); + command.args(&test_cmd[1..]); command.args(args); + if coverage { + std::fs::create_dir_all(LOCAL_COVERAGE_OUTPUT_DIR)?; + command.args([ + "--lcov", + "--output-path", + &format!("{LOCAL_COVERAGE_OUTPUT_DIR}/lcov.info"), + ]); + } for (key, value) in outer_env { if let Some(value) = value { From 2b7447788b1deaec2e3954bcc80ae3b4fbed9ec6 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 31 Mar 2026 18:52:04 -0400 Subject: [PATCH 02/19] Add trigger --- .github/workflows/ci-review-trigger.yml | 13 +++++++++++++ .github/workflows/coverage.yml | 17 +++++++++++++---- .github/workflows/unit-tests.yml | 6 ++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-review-trigger.yml b/.github/workflows/ci-review-trigger.yml index ce4e85f392b35..40c2255932aab 100644 --- a/.github/workflows/ci-review-trigger.yml +++ b/.github/workflows/ci-review-trigger.yml @@ -9,6 +9,7 @@ # # The available triggers are: # +# /ci-run-coverage : runs full coverage suite (unit + int + e2e) — NOT included in /ci-run-all (covered by others) # /ci-run-all : runs all of the below # /ci-run-cli : runs CLI - Linux # /ci-run-test-vector-api : runs make test-vector-api @@ -67,6 +68,7 @@ jobs: || startsWith(github.event.review.body, '/ci-run-unit-windows') || startsWith(github.event.review.body, '/ci-run-environment') || startsWith(github.event.review.body, '/ci-run-k8s') + || startsWith(github.event.review.body, '/ci-run-coverage') steps: - name: Generate authentication token id: generate_token @@ -193,3 +195,14 @@ jobs: with: ref: ${{ github.event.review.commit_id }} secrets: inherit + + coverage: + needs: validate + if: contains(github.event.review.body, '/ci-run-coverage') + permissions: + contents: read + packages: write + uses: ./.github/workflows/coverage.yml + with: + ref: ${{ github.event.review.commit_id }} + secrets: inherit diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 9669a48e56506..b2f7bef7ea684 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,13 +1,19 @@ -# Weekly Coverage +# Coverage # # Runs all test suites (unit, component validation, integration, e2e) with code coverage # instrumentation on a single commit. Results are merged into one lcov report and uploaded -# to Datadog. Scheduled weekly so coverage reflects the full test suite without impacting -# regular CI latency. +# to Datadog. Also runs on a weekly schedule to ensure full baseline coverage is always fresh. -name: Weekly Coverage +name: Coverage on: + workflow_call: + inputs: + ref: + description: 'Git ref to checkout' + required: false + type: string + schedule: - cron: "0 2 * * 0" # 2 AM UTC every Sunday workflow_dispatch: @@ -23,6 +29,7 @@ jobs: unit-coverage: uses: ./.github/workflows/unit-tests.yml with: + ref: ${{ inputs.ref }} coverage: true integration-tests: @@ -44,6 +51,8 @@ jobs: if: always() && needs.unit-coverage.result == 'success' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref }} - name: Download all coverage artifacts uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 803577f1aa567..627c848fe5a33 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -9,6 +9,10 @@ name: Unit and Component Validation Tests on: workflow_call: inputs: + ref: + description: 'Git ref to checkout' + required: false + type: string coverage: description: "Collect code coverage (sets COVERAGE=1 for make targets)" required: false @@ -28,6 +32,8 @@ jobs: runs-on: ubuntu-24.04-8core steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref }} - uses: ./.github/actions/setup with: From cf5c0b0107b8bf4f960ba6a3b5ba024a8c7ef67c Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 31 Mar 2026 19:15:09 -0400 Subject: [PATCH 03/19] fix(ci): add run_all input to integration.yml to force all tests when called from coverage workflow --- .github/workflows/coverage.yml | 1 + .github/workflows/integration.yml | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index b2f7bef7ea684..3aaa03a547f40 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -38,6 +38,7 @@ jobs: packages: write uses: ./.github/workflows/integration.yml with: + run_all: true coverage: true secrets: inherit diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 48ecf21b88237..015d78b636fb9 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -9,6 +9,11 @@ name: Integration Test Suite on: workflow_call: inputs: + run_all: + description: "Run all integration and e2e tests regardless of changed files" + required: false + type: boolean + default: false coverage: description: "Collect code coverage (outputs target/coverage/lcov.info per service)" required: false @@ -61,6 +66,7 @@ jobs: always() && (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' || + inputs.run_all == true || (github.event_name == 'merge_group' && needs.changes.result == 'success' && (needs.changes.outputs.dependencies == 'true' || @@ -79,7 +85,7 @@ jobs: - build-test-runner if: ${{ always() && !failure() && !cancelled() && needs.build-test-runner.result == 'success' && - (github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') }} + (github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' || inputs.run_all == true) }} strategy: fail-fast: false matrix: @@ -105,6 +111,7 @@ jobs: run: | if [[ "${{ github.event_name }}" == "workflow_dispatch" || \ "${{ github.event_name }}" == "workflow_call" || \ + "${{ inputs.run_all }}" == "true" || \ "${{ needs.changes.outputs.dependencies }}" == "true" || \ "${{ needs.changes.outputs.integration-yml }}" == "true" ]]; then echo "should_run=true" >> "$GITHUB_OUTPUT" @@ -161,7 +168,7 @@ jobs: - changes - build-test-runner if: ${{ always() && !failure() && !cancelled() && needs.build-test-runner.result == 'success' && - (github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') }} + (github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' || inputs.run_all == true) }} strategy: fail-fast: false matrix: @@ -180,6 +187,7 @@ jobs: run: | if [[ "${{ github.event_name }}" == "workflow_dispatch" || \ "${{ github.event_name }}" == "workflow_call" || \ + "${{ inputs.run_all }}" == "true" || \ "${{ needs.changes.outputs.dependencies }}" == "true" || \ "${{ needs.changes.outputs.integration-yml }}" == "true" ]]; then echo "should_run=true" >> "$GITHUB_OUTPUT" From cfe8f4b402ae235729f18f151ce68e48fb411734 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 31 Mar 2026 19:19:20 -0400 Subject: [PATCH 04/19] Format --- vdev/src/testing/runner.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/vdev/src/testing/runner.rs b/vdev/src/testing/runner.rs index 306ab3d50ca60..1c22f6bbf2759 100644 --- a/vdev/src/testing/runner.rs +++ b/vdev/src/testing/runner.rs @@ -269,7 +269,11 @@ where append_environment_variables(&mut command, config_environment_variables); command.arg(self.container_name()); - command.args(if coverage { COVERAGE_COMMAND } else { TEST_COMMAND }); + command.args(if coverage { + COVERAGE_COMMAND + } else { + TEST_COMMAND + }); command.args(args); if coverage { command.args([ @@ -431,7 +435,11 @@ impl TestRunner for LocalTestRunner { _build: bool, coverage: bool, ) -> Result<()> { - let test_cmd = if coverage { COVERAGE_COMMAND } else { TEST_COMMAND }; + let test_cmd = if coverage { + COVERAGE_COMMAND + } else { + TEST_COMMAND + }; let mut command = Command::new(test_cmd[0]); command.args(&test_cmd[1..]); command.args(args); From 4585e5bde5fd57d39d899b4ad4a9317994e0c0ad Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 1 Apr 2026 14:38:32 -0400 Subject: [PATCH 05/19] fix(ci): require all test suites to pass before coverage upload --- .github/workflows/coverage.yml | 17 ++++--- .github/workflows/test.yml | 3 ++ .github/workflows/unit-tests.yml | 83 ++++++++++++++++++++++++++------ Makefile | 4 +- 4 files changed, 84 insertions(+), 23 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 3aaa03a547f40..79bbbb479f2cd 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,8 +1,9 @@ # Coverage # -# Runs all test suites (unit, component validation, integration, e2e) with code coverage -# instrumentation on a single commit. Results are merged into one lcov report and uploaded -# to Datadog. Also runs on a weekly schedule to ensure full baseline coverage is always fresh. +# Runs all test suites (unit, component validation, CLI, API, behavior, +# integration) with code coverage instrumentation on a single commit. Results +# are merged into one lcov report and uploaded to Datadog. Also runs on a +# weekly schedule to ensure full baseline coverage is always fresh. name: Coverage @@ -26,11 +27,14 @@ env: CI: true jobs: - unit-coverage: + tests: uses: ./.github/workflows/unit-tests.yml with: ref: ${{ inputs.ref }} + default: true coverage: true + cli: true + vector-api: true integration-tests: permissions: @@ -46,10 +50,9 @@ jobs: name: Merge and Upload Coverage runs-on: ubuntu-24.04 needs: - - unit-coverage + - tests - integration-tests - # Run even if some integration jobs failed — partial coverage is still valuable. - if: always() && needs.unit-coverage.result == 'success' + if: ${{ !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a6cc5179c4329..f772830911bbd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,6 +58,9 @@ jobs: needs: changes if: ${{ needs.changes.outputs.source == 'true' || needs.changes.outputs.test-yml == 'true' }} uses: ./.github/workflows/unit-tests.yml + with: + default: true + behavior: true secrets: inherit check-scripts: diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 627c848fe5a33..cf9e12f808d05 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -1,10 +1,18 @@ -# Reusable Workflow: Unit and Component Validation Tests +# Reusable Workflow: Tests +# +# Runs test suites via the Makefile. By default runs unit tests and component +# validation. Callers can enable additional suites (CLI, Vector API, behavior) +# via boolean inputs. # -# Runs unit tests and component validation tests via the Makefile. # With coverage: false (default), plain cargo-nextest; uploads test results to Datadog. # With coverage: true, cargo-llvm-cov (COVERAGE=1); uploads an lcov artifact named "coverage-unit". +# +# When `default` is true (the default), all nextest-based suites run in a +# single `make test` invocation with the relevant features enabled. When +# `default` is false, only the explicitly enabled suites run, using a nextest +# filter expression to select the matching tests. -name: Unit and Component Validation Tests +name: Tests on: workflow_call: @@ -18,6 +26,30 @@ on: required: false type: boolean default: false + default: + description: "Run the full default unit test suite (--workspace with default features)" + required: true + type: boolean + component-validation: + description: "Include component validation tests" + required: false + type: boolean + default: true + cli: + description: "Include CLI tests" + required: false + type: boolean + default: false + vector-api: + description: "Include Vector API tests" + required: false + type: boolean + default: false + behavior: + description: "Include behavior tests (cargo run based, no coverage)" + required: false + type: boolean + default: false permissions: contents: read @@ -27,8 +59,8 @@ env: CI: true jobs: - unit-tests: - name: Unit and Component Validation Tests + tests: + name: Tests runs-on: ubuntu-24.04-8core steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -44,22 +76,45 @@ jobs: protoc: true libsasl2: true - - name: Unit tests - run: make test - env: - COVERAGE: ${{ inputs.coverage }} + - name: Run tests + run: | + add_suite() { + local enabled="$1" feature="$2" filter="$3" + [[ "${enabled}" != "true" ]] && return + FEATURES="${FEATURES:+$FEATURES,}${feature}" + if [[ "${{ inputs.default }}" != "true" ]]; then + SCOPE="${SCOPE:+$SCOPE | }${filter}" + fi + } + + if [[ "${{ inputs.default }}" == "true" ]]; then + FEATURES="default" + else + FEATURES="" + fi - - name: Component validation tests - run: make test-component-validation + add_suite "${{ inputs.component-validation }}" "component-validation-tests" "test(components::validation::tests)" + add_suite "${{ inputs.cli }}" "cli-tests" "binary(=integration)" + add_suite "${{ inputs.vector-api }}" "vector-api-tests" "binary(=vector_api)" + + if [[ -n "${SCOPE}" ]]; then + make test FEATURES="${FEATURES}" SCOPE="-E '${SCOPE}'" + else + make test FEATURES="${FEATURES}" + fi env: COVERAGE: ${{ inputs.coverage }} + - name: Behavior tests + if: ${{ inputs.behavior }} + run: make test-behavior + - name: Upload test results to Datadog - if: "!inputs.coverage && always()" + if: ${{ !inputs.coverage && always() }} run: scripts/upload-test-results.sh - name: Generate lcov report - if: inputs.coverage + if: ${{ inputs.coverage }} run: | make coverage-report # Normalize absolute source paths to relative so they merge cleanly with @@ -67,7 +122,7 @@ jobs: sed -i "s|SF:$(pwd)/|SF:|g" lcov.info - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - if: inputs.coverage + if: ${{ inputs.coverage }} with: name: coverage-unit path: lcov.info diff --git a/Makefile b/Makefile index d38e032b1ff09..e16b3da0fdce9 100644 --- a/Makefile +++ b/Makefile @@ -421,11 +421,11 @@ test-e2e-kubernetes: ## Runs Kubernetes E2E tests (Sorry, no `ENVIRONMENT=true` .PHONY: test-cli test-cli: ## Runs cli tests - ${MAYBE_ENVIRONMENT_EXEC} cargo nextest run --no-fail-fast --no-default-features --features cli-tests --test integration --test-threads 4 + ${MAYBE_ENVIRONMENT_EXEC} ${TEST_RUNNER} --no-fail-fast --no-default-features --features cli-tests --test integration --test-threads 4 .PHONY: test-vector-api test-vector-api: ## Runs vector API tests (top and tap) - ${MAYBE_ENVIRONMENT_EXEC} cargo nextest run --no-fail-fast --no-default-features --features vector-api-tests --test vector_api + ${MAYBE_ENVIRONMENT_EXEC} ${TEST_RUNNER} --no-fail-fast --no-default-features --features vector-api-tests --test vector_api .PHONY: test-component-validation test-component-validation: ## Runs component validation tests From 02162c3cf7e58d2228314ecdf0764e257ddc2675 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 1 Apr 2026 19:06:26 -0400 Subject: [PATCH 06/19] fix(ci): pass ref to integration workflow for consistent coverage builds --- .github/workflows/coverage.yml | 1 + .github/workflows/integration.yml | 13 ++++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 79bbbb479f2cd..0b3dcd965e91e 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -44,6 +44,7 @@ jobs: with: run_all: true coverage: true + ref: ${{ inputs.ref }} secrets: inherit coverage-upload: diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 015d78b636fb9..8307199965363 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -19,6 +19,10 @@ on: required: false type: boolean default: false + ref: + description: "Git ref to checkout (defaults to github.sha)" + required: false + type: string workflow_dispatch: pull_request: merge_group: @@ -76,7 +80,8 @@ jobs: }} uses: ./.github/workflows/build-test-runner.yml with: - commit_sha: ${{ github.sha }} + commit_sha: ${{ inputs.ref || github.sha }} + checkout_ref: ${{ inputs.ref }} integration-tests: runs-on: ubuntu-24.04-8core @@ -128,6 +133,7 @@ jobs: if: steps.check.outputs.should_run == 'true' with: submodules: "recursive" + ref: ${{ inputs.ref || github.sha }} - uses: ./.github/actions/setup if: steps.check.outputs.should_run == 'true' @@ -142,7 +148,7 @@ jobs: uses: ./.github/actions/pull-test-runner with: github_token: ${{ secrets.GITHUB_TOKEN }} - commit_sha: ${{ github.sha }} + commit_sha: ${{ inputs.ref || github.sha }} - name: Run Integration Tests for ${{ matrix.service }} if: steps.check.outputs.should_run == 'true' @@ -204,6 +210,7 @@ jobs: if: steps.check.outputs.should_run == 'true' with: submodules: "recursive" + ref: ${{ inputs.ref || github.sha }} - uses: ./.github/actions/setup if: steps.check.outputs.should_run == 'true' @@ -218,7 +225,7 @@ jobs: uses: ./.github/actions/pull-test-runner with: github_token: ${{ secrets.GITHUB_TOKEN }} - commit_sha: ${{ github.sha }} + commit_sha: ${{ inputs.ref || github.sha }} - name: Run E2E Tests for ${{ matrix.service }} if: steps.check.outputs.should_run == 'true' From f80296f3f24844f6f90513e43e4053d15a2e5fec Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 3 Apr 2026 10:36:47 -0400 Subject: [PATCH 07/19] Format --- .github/workflows/coverage.yml | 2 +- .github/workflows/integration.yml | 4 ++-- .github/workflows/unit-tests.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index ff610f3c21136..e7cdfa74c4f99 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -16,7 +16,7 @@ on: type: string schedule: - - cron: "0 2 * * 0" # 2 AM UTC every Sunday + - cron: "0 2 * * 0" # 2 AM UTC every Sunday workflow_dispatch: permissions: diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 7c910fda03749..fe884bea2312f 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -69,8 +69,8 @@ jobs: if: ${{ always() && (github.event_name == 'workflow_dispatch' || - github.event_name == 'workflow_call' || - inputs.run_all == true || + github.event_name == 'workflow_call' || + inputs.run_all == true || (github.event_name == 'merge_group' && needs.changes.result == 'success' && (needs.changes.outputs.dependencies == 'true' || diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index cf9e12f808d05..291daa4400038 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -18,7 +18,7 @@ on: workflow_call: inputs: ref: - description: 'Git ref to checkout' + description: "Git ref to checkout" required: false type: string coverage: From fa35541f34019e200fc5b6ebffb578537f3cd641 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 3 Apr 2026 10:49:34 -0400 Subject: [PATCH 08/19] fix(ci): preserve coverage across multi-environment integration tests --- scripts/run-integration-test.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/run-integration-test.sh b/scripts/run-integration-test.sh index 2c6e71a61c5c5..94a7155078781 100755 --- a/scripts/run-integration-test.sh +++ b/scripts/run-integration-test.sh @@ -149,8 +149,12 @@ for TEST_ENV in "${TEST_ENVIRONMENTS[@]}"; do # Normalize source paths in coverage report so they are relative to the repo root. # The test runner container mounts source at /home/vector; strip that prefix so # Datadog can resolve files against the repository root (e.g. SF:src/foo.rs). + # Append each environment's coverage to a combined file so multi-env services + # preserve all coverage data (not just the last environment). if [[ "$COVERAGE" == "true" && "$RET" -eq 0 && -f target/coverage/lcov.info ]]; then sed -i 's|SF:/home/vector/|SF:|g' target/coverage/lcov.info + cat target/coverage/lcov.info >> target/coverage/lcov-combined.info + rm target/coverage/lcov.info fi # Upload test results only if the vdev test step ran @@ -169,4 +173,9 @@ for TEST_ENV in "${TEST_ENVIRONMENTS[@]}"; do fi done +# Promote combined coverage file to the expected output path +if [[ "$COVERAGE" == "true" && -f target/coverage/lcov-combined.info ]]; then + mv target/coverage/lcov-combined.info target/coverage/lcov.info +fi + exit 0 From e9a4ded61c50dc1b42beed678e4ed5d9474999c9 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 3 Apr 2026 10:54:09 -0400 Subject: [PATCH 09/19] fix(ci): always mount coverage volume in test runner container --- vdev/src/testing/integration.rs | 1 - vdev/src/testing/runner.rs | 11 +++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/vdev/src/testing/integration.rs b/vdev/src/testing/integration.rs index 6f2ed7e3f53d4..52ad365b63626 100644 --- a/vdev/src/testing/integration.rs +++ b/vdev/src/testing/integration.rs @@ -116,7 +116,6 @@ impl ComposeTest { runner_name, &config.runner, compose.is_some().then_some(network_name), - coverage, )?; env_config.insert("VECTOR_IMAGE".to_string(), Some(runner.image_name())); diff --git a/vdev/src/testing/runner.rs b/vdev/src/testing/runner.rs index 1c22f6bbf2759..12da9b445ad63 100644 --- a/vdev/src/testing/runner.rs +++ b/vdev/src/testing/runner.rs @@ -301,7 +301,6 @@ impl IntegrationTestRunner { integration: Option, config: &IntegrationRunnerConfig, network: Option, - coverage: bool, ) -> Result { let mut volumes: Vec = config .volumes @@ -311,11 +310,11 @@ impl IntegrationTestRunner { volumes.push(format!("{VOLUME_TARGET}:/output")); - if coverage { - let coverage_dir = std::path::Path::new(app::path()).join(LOCAL_COVERAGE_OUTPUT_DIR); - std::fs::create_dir_all(&coverage_dir)?; - volumes.push(format!("{}:{COVERAGE_OUTPUT_DIR}", coverage_dir.display())); - } + // Always mount the coverage directory so the container can be reused + // for coverage runs without needing to be recreated. + let coverage_dir = std::path::Path::new(app::path()).join(LOCAL_COVERAGE_OUTPUT_DIR); + std::fs::create_dir_all(&coverage_dir)?; + volumes.push(format!("{}:{COVERAGE_OUTPUT_DIR}", coverage_dir.display())); Ok(Self { integration, From 0c96e2c0e46140dd096d41dd23b2991e5687ec41 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 3 Apr 2026 11:18:01 -0400 Subject: [PATCH 10/19] fix(ci): clear stale combined coverage on retry and fix COVERAGE docs --- Makefile | 2 +- scripts/run-integration-test.sh | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index e684692372d29..f78cefe4d33f5 100644 --- a/Makefile +++ b/Makefile @@ -432,7 +432,7 @@ test-component-validation: ## Runs component validation tests ${MAYBE_ENVIRONMENT_EXEC} ${TEST_RUNNER} --no-fail-fast --no-default-features --features component-validation-tests --status-level pass --test-threads 4 --lib components::validation::tests .PHONY: coverage-report -coverage-report: ## Generate lcov report after running tests with COVERAGE=1 (outputs lcov.info) +coverage-report: ## Generate lcov report after running tests with COVERAGE=true (outputs lcov.info) cargo llvm-cov report --lcov --output-path lcov.info ##@ Benching (Supports `ENVIRONMENT=true`) diff --git a/scripts/run-integration-test.sh b/scripts/run-integration-test.sh index 94a7155078781..2aed29634712c 100755 --- a/scripts/run-integration-test.sh +++ b/scripts/run-integration-test.sh @@ -118,6 +118,10 @@ else fi fi +# Remove stale combined coverage from a previous (possibly failed) attempt so +# retries via nick-fields/retry don't append to leftover data. +rm -f target/coverage/lcov-combined.info + for TEST_ENV in "${TEST_ENVIRONMENTS[@]}"; do # Execution flow for each environment: # 1. Clean up previous test output From 8d9f8664f964212a0f6b050cbd004a543a8446ca Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 3 Apr 2026 11:31:27 -0400 Subject: [PATCH 11/19] chore(ci): document E2E coverage instrumentation limitation --- .github/workflows/coverage.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index e7cdfa74c4f99..289ebf4cfaed6 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -4,6 +4,11 @@ # integration) with code coverage instrumentation on a single commit. Results # are merged into one lcov report and uploaded to Datadog. Also runs on a # weekly schedule to ensure full baseline coverage is always fresh. +# +# Limitation: integration and E2E coverage only instruments the test runner +# (cargo-nextest), not the separately-launched Vector binary inside compose +# services. Instrumenting the service binary would require building it with +# cargo-llvm-cov and merging its runtime profdata, which is out of scope here. name: Coverage From 3d9ca5f1de36b9a18e58c4324fce8331632ab7ff Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 15 Apr 2026 14:03:37 -0400 Subject: [PATCH 12/19] fix(ci): write per-environment coverage files to prevent overwrites --- scripts/run-integration-test.sh | 13 +++++++---- vdev/src/commands/compose_tests/test.rs | 31 +++++++++++++++++++++++++ vdev/src/commands/test.rs | 1 + vdev/src/testing/integration.rs | 5 ++++ vdev/src/testing/runner.rs | 20 +++++++++++++--- 5 files changed, 63 insertions(+), 7 deletions(-) diff --git a/scripts/run-integration-test.sh b/scripts/run-integration-test.sh index 2aed29634712c..99cd712cded2c 100755 --- a/scripts/run-integration-test.sh +++ b/scripts/run-integration-test.sh @@ -155,10 +155,15 @@ for TEST_ENV in "${TEST_ENVIRONMENTS[@]}"; do # Datadog can resolve files against the repository root (e.g. SF:src/foo.rs). # Append each environment's coverage to a combined file so multi-env services # preserve all coverage data (not just the last environment). - if [[ "$COVERAGE" == "true" && "$RET" -eq 0 && -f target/coverage/lcov.info ]]; then - sed -i 's|SF:/home/vector/|SF:|g' target/coverage/lcov.info - cat target/coverage/lcov.info >> target/coverage/lcov-combined.info - rm target/coverage/lcov.info + # vdev now writes per-environment coverage files (lcov-{env}.info). + # Find the file for this environment and append it to the combined output. + if [[ "$COVERAGE" == "true" && "$RET" -eq 0 ]]; then + LCOV_FILE="target/coverage/lcov-${TEST_ENV}.info" + if [[ -f "$LCOV_FILE" ]]; then + sed -i 's|SF:/home/vector/|SF:|g' "$LCOV_FILE" + cat "$LCOV_FILE" >> target/coverage/lcov-combined.info + rm "$LCOV_FILE" + fi fi # Upload test results only if the vdev test step ran diff --git a/vdev/src/commands/compose_tests/test.rs b/vdev/src/commands/compose_tests/test.rs index ab4f36693c8e6..1dee20b7a24ed 100644 --- a/vdev/src/commands/compose_tests/test.rs +++ b/vdev/src/commands/compose_tests/test.rs @@ -5,6 +5,7 @@ use anyhow::{Result, bail}; use crate::testing::{ config::ComposeTestConfig, integration::{ComposeTest, ComposeTestLocalConfig}, + runner::{LOCAL_COVERAGE_OUTPUT_DIR, coverage_filename}, }; use super::active_projects::find_active_environment_for_integration; @@ -33,9 +34,39 @@ pub fn exec( (None, None) => Box::new(envs.keys()), }; + let mut ran_environments: Vec = Vec::new(); for environment in environments { ComposeTest::generate(local_config, integration, environment, retries, coverage)? .test(args.to_owned())?; + if coverage { + ran_environments.push(environment.clone()); + } + } + + // Merge per-environment coverage files into a single lcov.info when + // multiple environments were tested. + if coverage && ran_environments.len() > 1 { + let coverage_dir = std::path::Path::new(LOCAL_COVERAGE_OUTPUT_DIR); + let merged_path = coverage_dir.join(coverage_filename(None)); + let mut merged = String::new(); + for env_name in &ran_environments { + let env_file = coverage_dir.join(coverage_filename(Some(env_name))); + match std::fs::read_to_string(&env_file) { + Ok(contents) => { + merged.push_str(&contents); + // Clean up per-environment file after merging. + let _ = std::fs::remove_file(&env_file); + } + Err(e) => { + warn!("Could not read coverage file {}: {e}", env_file.display()); + } + } + } + if !merged.is_empty() { + std::fs::write(&merged_path, merged)?; + info!("Merged coverage from {} environments into {}", ran_environments.len(), merged_path.display()); + } } + Ok(()) } diff --git a/vdev/src/commands/test.rs b/vdev/src/commands/test.rs index d3f6cba96b654..deb89c19b4fa6 100644 --- a/vdev/src/commands/test.rs +++ b/vdev/src/commands/test.rs @@ -54,6 +54,7 @@ impl Cli { &args, false, // Don't pre-build Vector for direct test runs false, + None, ) } } diff --git a/vdev/src/testing/integration.rs b/vdev/src/testing/integration.rs index 52ad365b63626..a5618f39ddac2 100644 --- a/vdev/src/testing/integration.rs +++ b/vdev/src/testing/integration.rs @@ -234,6 +234,11 @@ impl ComposeTest { &args, self.local_config.kind == ComposeTestKind::E2E, self.coverage, + if self.coverage { + Some(self.environment.as_str()) + } else { + None + }, )?; Ok(()) diff --git a/vdev/src/testing/runner.rs b/vdev/src/testing/runner.rs index 12da9b445ad63..1e338b64797ab 100644 --- a/vdev/src/testing/runner.rs +++ b/vdev/src/testing/runner.rs @@ -39,7 +39,7 @@ const COVERAGE_COMMAND: &[&str] = &[ /// Coverage output path inside the test runner container. const COVERAGE_OUTPUT_DIR: &str = "/coverage"; /// Coverage output path on the host (relative to project root). -const LOCAL_COVERAGE_OUTPUT_DIR: &str = "target/coverage"; +pub(crate) const LOCAL_COVERAGE_OUTPUT_DIR: &str = "target/coverage"; // The upstream container we publish artifacts to on a successful master build. const UPSTREAM_IMAGE: &str = "docker.io/timberio/vector-dev:latest"; @@ -63,6 +63,7 @@ pub fn get_agent_test_runner(container: bool) -> Result> { } pub trait TestRunner { + #[allow(clippy::too_many_arguments)] fn test( &self, outer_env: &Environment, @@ -71,9 +72,18 @@ pub trait TestRunner { args: &[String], build: bool, coverage: bool, + coverage_env: Option<&str>, ) -> Result<()>; } +/// Return the coverage output filename, optionally scoped to an environment. +pub(crate) fn coverage_filename(coverage_env: Option<&str>) -> String { + match coverage_env { + Some(env) => format!("lcov-{env}.info"), + None => "lcov.info".to_string(), + } +} + pub trait ContainerTestRunner: TestRunner { fn container_name(&self) -> String; @@ -250,6 +260,7 @@ where args: &[String], build: bool, coverage: bool, + coverage_env: Option<&str>, ) -> Result<()> { self.ensure_running(features, config_environment_variables, build)?; @@ -276,10 +287,11 @@ where }); command.args(args); if coverage { + let filename = coverage_filename(coverage_env); command.args([ "--lcov", "--output-path", - &format!("{COVERAGE_OUTPUT_DIR}/lcov.info"), + &format!("{COVERAGE_OUTPUT_DIR}/{filename}"), ]); } @@ -433,6 +445,7 @@ impl TestRunner for LocalTestRunner { args: &[String], _build: bool, coverage: bool, + coverage_env: Option<&str>, ) -> Result<()> { let test_cmd = if coverage { COVERAGE_COMMAND @@ -444,10 +457,11 @@ impl TestRunner for LocalTestRunner { command.args(args); if coverage { std::fs::create_dir_all(LOCAL_COVERAGE_OUTPUT_DIR)?; + let filename = coverage_filename(coverage_env); command.args([ "--lcov", "--output-path", - &format!("{LOCAL_COVERAGE_OUTPUT_DIR}/lcov.info"), + &format!("{LOCAL_COVERAGE_OUTPUT_DIR}/{filename}"), ]); } From 2debfbb91a537f4962c88f1590bdedb437721ee2 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 16 Apr 2026 16:54:04 -0400 Subject: [PATCH 13/19] fix(ci): address PR review on coverage suite --- .github/workflows/changes.yml | 1 + .github/workflows/unit-tests.yml | 4 ++-- scripts/run-integration-test.sh | 4 +--- vdev/src/commands/compose_tests/test.rs | 14 +++++++++----- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/changes.yml b/.github/workflows/changes.yml index 999548111e9a7..efb472db89a3b 100644 --- a/.github/workflows/changes.yml +++ b/.github/workflows/changes.yml @@ -280,6 +280,7 @@ jobs: - ".github/workflows/changes.yml" test-yml: - ".github/workflows/test.yml" + - ".github/workflows/unit-tests.yml" - ".github/workflows/changes.yml" integration-yml: - ".github/workflows/integration.yml" diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 291daa4400038..f2793d9f901e3 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -5,7 +5,7 @@ # via boolean inputs. # # With coverage: false (default), plain cargo-nextest; uploads test results to Datadog. -# With coverage: true, cargo-llvm-cov (COVERAGE=1); uploads an lcov artifact named "coverage-unit". +# With coverage: true, cargo-llvm-cov (COVERAGE=true); uploads an lcov artifact named "coverage-unit". # # When `default` is true (the default), all nextest-based suites run in a # single `make test` invocation with the relevant features enabled. When @@ -22,7 +22,7 @@ on: required: false type: string coverage: - description: "Collect code coverage (sets COVERAGE=1 for make targets)" + description: "Collect code coverage (sets COVERAGE=true for make targets)" required: false type: boolean default: false diff --git a/scripts/run-integration-test.sh b/scripts/run-integration-test.sh index 99cd712cded2c..2657116eeb2e6 100755 --- a/scripts/run-integration-test.sh +++ b/scripts/run-integration-test.sh @@ -155,10 +155,8 @@ for TEST_ENV in "${TEST_ENVIRONMENTS[@]}"; do # Datadog can resolve files against the repository root (e.g. SF:src/foo.rs). # Append each environment's coverage to a combined file so multi-env services # preserve all coverage data (not just the last environment). - # vdev now writes per-environment coverage files (lcov-{env}.info). - # Find the file for this environment and append it to the combined output. if [[ "$COVERAGE" == "true" && "$RET" -eq 0 ]]; then - LCOV_FILE="target/coverage/lcov-${TEST_ENV}.info" + LCOV_FILE="target/coverage/lcov.info" if [[ -f "$LCOV_FILE" ]]; then sed -i 's|SF:/home/vector/|SF:|g' "$LCOV_FILE" cat "$LCOV_FILE" >> target/coverage/lcov-combined.info diff --git a/vdev/src/commands/compose_tests/test.rs b/vdev/src/commands/compose_tests/test.rs index 1dee20b7a24ed..50b6aed05575c 100644 --- a/vdev/src/commands/compose_tests/test.rs +++ b/vdev/src/commands/compose_tests/test.rs @@ -43,9 +43,10 @@ pub fn exec( } } - // Merge per-environment coverage files into a single lcov.info when - // multiple environments were tested. - if coverage && ran_environments.len() > 1 { + // Consolidate per-environment coverage files into the canonical lcov.info + // so callers get a single, predictable output path regardless of how many + // environments ran. + if coverage && !ran_environments.is_empty() { let coverage_dir = std::path::Path::new(LOCAL_COVERAGE_OUTPUT_DIR); let merged_path = coverage_dir.join(coverage_filename(None)); let mut merged = String::new(); @@ -54,7 +55,6 @@ pub fn exec( match std::fs::read_to_string(&env_file) { Ok(contents) => { merged.push_str(&contents); - // Clean up per-environment file after merging. let _ = std::fs::remove_file(&env_file); } Err(e) => { @@ -64,7 +64,11 @@ pub fn exec( } if !merged.is_empty() { std::fs::write(&merged_path, merged)?; - info!("Merged coverage from {} environments into {}", ran_environments.len(), merged_path.display()); + info!( + "Wrote coverage for {} environment(s) to {}", + ran_environments.len(), + merged_path.display() + ); } } From 7c4006c10e8b9ffa64983c6afb8361d692434b4c Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 16 Apr 2026 17:10:34 -0400 Subject: [PATCH 14/19] fix(ci): scope CLI test filter to root vector package --- .github/workflows/unit-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index f2793d9f901e3..226016f7954e5 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -94,7 +94,7 @@ jobs: fi add_suite "${{ inputs.component-validation }}" "component-validation-tests" "test(components::validation::tests)" - add_suite "${{ inputs.cli }}" "cli-tests" "binary(=integration)" + add_suite "${{ inputs.cli }}" "cli-tests" "package(vector) & binary(=integration)" add_suite "${{ inputs.vector-api }}" "vector-api-tests" "binary(=vector_api)" if [[ -n "${SCOPE}" ]]; then From e0082be4c45c3d42ed07eae3fc81b67982e69f7b Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 17 Apr 2026 15:21:04 -0400 Subject: [PATCH 15/19] fix(ci): skip make test when no nextest suites are selected --- .github/workflows/unit-tests.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 226016f7954e5..90303ac76f06f 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -97,10 +97,15 @@ jobs: add_suite "${{ inputs.cli }}" "cli-tests" "package(vector) & binary(=integration)" add_suite "${{ inputs.vector-api }}" "vector-api-tests" "binary(=vector_api)" - if [[ -n "${SCOPE}" ]]; then + # Skip `make test` entirely if no nextest-based suite was selected + # (e.g. behavior-only runs). Running `make test` with empty FEATURES + # would otherwise execute the workspace-wide suite. + if [[ "${{ inputs.default }}" == "true" ]]; then + make test FEATURES="${FEATURES}" + elif [[ -n "${SCOPE}" ]]; then make test FEATURES="${FEATURES}" SCOPE="-E '${SCOPE}'" else - make test FEATURES="${FEATURES}" + echo "No nextest suites selected; skipping 'make test'" fi env: COVERAGE: ${{ inputs.coverage }} From 4d9b2473c34b2de8ed2cc3fdace6ea76dcbc87a9 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 17 Apr 2026 17:42:25 -0400 Subject: [PATCH 16/19] fix(ci): remove stale lcov.info before merging per-env coverage --- vdev/src/commands/compose_tests/test.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vdev/src/commands/compose_tests/test.rs b/vdev/src/commands/compose_tests/test.rs index 50b6aed05575c..e78b4c3413a03 100644 --- a/vdev/src/commands/compose_tests/test.rs +++ b/vdev/src/commands/compose_tests/test.rs @@ -49,6 +49,9 @@ pub fn exec( if coverage && !ran_environments.is_empty() { let coverage_dir = std::path::Path::new(LOCAL_COVERAGE_OUTPUT_DIR); let merged_path = coverage_dir.join(coverage_filename(None)); + // Remove any stale lcov.info from a previous run so callers never pick + // up outdated data if the merge below fails to read a per-env file. + let _ = std::fs::remove_file(&merged_path); let mut merged = String::new(); for env_name in &ran_environments { let env_file = coverage_dir.join(coverage_filename(Some(env_name))); From 2e72af32a795aee850d5c5492697483cd25fc5f5 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 22 Apr 2026 16:47:48 -0400 Subject: [PATCH 17/19] chore(ci): fix component-validation input and add OS-based FEATURES default --- .github/workflows/test.yml | 2 +- .github/workflows/unit-tests.yml | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cd85f0349fe40..84a4c31226b1f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,7 +61,7 @@ jobs: uses: ./.github/workflows/unit-tests.yml with: default: true - behavior: true + component-validation: true secrets: inherit check-scripts: diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 90303ac76f06f..098fa6c6db20d 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -87,8 +87,16 @@ jobs: fi } + # Mirror the Makefile's OS-based default: `default-msvc` on Windows, + # `default` elsewhere. Keep in sync with the FEATURES defaults in Makefile. + if [[ "${RUNNER_OS}" == "Windows" ]]; then + DEFAULT_FEATURES="default-msvc" + else + DEFAULT_FEATURES="default" + fi + if [[ "${{ inputs.default }}" == "true" ]]; then - FEATURES="default" + FEATURES="${DEFAULT_FEATURES}" else FEATURES="" fi From 1156c3f2fb18704644aa96a7d50735c8ef92e598 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 22 Apr 2026 17:56:20 -0400 Subject: [PATCH 18/19] Add DD_ENV/DD_API_KEY to unit-tests.yml --- .github/workflows/unit-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 098fa6c6db20d..2dd0d823de918 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -57,6 +57,8 @@ permissions: env: CARGO_INCREMENTAL: "0" CI: true + DD_ENV: "ci" + DD_API_KEY: ${{ secrets.DD_API_KEY }} jobs: tests: From b6e8e2cfec90e6639738a6485475d47d1bd36d3b Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 27 Apr 2026 11:32:29 -0400 Subject: [PATCH 19/19] chore(ci): grant id-token: write to coverage caller jobs --- .github/workflows/coverage.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 0003ed92a35e3..5af2787d2b90f 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -33,6 +33,9 @@ env: jobs: tests: + permissions: + contents: read + id-token: write uses: ./.github/workflows/unit-tests.yml with: ref: ${{ inputs.ref }} @@ -45,6 +48,7 @@ jobs: permissions: contents: read packages: write + id-token: write uses: ./.github/workflows/integration.yml with: run_all: true