Skip to content

Commit 3bac528

Browse files
authored
feat(context): add w3c() helper for W3C trace fields (#228)
* feat(context): add w3c() helper for W3C trace fields * feat: use python 3.14 * feat: use main for harness tests * fix(ci): drop unused Set up Python step so Post cleanup stops failing 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.
1 parent fa006b6 commit 3bac528

7 files changed

Lines changed: 421 additions & 0 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: dockerized-test
2+
3+
permissions:
4+
contents: read
5+
6+
on:
7+
push:
8+
branches: [main]
9+
pull_request:
10+
branches: ['*']
11+
workflow_dispatch:
12+
13+
jobs:
14+
dockerized-test:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v5
18+
19+
- name: Build the test image
20+
run: |
21+
docker build . \
22+
-t local/test \
23+
-f Dockerfile.test \
24+
--build-arg BASE_IMAGE=public.ecr.aws/lambda/python:3.14
25+
26+
- name: Run dockerized suites
27+
uses: aws/containerized-test-runner-for-aws-lambda@0863dd17b5fc19585250a2405c0f939a77b4f397 # main
28+
with:
29+
suiteFileArray: '["./test/dockerized/suites/*.json"]'
30+
dockerImageName: 'local/test'
31+
taskFolder: './test/dockerized/tasks'

‎Dockerfile.test‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
ARG BASE_IMAGE=public.ecr.aws/lambda/python:3.14
5+
FROM $BASE_IMAGE
6+
7+
COPY awslambdaric/*.py /tmp/awslambdaric-py/
8+
RUN RIC_PATH=$(python -c "import awslambdaric, os; print(os.path.dirname(awslambdaric.__file__))") \
9+
&& cp /tmp/awslambdaric-py/*.py "$RIC_PATH"/ \
10+
&& rm -rf /tmp/awslambdaric-py
11+
12+
COPY test/dockerized/tasks /var/task/

‎awslambdaric/lambda_context.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
import sys
88
import time
99

10+
# Allowlist of W3C trace-context fields that may be surfaced through
11+
# ``LambdaContext.w3c()``. Any other key carried on ``clientContext.w3c`` is
12+
# ignored, and any allowlisted key whose value is not a string is dropped.
13+
W3C_ALLOWED_FIELDS = ("traceparent", "tracestate", "baggage")
14+
1015

1116
class LambdaContext(object):
1217
def __init__(
@@ -26,6 +31,7 @@ def __init__(
2631
self.function_version = os.environ.get("AWS_LAMBDA_FUNCTION_VERSION")
2732
self.invoked_function_arn = invoked_function_arn
2833
self.tenant_id = tenant_id
34+
self._w3c_fields = self._extract_and_strip_w3c(client_context)
2935

3036
self.client_context = make_obj_from_dict(ClientContext, client_context)
3137
if self.client_context is not None:
@@ -49,6 +55,38 @@ def get_remaining_time_in_millis(self):
4955
delta_ms = self._epoch_deadline_time_in_ms - epoch_now_in_ms
5056
return delta_ms if delta_ms > 0 else 0
5157

58+
def w3c(self):
59+
"""
60+
Return the W3C trace-context at invoke time.
61+
"""
62+
return dict(self._w3c_fields)
63+
64+
@staticmethod
65+
def _extract_and_strip_w3c(client_context):
66+
"""
67+
Pop ``w3c`` out of the parsed ``client_context`` dict and return a
68+
normalized copy of its allowlisted string fields (see
69+
``W3C_ALLOWED_FIELDS``). Mutates ``client_context`` in place so the
70+
``w3c`` key is removed and cannot be read through
71+
``context.client_context``.
72+
"""
73+
if not isinstance(client_context, dict):
74+
return {}
75+
if "w3c" not in client_context:
76+
return {}
77+
78+
raw_w3c = client_context.pop("w3c")
79+
80+
if not isinstance(raw_w3c, dict):
81+
return {}
82+
83+
fields = {}
84+
for key in W3C_ALLOWED_FIELDS:
85+
value = raw_w3c.get(key)
86+
if isinstance(value, str):
87+
fields[key] = value
88+
return fields
89+
5290
def log(self, msg):
5391
for handler in logging.getLogger().handlers:
5492
if hasattr(handler, "log_sink"):

‎test/dockerized/suites/ctx.json‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"tests": [
3+
{
4+
"name": "client_context_is_echoed_when_no_w3c_key",
5+
"handler": "w3c.echo_client_context",
6+
"request": {},
7+
"clientContext": {
8+
"custom": { "value": "hello" },
9+
"env": { "stage": "beta" }
10+
},
11+
"assertions": [
12+
{
13+
"response": {
14+
"custom": { "value": "hello" },
15+
"env": { "stage": "beta" }
16+
}
17+
}
18+
]
19+
},
20+
21+
{
22+
"name": "client_context_is_null_when_header_absent",
23+
"handler": "w3c.echo_client_context",
24+
"request": {},
25+
"assertions": [
26+
{ "response": null }
27+
]
28+
}
29+
]
30+
}

‎test/dockerized/suites/w3c.json‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
{
2+
"tests": [
3+
{
4+
"name": "w3c_is_callable_on_context",
5+
"handler": "w3c.w3c_is_callable",
6+
"request": {},
7+
"assertions": [
8+
{ "response": { "isCallable": true } }
9+
]
10+
},
11+
12+
{
13+
"name": "w3c_returns_empty_when_no_client_context_header",
14+
"handler": "w3c.get_w3c",
15+
"request": {},
16+
"assertions": [
17+
{ "response": {} }
18+
]
19+
},
20+
21+
{
22+
"name": "w3c_returns_empty_when_client_context_has_no_w3c_key",
23+
"handler": "w3c.get_w3c",
24+
"request": {},
25+
"clientContext": {
26+
"custom": { "value": "test" }
27+
},
28+
"assertions": [
29+
{ "response": {} }
30+
]
31+
},
32+
33+
{
34+
"name": "w3c_returns_baggage_only",
35+
"handler": "w3c.get_w3c",
36+
"request": {},
37+
"clientContext": {
38+
"w3c": { "baggage": "userId=alice" }
39+
},
40+
"assertions": [
41+
{ "response": { "baggage": "userId=alice" } }
42+
]
43+
},
44+
45+
{
46+
"name": "w3c_returns_all_three_allowlisted_fields",
47+
"handler": "w3c.get_w3c",
48+
"request": {},
49+
"clientContext": {
50+
"w3c": {
51+
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
52+
"tracestate": "rojo=00f067aa0ba902b7",
53+
"baggage": "userId=alice"
54+
}
55+
},
56+
"assertions": [
57+
{
58+
"response": {
59+
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
60+
"tracestate": "rojo=00f067aa0ba902b7",
61+
"baggage": "userId=alice"
62+
}
63+
}
64+
]
65+
},
66+
67+
{
68+
"name": "w3c_allowlist_drops_non_allowlisted_keys",
69+
"handler": "w3c.get_w3c",
70+
"request": {},
71+
"clientContext": {
72+
"w3c": {
73+
"baggage": "keep=me",
74+
"unknownField": "should-not-appear",
75+
"x-custom-trace": "should-not-appear"
76+
}
77+
},
78+
"assertions": [
79+
{ "response": { "baggage": "keep=me" } }
80+
]
81+
},
82+
83+
{
84+
"name": "w3c_drops_allowlisted_fields_with_non_string_values",
85+
"handler": "w3c.get_w3c",
86+
"request": {},
87+
"clientContext": {
88+
"w3c": {
89+
"traceparent": 42,
90+
"tracestate": null,
91+
"baggage": { "nested": "no" }
92+
}
93+
},
94+
"assertions": [
95+
{ "response": {} }
96+
]
97+
},
98+
99+
{
100+
"name": "w3c_treats_non_object_as_empty",
101+
"handler": "w3c.get_w3c",
102+
"request": {},
103+
"clientContext": {
104+
"w3c": "not-an-object"
105+
},
106+
"assertions": [
107+
{ "response": {} }
108+
]
109+
},
110+
111+
{
112+
"name": "w3c_treats_array_as_empty",
113+
"handler": "w3c.get_w3c",
114+
"request": {},
115+
"clientContext": {
116+
"w3c": ["baggage=abc"]
117+
},
118+
"assertions": [
119+
{ "response": {} }
120+
]
121+
},
122+
123+
{
124+
"name": "w3c_strips_source_client_context_w3c_after_construction",
125+
"handler": "w3c.get_w3c_and_source",
126+
"request": {},
127+
"clientContext": {
128+
"custom": { "value": "test" },
129+
"w3c": {
130+
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
131+
"baggage": "userId=alice"
132+
}
133+
},
134+
"assertions": [
135+
{
136+
"response": {
137+
"w3c": {
138+
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
139+
"baggage": "userId=alice"
140+
},
141+
"clientContextIsDefined": true,
142+
"clientContextHasW3c": false,
143+
"clientContext": { "custom": { "value": "test" } }
144+
}
145+
}
146+
]
147+
}
148+
]
149+
}

‎test/dockerized/tasks/w3c.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
def _serialize_client_context(client_context):
5+
if client_context is None:
6+
return None
7+
result = {}
8+
for field in ("custom", "env"):
9+
value = getattr(client_context, field, None)
10+
if value is not None:
11+
result[field] = value
12+
return result
13+
14+
def get_w3c(event, context):
15+
return context.w3c()
16+
17+
def get_w3c_and_source(event, context):
18+
client_context = context.client_context
19+
return {
20+
"w3c": context.w3c(),
21+
"clientContextIsDefined": client_context is not None,
22+
"clientContextHasW3c": client_context is not None
23+
and hasattr(client_context, "w3c"),
24+
"clientContext": _serialize_client_context(client_context),
25+
}
26+
27+
def echo_client_context(event, context):
28+
return _serialize_client_context(context.client_context)
29+
30+
def w3c_is_callable(event, context):
31+
return {"isCallable": callable(getattr(context, "w3c", None))}

0 commit comments

Comments
 (0)