From ff286e54462d2cb0083a49bc46f4bcd0a54e111a Mon Sep 17 00:00:00 2001 From: Maxime David Date: Tue, 6 Oct 2026 11:26:24 +0000 Subject: [PATCH 1/6] feat: add W3C in context --- .github/workflows/dockerized-test.yml | 48 +++++ .gitignore | 2 + Dockerfile.test | 22 ++ .../Internal/SnapStartEmptyLambdaContext.cs | 5 + .../src/Amazon.Lambda.Core/ILambdaContext.cs | 11 + .../Context/LambdaContext.cs | 5 + .../Context/W3CTraceContext.cs | 77 +++++++ .../TestLambdaContext.cs | 22 ++ .../W3CContextTests.cs | 198 ++++++++++++++++++ test/dockerized/W3CTestHandler/Program.cs | 96 +++++++++ .../W3CTestHandler/W3CTestHandler.csproj | 20 ++ test/dockerized/suites/ctx.json | 31 +++ test/dockerized/suites/w3c.json | 150 +++++++++++++ 13 files changed, 687 insertions(+) create mode 100644 .github/workflows/dockerized-test.yml create mode 100644 Dockerfile.test create mode 100644 Libraries/src/Amazon.Lambda.RuntimeSupport/Context/W3CTraceContext.cs create mode 100644 Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/W3CContextTests.cs create mode 100644 test/dockerized/W3CTestHandler/Program.cs create mode 100644 test/dockerized/W3CTestHandler/W3CTestHandler.csproj create mode 100644 test/dockerized/suites/ctx.json create mode 100644 test/dockerized/suites/w3c.json diff --git a/.github/workflows/dockerized-test.yml b/.github/workflows/dockerized-test.yml new file mode 100644 index 000000000..99298aa4b --- /dev/null +++ b/.github/workflows/dockerized-test.yml @@ -0,0 +1,48 @@ +name: dockerized-test + +permissions: + contents: read + +on: + push: + branches: [master] + pull_request: + branches: ['*'] + workflow_dispatch: + +jobs: + dockerized-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Publish W3CTestHandler + run: | + dotnet publish test/dockerized/W3CTestHandler/W3CTestHandler.csproj \ + -c Release \ + -r linux-x64 \ + --self-contained \ + -o test/dockerized/tasks + chmod +x test/dockerized/tasks/bootstrap + + - name: Build the test image + run: | + docker build . \ + -t local/test \ + -f Dockerfile.test \ + --build-arg BASE_IMAGE=public.ecr.aws/lambda/provided:al2023 + + - name: Run dockerized suites + uses: aws/containerized-test-runner-for-aws-lambda@0863dd17b5fc19585250a2405c0f939a77b4f397 # main + with: + suiteFileArray: '["./test/dockerized/suites/*.json"]' + dockerImageName: 'local/test' + taskFolder: './test/dockerized/tasks' diff --git a/.gitignore b/.gitignore index fa7937bf4..1feb44e78 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,5 @@ global.json # JetBrains Rider per-project cache **/*.lscache + +test/dockerized/tasks/ diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 000000000..fe42af7f0 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,22 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +ARG BASE_IMAGE=public.ecr.aws/lambda/provided:al2023 +FROM ${BASE_IMAGE} + +ARG TARGETARCH=amd64 +ARG RIE_URL_AMD64=https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie +ARG RIE_URL_ARM64=https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie-arm64 + +RUN set -eu; \ + case "${TARGETARCH}" in \ + arm64) RIE_URL="${RIE_URL_ARM64}" ;; \ + *) RIE_URL="${RIE_URL_AMD64}" ;; \ + esac; \ + curl -fsSL -o /usr/local/bin/aws-lambda-rie "${RIE_URL}"; \ + chmod +x /usr/local/bin/aws-lambda-rie + +COPY test/dockerized/tasks /var/task +RUN chmod +x /var/task/bootstrap + +ENTRYPOINT ["/usr/local/bin/aws-lambda-rie", "/var/task/bootstrap"] diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/SnapStartEmptyLambdaContext.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/SnapStartEmptyLambdaContext.cs index acd7c1ad5..d361cd4db 100644 --- a/Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/SnapStartEmptyLambdaContext.cs +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/SnapStartEmptyLambdaContext.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using Amazon.Lambda.Core; namespace Amazon.Lambda.AspNetCoreServer.Internal; @@ -64,4 +65,8 @@ public SnapStartEmptyLambdaContext() public IClientApplication Client { get; } public IDictionary Custom { get; } = new Dictionary(); public string TenantId { get; } + + private static readonly IReadOnlyDictionary EmptyW3C = + new ReadOnlyDictionary(new Dictionary()); + public IReadOnlyDictionary W3C() => EmptyW3C; } diff --git a/Libraries/src/Amazon.Lambda.Core/ILambdaContext.cs b/Libraries/src/Amazon.Lambda.Core/ILambdaContext.cs index bcdce39df..84aa5b70a 100644 --- a/Libraries/src/Amazon.Lambda.Core/ILambdaContext.cs +++ b/Libraries/src/Amazon.Lambda.Core/ILambdaContext.cs @@ -1,6 +1,7 @@ namespace Amazon.Lambda.Core { using System; + using System.Collections.Generic; /// /// Object that allows you to access useful information available within @@ -107,6 +108,16 @@ public interface ILambdaContext /// handlers). /// ILambdaSerializer Serializer { get { return null; } } + + /// + /// Returns the W3C trace-context fields (traceparent, + /// tracestate, baggage) carried on the invoke's + /// clientContext.w3c payload. + /// + IReadOnlyDictionary W3C() + { + return new System.Collections.ObjectModel.ReadOnlyDictionary(new Dictionary()); + } #endif } } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Context/LambdaContext.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Context/LambdaContext.cs index 943a61d91..ffcc37ded 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Context/LambdaContext.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Context/LambdaContext.cs @@ -15,6 +15,7 @@ using Amazon.Lambda.Core; using System; +using System.Collections.Generic; using Amazon.Lambda.RuntimeSupport.Helpers; namespace Amazon.Lambda.RuntimeSupport @@ -30,6 +31,7 @@ internal class LambdaContext : ILambdaContext private readonly int _memoryLimitInMB; private readonly Lazy _cognitoIdentityLazy; private readonly Lazy _cognitoClientContextLazy; + private readonly Lazy> _w3cLazy; private readonly IConsoleLoggerWriter _consoleLogger; public LambdaContext(RuntimeApiHeaders runtimeApiHeaders, LambdaEnvironment lambdaEnvironment, IConsoleLoggerWriter consoleLogger) @@ -49,6 +51,7 @@ public LambdaContext(RuntimeApiHeaders runtimeApiHeaders, LambdaEnvironment lamb long.TryParse(_runtimeApiHeaders.DeadlineMs, out _deadlineMs); _cognitoIdentityLazy = new Lazy(() => CognitoIdentity.FromJson(runtimeApiHeaders.CognitoIdentityJson)); _cognitoClientContextLazy = new Lazy(() => CognitoClientContext.FromJson(runtimeApiHeaders.ClientContextJson)); + _w3cLazy = new Lazy>(() => W3CTraceContext.FromClientContextJson(runtimeApiHeaders.ClientContextJson)); } public string TraceId => _runtimeApiHeaders.TraceId; @@ -77,6 +80,8 @@ public LambdaContext(RuntimeApiHeaders runtimeApiHeaders, LambdaEnvironment lamb public string TenantId => _runtimeApiHeaders.TenantId; + public IReadOnlyDictionary W3C() => _w3cLazy.Value; + /// /// The serializer the Lambda function registered with the runtime, surfaced via /// . Assigned per-invocation by diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Context/W3CTraceContext.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Context/W3CTraceContext.cs new file mode 100644 index 000000000..172b89dd4 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Context/W3CTraceContext.cs @@ -0,0 +1,77 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Text.Json; + +namespace Amazon.Lambda.RuntimeSupport +{ + /// + /// Extracts the W3C trace-context fields (traceparent, tracestate, baggage) + /// carried on clientContext.w3c at invoke time. Any other key on + /// w3c is ignored, and any allowlisted key whose value is not a + /// string is dropped. + /// + internal static class W3CTraceContext + { + internal static readonly string[] AllowedFields = { "traceparent", "tracestate", "baggage" }; + + private static readonly IReadOnlyDictionary Empty = + new ReadOnlyDictionary(new Dictionary()); + + internal static IReadOnlyDictionary FromClientContextJson(string clientContextJson) + { + if (string.IsNullOrWhiteSpace(clientContextJson)) + { + return Empty; + } + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(clientContextJson); + } + catch (JsonException) + { + return Empty; + } + + using (doc) + { + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return Empty; + } + + if (!root.TryGetProperty("w3c", out var w3c)) + { + return Empty; + } + + if (w3c.ValueKind != JsonValueKind.Object) + { + return Empty; + } + + var fields = new Dictionary(AllowedFields.Length); + foreach (var key in AllowedFields) + { + if (!w3c.TryGetProperty(key, out var value)) + { + continue; + } + // Allowlisted keys whose value is not a JSON string are dropped. + if (value.ValueKind != JsonValueKind.String) + { + continue; + } + fields[key] = value.GetString(); + } + + return new ReadOnlyDictionary(fields); + } + } + } +} diff --git a/Libraries/src/Amazon.Lambda.TestUtilities/TestLambdaContext.cs b/Libraries/src/Amazon.Lambda.TestUtilities/TestLambdaContext.cs index b472403e0..d5b38e438 100644 --- a/Libraries/src/Amazon.Lambda.TestUtilities/TestLambdaContext.cs +++ b/Libraries/src/Amazon.Lambda.TestUtilities/TestLambdaContext.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; @@ -87,5 +88,26 @@ public class TestLambdaContext : ILambdaContext /// in production. /// public ILambdaSerializer Serializer { get; set; } + + /// + /// The W3C trace-context fields (traceparent, tracestate, + /// baggage) a test wants to pretend were carried on + /// clientContext.w3c at invoke time. + /// + public IDictionary W3CFields { get; set; } + + /// + /// Returns the W3C trace-context fields configured via , + /// wrapped in a read-only dictionary. Returns an empty read-only dictionary + /// when no fields were set. + /// + public IReadOnlyDictionary W3C() + { + if (W3CFields == null) + { + return new ReadOnlyDictionary(new Dictionary()); + } + return new ReadOnlyDictionary(new Dictionary(W3CFields)); + } } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/W3CContextTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/W3CContextTests.cs new file mode 100644 index 000000000..5b1316ef0 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/W3CContextTests.cs @@ -0,0 +1,198 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.Globalization; +using Xunit; + +namespace Amazon.Lambda.RuntimeSupport.UnitTests +{ + + public class W3CContextTests + { + private readonly TestEnvironmentVariables _environmentVariables = new TestEnvironmentVariables(); + + private LambdaContext BuildContext(string clientContextJson) + { + var deadlineMs = DateTimeOffset.UtcNow.AddHours(1) + .ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture); + var headers = new Dictionary> + { + ["Lambda-Runtime-Aws-Request-Id"] = new[] { Guid.NewGuid().ToString() }, + ["Lambda-Runtime-Deadline-Ms"] = new[] { deadlineMs } + }; + if (clientContextJson != null) + { + headers["Lambda-Runtime-Client-Context"] = new[] { clientContextJson }; + } + + var runtimeApiHeaders = new RuntimeApiHeaders(headers); + var env = new LambdaEnvironment(_environmentVariables); + return new LambdaContext(runtimeApiHeaders, env, + new Helpers.LogLevelLoggerWriter(new SystemEnvironmentVariables())); + } + + [Fact] + public void W3C_ReturnsEmpty_WhenClientContextHeaderAbsent() + { + var context = BuildContext(clientContextJson: null); + Assert.Empty(context.W3C()); + } + + [Fact] + public void W3C_ReturnsEmpty_WhenClientContextHasNoW3CKey() + { + var context = BuildContext("{\"custom\":{\"value\":\"test\"}}"); + Assert.Empty(context.W3C()); + // ClientContext is still populated — w3c is just not there. + Assert.NotNull(context.ClientContext); + Assert.Equal("test", context.ClientContext.Custom["value"]); + } + + [Fact] + public void W3C_ReturnsBaggageOnly() + { + var context = BuildContext("{\"w3c\":{\"baggage\":\"userId=alice\"}}"); + var w3c = context.W3C(); + Assert.Single(w3c); + Assert.Equal("userId=alice", w3c["baggage"]); + } + + [Fact] + public void W3C_ReturnsAllThreeAllowlistedFields() + { + const string json = @"{ + ""custom"": { ""value"": ""test"" }, + ""w3c"": { + ""traceparent"": ""00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"", + ""tracestate"": ""rojo=00f067aa0ba902b7"", + ""baggage"": ""userId=alice"" + } + }"; + var context = BuildContext(json); + var w3c = context.W3C(); + Assert.Equal(3, w3c.Count); + Assert.Equal("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", w3c["traceparent"]); + Assert.Equal("rojo=00f067aa0ba902b7", w3c["tracestate"]); + Assert.Equal("userId=alice", w3c["baggage"]); + } + + [Fact] + public void W3C_StripsSource_WhenReadFromClientContext() + { + const string json = @"{ + ""custom"": { ""value"": ""test"" }, + ""w3c"": { + ""traceparent"": ""00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"", + ""baggage"": ""userId=alice"" + } + }"; + var context = BuildContext(json); + + // W3C fields are surfaced through W3C() + Assert.Equal(2, context.W3C().Count); + // but IClientContext has no way to read them back + Assert.NotNull(context.ClientContext); + Assert.Null(context.ClientContext.Environment); + Assert.Null(context.ClientContext.Client); + Assert.NotNull(context.ClientContext.Custom); + Assert.Equal("test", context.ClientContext.Custom["value"]); + Assert.False(context.ClientContext.Custom.ContainsKey("w3c")); + Assert.False(context.ClientContext.Custom.ContainsKey("traceparent")); + } + + [Fact] + public void W3C_DropsAllowlistedFields_WithNonStringValues() + { + const string json = @"{ + ""w3c"": { + ""baggage"": ""abc"", + ""traceparent"": 42, + ""tracestate"": null + } + }"; + var context = BuildContext(json); + var w3c = context.W3C(); + Assert.Single(w3c); + Assert.Equal("abc", w3c["baggage"]); + } + + [Fact] + public void W3C_TreatsNonObjectValueAsEmpty() + { + var context = BuildContext("{\"w3c\":\"not-an-object\"}"); + Assert.Empty(context.W3C()); + } + + [Fact] + public void W3C_TreatsArrayValueAsEmpty() + { + var context = BuildContext("{\"w3c\":[\"baggage=abc\"]}"); + Assert.Empty(context.W3C()); + } + + [Fact] + public void W3C_ResultIsReadOnly() + { + var context = BuildContext("{\"w3c\":{\"baggage\":\"abc\"}}"); + var w3c = context.W3C(); + + Assert.IsType>(w3c); + + var mutable = (ICollection>)w3c; + Assert.True(mutable.IsReadOnly); + Assert.Throws(() => + mutable.Add(new KeyValuePair("baggage", "tampered"))); + + Assert.Equal("abc", context.W3C()["baggage"]); + } + + [Fact] + public void W3C_AllowlistDrops_NonAllowlistedKeys_EvenWhenValueIsString() + { + const string json = @"{ + ""w3c"": { + ""baggage"": ""keep=me"", + ""unknownField"": ""should-not-appear"", + ""x-custom-trace"": ""should-not-appear"" + } + }"; + var context = BuildContext(json); + var w3c = context.W3C(); + Assert.Single(w3c); + Assert.Equal("keep=me", w3c["baggage"]); + Assert.False(w3c.ContainsKey("unknownField")); + } + + [Fact] + public void W3C_OmitsAbsentAllowlistedKeys() + { + var context = BuildContext("{\"w3c\":{\"baggage\":\"abc\"}}"); + var w3c = context.W3C(); + Assert.True(w3c.ContainsKey("baggage")); + Assert.False(w3c.ContainsKey("traceparent")); + Assert.False(w3c.ContainsKey("tracestate")); + } + + [Fact] + public void W3C_ReturnsEmpty_WhenAllAllowlistedValuesAreNonStrings() + { + const string json = @"{ + ""w3c"": { + ""traceparent"": 42, + ""tracestate"": null, + ""baggage"": { ""nested"": ""no"" } + } + }"; + var context = BuildContext(json); + Assert.Empty(context.W3C()); + } + + [Fact] + public void W3C_ReturnsEmpty_WhenClientContextJsonIsMalformed() + { + Assert.Empty(W3CTraceContext.FromClientContextJson("{ not valid json")); + } + } +} diff --git a/test/dockerized/W3CTestHandler/Program.cs b/test/dockerized/W3CTestHandler/Program.cs new file mode 100644 index 000000000..c93d8ee5f --- /dev/null +++ b/test/dockerized/W3CTestHandler/Program.cs @@ -0,0 +1,96 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Amazon.Lambda.Core; +using Amazon.Lambda.RuntimeSupport; +using Amazon.Lambda.Serialization.SystemTextJson; + +namespace W3CTestHandler +{ + public static class Program + { + private static readonly DefaultLambdaJsonSerializer JsonSerializer = new DefaultLambdaJsonSerializer(); + + public static async Task Main(string[] args) + { + // The RIE and the Lambda runtime API both pass the configured + // handler through the _HANDLER environment variable. + var handler = Environment.GetEnvironmentVariable("_HANDLER") ?? string.Empty; + + Func dispatch = handler switch + { + "w3c.get_w3c" => GetW3c, + "w3c.get_w3c_and_source" => GetW3cAndSource, + "w3c.echo_client_context" => EchoClientContext, + "w3c.w3c_is_callable" => W3CIsCallable, + _ => throw new Exception($"Handler '{handler}' is not supported."), + }; + + using var wrapper = HandlerWrapper.GetHandlerWrapper( + (Func)dispatch, + JsonSerializer); + using var bootstrap = new LambdaBootstrap(wrapper); + await bootstrap.RunAsync(); + } + + /// Returns the raw W3C() dictionary — the primary assertion target. + public static object GetW3c(JsonNode input, ILambdaContext context) + { + // Returning the IReadOnlyDictionary directly serializes as a JSON object. + return context.W3C(); + } + + public static object GetW3cAndSource(JsonNode input, ILambdaContext context) + { + var clientContext = context.ClientContext; + return new Dictionary + { + ["w3c"] = context.W3C(), + ["clientContextIsDefined"] = clientContext != null, + // IClientContext has no "w3c" accessor at all on .NET — the + // strip-the-source step is naturally met by the type system. + ["clientContextHasW3c"] = false, + ["clientContext"] = SerializeClientContext(clientContext), + }; + } + + public static object EchoClientContext(JsonNode input, ILambdaContext context) + { + return SerializeClientContext(context.ClientContext); + } + + public static object W3CIsCallable(JsonNode input, ILambdaContext context) + { + var method = context.GetType().GetMethod(nameof(ILambdaContext.W3C), Type.EmptyTypes); + return new Dictionary + { + ["isCallable"] = method != null, + }; + } + + private static object SerializeClientContext(IClientContext clientContext) + { + if (clientContext == null) + { + return null; + } + var result = new Dictionary(); + if (clientContext.Custom != null && clientContext.Custom.Count > 0) + { + result["custom"] = clientContext.Custom.ToDictionary(kv => kv.Key, kv => (object)kv.Value); + } + if (clientContext.Environment != null && clientContext.Environment.Count > 0) + { + result["env"] = clientContext.Environment.ToDictionary(kv => kv.Key, kv => (object)kv.Value); + } + return result.Count == 0 ? null : result; + } + } +} diff --git a/test/dockerized/W3CTestHandler/W3CTestHandler.csproj b/test/dockerized/W3CTestHandler/W3CTestHandler.csproj new file mode 100644 index 000000000..028ddcac5 --- /dev/null +++ b/test/dockerized/W3CTestHandler/W3CTestHandler.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + bootstrap + linux-x64 + true + false + true + IDE0060 + + + + + + + + + diff --git a/test/dockerized/suites/ctx.json b/test/dockerized/suites/ctx.json new file mode 100644 index 000000000..9f93fdb1e --- /dev/null +++ b/test/dockerized/suites/ctx.json @@ -0,0 +1,31 @@ +{ + "name": "ctx", + "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 000000000..f907b8732 --- /dev/null +++ b/test/dockerized/suites/w3c.json @@ -0,0 +1,150 @@ +{ + "name": "w3c", + "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" } } + } + } + ] + } + ] +} From 069a8823012530643e372092c2a3bbbadd1ae6bc Mon Sep 17 00:00:00 2001 From: Maxime David Date: Tue, 6 Oct 2026 17:53:20 +0000 Subject: [PATCH 2/6] chore: add autover change file for W3C trace context Satisfies the 'Check for Change File(s) in .autover/changes/' CI gate by recording the W3C() API addition across Amazon.Lambda.Core, Amazon.Lambda.RuntimeSupport, Amazon.Lambda.TestUtilities and Amazon.Lambda.AspNetCoreServer. --- .../f13d1778-21ba-4438-b4d5-aecbc524e11c.json | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .autover/changes/f13d1778-21ba-4438-b4d5-aecbc524e11c.json diff --git a/.autover/changes/f13d1778-21ba-4438-b4d5-aecbc524e11c.json b/.autover/changes/f13d1778-21ba-4438-b4d5-aecbc524e11c.json new file mode 100644 index 000000000..dfc18a465 --- /dev/null +++ b/.autover/changes/f13d1778-21ba-4438-b4d5-aecbc524e11c.json @@ -0,0 +1,32 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.Core", + "Type": "Minor", + "ChangelogMessages": [ + "Add ILambdaContext.W3C() to expose the W3C trace-context fields (traceparent, tracestate, baggage) carried on clientContext.w3c at invoke time." + ] + }, + { + "Name": "Amazon.Lambda.RuntimeSupport", + "Type": "Minor", + "ChangelogMessages": [ + "Implement ILambdaContext.W3C() in LambdaContext, surfacing the allowlisted W3C trace-context fields (traceparent, tracestate, baggage) parsed from the client context header." + ] + }, + { + "Name": "Amazon.Lambda.TestUtilities", + "Type": "Minor", + "ChangelogMessages": [ + "Add W3C trace-context support to TestLambdaContext via the settable W3CFields property and the W3C() method." + ] + }, + { + "Name": "Amazon.Lambda.AspNetCoreServer", + "Type": "Minor", + "ChangelogMessages": [ + "Implement ILambdaContext.W3C() on the SnapStart empty context, returning an empty trace-context during initialization." + ] + } + ] +} From e762aad9ba847c56832cdd64c6dbe9d112e84dc9 Mon Sep 17 00:00:00 2001 From: Maxime David Date: Tue, 6 Oct 2026 17:57:54 +0000 Subject: [PATCH 3/6] feat: simplify dockerfile --- Dockerfile.test | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/Dockerfile.test b/Dockerfile.test index fe42af7f0..beec40f2b 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -4,17 +4,11 @@ ARG BASE_IMAGE=public.ecr.aws/lambda/provided:al2023 FROM ${BASE_IMAGE} -ARG TARGETARCH=amd64 -ARG RIE_URL_AMD64=https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie -ARG RIE_URL_ARM64=https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie-arm64 -RUN set -eu; \ - case "${TARGETARCH}" in \ - arm64) RIE_URL="${RIE_URL_ARM64}" ;; \ - *) RIE_URL="${RIE_URL_AMD64}" ;; \ - esac; \ - curl -fsSL -o /usr/local/bin/aws-lambda-rie "${RIE_URL}"; \ - chmod +x /usr/local/bin/aws-lambda-rie +ARG RIE_URL=https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie + +RUN curl -fsSL -o /usr/local/bin/aws-lambda-rie "${RIE_URL}" \ + && chmod +x /usr/local/bin/aws-lambda-rie COPY test/dockerized/tasks /var/task RUN chmod +x /var/task/bootstrap From 1dc90cc2fc939bb4fec532d82a433b0c225240c3 Mon Sep 17 00:00:00 2001 From: Maxime David Date: Tue, 6 Oct 2026 18:00:50 +0000 Subject: [PATCH 4/6] feat: update actions --- .github/workflows/dockerized-test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dockerized-test.yml b/.github/workflows/dockerized-test.yml index 99298aa4b..e684410a0 100644 --- a/.github/workflows/dockerized-test.yml +++ b/.github/workflows/dockerized-test.yml @@ -14,10 +14,10 @@ jobs: dockerized-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | 8.0.x From d87fe99b48c1f3f76ca204d1f317b556c99573e7 Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Tue, 6 Oct 2026 22:26:09 +0000 Subject: [PATCH 5/6] refactor: make ILambdaContext.W3C a property instead of a method Converts the W3C trace-context accessor from a W3C() method to a W3C property to follow .NET naming/design conventions (a parameterless accessor returning data should be a property). Updates the interface default implementation, LambdaContext, SnapStartEmptyLambdaContext, and TestLambdaContext, plus all call sites in the unit tests and the dockerized W3C test handler (the reflection accessibility check now looks up the property). Changelog wording updated to match. --- .../f13d1778-21ba-4438-b4d5-aecbc524e11c.json | 8 +++--- .../Internal/SnapStartEmptyLambdaContext.cs | 2 +- .../src/Amazon.Lambda.Core/ILambdaContext.cs | 9 +++--- .../Context/LambdaContext.cs | 2 +- .../TestLambdaContext.cs | 13 +++++---- .../W3CContextTests.cs | 28 +++++++++---------- test/dockerized/W3CTestHandler/Program.cs | 10 +++---- 7 files changed, 37 insertions(+), 35 deletions(-) diff --git a/.autover/changes/f13d1778-21ba-4438-b4d5-aecbc524e11c.json b/.autover/changes/f13d1778-21ba-4438-b4d5-aecbc524e11c.json index dfc18a465..c5e429339 100644 --- a/.autover/changes/f13d1778-21ba-4438-b4d5-aecbc524e11c.json +++ b/.autover/changes/f13d1778-21ba-4438-b4d5-aecbc524e11c.json @@ -4,28 +4,28 @@ "Name": "Amazon.Lambda.Core", "Type": "Minor", "ChangelogMessages": [ - "Add ILambdaContext.W3C() to expose the W3C trace-context fields (traceparent, tracestate, baggage) carried on clientContext.w3c at invoke time." + "Add ILambdaContext.W3C to expose the W3C trace-context fields (traceparent, tracestate, baggage) carried on clientContext.w3c at invoke time." ] }, { "Name": "Amazon.Lambda.RuntimeSupport", "Type": "Minor", "ChangelogMessages": [ - "Implement ILambdaContext.W3C() in LambdaContext, surfacing the allowlisted W3C trace-context fields (traceparent, tracestate, baggage) parsed from the client context header." + "Implement ILambdaContext.W3C in LambdaContext, surfacing the allowlisted W3C trace-context fields (traceparent, tracestate, baggage) parsed from the client context header." ] }, { "Name": "Amazon.Lambda.TestUtilities", "Type": "Minor", "ChangelogMessages": [ - "Add W3C trace-context support to TestLambdaContext via the settable W3CFields property and the W3C() method." + "Add W3C trace-context support to TestLambdaContext via the settable W3CFields property and the W3C property." ] }, { "Name": "Amazon.Lambda.AspNetCoreServer", "Type": "Minor", "ChangelogMessages": [ - "Implement ILambdaContext.W3C() on the SnapStart empty context, returning an empty trace-context during initialization." + "Implement ILambdaContext.W3C on the SnapStart empty context, returning an empty trace-context during initialization." ] } ] diff --git a/Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/SnapStartEmptyLambdaContext.cs b/Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/SnapStartEmptyLambdaContext.cs index d361cd4db..eec65b007 100644 --- a/Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/SnapStartEmptyLambdaContext.cs +++ b/Libraries/src/Amazon.Lambda.AspNetCoreServer/Internal/SnapStartEmptyLambdaContext.cs @@ -68,5 +68,5 @@ public SnapStartEmptyLambdaContext() private static readonly IReadOnlyDictionary EmptyW3C = new ReadOnlyDictionary(new Dictionary()); - public IReadOnlyDictionary W3C() => EmptyW3C; + public IReadOnlyDictionary W3C => EmptyW3C; } diff --git a/Libraries/src/Amazon.Lambda.Core/ILambdaContext.cs b/Libraries/src/Amazon.Lambda.Core/ILambdaContext.cs index 84aa5b70a..b81372a54 100644 --- a/Libraries/src/Amazon.Lambda.Core/ILambdaContext.cs +++ b/Libraries/src/Amazon.Lambda.Core/ILambdaContext.cs @@ -110,13 +110,12 @@ public interface ILambdaContext ILambdaSerializer Serializer { get { return null; } } /// - /// Returns the W3C trace-context fields (traceparent, - /// tracestate, baggage) carried on the invoke's - /// clientContext.w3c payload. + /// The W3C trace-context fields (traceparent, tracestate, + /// baggage) carried on the invoke's clientContext.w3c payload. /// - IReadOnlyDictionary W3C() + IReadOnlyDictionary W3C { - return new System.Collections.ObjectModel.ReadOnlyDictionary(new Dictionary()); + get { return new System.Collections.ObjectModel.ReadOnlyDictionary(new Dictionary()); } } #endif } diff --git a/Libraries/src/Amazon.Lambda.RuntimeSupport/Context/LambdaContext.cs b/Libraries/src/Amazon.Lambda.RuntimeSupport/Context/LambdaContext.cs index ffcc37ded..f91c80a81 100644 --- a/Libraries/src/Amazon.Lambda.RuntimeSupport/Context/LambdaContext.cs +++ b/Libraries/src/Amazon.Lambda.RuntimeSupport/Context/LambdaContext.cs @@ -80,7 +80,7 @@ public LambdaContext(RuntimeApiHeaders runtimeApiHeaders, LambdaEnvironment lamb public string TenantId => _runtimeApiHeaders.TenantId; - public IReadOnlyDictionary W3C() => _w3cLazy.Value; + public IReadOnlyDictionary W3C => _w3cLazy.Value; /// /// The serializer the Lambda function registered with the runtime, surfaced via diff --git a/Libraries/src/Amazon.Lambda.TestUtilities/TestLambdaContext.cs b/Libraries/src/Amazon.Lambda.TestUtilities/TestLambdaContext.cs index d5b38e438..cbd3fd106 100644 --- a/Libraries/src/Amazon.Lambda.TestUtilities/TestLambdaContext.cs +++ b/Libraries/src/Amazon.Lambda.TestUtilities/TestLambdaContext.cs @@ -97,17 +97,20 @@ public class TestLambdaContext : ILambdaContext public IDictionary W3CFields { get; set; } /// - /// Returns the W3C trace-context fields configured via , + /// The W3C trace-context fields configured via , /// wrapped in a read-only dictionary. Returns an empty read-only dictionary /// when no fields were set. /// - public IReadOnlyDictionary W3C() + public IReadOnlyDictionary W3C { - if (W3CFields == null) + get { - return new ReadOnlyDictionary(new Dictionary()); + if (W3CFields == null) + { + return new ReadOnlyDictionary(new Dictionary()); + } + return new ReadOnlyDictionary(new Dictionary(W3CFields)); } - return new ReadOnlyDictionary(new Dictionary(W3CFields)); } } } diff --git a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/W3CContextTests.cs b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/W3CContextTests.cs index 5b1316ef0..cb9b8e1c4 100644 --- a/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/W3CContextTests.cs +++ b/Libraries/test/Amazon.Lambda.RuntimeSupport.Tests/Amazon.Lambda.RuntimeSupport.UnitTests/W3CContextTests.cs @@ -37,14 +37,14 @@ private LambdaContext BuildContext(string clientContextJson) public void W3C_ReturnsEmpty_WhenClientContextHeaderAbsent() { var context = BuildContext(clientContextJson: null); - Assert.Empty(context.W3C()); + Assert.Empty(context.W3C); } [Fact] public void W3C_ReturnsEmpty_WhenClientContextHasNoW3CKey() { var context = BuildContext("{\"custom\":{\"value\":\"test\"}}"); - Assert.Empty(context.W3C()); + Assert.Empty(context.W3C); // ClientContext is still populated — w3c is just not there. Assert.NotNull(context.ClientContext); Assert.Equal("test", context.ClientContext.Custom["value"]); @@ -54,7 +54,7 @@ public void W3C_ReturnsEmpty_WhenClientContextHasNoW3CKey() public void W3C_ReturnsBaggageOnly() { var context = BuildContext("{\"w3c\":{\"baggage\":\"userId=alice\"}}"); - var w3c = context.W3C(); + var w3c = context.W3C; Assert.Single(w3c); Assert.Equal("userId=alice", w3c["baggage"]); } @@ -71,7 +71,7 @@ public void W3C_ReturnsAllThreeAllowlistedFields() } }"; var context = BuildContext(json); - var w3c = context.W3C(); + var w3c = context.W3C; Assert.Equal(3, w3c.Count); Assert.Equal("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", w3c["traceparent"]); Assert.Equal("rojo=00f067aa0ba902b7", w3c["tracestate"]); @@ -90,8 +90,8 @@ public void W3C_StripsSource_WhenReadFromClientContext() }"; var context = BuildContext(json); - // W3C fields are surfaced through W3C() - Assert.Equal(2, context.W3C().Count); + // W3C fields are surfaced through W3C + Assert.Equal(2, context.W3C.Count); // but IClientContext has no way to read them back Assert.NotNull(context.ClientContext); Assert.Null(context.ClientContext.Environment); @@ -113,7 +113,7 @@ public void W3C_DropsAllowlistedFields_WithNonStringValues() } }"; var context = BuildContext(json); - var w3c = context.W3C(); + var w3c = context.W3C; Assert.Single(w3c); Assert.Equal("abc", w3c["baggage"]); } @@ -122,21 +122,21 @@ public void W3C_DropsAllowlistedFields_WithNonStringValues() public void W3C_TreatsNonObjectValueAsEmpty() { var context = BuildContext("{\"w3c\":\"not-an-object\"}"); - Assert.Empty(context.W3C()); + Assert.Empty(context.W3C); } [Fact] public void W3C_TreatsArrayValueAsEmpty() { var context = BuildContext("{\"w3c\":[\"baggage=abc\"]}"); - Assert.Empty(context.W3C()); + Assert.Empty(context.W3C); } [Fact] public void W3C_ResultIsReadOnly() { var context = BuildContext("{\"w3c\":{\"baggage\":\"abc\"}}"); - var w3c = context.W3C(); + var w3c = context.W3C; Assert.IsType>(w3c); @@ -145,7 +145,7 @@ public void W3C_ResultIsReadOnly() Assert.Throws(() => mutable.Add(new KeyValuePair("baggage", "tampered"))); - Assert.Equal("abc", context.W3C()["baggage"]); + Assert.Equal("abc", context.W3C["baggage"]); } [Fact] @@ -159,7 +159,7 @@ public void W3C_AllowlistDrops_NonAllowlistedKeys_EvenWhenValueIsString() } }"; var context = BuildContext(json); - var w3c = context.W3C(); + var w3c = context.W3C; Assert.Single(w3c); Assert.Equal("keep=me", w3c["baggage"]); Assert.False(w3c.ContainsKey("unknownField")); @@ -169,7 +169,7 @@ public void W3C_AllowlistDrops_NonAllowlistedKeys_EvenWhenValueIsString() public void W3C_OmitsAbsentAllowlistedKeys() { var context = BuildContext("{\"w3c\":{\"baggage\":\"abc\"}}"); - var w3c = context.W3C(); + var w3c = context.W3C; Assert.True(w3c.ContainsKey("baggage")); Assert.False(w3c.ContainsKey("traceparent")); Assert.False(w3c.ContainsKey("tracestate")); @@ -186,7 +186,7 @@ public void W3C_ReturnsEmpty_WhenAllAllowlistedValuesAreNonStrings() } }"; var context = BuildContext(json); - Assert.Empty(context.W3C()); + Assert.Empty(context.W3C); } [Fact] diff --git a/test/dockerized/W3CTestHandler/Program.cs b/test/dockerized/W3CTestHandler/Program.cs index c93d8ee5f..623dd0444 100644 --- a/test/dockerized/W3CTestHandler/Program.cs +++ b/test/dockerized/W3CTestHandler/Program.cs @@ -40,11 +40,11 @@ public static async Task Main(string[] args) await bootstrap.RunAsync(); } - /// Returns the raw W3C() dictionary — the primary assertion target. + /// Returns the raw W3C dictionary — the primary assertion target. public static object GetW3c(JsonNode input, ILambdaContext context) { // Returning the IReadOnlyDictionary directly serializes as a JSON object. - return context.W3C(); + return context.W3C; } public static object GetW3cAndSource(JsonNode input, ILambdaContext context) @@ -52,7 +52,7 @@ public static object GetW3cAndSource(JsonNode input, ILambdaContext context) var clientContext = context.ClientContext; return new Dictionary { - ["w3c"] = context.W3C(), + ["w3c"] = context.W3C, ["clientContextIsDefined"] = clientContext != null, // IClientContext has no "w3c" accessor at all on .NET — the // strip-the-source step is naturally met by the type system. @@ -68,10 +68,10 @@ public static object EchoClientContext(JsonNode input, ILambdaContext context) public static object W3CIsCallable(JsonNode input, ILambdaContext context) { - var method = context.GetType().GetMethod(nameof(ILambdaContext.W3C), Type.EmptyTypes); + var property = context.GetType().GetProperty(nameof(ILambdaContext.W3C)); return new Dictionary { - ["isCallable"] = method != null, + ["isCallable"] = property != null, }; } From 290f34885742ffd8dd566c070febfbfa44f364de Mon Sep 17 00:00:00 2001 From: Norm Johanson Date: Tue, 6 Oct 2026 22:31:33 +0000 Subject: [PATCH 6/6] ci: install only .NET 10 in dockerized-test workflow The W3CTestHandler targets net10.0, so the dockerized-test workflow only needs the .NET 10 SDK; drop the unused 8.0.x and 9.0.x installs. --- .github/workflows/dockerized-test.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/dockerized-test.yml b/.github/workflows/dockerized-test.yml index e684410a0..30a6af3f8 100644 --- a/.github/workflows/dockerized-test.yml +++ b/.github/workflows/dockerized-test.yml @@ -20,8 +20,6 @@ jobs: uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: | - 8.0.x - 9.0.x 10.0.x - name: Publish W3CTestHandler