Skip to content

fix(triggers): use region-derived partition in Lambda Function URL ARN - #1431

Open
pedramsafaei wants to merge 2 commits into
DataDog:mainfrom
pedramsafaei:fix/lambda-function-url-arn-partition
Open

pedramsafaei wants to merge 2 commits into
DataDog:mainfrom
pedramsafaei:fix/lambda-function-url-arn-partition

Conversation

@pedramsafaei

@pedramsafaei pedramsafaei commented Oct 19, 2026 •

Copy link
Copy Markdown

What's broken

LambdaFunctionUrlEvent::get_arn hardcoded the aws partition when building the inferred span ARN for Lambda Function URL invocations:

format!(
    "arn:aws:lambda:{region}:{}:url:{}",
    self.request_context.account_id, function_name
)

GovCloud (us-gov-*) and China (cn-*) regions live in the aws-us-gov and aws-cn partitions, so this builds an ARN that cannot exist. The value is stored as the inferred span's function_trigger.event_source_arn, so Function URL invocations in those regions report the wrong ARN.

The fix

Derive the partition from the region with the existing config::aws::get_aws_partition_by_region helper. The API Gateway REST/HTTP/WebSocket and SQS triggers already build their ARNs that way. Function URL was the last trigger in bottlecap/src still hardcoding a partition.

Only the partition changes. Commercial regions are unchanged; the service, region, account, url resource marker and function name stay the same.

Region Before After
sa-east-1 arn:aws:lambda:sa-east-1:…:url:fn unchanged
us-gov-west-1 arn:aws:lambda:us-gov-west-1:…:url:fn arn:aws-us-gov:lambda:us-gov-west-1:…:url:fn
cn-north-1 arn:aws:lambda:cn-north-1:…:url:fn arn:aws-cn:lambda:cn-north-1:…:url:fn

Changes

  • get_arn now resolves the partition via get_aws_partition_by_region(region).
  • Added test_get_arn_non_default_partitions, covering us-gov-west-1 → aws-us-gov and cn-north-1 → aws-cn.
  • Marked both get_arn tests #[serial]. They set the process-wide AWS_LAMBDA_FUNCTION_NAME variable, so running them at the same time is racy. serial_test is already a dev-dependency.

Tests

The new test fails against the unmodified get_arn and passes with the fix:

left:  "arn:aws:lambda:us-gov-west-1:601427279990:url:mock-lambda"
right: "arn:aws-us-gov:lambda:us-gov-west-1:601427279990:url:mock-lambda"

left:  "arn:aws:lambda:cn-north-1:601427279990:url:mock-lambda"
right: "arn:aws-cn:lambda:cn-north-1:601427279990:url:mock-lambda"

With the fix applied:

  • cargo test --lib lifecycle::invocation::triggers::lambda_function_url_event — 9 passed, 0 failed
  • cargo test --lib lifecycle::invocation::triggers — 167 passed, 0 failed
  • cargo fmt --all -- --check — clean
  • cargo clippy --workspace --all-targets --features default -- -D warnings — clean
  • cargo clippy --workspace --all-targets --features default,test-mode -- -D warnings — clean
  • cargo build --release --bin bottlecap — succeeds

No new test fixtures or identifiers were added; the test reuses the existing lambda_function_url_event.json payload.

Local end-to-end run

The unit test only checks what get_arn returns, so I also checked the field in the payload the process actually sends. The release bottlecap binary built from main and the one built from this branch were each run as a child process against a local Lambda Extensions API / Telemetry API simulator and a local trace intake, and the protobuf payload each process sent over the socket was decompressed and decoded. Six runs: two binaries × us-east-1, us-gov-west-1, cn-north-1. Same fixture, same simulator, same build flags from a shared target directory — only the binary and AWS_DEFAULT_REGION differ.

This is local only. No real Lambda invocation in any partition, no GovCloud or China account, and no Datadog backend ingestion (the intake is a local stub). No AWS API, SDK or CLI call was made: the child process environment carries no credential variables at all, and DD_API_KEY is a literal dummy, so the secrets path never reaches KMS/Secrets Manager/SSM/STS. CI's cargo nextest was not available on the machine used, so cargo test stands in for it.

Loopback ports, per-run request/extension/span IDs and the fixture's sourceIp are replaced with placeholders below. Everything else is unedited. The account ID 601427279990 and the lambda-url.eu-south-1.amazonaws.com host are the repository fixture's own public values.

