-
Notifications
You must be signed in to change notification settings - Fork 5
Propagate Lambda invoke request IDs as gRPC metadata #1114
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ import ( | |
| "time" | ||
|
|
||
| "github.com/aws/aws-sdk-go-v2/aws" | ||
| awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" | ||
| "github.com/aws/aws-sdk-go-v2/service/lambda" | ||
| "github.com/aws/aws-sdk-go-v2/service/lambda/types" | ||
| "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" | ||
|
|
@@ -24,6 +25,8 @@ type lambdaTransport struct { | |
| functionName string | ||
| } | ||
|
|
||
| const lambdaInvokeRequestIDMetadataKey = "x-amzn-requestid" | ||
|
|
||
| func (l *lambdaTransport) RoundTrip(ctx context.Context, req *Request) (*Response, error) { | ||
| payload, frameOnly, err := req.marshalPayload() | ||
| if err != nil { | ||
|
|
@@ -82,7 +85,17 @@ func (l *lambdaTransport) RoundTrip(ctx context.Context, req *Request) (*Respons | |
| return nil, fmt.Errorf("lambda_transport: failed to unmarshal response: %w", err) | ||
| } | ||
|
|
||
| return resp, err | ||
| if requestID, ok := awsmiddleware.GetRequestIDMetadata(invokeResp.ResultMetadata); ok && requestID != "" { | ||
| headers := resp.Headers() | ||
| headers.Set(lambdaInvokeRequestIDMetadataKey, requestID) | ||
| respHeaders, err := MarshalMetadata(headers) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("lambda_transport: failed to encode response metadata: %w", err) | ||
| } | ||
| resp.msg.SetHeaders(respHeaders) | ||
| } | ||
|
|
||
| return resp, nil | ||
| } | ||
|
|
||
| // NewLambdaClientTransport returns a new client transport that invokes a lambda function. | ||
|
|
@@ -136,6 +149,8 @@ func (c *clientConn) Invoke(ctx context.Context, method string, args any, reply | |
| return err | ||
| } | ||
|
|
||
| populateResponseMetadata(tresp, opts) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: |
||
|
|
||
| if st.Code() != codes.OK { | ||
| return st.Err() | ||
| } | ||
|
|
@@ -145,21 +160,33 @@ func (c *clientConn) Invoke(ctx context.Context, method string, args any, reply | |
| return err | ||
| } | ||
|
|
||
| // TODO(morgabra): call opts here, some are probably important (e.g. PerRPCCredsCallOption, etc) | ||
| return nil | ||
| } | ||
|
|
||
| func populateResponseMetadata(resp *Response, opts []grpc.CallOption) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: this refactor deleted the |
||
| var headers metadata.MD | ||
| var trailers metadata.MD | ||
|
|
||
| for _, opt := range opts { | ||
| switch o := opt.(type) { | ||
| case grpc.HeaderCallOption: | ||
| for k, v := range tresp.Headers() { | ||
| o.HeaderAddr.Append(k, v...) | ||
| if o.HeaderAddr == nil { | ||
| continue | ||
| } | ||
| if headers == nil { | ||
| headers = resp.Headers() | ||
| } | ||
| *o.HeaderAddr = headers | ||
| case grpc.TrailerCallOption: | ||
| for k, v := range tresp.Trailers() { | ||
| o.TrailerAddr.Append(k, v...) | ||
| if o.TrailerAddr == nil { | ||
| continue | ||
| } | ||
| if trailers == nil { | ||
| trailers = resp.Trailers() | ||
| } | ||
| *o.TrailerAddr = trailers | ||
| } | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func (c *clientConn) NewStream(ctx context.Context, desc *grpc.StreamDesc, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| package grpc | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "testing" | ||
|
|
||
| "github.com/aws/aws-sdk-go-v2/aws" | ||
| "github.com/aws/aws-sdk-go-v2/credentials" | ||
| "github.com/aws/aws-sdk-go-v2/service/lambda" | ||
| "github.com/stretchr/testify/require" | ||
| "google.golang.org/grpc" | ||
| "google.golang.org/grpc/codes" | ||
| "google.golang.org/grpc/metadata" | ||
| "google.golang.org/grpc/status" | ||
| "google.golang.org/protobuf/types/known/anypb" | ||
| "google.golang.org/protobuf/types/known/structpb" | ||
|
|
||
| pbtransport "github.com/conductorone/baton-sdk/pb/c1/transport/v1" | ||
| ) | ||
|
|
||
| type staticClientTransport struct { | ||
| response *Response | ||
| err error | ||
| } | ||
|
|
||
| func (t *staticClientTransport) RoundTrip(context.Context, *Request) (*Response, error) { | ||
| return t.response, t.err | ||
| } | ||
|
|
||
| func testResponse(t *testing.T, code codes.Code, headers, trailers metadata.MD) *Response { | ||
| t.Helper() | ||
|
|
||
| response, err := anypb.New(&structpb.Struct{}) | ||
| require.NoError(t, err) | ||
| responseStatus, err := anypb.New(status.New(code, "response status").Proto()) | ||
| require.NoError(t, err) | ||
| responseHeaders, err := MarshalMetadata(headers) | ||
| require.NoError(t, err) | ||
| responseTrailers, err := MarshalMetadata(trailers) | ||
| require.NoError(t, err) | ||
|
|
||
| return &Response{ | ||
| msg: pbtransport.Response_builder{ | ||
| Resp: response, | ||
| Status: responseStatus, | ||
| Headers: responseHeaders, | ||
| Trailers: responseTrailers, | ||
| }.Build(), | ||
| } | ||
| } | ||
|
|
||
| func TestLambdaClientConnPropagatesInvokeRequestIDAndResponseMetadata(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| transportResponse := testResponse(t, codes.OK, metadata.Pairs("x-service-header", "server-value"), metadata.Pairs("x-service-trailer", "trailer-value")) | ||
| payload, err := json.Marshal(transportResponse) | ||
| require.NoError(t, err) | ||
|
|
||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | ||
| w.Header().Set("X-Amzn-Requestid", "invoke-request-id") | ||
| _, _ = w.Write(payload) | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| lambdaClient := lambda.NewFromConfig(aws.Config{ | ||
| Region: "us-east-1", | ||
| BaseEndpoint: aws.String(server.URL), | ||
| Credentials: credentials.NewStaticCredentialsProvider("access-key", "secret-key", ""), | ||
| }) | ||
| transport, err := NewLambdaClientTransport(context.Background(), lambdaClient, "test-function") | ||
| require.NoError(t, err) | ||
|
|
||
| var headers metadata.MD | ||
| var trailers metadata.MD | ||
| err = NewClientConn(transport).Invoke( | ||
| context.Background(), | ||
| "/test.Service/Method", | ||
| &structpb.Struct{}, | ||
| &structpb.Struct{}, | ||
| grpc.Header(&headers), | ||
| grpc.Trailer(&trailers), | ||
| ) | ||
| require.NoError(t, err) | ||
| require.Equal(t, []string{"invoke-request-id"}, headers.Get(lambdaInvokeRequestIDMetadataKey)) | ||
| require.Equal(t, []string{"server-value"}, headers.Get("x-service-header")) | ||
| require.Equal(t, []string{"trailer-value"}, trailers.Get("x-service-trailer")) | ||
| } | ||
|
|
||
| func TestClientConnReturnsResponseMetadataWithStatusError(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| transport := &staticClientTransport{ | ||
| response: testResponse(t, codes.PermissionDenied, metadata.Pairs("x-service-header", "server-value"), metadata.Pairs("x-service-trailer", "trailer-value")), | ||
|
Comment on lines
+92
to
+96
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: this test does not exercise the case the new |
||
| } | ||
| var headers metadata.MD | ||
| var trailers metadata.MD | ||
|
|
||
| err := NewClientConn(transport).Invoke( | ||
| context.Background(), | ||
| "/test.Service/Method", | ||
| &structpb.Struct{}, | ||
| &structpb.Struct{}, | ||
| grpc.Header(&headers), | ||
| grpc.Trailer(&trailers), | ||
| ) | ||
| require.Equal(t, codes.PermissionDenied, status.Code(err)) | ||
| require.Equal(t, []string{"server-value"}, headers.Get("x-service-header")) | ||
| require.Equal(t, []string{"trailer-value"}, trailers.Get("x-service-trailer")) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Suggestion: the invoke request ID is only surfaced on the success path. On the
FunctionErrorpath above,classifyLambdaFailurepopulatesLambdaInvokeFailure.RequestIDpurely from parsing the tail log'sREPORT RequestId:line, so it is empty whenever the REPORT line falls outside the 4KB tail window — exactly the OOM/timeout cases where the ID is most useful for CloudWatch lookup. Consider passingawsmiddleware.GetRequestIDMetadata(invokeResp.ResultMetadata)intoclassifyLambdaFailureas a fallback whenreport.RequestIDis empty.