Skip to content

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

Open
lucaspimentel wants to merge 26 commits into
mainfrom
lpimentel/bottlecap-testmode-binary
Open

lucaspimentel wants to merge 26 commits into
mainfrom
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 bottlecap-test-mode binary: the APM trace-processing surface as a long-lived HTTP server, with no Lambda Extension lifecycle. It powers the APM parity harness (#1194), which drives fixture payloads through three agents and diffs what reaches the fake intake, and enables local tracer debugging without Lambda RIE.

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

Why

Today, bottlecap's trace processing is only reachable through a live Lambda invocation: the trace agent runs inside the extension's lifecycle, so exercising it requires Lambda RIE, and flush timing is whatever the invocation dictates. The parity harness needs a deterministic target it can drive with fixture payloads and diff against a fake intake, and local tracer debugging should not require standing up RIE. This binary exposes the same pipeline as a plain HTTP server with explicit flush control via POST /flush.

Design

  • Second [[bin]] instead of a runtime branch: it shares only ~20 lines with the Lambda binary (config, logging); a separate target makes the reduced surface (no telemetry listener, LWA, logs agent, proxy, DogStatsD) compiler-enforced. Gated behind the test-mode feature, so default, fips, and cargo build --workspace are unaffected.
  • Reuses the Lambda pipeline: TraceAgent, FlushingService, and the trace/stats flushers, so it exercises the same code paths. start_trace_agent moved into a bottlecap::startup library module, split into build_trace_agent (unspawned agent + named handles) and the spawning wrapper, because test-mode must attach POST /flush before spawning. The Lambda call site is unchanged.
  • Endpoints: /v0.4/traces, /v0.5/traces, /v0.6/stats, /info come unchanged from TraceAgent's router (proxy routes included). POST /flush drains the ingest barrier and runs a final blocking flush, bounded at 30s: 204 on full delivery, 502 on lost payloads, 504 on timeout, 500 on panic.
  • Config: same DD_* env vars as Lambda. DD_APM_DD_URL redirects the intake, DD_APM_RECEIVER_PORT overrides the listener port (the Go trace agent's variable; invalid values fail startup), DD_SERVERLESS_FLUSH_STRATEGY opts into a periodic flush ticker, DD_TESTMODE_FUNCTION_ARN overrides the stub ARN. API key is hardcoded to "stub-key".

Determinism fixes in the library crate

The harness must observe the right answer through HTTP alone. Four gaps, none of which change production behavior:

  • Ingest barrier (traces/trace_agent.rs): a 200 could return while the payload was still queued, so an immediate /flush missed it. Forwarders now only acknowledge when their channel is empty, and /flush awaits TraceAgent::ingest_barrier() first. A dead forwarder reports loss instead of success.
  • Stats follow the redirected intake (startup.rs): otherwise a redirected harness sent stats to Datadog with the stub key, leaving /v0.6/stats unexercised.
  • Flush failures are reported (flushing/service.rs): the blocking flush discarded all flusher results, so /flush answered 204 after dropping payloads. It now returns whether any flusher gave up.
  • Flushes are serialized (test-mode binary): the periodic driver, /flush, and shutdown drain shared one FlushingService, letting one report success on empty queues while the other's send was still in flight. All three run under a shared lock.

Shutdown

Exits non-zero if the listener fails to bind instead of idling. SIGINT/SIGTERM cancel the listener (10s grace, then forced abort, since Axum connection tasks can outlive it), await the ingest barrier, and run the final blocking flush.

Testing

  • cargo clippy --workspace --all-targets for default and default,test-mode with -D warnings; cargo fmt --check
  • cargo nextest run --locked --offline --workspace --features test-mode --test-threads 1: 702 passed
  • Test-mode binary tests: 10 passed (POST /flush status contract, ingest barrier, stats URL override, receiver-port parsing, shutdown/cancellation regressions)
  • Manual: real msgpack payload on /v0.4/traces flushed to a local fake intake; malformed payload → 500; bound port → clean startup failure; SIGINT → clean drain
  • Unchanged logs::flusher::tests::send_bounds_retries_on_transport_error is flaky under concurrent runs only; passes in isolation and in serial runs.

End-to-end coverage lands with the parity harness (#1194 and the future apm-agent-parity-rs repo).

Follow-ups

  • CI now lints --features default,test-mode (GitHub Actions + GitLab) and explicitly runs the test-mode binary tests.
  • POST /flush status contract is covered through the router with an injectable flush operation.

@datadog-prod-us1-5

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

Copy link
Copy Markdown

Pipelines

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: b08fde8 | 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-23T23:15:25.241878Z b08fde8 Draft marked ready
ℹ️ 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 thread bottlecap/src/bin/bottlecap-test-mode/main.rs Outdated
Comment thread bottlecap/src/startup.rs Outdated
Comment thread bottlecap/src/bin/bottlecap-test-mode/main.rs Outdated
Comment thread bottlecap/src/bin/bottlecap-test-mode/main.rs Outdated
Comment thread bottlecap/src/bin/bottlecap-test-mode/main.rs Outdated
@lucaspimentel
lucaspimentel force-pushed the lpimentel/bottlecap-test-mode branch from abb35a3 to 211d68c Compare September 3, 2026 21:20
@lucaspimentel
lucaspimentel force-pushed the lpimentel/bottlecap-testmode-binary branch from 0145da0 to b0f9bcc Compare September 3, 2026 21:22
@lucaspimentel
lucaspimentel force-pushed the lpimentel/bottlecap-test-mode branch 2 times, most recently from 901ea64 to e852fe4 Compare September 9, 2026 15:22
@lucaspimentel
lucaspimentel force-pushed the lpimentel/bottlecap-testmode-binary branch from b0f9bcc to f7dbb9d Compare September 9, 2026 16:31
@lucaspimentel
lucaspimentel force-pushed the lpimentel/bottlecap-test-mode branch from e49b50d to 1976b21 Compare September 10, 2026 15:30
Base automatically changed from lpimentel/bottlecap-test-mode to main September 10, 2026 17:51
@lucaspimentel
lucaspimentel force-pushed the lpimentel/bottlecap-testmode-binary branch from f7dbb9d to c559152 Compare September 10, 2026 18:49
@lucaspimentel
lucaspimentel requested a balanced review from Copilot September 10, 2026 20:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Concurrent flushes can report success or exit while an earlier destructive flush remains incomplete.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a feature-gated, long-running APM test-mode server while sharing the production trace pipeline.

Changes:

  • Adds trace ingestion, manual/periodic flushing, and graceful shutdown.
  • Extracts shared trace-agent startup and adds ingestion barriers.
  • Adds flush failure reporting and CI coverage for test-mode.
File summaries
File Description
.github/workflows/rs_ci.yml Checks the test-mode feature.
.gitlab/templates/pipeline.yaml.tpl Checks the test-mode feature.
bottlecap/Cargo.toml Registers the binary and Tokio signal support.
bottlecap/src/bin/bottlecap-test-mode/main.rs Implements the test-mode server.
bottlecap/src/bin/bottlecap/main.rs Uses shared startup assembly.
bottlecap/src/flushing/service.rs Reports undelivered blocking flushes.
bottlecap/src/lib.rs Exposes the startup module.
bottlecap/src/startup.rs Builds and starts the shared trace pipeline.
bottlecap/src/traces/trace_agent.rs Adds trace/stats ingestion barriers.
Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 5
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread bottlecap/src/bin/bottlecap-test-mode/main.rs Outdated
Comment thread bottlecap/src/traces/trace_agent.rs Outdated
Comment thread bottlecap/src/bin/bottlecap-test-mode/main.rs Outdated
Comment thread bottlecap/src/bin/bottlecap-test-mode/main.rs Outdated
Comment thread bottlecap/src/bin/bottlecap-test-mode/main.rs Outdated
@lucaspimentel

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: bf758e621f

ℹ️ 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".

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Critical flush and ingest-barrier issues, lifecycle defects, and missing feature-enabled CI tests remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (4)

Previously missed (2) — in code that hasn't changed since the last review.

bottlecap/src/traces/trace_agent.rs:245

  • With biased, this branch is always preferred whenever trace_rx is ready. Under sustained trace traffic the payload channel can remain ready indefinitely, starving the barrier branch; /flush can then wait until its 30-second timeout despite a healthy forwarder. Use an ordering/drain protocol that preserves all payloads before the barrier while guaranteeing barrier progress.
    bottlecap/src/traces/trace_agent.rs:275
  • With biased, this branch is always preferred whenever stats_rx is ready. Under sustained stats traffic the payload channel can remain ready indefinitely, starving the barrier branch; /flush can then wait until its 30-second timeout despite a healthy forwarder. Use an ordering/drain protocol that preserves all payloads before the barrier while guaranteeing barrier progress.

.github/workflows/rs_ci.yml:86

  • This CI change only compiles the new required-features binary with Clippy. The test job at rs_ci.yml:118-147 still runs cargo nextest run --workspace without --features test-mode, so the new binary's #[cfg(test)] tests—including the /flush 204/502/500/504 cases—are skipped in CI. Add a feature-enabled test invocation or matrix so these tests actually run.
      # The test-mode feature gates the bottlecap-test-mode binary via
      # required-features, so no other job compiles it.

bottlecap/src/bin/bottlecap-test-mode/main.rs:273

  • FlushControl::get_flush_interval() returns a Tokio interval whose first tick is immediate. Unlike the production flush loops (main.rs:493-495 and main.rs:675), this loop does not discard that tick, so periodically,<n> triggers an extra flush at startup instead of waiting for the configured interval. Consume the initial tick before entering the loop.
    tokio::spawn(async move {
        loop {
  • Files reviewed: 11/11 changed files
  • Comments generated: 3
  • Review effort level: Lite

Comment thread bottlecap/src/bin/bottlecap-test-mode/main.rs Outdated
Comment thread bottlecap/src/traces/trace_agent.rs Outdated
Comment thread bottlecap/src/bin/bottlecap-test-mode/main.rs Outdated
@lucaspimentel
lucaspimentel force-pushed the lpimentel/bottlecap-testmode-binary branch 3 times, most recently from 311e9cc to 92618ef Compare September 16, 2026 16:33
lucaspimentel and others added 25 commits September 22, 2026 18:54
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.
Trace and stats requests only await the hand-off into an intermediate
channel, so a request can be answered while its payload is still queued
ahead of the aggregator. A flush issued right after an accepted request
could therefore miss it, leaving the payload buffered until the next
flush and making the parity harness nondeterministic.

Add an ingest barrier over both forwarder tasks and await it in POST
/flush and in the shutdown drain.

🤖
DD_APM_DD_URL only moved the trace intake, so a harness pointing traces
at a local fake-intake still sent stats toward Datadog with the stub API
key, leaving the binary's advertised /v0.6/stats path unexercised.

Derive the stats endpoint from the resolved trace intake in test mode.
With DD_APM_DD_URL unset this reproduces the site-derived default, and
production routing is unchanged.

🤖
The blocking flush path discarded every flusher result, so payloads that
could not be delivered were dropped while POST /flush still answered 204.
The harness read that as a successful drain.

Report undelivered payloads from the blocking flush, log which domains
dropped data, and answer 502 instead of 204 when any did.

🤖
Cancelling the shutdown token only signals graceful shutdown, so the
final drain could run while a request handler was still processing and
its payload would be dropped when the process exited.

Await the listener task, bounded so a lingering connection cannot wedge
shutdown.

🤖
A failure to bind port 8126 was only logged, leaving a live process with
no listener while main waited on ctrl-c. The harness saw a healthy
process, or connected to whatever already held the port.

Propagate the listener error and race it against ctrl-c so startup
failures end the process with a non-zero exit.

🤖
Each flusher drains its aggregator before awaiting network delivery, so
the periodic driver, POST /flush, and the shutdown drain could overlap
and let one report success on empty queues while another's send was
still in flight. All three now run the barrier-plus-flush sequence under
a shared lock, which also makes the final drain wait for an in-flight
periodic flush.

🤖
A closed barrier channel or a dropped acknowledgement means the forwarder
task is gone and the payloads queued ahead of it were lost, not drained.
The barrier now returns an error instead of treating that as success, and
POST /flush reports 502 rather than 204.

🤖
The endpoint table listed only the five core paths, but the binary also
serves every proxy route the trace agent's router registers.

🤖
The handler now takes the flush work as an injectable operation, so the
204, 502, 500 and 504 branches can be driven directly. Without this a
regression in the status the harness reads would pass CI.

🤖
The ingest barrier held only its own acknowledgement channels, while every
payload sender for the stats forwarder lived inside the trace agent. On the
normal test-mode shutdown path the agent is dropped before the final drain
runs, so the stats forwarder had already exited (after draining its queue)
and the barrier reported it as dead, logging that accepted payloads were
lost on every clean shutdown even though nothing was lost.

The barrier now keeps a payload sender for each forwarder, so a live barrier
keeps both forwarders running and a barrier error again means a forwarder
genuinely died. The stats route is also no longer passed in to the router
builder, so it cannot be wired to a channel outside the barrier's coverage.

🤖
Only SIGINT was handled, so a container runtime or harness stopping the
binary with SIGTERM killed it outright and the final drain never ran,
losing the last accepted payloads. The signal driver is now pulled in by
the test-mode feature rather than unconditionally, so the shipped
extension no longer carries it.

Also corrects the periodic flush comment: the `end` strategy yields the
placeholder interval that means "never race a flush", so it is periodic
only in name.

🤖
The path the config crate appends to the trace intake URL was declared
twice, once for data streams and once for the test-mode binary. Both now
use a single definition.

🤖
The test-mode binary now reads DD_APM_RECEIVER_PORT (the same variable
the Go trace agent uses) and binds its listener to that port instead of
the default 8126. Unset or empty keeps the default; invalid values fail
startup rather than silently listening on the wrong port. Production
Lambda behavior is unchanged.
Extract the shared select-loop scaffolding behind the trace and stats
forwarder tasks into one generic helper. Also wait for both forwarders
concurrently in IngestBarrier::wait instead of sequentially, since they
are independent tasks with no ordering dependency, roughly halving the
barrier's contribution to flush latency.

🤖
Both looked like unnecessary complexity on review, but dropping the
handler (e.g. on request cancellation) relies on JoinSet aborting its
child task, which a bare JoinHandle would not do, and select! evaluates
branch futures eagerly, so calling flush_op() outside the async block
would run it even when the cancellation branch wins.

🤖
@lucaspimentel
lucaspimentel force-pushed the lpimentel/bottlecap-testmode-binary branch from 14b8be7 to b08fde8 Compare September 22, 2026 22:54
@lucaspimentel
lucaspimentel marked this pull request as ready for review September 23, 2026 23:09
@lucaspimentel
lucaspimentel requested review from a team as code owners September 23, 2026 23:09
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.

2 participants