Input

The unmodified bottlecap/tests/payloads/lambda_function_url_event.json, POSTed to /lambda/start-invocation in all six runs. Only the fields that drive the ARN are shown:

{
  "version": "2.0",
  "routeKey": "$default",
  "rawPath": "/",
  "headers": { "...": "elided — 20 browser headers, verbatim upstream fixture" },
  "requestContext": {
    "accountId": "601427279990",
    "apiId": "a8hyhsshac",
    "domainName": "a8hyhsshac.lambda-url.eu-south-1.amazonaws.com",
    "http": {
      "method": "GET",
      "path": "/",
      "protocol": "HTTP/1.1",
      "sourceIp": "<source-ip>",
      "userAgent": "Mozilla/5.0 (Macintosh; …) Chrome/95.0.4638.69 Safari/537.36"
    },
    "requestId": "<fixture-request-id>",
    "stage": "$default",
    "timeEpoch": 1637169449721
  },
  "isBase64Encoded": false
}

requestContext.accountId is the only account source for the URL ARN, and the fixture carries no region of its own — the region comes from the process environment, which is exactly the input this PR changes. So the same bytes exercise all three partitions.

As received by the extension:

Received start invocation request from headers:{"content-length": "1604",
  "content-type": "application/json", "host": "127.0.0.1:<agent-port>",
  "lambda-runtime-aws-request-id": "<request-id>", …}
Processing UniversalInstrumentationStart for request_id: <request-id>

Extensions API and Telemetry API requests and responses

Captured on both sides — request bodies as the simulator received them, responses as it returned them. This excerpt is the us-gov-west-1 run of this branch's binary:

POST /2020-01-01/extension/register
     Lambda-Extension-Accept-Feature: accountId
     {"events":["INVOKE","SHUTDOWN"]}
  → 200  Lambda-Extension-Identifier: <extension-id>
         {"accountId":"601427279990","functionName":"mock-lambda"}

PUT  /2022-07-01/telemetry
     {"schemaVersion":"2022-12-13",
      "destination":{"protocol":"HTTP","URI":"http://sandbox:<telemetry-port>/"},
      "types":["platform"],
      "buffering":{"maxItems":1000,"maxBytes":1048576,"timeoutMs":25}}
  → 200
  extension-side ack: EXTENSION | Subscribed to Telemetry API: Response { status: 200, … }

GET  /2020-01-01/extension/event/next        (call 1)
  → 200 {"eventType":"INVOKE","requestId":"<request-id>","deadlineMs":…,
         "invokedFunctionArn":"arn:aws-us-gov:lambda:us-gov-west-1:601427279990:function:mock-lambda",
         "tracing":{"type":"X-Amzn-Trace-Id","value":"Root=…;Parent=…;Sampled=1"}}

POST /lambda/start-invocation   (the fixture above)        → 200 {}
POST /lambda/end-invocation     {"statusCode":200}
     x-datadog-trace-id: <trace-id>  x-datadog-span-id: <span-id>
     x-datadog-sampling-priority: 2                        → 200 {}

GET  /2020-01-01/extension/event/next        (call 2 — held open, as the real /next is)
  → 200 {"eventType":"SHUTDOWN","shutdownReason":"SPINDOWN","deadlineMs":…}

Five telemetry records were delivered to the extension's own listener between those calls — platform.initStart, platform.initReport, platform.start, platform.runtimeDone, platform.report — each answered 200 and each echoed back in the extension's debug log as a parsed record, so delivery is confirmed on both sides. End of invocation and flush:

Processing UniversalInstrumentationEnd for request_id: <request-id>
TRACES | trace payload size after enrichment: 3389 bytes
TRACES | batched 1 payload(s) totaling 3389 bytes (cap 12000000 bytes)
TRACES | Flushing 1 traces
TRACES | Successfully sent trace (1 attempts, 1342 bytes)
Exiting: SPINDOWN, deadline: …
Extension loop completed successfully

Every run did the same nine things: registered, subscribed to telemetry, served the invoke, brought up both listeners, answered start-invocation with 200 {}, answered end-invocation with 200 {}, served the shutdown, and exited the process. Each run made exactly 2 /next calls, sent exactly 1 trace payload, and exited 0.

Decoded protobuf, before and after

