Skip to content

[APMSVLS-501] feat(bottlecap): add bottlecap-test-mode binary - #1216

Draft
lucaspimentel wants to merge 4 commits into
lpimentel/bottlecap-test-modefrom
lpimentel/bottlecap-testmode-binary
Draft

[APMSVLS-501] feat(bottlecap): add bottlecap-test-mode binary#1216
lucaspimentel wants to merge 4 commits into
lpimentel/bottlecap-test-modefrom
lpimentel/bottlecap-testmode-binary

Conversation

@lucaspimentel

@lucaspimentel lucaspimentel commented Apr 29, 2026

Copy link
Copy Markdown
Member

Part of a PR stack:

  1. [APMSVLS-501] refactor(bottlecap): preparatory work for a new "test-mode" binary #1344
  2. [APMSVLS-501] feat(bottlecap): add bottlecap-test-mode binary #1216 👈🏽 this PR

Overview

Adds a second [[bin]] target, bottlecap-test-mode, that runs the APM trace-processing surface as a long-lived HTTP server with no AWS Lambda Extension lifecycle. Reuses TraceAgent, FlushingService, and the trace/stats/proxy flushers built by bottlecap::startup::build_trace_agent, which this PR extracts from the Lambda binary (see below); the only Lambda-binary code it duplicates is init_ustr and enable_logging_subsystem (~20 lines, called out in the design doc).

