Skip to content

[SVLS-9883] feat: log the duration of each cold start step - #1412

Open
lym953 wants to merge 3 commits into
mainfrom
yiming.luo/cold-start-duration-logs
Open

lym953 wants to merge 3 commits into
mainfrom
yiming.luo/cold-start-duration-logs

Conversation

@lym953

@lym953 lym953 commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Overview

The extension's cold start duration increases from time to time, and finding the cause takes a lot of engineering time. The extension logs only the total time to ready. So, to find which step became slower, we must add temporary timers and build a test layer.

This PR adds a debug log for each step of the extension's initialization that takes measurable time. Each log uses the cold_start_duration tracing target. The existing "Datadog Next-Gen Extension ready in Nms" log also moves to this target, with the same message. The target lets #1413 turn on these logs alone, without the rest of the debug logs.

The logs use a log_duration! macro in logger.rs, so later targets such as trace_flush_duration can use it from any module. It is a macro and not a function, because tracing requires the target to be a constant at each call site.

Example output for one cold start at DD_LOG_LEVEL=debug:

DD_EXTENSION | DEBUG | Enabling logging took 5.836ms
DD_EXTENSION | DEBUG | Loading AWS config took 2.444ms
DD_EXTENSION | DEBUG | Creating the Extensions API client took 0.869ms
DD_EXTENSION | DEBUG | Loading config took 1.609ms
...
DD_EXTENSION | DEBUG | Starting the telemetry listener took 2.206ms
DD_EXTENSION | DEBUG | Datadog Next-Gen Extension ready in 21ms

Which steps get a log

To select the steps, I made a probe build with a timer on every initialization step. Then I ran five cold starts with it. This PR keeps these steps:

  • Each step that took more than 0.1 ms in one or more runs.
  • Two steps that took less than 0.1 ms in the test, but do slow work when a feature is on:
    • Resolving additional endpoint secrets. It calls AWS when an additional endpoints secret ARN (Amazon Resource Name) is set, for example DD_ADDITIONAL_ENDPOINTS_SECRET_ARN.
    • Creating the AppSec processor. It builds the WAF (web application firewall) when AppSec is on. With DD_SERVERLESS_APPSEC_ENABLED=true, this step took 38 ms and 41 ms in two cold starts.

The probe build also timed the steps that follow. Each one took less than 0.1 ms in all five runs, so this PR has no log for them:

  • Creating the shared HTTP client
  • Setting up tags
  • Starting the logs agent
  • Starting the invocation processor
  • Starting the trace agent
  • Starting the Runtime API proxy
  • Starting the lifecycle listener
  • Starting the OTLP agent

Each step has its own timer, so the step durations do not add up to the total. For example, the registration request runs in a background task at the same time as config loading. "Waiting for extension registration" is the time that the main task waits for it.

Testing

Steps

  1. Build the arm64 layer with ARCHITECTURE=arm64 FIPS=0 ./scripts/build_bottlecap_layer.sh and publish it to the sandbox account.
  2. Deploy a Python 3.13 arm64 function with the layer and DD_LOG_LEVEL=debug.
  3. Force three cold starts. Change an environment variable before each invoke, so that Lambda creates a new execution environment.
  4. Read the extension logs from CloudWatch.

Result

All three cold starts logged each step. Durations in milliseconds:

Step Cold start 1 Cold start 2 Cold start 3
Enabling logging 5.836 5.282 8.723
Loading AWS config 2.444 1.943 2.295
Creating the Extensions API client 0.869 0.708 0.780
Loading config 1.609 1.336 1.502
Resolving additional endpoint secrets 0.001 0.000 0.000
Registering the extension 8.887 3.894 4.085
Waiting for extension registration 7.193 2.635 2.658
Starting DogStatsD 0.293 0.308 0.351
Creating the AppSec processor 0.001 0.000 0.001
Starting the telemetry listener 2.206 2.165 63.878
Total, "ready in" 21 15 80

The test function had no additional endpoint secrets and AppSec was off, so those two steps took almost no time. The AppSec measurement above came from the probe build. I did not test the additional endpoint secret path, and I did not test on amd64 or FIPS builds.

Next steps:

  1. Add logging for other things, such as log/trace/metric flushing duration.
  2. Support using an env var to configure which log groups are on

🤖 Generated with Claude Code

@datadog-official

datadog-official Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Pipelines

✨ Unblock PR with BitsAI

❌ Errors

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

🚦 3 Pipeline jobs failed

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

View more details · View in GitLab

DataDog/datadog-lambda-extension | integration-suite: [otlp] — 🔧 Needs a code fix, caused by this PR

View more details · View in GitLab

Bottlecap (Rust) | Audit

View more details · View in GitHub Actions

Useful? React with 👍 / 👎

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

@lym953
lym953 marked this pull request as ready for review October 7, 2026 17:51
@lym953
lym953 requested a review from a team as a code owner October 7, 2026 17:51
@lym953
lym953 requested review from clifordshelton and a balanced review from Copilot October 7, 2026 17:51
@lym953

lym953 commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

The failure of "Lockfile Scan" job is unrelated. Will fix it in a separate PR.

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.

🟢 Approval recommended

The instrumentation is scoped, consistent, and introduces no identified correctness issues.

0 open findings

What changed in this PR

Adds targeted debug timing for measurable cold-start initialization steps, improving startup performance diagnostics.

Changes:

  • Adds a reusable duration-logging macro and cold-start tracing target.
  • Instruments key initialization steps and assigns the existing readiness log to the same target.
File Description
bottlecap/​src/​logger.rs Defines the timing macro and cold-start target.
bottlecap/​src/​bin/​bottlecap/​main.rs Records durations across extension initialization.

🧠 Review effort: Balanced


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

lym953 added a commit that referenced this pull request Oct 9, 2026
…he cargo-audit waiver (#1414)

## Problem

The "Bottlecap (Rust) / Lockfile Scan" check fails on every branch, for
example #1412 and an automated dd-trace-go upgrade branch. Trivy finds
advisory GHSA-2mjx-qc3c-rqvc in `rustls` 0.23.43 and exits 1. `main`
will fail the same way on its next run.

## Why this PR ignores the advisory

The fix is in `rustls` 0.23.45, and we cannot use that version yet:

- `rustls` 0.23.45 requires `aws-lc-rs` 1.18 or later. `aws-lc-rs` 1.18
uses AWS-LC-FIPS 4.x, which has no FIPS 140-3 certification yet. Our
FIPS builds must use a certified crypto module. So the team reverted the
bump to 0.23.45 in #1389, and #1390 pinned `aws-lc-rs` below 1.18.
- The non-FIPS builds do not use `aws-lc-rs`, but both builds use the
same `Cargo.lock`, so they get the same `rustls` version.
- `datadog-fips` from `serverless-components` also requires `rustls`
below 0.23.45. In a copy of `main` without our own cap, `cargo update -p
rustls --precise 0.23.45` fails on that requirement.

#1389 accepted this risk for `cargo-audit`, where the advisory has its
RustSec ID, RUSTSEC-2026-0285. The GitHub advisory links to that RustSec
page. The advisory says that the handshake transcript stays
authenticated, so an attacker cannot change or complete a handshake. The
effect is that a peer can send in plaintext some handshake messages that
must be encrypted.

## Change

Trivy uses its own ignore list, `.trivyignore.yaml`, and that list did
not have the advisory. This PR adds GHSA-2mjx-qc3c-rqvc to it, with the
same reason. It also adds a line to `bottlecap/.cargo/audit.toml` that
names the Trivy entry. Then the person who removes one ignore after the
certification also removes the other.

`.github/workflows/vulnerability-scan.yml` reads the same file for its
daily `Cargo.lock` scan, so that scan also skips the advisory. The daily
image scans in that workflow do not read the file, and this PR does not
change them.

## Testing

### Steps

1. Copy `bottlecap/Cargo.lock`, `bottlecap/Cargo.toml` and
`.trivyignore.yaml` into an empty directory.
2. Run Trivy with the options of the CI step: `trivy fs --scanners vuln
--ignorefile .trivyignore.yaml --ignore-unfixed --exit-code 1 --format
table bottlecap`.
3. Do steps 1 and 2 for `main` and for this branch.

### Result

| Check | Before (`main`, `469decc8`) | After (this branch) |
| --- | --- | --- |
| Trivy findings | 1, GHSA-2mjx-qc3c-rqvc in `rustls` 0.23.43 | 0 |
| Trivy exit code | 1 | 0 |

I used the local Trivy installation, not the version and database that
CI uses. The CI run on this PR is the real check.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
lym953 and others added 2 commits October 9, 2026 14:56
Add debug logs under the "cold_start_duration" tracing target for each
non-trivial step before the extension is ready, and move the existing
"ready in" total under the same target.

Steps were chosen from five cold starts of a probe build that timed every
step. Steps that took under 0.1 ms in all runs were left out, except the
two that do expensive work only when a feature is on: resolving
additional endpoint secrets and creating the AppSec processor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the cold-start-only helper with a log_duration! macro in the
library crate, so that later duration targets (trace flush, for example)
reuse it from any module. A function cannot take the target, because
tracing requires it to be a constant at each call site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lym953
lym953 force-pushed the yiming.luo/cold-start-duration-logs branch from 776fb27 to acf98dd Compare October 9, 2026 18:56

@clifordshelton clifordshelton 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.

Thanks for this! Per-step cold start timings will save us from building probe layers. I have two main requests, plus a few smaller ones inline.

1. Put all the timings in one JSON log line

Right now the numbers are only inside the message text, like Loading config took 1.609ms. To us that's easy to read, but to Datadog it's just a string. To put it on a chart, someone would have to write a custom parser (grok rule) for each step's message, and keep it in sync whenever a message changes. In practice that means people end up reading these logs one by one instead of graphing them.

If the timings are JSON fields instead, Datadog picks them up as numbers automatically. Then we can:

  • Graph each step over time, e.g. average or p99 of load_config_ms, and see right away if a release made a step slower.
  • Split the chart by runtime, region, architecture, or extension version to see where a slowdown happens.
  • Sort or filter logs by a step's duration to find the slowest cold starts, e.g. @cold_start.start_telemetry_listener_ms:>50.
  • Build a dashboard or monitor from them later without extra setup.

Instead, it would be better if we log all the step timings once as JSON fields on a single line and remove the separate per-step lines:

{"status":"debug",
 "message":"DD_EXTENSION | DEBUG | Cold start step durations",
 "cold_start":{"total_ms":21,"enable_logging_ms":5.84,"load_aws_config_ms":2.44,
               "load_config_ms":1.61,"wait_for_register_ms":7.19,
               "start_dogstatsd_ms":0.29,"start_telemetry_listener_ms":2.21}}

With this, a chart of @cold_start.start_telemetry_listener_ms would have shown the 63.9 ms outlier in your test table. Make sure we use numbers for the values and not strings so that it parses correctly.

To do this, logger::Formatter needs to write these extra fields into the JSON. Today it only writes status and message and drops everything else.

2. Turn these logs off by default

Your numbers in #1413 show these lines were 33 of 117 debug lines across 3 cold starts. That's a lot of extra lines (~28%) for someone using DD_LOG_LEVEL=debug to debug something else.

How about we make the cold_start_duration target off by default, even when DD_LOG_LEVEL=debug. People who need the timings can turn them on with DD_LOG_LEVEL_BY_TARGET=cold_start_duration=debug from #1413. Let's use the same rule for future targets like trace_flush_duration.

Comment thread bottlecap/src/logger.rs Outdated
/// Logs at debug level how long `step` took since `start`, under a duration target such as
/// [`COLD_START_DURATION`]. A macro, because `tracing` needs the target as a constant.
#[macro_export]
macro_rules! log_duration {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With one summary line (see the main comment), we don't need a log per step. You can record each step's time into a struct instead, and log the struct once, right after "ready in". Use short snake_case names like load_config, since they become the JSON field names.

Comment thread bottlecap/src/bin/bottlecap/main.rs Outdated
)
.await
.await;
log_duration!(COLD_START_DURATION, "Registering the extension", step_start);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We can remove this one because it's sort of a duplicate. Registration runs in the background while config loads, so this time overlaps with other steps. The wait at line 200 is the part that actually delays startup, so that's the one to keep.

Comment thread bottlecap/src/bin/bottlecap/main.rs Outdated
resolve_additional_endpoints_secrets(&mut lambda_config, &aws_config).await;
log_duration!(
COLD_START_DURATION,
"Resolving additional endpoint secrets",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This step only does real work (calling AWS to fetch the secret) when an additional endpoints secret ARN such as DD_ADDITIONAL_ENDPOINTS_SECRET_ARN is set. For everyone else it does nothing, which is why it showed ~0 ms in all three of your cold starts. If we always record it, most functions get a field that is always ~0 and never tells us anything. It's better to only record it when that ARN is set, so when it does show up, it's meaningful.

Comment thread bottlecap/src/bin/bottlecap/main.rs Outdated
};
log_duration!(
COLD_START_DURATION,
"Creating the AppSec processor",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Same idea here. The AppSec processor only builds the WAF when DD_SERVERLESS_APPSEC_ENABLED=true, which is when it gets slow (38–41 ms in your probe build). When AppSec is off it's always ~0 ms, so let's only record it when AppSec is enabled.

let mut flush_control = FlushControl::new(flush_strategy, config.flush_timeout);

debug!(
target: COLD_START_DURATION,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I'd keep "ready in" as it was, on the default target. This line has been around a long time, and support and engineers search for it when looking into cold starts. If it moves to the cold_start_duration target, then turning that target off (the default suggested in the main comment) would also hide "ready in". People would lose a line they rely on without realizing why. Keeping it on the default target means it still shows up with DD_LOG_LEVEL=debug like it does today, and the new timing line is the only thing the target controls.

Replace the per-step debug lines with one "Cold start step durations"
line. Each step is a numeric field under a nested `cold_start` object,
rounded to 2 decimals, so that Datadog can graph and filter each step.

The steps are collected in a ColdStartDurations struct. It replaces the
`start_time` parameter of extension_loop_active, so the function keeps
7 parameters. The registration task returns its duration with its
result.

logger::Formatter writes the fields of an event as JSON values, and
nests a field named `group.key` under `group`. It used to drop every
field except the message. No other log call passes fields.

The summary line uses `event!`, because `debug!` in tracing 0.1.44
cannot parse dotted field names after `target:`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

3 participants