Each run produced one payload: Content-Type: application/x-protobuf, Content-Encoding: zstd. Bodies were zstd-decompressed and read as AgentPayload → TracerPayload → TraceChunk → Span, with Span.meta (field 10) read as a map<string,string>.

Span.meta["function_trigger.event_source_arn"], on the invocation span parented by the inferred aws.lambda.url span:

region main binary this branch's binary
us-east-1 arn:aws:lambda:us-east-1:601427279990:url:mock-lambda arn:aws:lambda:us-east-1:601427279990:url:mock-lambda
us-gov-west-1 arn:aws:lambda:us-gov-west-1:601427279990:url:mock-lambda ← bug arn:aws-us-gov:lambda:us-gov-west-1:601427279990:url:mock-lambda
cn-north-1 arn:aws:lambda:cn-north-1:601427279990:url:mock-lambda ← bug arn:aws-cn:lambda:cn-north-1:601427279990:url:mock-lambda

Commercial is the same string on both sides, and the decoded payload is the same size (3308 B both). GovCloud went 3404 → 3410 B and China 3337 → 3342 B, which is the longer partition string.

Everything else in the payload is identical. GovCloud, both sides:

TraceChunk.priority = 2 ; 3 spans ; 1 payload

Span #1  2 name="aws.lambda"  3 resource="mock-lambda"  12 type="serverless"  9 error=<absent>
  meta.function_trigger.event_source = "lambda-function-url"
  meta.function_arn    = "arn:aws-us-gov:lambda:us-gov-west-1:601427279990:function:mock-lambda"
  meta.region = "us-gov-west-1"  meta.account_id = "601427279990"  meta.cold_start = "true"
  meta._dd.origin = "lambda"     meta.span.kind = "server"
  meta.http.method = "GET"       meta.http.status_code = "200"
  meta.http.url_details.path = "/"
  meta.http.url = "https://a8hyhsshac.lambda-url.eu-south-1.amazonaws.com/"
  meta.function_trigger.event_source_arn → the only field that differs (table above)

Span #2  2 name="aws.lambda.url"  3 resource="GET /"  12 type="http"
  meta.operation_name = "aws.lambda.url"  meta.endpoint = "/"
  meta._inferred_span.synchronicity = "sync"  meta._inferred_span.tag_source = "self"
  meta.http.protocol = "HTTP/1.1"  meta.http.status_code = "200"

Span #3  2 name="aws.lambda.cold_start"  12 type="serverless"

China looks the same, with the same single change (arn:aws:…:url:… → arn:aws-cn:…:url:…) and meta.function_arn = arn:aws-cn:…:function:mock-lambda on both sides.

One thing worth noting about the main payloads on their own: the same GovCloud payload that carries function_trigger.event_source_arn = arn:aws:… also carries function_arn = arn:aws-us-gov:…, so one function ends up with two different partitions, because the function-ARN path already derives the partition from the region with the same helper. China is split the same way. This PR makes the two agree rather than introducing a new convention.

Exact tests and commands

Run from bottlecap/ in each worktree, matching .github/workflows/rs_ci.yml:

cargo build --release --bin bottlecap                                     # both sides, rc 0
cargo fmt --all -- --check                                               # rc 0
cargo clippy --workspace --all-targets --features default                 # rc 0
cargo clippy --workspace --all-targets --features default,test-mode       # rc 0
cargo test --release lifecycle::invocation::triggers -- --nocapture       # branch: 167 passed, 0 failed
cargo test --release lifecycle::invocation::triggers                      # main:   166 passed, 0 failed
cargo test --release lifecycle::                                          # branch: 280 passed, 0 failed
cargo test --release --lib --no-run  → test binary with a scrubbed env, --test-threads=1
                                                                          # branch: 688 passed, 0 failed
                                                                          # main:   687 passed, 0 failed

The one extra trigger test is the test this PR adds:

test lifecycle::invocation::triggers::lambda_function_url_event::tests::test_get_arn ... ok
test lifecycle::invocation::triggers::lambda_function_url_event::tests::test_get_arn_non_default_partitions ... ok
test result: ok. 167 passed; 0 failed; 0 ignored; 0 measured; 521 filtered out

grep -c test_get_arn_non_default_partitions returns 1 against the branch log and 0 against the main log, which confirms the two binaries above are the two sides of this PR. The six runs used one script and one fixture, changing only --binary and --region.

Limits of the local run