Endpoints on 127.0.0.1:8126:

  • /v0.4/traces, /v0.5/traces, /v0.6/stats, /info — unchanged from the Lambda binary's TraceAgent router.
  • POST /flush — new, registered by a FlushRouterExtension impl attached via TraceAgent::with_router_extension(...) (the seam from [APMSVLS-501] refactor(bottlecap): preparatory work for a new "test-mode" binary #1344). Calls FlushingService::flush_blocking_final() in a spawned task bounded at 30s: 204 No Content on success, 500 if the flush panics, 504 after aborting a timed-out flush. flush_blocking_final expect()s on the metrics aggregator handle, so without the spawn a dead aggregator would panic the connection task and the harness would see a dropped connection rather than a status it can act on; the per-flusher HTTP timeouts also bound only individual calls, so stacked retries could otherwise outlive any sensible harness wait.

Configuration: same DD_* env vars as the Lambda binary. Notable inputs:

  • DD_APM_DD_URL — redirects trace intake (the parity harness points this at the fake-intake from [APMSVLS-497][APMSVLS-498] test: add fake-intake for APM payload-level tests #1194).
  • DD_SERVERLESS_FLUSH_STRATEGY — opt-in periodic flush ticker, decoupled from managed-instance mode.
  • DD_TESTMODE_FUNCTION_ARN — overrides the stub ARN used for tag generation (defaults to arn:aws:lambda:us-east-1:000000000000:function:testmode).

API key is hardcoded to "stub-key" (no secrets resolver path); the parity harness fake-intake ignores auth.

bottlecap::startup extraction

This PR also promotes start_trace_agent out of src/bin/bottlecap/main.rs into a new top-level library module, bottlecap::startup, and splits it into:

  • build_trace_agent — returns an unspawned TraceAgent plus a TraceAgentPipeline struct with named pub fields (flushers, trace-channel sender, shutdown token, aggregator/concentrator handles).
  • start_trace_agent — thin wrapper that spawns the agent. The Lambda binary's call site is unchanged.

bottlecap-test-mode needs the unspawned agent so it can attach its /flush RouterExtension before spawning, which is why the split exists. Placed at the crate root rather than under traces/ because it wires trace, stats, proxy, lifecycle, tags, appsec, and flushing together: cross-cutting orchestration, not a trace-domain API.

This extraction was originally part of #1344 and moved here after review feedback: with no second binary in that PR, build_trace_agent had no caller and the public module looked unmotivated. Landing it next to its first consumer makes the shared-library placement self-evident. Behavior is unchanged for the Lambda binary; trace_agent.rs changes are two retargeted rustdoc links.

Feature gating

Gated behind the test-mode cargo feature via required-features = ["test-mode"], the same feature that gates InvocationProcessorHandle::noop() (added in #1344). The binary is therefore not built in default or fips builds, including by cargo build --workspace. Build/run with:

cargo build --bin bottlecap-test-mode --features test-mode
cargo run   --bin bottlecap-test-mode --features test-mode

No CI job currently builds or lints with --features test-mode, so this binary is invisible to CI as things stand. Tracked in Follow-ups below.

Shutdown ordering

signal::ctrl_c() cancels the shutdown token first (drives axum's graceful_shutdown so any in-flight /v0.4/traces request drains through the trace aggregator), then runs flush_blocking_final(). The periodic flush task selects on the same token so it doesn't leak when the listener stops.

Why a second binary

The overlap between Lambda-mode and test-mode is small (~20 lines: init_ustr, logging, config load), and the rest is intentionally different (no telemetry listener, no LWA, no logs agent, no proxy, no DogStatsD UDP, no event-bus-driven lifecycle). A second [[bin]] makes the test-mode surface explicit and structural, enforced by the compiler instead of by a runtime branch. Rejected alternatives (env-var branch, auto-detect, CLI flag, single binary with mode gating) are in the design doc.

Design doc: lucas-pimentel/docs/bottlecap-test-mode.md (local; happy to land it in-repo if reviewers prefer).

Testing

  • cargo check --bin bottlecap-test-mode --features test-mode
  • cargo clippy --workspace --all-targets --features default -- -D warnings (existing surface unchanged)
  • cargo clippy --workspace --all-targets --features default,test-mode -- -D warnings
  • cargo fmt --all -- --check
  • cargo test -p bottlecap --lib — 548 passed (unchanged from [APMSVLS-501] refactor(bottlecap): preparatory work for a new "test-mode" binary #1344; the extraction is a pure move)
  • Manual smoke test against a local fake intake on :8200:
    • POST /v0.4/traces (real msgpack span) → 200, buffered, periodic flush fired at 2s, intake received POST /api/v0.2/traces (485 bytes, DD-API-KEY: stub-key).
    • POST /flush → 204. (Predates the timeout/panic guard in 4efbc0dd; the 500 and 504 paths were verified separately against an extracted copy of the handler, not against the running binary.)
    • POST /v0.5/traces (malformed) → 500 (correct rejection from the existing v0.5 deserializer).
    • GET /info → 200 with the standard endpoints list.
    • SIGINT → TRACE_AGENT | Shutdown signal received, shutting downAggregator service stopped → clean exit.
    • Span dedup verified: same trace_id+span_id sent twice, second dropped by the existing dedup service.

No new unit tests in this PR. The seam (RouterExtension) and the no-op handle (InvocationProcessorHandle::noop()) are both covered by tests added in #1344; end-to-end coverage for test-mode lands as part of the parity harness (#1194 and the future apm-agent-parity-rs repo).

Follow-ups

Known gaps, called out so they are not mistaken for oversights. Neither blocks the binary from working; happy to fold either into this PR if reviewers prefer.

  • CI does not exercise --features test-mode. Existing jobs run --features default and --no-default-features --features fips, and cargo build --all skips required-features targets, so nothing in CI compiles this binary. A clippy/build step passing --features test-mode would keep it green; without it, a change to library code can break this target without any job failing. Addressed: a cargo clippy --workspace --features default,test-mode step was added to both the GitHub Actions and GitLab pipelines.
  • The POST /flush guard in 4efbc0dd has no committed test. The 204/500/504 paths were verified manually, but the test-mode binary has no #[cfg(test)] module. Testing it in place needs the handler factored out of the closure, or FlushingService made injectable, so it is deliberately deferred rather than bolted on.

🤖 Generated with Claude Code

@datadog-prod-us1-5

datadog-prod-us1-5 Bot commented Aug 26, 2026

Copy link
Copy Markdown

Pipelines  Tests

Unblock PR with BitsAI

⚠️ Warnings

Your PR has failed checks. Please review the issues below and take necessary action before merging.

🚦 2 Pipeline jobs failed

DataDog/datadog-lambda-extension | e2e-test-status (amd64, fips) — 🔧 Needs a code fix, caused by this PR

View more details · View in GitLab

DataDog/datadog-lambda-extension | e2e-test-status (amd64)

View more details · View in GitLab

ℹ️ Info

🔄 Datadog auto-retried 1 job - 1 passed on retry View in Datadog

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: b0f9bcc | Docs | View more details | Give us feedback!

@lucaspimentel lucaspimentel changed the title [APMSVLS-501] feat(bottlecap): add bottlecap-test-mode binary [APMSVLS-501] feat(bottlecap): add bottlecap-test-mode binary Aug 27, 2026
@lucaspimentel
lucaspimentel force-pushed the lpimentel/bottlecap-test-mode branch from b7bfcb6 to abb35a3 Compare September 2, 2026 20:29
@lucaspimentel
lucaspimentel force-pushed the lpimentel/bottlecap-testmode-binary branch from 4efbc0d to 0145da0 Compare September 2, 2026 20:56
@lucaspimentel

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-03T16:55:47.384545Z 0145da0 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0145da0538

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +208 to +210
let mut task = tokio::task::spawn(async move {
fs.flush_blocking_final().await;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Drain queued inputs before servicing /flush

When a client calls /flush immediately after an accepted /v0.4, /v0.5, or /v0.6 request, the payload may still be in TraceAgent's intermediate MPSC channel: the request handlers await enqueueing, but the separate receiver tasks have not necessarily inserted it into the trace or stats aggregator. This flush can therefore issue the aggregator drain first, return 204, and leave the just-accepted payload buffered until another flush, making the parity harness nondeterministic; establish a barrier with both receiver tasks before flushing.

AGENTS.md reference: AGENTS.md:L40-L56

Useful? React with 👍 / 👎.

Comment thread bottlecap/src/startup.rs
stats_aggregator.clone(),
Arc::clone(config),
trace_http_client.clone(),
libdd_trace_utils::config_utils::trace_stats_url(&config.site),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make the stats intake configurable in test mode

When the parity harness points the advertised DD_APM_DD_URL at fake-intake and submits client-computed data to /v0.6/stats, this constructs the stats destination solely from DD_SITE, so traces reach fake-intake while stats are sent toward a Datadog stats host with the stub API key. The new binary consequently cannot exercise its advertised stats accept/aggregate/flush path against the local intake; test mode needs a stats URL override or must derive the fake-intake stats endpoint from its custom APM destination.

Useful? React with 👍 / 👎.

Comment on lines +211 to +212
match tokio::time::timeout(FLUSH_REQUEST_TIMEOUT, &mut task).await {
Ok(Ok(())) => StatusCode::NO_CONTENT,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Report downstream flush failures instead of 204

If fake-intake is unavailable or returns a non-success status, TraceFlusher, StatsFlusher, and the proxy/log flushers return retry payloads rather than panicking, while FlushingService::flush_blocking_final() discards every result and still returns (). The join therefore succeeds and this branch returns 204 even though data was drained and not delivered, giving the harness a false successful-drain signal; propagate the flusher results and map any exhausted send failure to a non-2xx response.

AGENTS.md reference: AGENTS.md:L23-L23

Useful? React with 👍 / 👎.

Comment on lines +181 to +182
shutdown_token.cancel();
flushing_service.flush_blocking_final().await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Await graceful shutdown before the final drain

When SIGINT arrives while a trace or stats request is in flight, cancellation only signals axum's graceful-shutdown future; the listener's discarded JoinHandle is not awaited before flush_blocking_final() starts. The flush can drain the aggregators before the request handler finishes processing and before the intermediate channel receivers forward its payload, after which main exits and drops that data; retain and await the listener task before performing the final flush.

AGENTS.md reference: AGENTS.md:L40-L56

Useful? React with 👍 / 👎.

Comment on lines +152 to +155
tokio::spawn(async move {
if let Err(e) = trace_agent.start().await {
error!("Error starting trace agent: {e:?}");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exit when the trace listener fails to start

If port 8126 is already occupied or axum otherwise fails during startup, this detached task only logs the error and terminates while main continues waiting indefinitely for Ctrl-C. A harness therefore sees a live test-mode process with no listener—or may accidentally connect to the process already using the port—instead of a startup failure; propagate the listener result to main or coordinate startup through a channel.

Useful? React with 👍 / 👎.

@lucaspimentel
lucaspimentel force-pushed the lpimentel/bottlecap-test-mode branch from abb35a3 to 211d68c Compare September 3, 2026 21:20
lucaspimentel and others added 4 commits September 3, 2026 17:21
Moves start_trace_agent out of the Lambda binary into bottlecap::startup
so both [[bin]] targets can share it, and splits it into:

- build_trace_agent, which returns an unspawned TraceAgent plus a
  TraceAgentPipeline handle struct
- start_trace_agent, a thin wrapper that spawns the agent

bottlecap-test-mode needs the unspawned agent so it can attach its
/flush RouterExtension before spawning; the Lambda binary keeps calling
start_trace_agent and its call site is unchanged.

Placed at the crate root rather than under traces/ because it wires
trace, stats, proxy, lifecycle, tags, appsec, and flushing together.
A second [[bin]] target that runs the APM trace-processing surface as a
long-lived HTTP server with no Lambda lifecycle. Listens on
127.0.0.1:8126 and exposes the standard tracer endpoints
(/v0.4/traces, /v0.5/traces, /v0.6/stats, /info) plus POST /flush for
deterministic harness-driven flushing. Configured by the same DD_* env
vars the Lambda binary reads. Optional periodic flushing via
DD_SERVERLESS_FLUSH_STRATEGY (decoupled from managed-instance mode).

Gated behind the `test-mode` cargo feature (required-features), so it is
not built in default or fips builds. Build with
`cargo build --bin bottlecap-test-mode --features test-mode`.

Intended for the cross-agent parity harness (APMSVLS-496) and for local
dev workflows that need a tracer endpoint without standing up a Lambda.

APMSVLS-501

🤖 Co-Authored-By: Claude Code <noreply@anthropic.com>
flush_blocking_final expects on the metrics aggregator handle, so a dead
aggregator task panicked the connection task and the harness saw a
dropped connection instead of a status it could act on. The five flushers
also bound only their individual HTTP calls, so stacked retries could
leave a request outstanding far longer than a harness should wait.

Runs the flush in a spawned task and caps it at 30s: 204 on success, 500
if the task panics, 504 after aborting a timed-out flush.

Restores the hardening that previously lived in the trace agent's
hardcoded /flush handler, now on the consumer side where the route lives.
The bottlecap-test-mode binary is gated behind required-features, so
no existing CI job compiles it and a change to library code could
break it without any job failing. Add a clippy pass with the test-mode
feature enabled to both the GitHub Actions and GitLab pipelines.
@lucaspimentel
lucaspimentel force-pushed the lpimentel/bottlecap-testmode-binary branch from 0145da0 to b0f9bcc Compare September 3, 2026 21:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant