From 142f9cbc7c03833544c35e674b0b992e31dc91f9 Mon Sep 17 00:00:00 2001 From: Maxime David Date: Mon, 5 Oct 2026 11:32:08 +0000 Subject: [PATCH 1/4] feat(context): add w3c() helper for W3C trace fields --- .github/workflows/dockerized-test.yml | 42 ++++++++ Dockerfile.test | 18 ++++ awslambdaric/lambda_context.py | 38 +++++++ test/dockerized/suites/ctx.json | 30 ++++++ test/dockerized/suites/w3c.json | 149 ++++++++++++++++++++++++++ test/dockerized/tasks/w3c.py | 31 ++++++ tests/test_lambda_context.py | 130 ++++++++++++++++++++++ 7 files changed, 438 insertions(+) create mode 100644 .github/workflows/dockerized-test.yml create mode 100644 Dockerfile.test create mode 100644 test/dockerized/suites/ctx.json create mode 100644 test/dockerized/suites/w3c.json create mode 100644 test/dockerized/tasks/w3c.py diff --git a/.github/workflows/dockerized-test.yml b/.github/workflows/dockerized-test.yml new file mode 100644 index 0000000..31eb2fa --- /dev/null +++ b/.github/workflows/dockerized-test.yml @@ -0,0 +1,42 @@ +name: dockerized-test + +permissions: + contents: read + +on: + push: + branches: [main] + pull_request: + branches: ['*'] + workflow_dispatch: + +jobs: + dockerized-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: pip + cache-dependency-path: requirements/dev.txt + + - name: Build the test image + # The test image overlays the local pure-Python modules on top of the + # awslambdaric already installed in public.ecr.aws/lambda/python:3.13, + # preserving the compiled runtime_client extension that ships in the + # base image. + run: | + docker build . \ + -t local/test \ + -f Dockerfile.test \ + --build-arg BASE_IMAGE=public.ecr.aws/lambda/python:3.13 + + - name: Run dockerized suites + uses: aws/containerized-test-runner-for-aws-lambda@76eacfb110903739d9c5f7fcdaafede6324a1473 # maxday/client-context + with: + suiteFileArray: '["./test/dockerized/suites/*.json"]' + dockerImageName: 'local/test' + taskFolder: './test/dockerized/tasks' diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..7365339 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,18 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +ARG BASE_IMAGE=public.ecr.aws/lambda/python:3.13 +FROM $BASE_IMAGE + +# Overlay the local pure-Python awslambdaric modules on top of the one shipped +# in the base image. The compiled `runtime_client` extension that ships in the +# base image is preserved; only the Python modules we care about (lambda_context, +# bootstrap, etc.) are swapped. The destination directory is discovered via +# `import awslambdaric` so this works for any BASE_IMAGE whose Python version +# changes the site-packages path. +COPY awslambdaric/*.py /tmp/awslambdaric-py/ +RUN RIC_PATH=$(python -c "import awslambdaric, os; print(os.path.dirname(awslambdaric.__file__))") \ + && cp /tmp/awslambdaric-py/*.py "$RIC_PATH"/ \ + && rm -rf /tmp/awslambdaric-py + +COPY test/dockerized/tasks /var/task/ diff --git a/awslambdaric/lambda_context.py b/awslambdaric/lambda_context.py index e0a3363..92a579a 100644 --- a/awslambdaric/lambda_context.py +++ b/awslambdaric/lambda_context.py @@ -7,6 +7,11 @@ import sys import time +# Allowlist of W3C trace-context fields that may be surfaced through +# ``LambdaContext.w3c()``. Any other key carried on ``clientContext.w3c`` is +# ignored, and any allowlisted key whose value is not a string is dropped. +W3C_ALLOWED_FIELDS = ("traceparent", "tracestate", "baggage") + class LambdaContext(object): def __init__( @@ -26,6 +31,7 @@ def __init__( self.function_version = os.environ.get("AWS_LAMBDA_FUNCTION_VERSION") self.invoked_function_arn = invoked_function_arn self.tenant_id = tenant_id + self._w3c_fields = self._extract_and_strip_w3c(client_context) self.client_context = make_obj_from_dict(ClientContext, client_context) if self.client_context is not None: @@ -49,6 +55,38 @@ def get_remaining_time_in_millis(self): delta_ms = self._epoch_deadline_time_in_ms - epoch_now_in_ms return delta_ms if delta_ms > 0 else 0 + def w3c(self): + """ + Return the W3C trace-context at invoke time. + """ + return dict(self._w3c_fields) + + @staticmethod + def _extract_and_strip_w3c(client_context): + """ + Pop ``w3c`` out of the parsed ``client_context`` dict and return a + normalized copy of its allowlisted string fields (see + ``W3C_ALLOWED_FIELDS``). Mutates ``client_context`` in place so the + ``w3c`` key is removed and cannot be read through + ``context.client_context``. + """ + if not isinstance(client_context, dict): + return {} + if "w3c" not in client_context: + return {} + + raw_w3c = client_context.pop("w3c") + + if not isinstance(raw_w3c, dict): + return {} + + fields = {} + for key in W3C_ALLOWED_FIELDS: + value = raw_w3c.get(key) + if isinstance(value, str): + fields[key] = value + return fields + def log(self, msg): for handler in logging.getLogger().handlers: if hasattr(handler, "log_sink"): diff --git a/test/dockerized/suites/ctx.json b/test/dockerized/suites/ctx.json new file mode 100644 index 0000000..a57c226 --- /dev/null +++ b/test/dockerized/suites/ctx.json @@ -0,0 +1,30 @@ +{ + "tests": [ + { + "name": "client_context_is_echoed_when_no_w3c_key", + "handler": "w3c.echo_client_context", + "request": {}, + "clientContext": { + "custom": { "value": "hello" }, + "env": { "stage": "beta" } + }, + "assertions": [ + { + "response": { + "custom": { "value": "hello" }, + "env": { "stage": "beta" } + } + } + ] + }, + + { + "name": "client_context_is_null_when_header_absent", + "handler": "w3c.echo_client_context", + "request": {}, + "assertions": [ + { "response": null } + ] + } + ] +} diff --git a/test/dockerized/suites/w3c.json b/test/dockerized/suites/w3c.json new file mode 100644 index 0000000..b59dd75 --- /dev/null +++ b/test/dockerized/suites/w3c.json @@ -0,0 +1,149 @@ +{ + "tests": [ + { + "name": "w3c_is_callable_on_context", + "handler": "w3c.w3c_is_callable", + "request": {}, + "assertions": [ + { "response": { "isCallable": true } } + ] + }, + + { + "name": "w3c_returns_empty_when_no_client_context_header", + "handler": "w3c.get_w3c", + "request": {}, + "assertions": [ + { "response": {} } + ] + }, + + { + "name": "w3c_returns_empty_when_client_context_has_no_w3c_key", + "handler": "w3c.get_w3c", + "request": {}, + "clientContext": { + "custom": { "value": "test" } + }, + "assertions": [ + { "response": {} } + ] + }, + + { + "name": "w3c_returns_baggage_only", + "handler": "w3c.get_w3c", + "request": {}, + "clientContext": { + "w3c": { "baggage": "userId=alice" } + }, + "assertions": [ + { "response": { "baggage": "userId=alice" } } + ] + }, + + { + "name": "w3c_returns_all_three_allowlisted_fields", + "handler": "w3c.get_w3c", + "request": {}, + "clientContext": { + "w3c": { + "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + "tracestate": "rojo=00f067aa0ba902b7", + "baggage": "userId=alice" + } + }, + "assertions": [ + { + "response": { + "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + "tracestate": "rojo=00f067aa0ba902b7", + "baggage": "userId=alice" + } + } + ] + }, + + { + "name": "w3c_allowlist_drops_non_allowlisted_keys", + "handler": "w3c.get_w3c", + "request": {}, + "clientContext": { + "w3c": { + "baggage": "keep=me", + "unknownField": "should-not-appear", + "x-custom-trace": "should-not-appear" + } + }, + "assertions": [ + { "response": { "baggage": "keep=me" } } + ] + }, + + { + "name": "w3c_drops_allowlisted_fields_with_non_string_values", + "handler": "w3c.get_w3c", + "request": {}, + "clientContext": { + "w3c": { + "traceparent": 42, + "tracestate": null, + "baggage": { "nested": "no" } + } + }, + "assertions": [ + { "response": {} } + ] + }, + + { + "name": "w3c_treats_non_object_as_empty", + "handler": "w3c.get_w3c", + "request": {}, + "clientContext": { + "w3c": "not-an-object" + }, + "assertions": [ + { "response": {} } + ] + }, + + { + "name": "w3c_treats_array_as_empty", + "handler": "w3c.get_w3c", + "request": {}, + "clientContext": { + "w3c": ["baggage=abc"] + }, + "assertions": [ + { "response": {} } + ] + }, + + { + "name": "w3c_strips_source_client_context_w3c_after_construction", + "handler": "w3c.get_w3c_and_source", + "request": {}, + "clientContext": { + "custom": { "value": "test" }, + "w3c": { + "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + "baggage": "userId=alice" + } + }, + "assertions": [ + { + "response": { + "w3c": { + "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + "baggage": "userId=alice" + }, + "clientContextIsDefined": true, + "clientContextHasW3c": false, + "clientContext": { "custom": { "value": "test" } } + } + } + ] + } + ] +} diff --git a/test/dockerized/tasks/w3c.py b/test/dockerized/tasks/w3c.py new file mode 100644 index 0000000..1b0d15c --- /dev/null +++ b/test/dockerized/tasks/w3c.py @@ -0,0 +1,31 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +def _serialize_client_context(client_context): + if client_context is None: + return None + result = {} + for field in ("custom", "env"): + value = getattr(client_context, field, None) + if value is not None: + result[field] = value + return result + +def get_w3c(event, context): + return context.w3c() + +def get_w3c_and_source(event, context): + client_context = context.client_context + return { + "w3c": context.w3c(), + "clientContextIsDefined": client_context is not None, + "clientContextHasW3c": client_context is not None + and hasattr(client_context, "w3c"), + "clientContext": _serialize_client_context(client_context), + } + +def echo_client_context(event, context): + return _serialize_client_context(context.client_context) + +def w3c_is_callable(event, context): + return {"isCallable": callable(getattr(context, "w3c", None))} diff --git a/tests/test_lambda_context.py b/tests/test_lambda_context.py index f7959ab..d2b6028 100644 --- a/tests/test_lambda_context.py +++ b/tests/test_lambda_context.py @@ -159,3 +159,133 @@ def test_log_without_handlers(self, mock_sys): context.log("YOLO!") mock_sys.stdout.write("YOLO!") + + +class TestLambdaContextW3C(unittest.TestCase): + _DEADLINE_EPOCH_MS = 1415836801000 + _INVOKE_ID = "invoke-id-w3c" + _ARN = "arn:test:w3c" + + def _build(self, client_context): + return LambdaContext( + self._INVOKE_ID, + client_context, + {}, + self._DEADLINE_EPOCH_MS, + self._ARN, + ) + + def test_w3c_returns_empty_when_client_context_is_none(self): + context = self._build(None) + self.assertEqual(context.w3c(), {}) + + def test_w3c_returns_empty_when_client_context_has_no_w3c_key(self): + client_context = {"custom": {"value": "test"}} + context = self._build(client_context) + self.assertEqual(context.w3c(), {}) + self.assertEqual(client_context, {"custom": {"value": "test"}}) + + def test_w3c_returns_baggage_only(self): + client_context = {"w3c": {"baggage": "abc"}} + context = self._build(client_context) + self.assertEqual(context.w3c(), {"baggage": "abc"}) + + def test_w3c_returns_every_allowlisted_field(self): + client_context = { + "custom": {"value": "test"}, + "w3c": { + "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + "tracestate": "rojo=00f067aa0ba902b7", + "baggage": "userId=alice", + }, + } + context = self._build(client_context) + self.assertEqual( + context.w3c(), + { + "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + "tracestate": "rojo=00f067aa0ba902b7", + "baggage": "userId=alice", + }, + ) + + def test_w3c_strips_source_client_context_w3c_after_construction(self): + client_context = { + "custom": {"value": "test"}, + "w3c": { + "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + "baggage": "userId=alice", + }, + } + context = self._build(client_context) + self.assertIsNotNone(context.client_context) + self.assertFalse(hasattr(context.client_context, "w3c")) + self.assertNotIn("w3c", client_context) + self.assertEqual(client_context, {"custom": {"value": "test"}}) + + def test_w3c_ignores_non_string_values_while_stripping_the_source(self): + client_context = { + "w3c": { + "baggage": "abc", + "traceparent": 42, # wrong type — must be dropped + "tracestate": None, # wrong type — must be dropped + }, + } + context = self._build(client_context) + self.assertEqual(context.w3c(), {"baggage": "abc"}) + self.assertNotIn("w3c", client_context) + + def test_w3c_treats_non_dict_value_as_empty_and_still_strips_source(self): + client_context = {"w3c": "not-an-object"} + context = self._build(client_context) + self.assertEqual(context.w3c(), {}) + self.assertNotIn("w3c", client_context) + + def test_w3c_treats_list_value_as_empty_and_still_strips_source(self): + client_context = {"w3c": ["baggage=abc"]} + context = self._build(client_context) + self.assertEqual(context.w3c(), {}) + self.assertNotIn("w3c", client_context) + + def test_w3c_returns_fresh_copy_so_callers_cannot_mutate_underlying_map(self): + client_context = {"w3c": {"baggage": "abc"}} + context = self._build(client_context) + first = context.w3c() + first["baggage"] = "tampered" + first["injected"] = "nope" + self.assertEqual(context.w3c(), {"baggage": "abc"}) + + def test_w3c_drops_non_allowlisted_keys_even_when_value_is_a_valid_string(self): + client_context = { + "w3c": { + "baggage": "keep=me", + # Non-allowlisted keys — must NOT be surfaced by w3c() + "unknownField": "should-not-appear", + "x-custom-trace": "should-not-appear", + "__proto__": "should-not-appear", + "constructor": "should-not-appear", + "toString": "should-not-appear", + }, + } + context = self._build(client_context) + self.assertEqual(context.w3c(), {"baggage": "keep=me"}) + self.assertNotIn("w3c", client_context) + + def test_w3c_omits_allowlisted_keys_when_absent_no_none_leaks(self): + client_context = {"w3c": {"baggage": "abc"}} + result = self._build(client_context).w3c() + self.assertEqual(result, {"baggage": "abc"}) + self.assertNotIn("traceparent", result) + self.assertNotIn("tracestate", result) + + def test_w3c_drops_allowlisted_keys_whose_value_is_not_a_string(self): + client_context = { + "w3c": { + "traceparent": 42, + "tracestate": None, + "baggage": {"nested": "no"}, + }, + } + context = self._build(client_context) + self.assertEqual(context.w3c(), {}) + self.assertNotIn("w3c", client_context) From 4fdf799ed55441e67af14a47a46a3246d63d6d3b Mon Sep 17 00:00:00 2001 From: Maxime David Date: Mon, 5 Oct 2026 11:34:53 +0000 Subject: [PATCH 2/4] feat: use python 3.14 --- .github/workflows/dockerized-test.yml | 8 ++------ Dockerfile.test | 8 +------- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/workflows/dockerized-test.yml b/.github/workflows/dockerized-test.yml index 31eb2fa..a1106e4 100644 --- a/.github/workflows/dockerized-test.yml +++ b/.github/workflows/dockerized-test.yml @@ -19,20 +19,16 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: '3.14' cache: pip cache-dependency-path: requirements/dev.txt - name: Build the test image - # The test image overlays the local pure-Python modules on top of the - # awslambdaric already installed in public.ecr.aws/lambda/python:3.13, - # preserving the compiled runtime_client extension that ships in the - # base image. run: | docker build . \ -t local/test \ -f Dockerfile.test \ - --build-arg BASE_IMAGE=public.ecr.aws/lambda/python:3.13 + --build-arg BASE_IMAGE=public.ecr.aws/lambda/python:3.14 - name: Run dockerized suites uses: aws/containerized-test-runner-for-aws-lambda@76eacfb110903739d9c5f7fcdaafede6324a1473 # maxday/client-context diff --git a/Dockerfile.test b/Dockerfile.test index 7365339..c856906 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -1,15 +1,9 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -ARG BASE_IMAGE=public.ecr.aws/lambda/python:3.13 +ARG BASE_IMAGE=public.ecr.aws/lambda/python:3.14 FROM $BASE_IMAGE -# Overlay the local pure-Python awslambdaric modules on top of the one shipped -# in the base image. The compiled `runtime_client` extension that ships in the -# base image is preserved; only the Python modules we care about (lambda_context, -# bootstrap, etc.) are swapped. The destination directory is discovered via -# `import awslambdaric` so this works for any BASE_IMAGE whose Python version -# changes the site-packages path. COPY awslambdaric/*.py /tmp/awslambdaric-py/ RUN RIC_PATH=$(python -c "import awslambdaric, os; print(os.path.dirname(awslambdaric.__file__))") \ && cp /tmp/awslambdaric-py/*.py "$RIC_PATH"/ \ From dd0145b31ca0d76ca97ea8f967bb455b27ed5ee5 Mon Sep 17 00:00:00 2001 From: Maxime David Date: Mon, 5 Oct 2026 11:36:05 +0000 Subject: [PATCH 3/4] feat: use main for harness tests --- .github/workflows/dockerized-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dockerized-test.yml b/.github/workflows/dockerized-test.yml index a1106e4..8d01e22 100644 --- a/.github/workflows/dockerized-test.yml +++ b/.github/workflows/dockerized-test.yml @@ -31,7 +31,7 @@ jobs: --build-arg BASE_IMAGE=public.ecr.aws/lambda/python:3.14 - name: Run dockerized suites - uses: aws/containerized-test-runner-for-aws-lambda@76eacfb110903739d9c5f7fcdaafede6324a1473 # maxday/client-context + uses: aws/containerized-test-runner-for-aws-lambda@0863dd17b5fc19585250a2405c0f939a77b4f397 # main with: suiteFileArray: '["./test/dockerized/suites/*.json"]' dockerImageName: 'local/test' From 62a2995185352a0810f3183c51a27055c06780af Mon Sep 17 00:00:00 2001 From: Maxime David Date: Mon, 5 Oct 2026 11:39:56 +0000 Subject: [PATCH 4/4] fix(ci): drop unused Set up Python step so Post cleanup stops failing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dockerized-test workflow never runs pip on the runner — Python is only invoked inside the Dockerfile build using the base image's own Python. The 'cache: pip' directive on setup-python@v5 therefore ran its Post cleanup against an empty /home/runner/.cache/pip and failed the job even though all 12 dockerized suite cases passed. Also bumps actions/checkout to v5 to drop the Node 20 deprecation warning. --- .github/workflows/dockerized-test.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/dockerized-test.yml b/.github/workflows/dockerized-test.yml index 8d01e22..c2bb3c0 100644 --- a/.github/workflows/dockerized-test.yml +++ b/.github/workflows/dockerized-test.yml @@ -14,14 +14,7 @@ jobs: dockerized-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.14' - cache: pip - cache-dependency-path: requirements/dev.txt + - uses: actions/checkout@v5 - name: Build the test image run: |