Four things about the local environment, none of them in the diff:

  • cargo test --release --workspace under the machine's inherited environment fails 2–3 tests in bottlecap/src/tags/lambda/tags.rs, a file this PR does not touch. They are off-by-one tag-count assertions (left: 14, right: 13) — stray DD_*/AWS_* variables in the ambient environment add an extra tag to the map. The same failures appear on main, and with the environment scrubbed both sides are green (687/687 and 688/688).
  • Each run logs ERROR | Error shipping data … 0 series and 1 sketches from the metrics path, because the local stub intake does not implement the sketches contract. Identical on both sides; the trace send in the same flush reports Successfully sent trace (1 attempts, …).
  • meta.runtime reflects the local machine rather than a Lambda runtime, since the proc-runtime search ran off-Lambda. Identical on both sides.
  • Building needed two local accommodations, neither touching source: LIBCLANG_PATH plus BINDGEN_EXTRA_CLANG_ARGS for the libddwaf-sys bindgen step, and RUSTFLAGS="-C link-arg=-lgcc" because the prebuilt libddwaf archive references the libgcc __cpu_model / __cpu_indicator_init helpers.

Note

get_aws_partition_by_region maps us-gov-* and cn-* and falls back to aws for everything else, so the ISO partitions are not covered. That is existing behavior of the shared helper used by all the other triggers, and this change deliberately does not alter it. Happy to extend it in a separate PR if you'd like.

Copilot AI balanced review requested due to automatic review settings October 19, 2026 08:15
@pedramsafaei
pedramsafaei requested a review from a team as a code owner October 19, 2026 08:15
@pedramsafaei
pedramsafaei requested a review from lym953 October 19, 2026 08:15
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 19, 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 ⚠️ Failed 2026-10-19T08:16:42.339937Z a6ec555 PR opened
ℹ️ 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.

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

The new test introduces unsafe environment mutations that are not isolated from concurrent native environment access.

1 open issue
What changed in this PR

Corrects Lambda Function URL inferred-span ARNs for GovCloud and China using the existing region-to-partition helper.

Changes:

  • Derives the ARN partition from the region, preserving commercial-region output.
  • Adds non-default partition regression tests and serializes both ARN tests.
File Description
bottlecap/​src/​lifecycle/​invocation/​triggers/​lambda_function_url_event.rs Updates partition selection and adds regression coverage.

Review effort: Balanced

Comment on lines +345 to +365
#[serial] // mutates the shared AWS_LAMBDA_FUNCTION_NAME env var
fn test_get_arn_non_default_partitions() {
unsafe { env::set_var("AWS_LAMBDA_FUNCTION_NAME", "mock-lambda") };
let json = read_json_file("lambda_function_url_event.json");
let payload = serde_json::from_str(&json).expect("Failed to deserialize into Value");
let event = LambdaFunctionUrlEvent::new(payload)
.expect("Failed to deserialize LambdaFunctionUrlEvent");

// GovCloud regions use the aws-us-gov partition.
assert_eq!(
event.get_arn("us-gov-west-1"),
"arn:aws-us-gov:lambda:us-gov-west-1:601427279990:url:mock-lambda"
);

// China regions use the aws-cn partition.
assert_eq!(
event.get_arn("cn-north-1"),
"arn:aws-cn:lambda:cn-north-1:601427279990:url:mock-lambda"
);

unsafe { env::remove_var("AWS_LAMBDA_FUNCTION_NAME") };
The inferred span ARN for Lambda Function URL events hardcoded the "aws"
partition. In GovCloud (us-gov-*) and China (cn-*) regions this produced an
incorrect ARN, since those regions use the aws-us-gov and aws-cn partitions
respectively. Derive the partition from the region with
get_aws_partition_by_region, matching the existing API Gateway and SQS
trigger implementations.

Adds a regression test covering the aws-us-gov and aws-cn partitions, and
marks the two get_arn tests #[serial] since they mutate the shared
AWS_LAMBDA_FUNCTION_NAME environment variable.
Derive the expected ARN function name from the current
AWS_LAMBDA_FUNCTION_NAME value instead of calling set_var/remove_var,
removing the unsafe environment mutation whose safety requirement could
not be guaranteed against unsynchronized concurrent env readers. The
serial attribute is retained so this test still coordinates with
test_get_arn, which mutates the shared variable.
@pedramsafaei
pedramsafaei force-pushed the fix/lambda-function-url-arn-partition branch from 1ebe3b3 to e8a3a3d Compare October 26, 2026 04:38
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