Skip to content

Commit fb9cdf4

Browse files
feat: AWS lambda programatic wrap (#1308)
* feat: Support programatically wrapping lambda handlers * chore: Update README and conditionally flush metrics * fix: No ruby 3.1 shorthand * fix: typo and module length * Add tests for instrument_handler * fix: Trailing whitespace --------- Co-authored-by: Kayla Reopelle <[email protected]>
1 parent e1e1cc4 commit fb9cdf4

File tree

5 files changed

+296
-131
lines changed

5 files changed

+296
-131
lines changed

instrumentation/aws_lambda/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,32 @@ def otel_wrapper(event:, context:)
2828
end
2929
```
3030

31+
### Alternative Usage
32+
33+
If using a Lambda Layer is not an option for your given setup, you can programmatically instrument a handler by using the `OpenTelemetry::Instrumentation::AwsLambda::Wrap` module.
34+
35+
```ruby
36+
require 'opentelemetry/sdk'
37+
require 'opentelemetry/instrumentation/aws_lambda'
38+
39+
OpenTelemetry::SDK.configure do |c|
40+
c.service_name = '<YOUR_SERVICE_NAME>'
41+
c.use 'OpenTelemetry::Instrumentation::AwsLambda'
42+
end
43+
44+
# Lambda Handler
45+
module Example
46+
class Handler
47+
extend OpenTelemetry::Instrumentation::AwsLambda::Wrap
48+
49+
def self.process(event:, context:)
50+
puts event.inspect
51+
end
52+
instrument_handler :process
53+
end
54+
end
55+
```
56+
3157
## Example
3258

3359
To run the example:

instrumentation/aws_lambda/lib/opentelemetry/instrumentation/aws_lambda/handler.rb

Lines changed: 4 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
module OpenTelemetry
88
module Instrumentation
99
module AwsLambda
10-
AWS_TRIGGERS = ['aws:sqs', 'aws:s3', 'aws:sns', 'aws:dynamodb'].freeze
11-
1210
# Handler class that creates a span around the _HANDLER
1311
class Handler
12+
extend OpenTelemetry::Instrumentation::AwsLambda::Wrap
13+
1414
attr_reader :handler_method, :handler_class
1515

1616
# anytime when the code in a Lambda function is updated or the functional configuration is changed,
@@ -28,47 +28,9 @@ def initialize
2828
# Try to record and re-raise any exception from the wrapped function handler
2929
# Instrumentation should never raise its own exception
3030
def call_wrapped(event:, context:)
31-
parent_context = extract_parent_context(event)
32-
33-
span_kind = if event['Records'] && AWS_TRIGGERS.include?(event['Records'].dig(0, 'eventSource'))
34-
:consumer
35-
else
36-
:server
37-
end
38-
39-
original_handler_error = nil
40-
original_response = nil
41-
OpenTelemetry::Context.with_current(parent_context) do
42-
tracer.in_span(@original_handler, attributes: otel_attributes(event, context), kind: span_kind) do |span|
43-
begin
44-
response = call_original_handler(event: event, context: context)
45-
status_code = response['statusCode'] || response[:statusCode] if response.is_a?(Hash)
46-
span.set_attribute(OpenTelemetry::SemanticConventions::Trace::HTTP_STATUS_CODE, status_code) if status_code
47-
rescue StandardError => e
48-
original_handler_error = e
49-
ensure
50-
original_response = response
51-
end
52-
if original_handler_error
53-
span.record_exception(original_handler_error)
54-
span.status = OpenTelemetry::Trace::Status.error(original_handler_error.message)
55-
end
56-
end
31+
self.class.wrap_lambda(event: event, context: context, handler: @original_handler, flush_timeout: @flush_timeout) do
32+
call_original_handler(event: event, context: context)
5733
end
58-
59-
OpenTelemetry.tracer_provider.force_flush(timeout: @flush_timeout)
60-
61-
raise original_handler_error if original_handler_error
62-
63-
original_response
64-
end
65-
66-
def instrumentation_config
67-
AwsLambda::Instrumentation.instance.config
68-
end
69-
70-
def tracer
71-
AwsLambda::Instrumentation.instance.tracer
7234
end
7335

7436
private
@@ -94,95 +56,6 @@ def call_original_handler(event:, context:)
9456
__send__(@handler_method, event: event, context: context)
9557
end
9658
end
97-
98-
# Extract parent context from request headers
99-
# Downcase Traceparent and Tracestate because TraceContext::TextMapPropagator's TRACEPARENT_KEY and TRACESTATE_KEY are all lowercase
100-
# If any error occur, rescue and give empty context
101-
def extract_parent_context(event)
102-
headers = event['headers'] || {}
103-
headers.transform_keys! do |key|
104-
%w[Traceparent Tracestate].include?(key) ? key.downcase : key
105-
end
106-
107-
OpenTelemetry.propagation.extract(
108-
headers,
109-
getter: OpenTelemetry::Context::Propagation.text_map_getter
110-
)
111-
rescue StandardError => e
112-
OpenTelemetry.logger.error("aws-lambda instrumentation exception occurred while extracting the parent context: #{e.message}")
113-
OpenTelemetry::Context.empty
114-
end
115-
116-
# lambda event version 1.0 and version 2.0
117-
# https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html
118-
def v1_proxy_attributes(event)
119-
attributes = {
120-
OpenTelemetry::SemanticConventions::Trace::HTTP_METHOD => event['httpMethod'],
121-
OpenTelemetry::SemanticConventions::Trace::HTTP_ROUTE => event['resource'],
122-
OpenTelemetry::SemanticConventions::Trace::HTTP_TARGET => event['resource']
123-
}
124-
attributes[OpenTelemetry::SemanticConventions::Trace::HTTP_TARGET] += "?#{event['queryStringParameters']}" if event['queryStringParameters']
125-
126-
headers = event['headers']
127-
if headers
128-
attributes[OpenTelemetry::SemanticConventions::Trace::HTTP_USER_AGENT] = headers['User-Agent']
129-
attributes[OpenTelemetry::SemanticConventions::Trace::HTTP_SCHEME] = headers['X-Forwarded-Proto']
130-
attributes[OpenTelemetry::SemanticConventions::Trace::NET_HOST_NAME] = headers['Host']
131-
end
132-
attributes
133-
end
134-
135-
def v2_proxy_attributes(event)
136-
request_context = event['requestContext']
137-
attributes = {
138-
OpenTelemetry::SemanticConventions::Trace::NET_HOST_NAME => request_context['domainName'],
139-
OpenTelemetry::SemanticConventions::Trace::HTTP_METHOD => request_context['http']['method'],
140-
OpenTelemetry::SemanticConventions::Trace::HTTP_USER_AGENT => request_context['http']['userAgent'],
141-
OpenTelemetry::SemanticConventions::Trace::HTTP_ROUTE => request_context['http']['path'],
142-
OpenTelemetry::SemanticConventions::Trace::HTTP_TARGET => request_context['http']['path']
143-
}
144-
attributes[OpenTelemetry::SemanticConventions::Trace::HTTP_TARGET] += "?#{event['rawQueryString']}" if event['rawQueryString']
145-
attributes
146-
end
147-
148-
# fass.trigger set to http: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/faas/aws-lambda.md#api-gateway
149-
# TODO: need to update Semantic Conventions for invocation_id, trigger and resource_id
150-
def otel_attributes(event, context)
151-
span_attributes = {}
152-
span_attributes['faas.invocation_id'] = context.aws_request_id
153-
span_attributes['cloud.resource_id'] = context.invoked_function_arn
154-
span_attributes[OpenTelemetry::SemanticConventions::Trace::AWS_LAMBDA_INVOKED_ARN] = context.invoked_function_arn
155-
span_attributes[OpenTelemetry::SemanticConventions::Resource::CLOUD_ACCOUNT_ID] = context.invoked_function_arn.split(':')[4]
156-
157-
if event['requestContext']
158-
request_attributes = event['version'] == '2.0' ? v2_proxy_attributes(event) : v1_proxy_attributes(event)
159-
request_attributes[OpenTelemetry::SemanticConventions::Trace::FAAS_TRIGGER] = 'http'
160-
span_attributes.merge!(request_attributes)
161-
end
162-
163-
if event['Records']
164-
trigger_attributes = trigger_type_attributes(event)
165-
span_attributes.merge!(trigger_attributes)
166-
end
167-
168-
span_attributes
169-
rescue StandardError => e
170-
OpenTelemetry.logger.error("aws-lambda instrumentation exception occurred while preparing span attributes: #{e.message}")
171-
{}
172-
end
173-
174-
# sqs spec for lambda: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/faas/aws-lambda.md#sqs
175-
# current there is no spec for 'aws:sns', 'aws:s3' and 'aws:dynamodb'
176-
def trigger_type_attributes(event)
177-
attributes = {}
178-
case event['Records'].dig(0, 'eventSource')
179-
when 'aws:sqs'
180-
attributes[OpenTelemetry::SemanticConventions::Trace::FAAS_TRIGGER] = 'pubsub'
181-
attributes[OpenTelemetry::SemanticConventions::Trace::MESSAGING_OPERATION] = 'process'
182-
attributes[OpenTelemetry::SemanticConventions::Trace::MESSAGING_SYSTEM] = 'AmazonSQS'
183-
end
184-
attributes
185-
end
18659
end
18760
end
18861
end

instrumentation/aws_lambda/lib/opentelemetry/instrumentation/aws_lambda/instrumentation.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base
2121
private
2222

2323
def require_dependencies
24+
require_relative 'wrap'
2425
require_relative 'handler'
2526
end
2627
end
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright The OpenTelemetry Authors
4+
#
5+
# SPDX-License-Identifier: Apache-2.0
6+
7+
module OpenTelemetry
8+
module Instrumentation
9+
module AwsLambda
10+
# Helper module that can be used to wrap a lambda handler method
11+
module Wrap # rubocop:disable Metrics/ModuleLength
12+
AWS_TRIGGERS = ['aws:sqs', 'aws:s3', 'aws:sns', 'aws:dynamodb'].freeze
13+
DEFAULT_FLUSH_TIMEOUT = ENV.fetch('OTEL_INSTRUMENTATION_AWS_LAMBDA_FLUSH_TIMEOUT', '30000').to_i
14+
15+
def instrument_handler(method, flush_timeout: DEFAULT_FLUSH_TIMEOUT)
16+
raise ArgumentError, "#{method} is not a method of #{name}" unless respond_to?(method)
17+
18+
uninstrumented_method = "#{method}_without_instrumentation"
19+
singleton_class.alias_method uninstrumented_method, method
20+
21+
handler = "#{name}.#{method}"
22+
23+
define_singleton_method(method) do |event:, context:|
24+
wrap_lambda(event: event, context: context, handler: handler, flush_timeout: flush_timeout) { public_send(uninstrumented_method, event: event, context: context) }
25+
end
26+
end
27+
28+
# Try to record and re-raise any exception from the wrapped function handler
29+
# Instrumentation should never raise its own exception
30+
def wrap_lambda(event:, context:, handler:, flush_timeout: DEFAULT_FLUSH_TIMEOUT)
31+
parent_context = extract_parent_context(event)
32+
33+
span_kind = if event['Records'] && AWS_TRIGGERS.include?(event['Records'].dig(0, 'eventSource'))
34+
:consumer
35+
else
36+
:server
37+
end
38+
39+
original_handler_error = nil
40+
original_response = nil
41+
OpenTelemetry::Context.with_current(parent_context) do
42+
tracer.in_span(handler, attributes: otel_attributes(event, context), kind: span_kind) do |span|
43+
begin
44+
response = yield
45+
46+
unless span.attributes.key?(OpenTelemetry::SemanticConventions::Trace::HTTP_STATUS_CODE)
47+
status_code = response['statusCode'] || response[:statusCode] if response.is_a?(Hash)
48+
span.set_attribute(OpenTelemetry::SemanticConventions::Trace::HTTP_STATUS_CODE, status_code) if status_code
49+
end
50+
rescue StandardError => e
51+
original_handler_error = e
52+
ensure
53+
original_response = response
54+
end
55+
if original_handler_error
56+
span.record_exception(original_handler_error)
57+
span.status = OpenTelemetry::Trace::Status.error(original_handler_error.message)
58+
end
59+
end
60+
end
61+
62+
OpenTelemetry.tracer_provider.force_flush(timeout: flush_timeout)
63+
OpenTelemetry.meter_provider.force_flush(timeout: flush_timeout) if OpenTelemetry.respond_to?(:meter_provider)
64+
65+
raise original_handler_error if original_handler_error
66+
67+
original_response
68+
end
69+
70+
def instrumentation_config
71+
AwsLambda::Instrumentation.instance.config
72+
end
73+
74+
def tracer
75+
AwsLambda::Instrumentation.instance.tracer
76+
end
77+
78+
private
79+
80+
# Extract parent context from request headers
81+
# Downcase Traceparent and Tracestate because TraceContext::TextMapPropagator's TRACEPARENT_KEY and TRACESTATE_KEY are all lowercase
82+
# If any error occur, rescue and give empty context
83+
def extract_parent_context(event)
84+
headers = event['headers'] || {}
85+
headers.transform_keys! do |key|
86+
%w[Traceparent Tracestate].include?(key) ? key.downcase : key
87+
end
88+
89+
OpenTelemetry.propagation.extract(
90+
headers,
91+
getter: OpenTelemetry::Context::Propagation.text_map_getter
92+
)
93+
rescue StandardError => e
94+
OpenTelemetry.logger.error("aws-lambda instrumentation exception occurred while extracting the parent context: #{e.message}")
95+
OpenTelemetry::Context.empty
96+
end
97+
98+
# lambda event version 1.0 and version 2.0
99+
# https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html
100+
def v1_proxy_attributes(event)
101+
attributes = {
102+
OpenTelemetry::SemanticConventions::Trace::HTTP_METHOD => event['httpMethod'],
103+
OpenTelemetry::SemanticConventions::Trace::HTTP_ROUTE => event['resource'],
104+
OpenTelemetry::SemanticConventions::Trace::HTTP_TARGET => event['resource']
105+
}
106+
attributes[OpenTelemetry::SemanticConventions::Trace::HTTP_TARGET] += "?#{event['queryStringParameters']}" if event['queryStringParameters']
107+
108+
headers = event['headers']
109+
if headers
110+
attributes[OpenTelemetry::SemanticConventions::Trace::HTTP_USER_AGENT] = headers['User-Agent']
111+
attributes[OpenTelemetry::SemanticConventions::Trace::HTTP_SCHEME] = headers['X-Forwarded-Proto']
112+
attributes[OpenTelemetry::SemanticConventions::Trace::NET_HOST_NAME] = headers['Host']
113+
end
114+
attributes
115+
end
116+
117+
def v2_proxy_attributes(event)
118+
request_context = event['requestContext']
119+
attributes = {
120+
OpenTelemetry::SemanticConventions::Trace::NET_HOST_NAME => request_context['domainName'],
121+
OpenTelemetry::SemanticConventions::Trace::HTTP_METHOD => request_context['http']['method'],
122+
OpenTelemetry::SemanticConventions::Trace::HTTP_USER_AGENT => request_context['http']['userAgent'],
123+
OpenTelemetry::SemanticConventions::Trace::HTTP_ROUTE => request_context['http']['path'],
124+
OpenTelemetry::SemanticConventions::Trace::HTTP_TARGET => request_context['http']['path']
125+
}
126+
attributes[OpenTelemetry::SemanticConventions::Trace::HTTP_TARGET] += "?#{event['rawQueryString']}" if event['rawQueryString']
127+
attributes
128+
end
129+
130+
# fass.trigger set to http: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/faas/aws-lambda.md#api-gateway
131+
# TODO: need to update Semantic Conventions for invocation_id, trigger and resource_id
132+
def otel_attributes(event, context)
133+
span_attributes = {}
134+
span_attributes['faas.invocation_id'] = context.aws_request_id
135+
span_attributes['cloud.resource_id'] = context.invoked_function_arn
136+
span_attributes[OpenTelemetry::SemanticConventions::Trace::AWS_LAMBDA_INVOKED_ARN] = context.invoked_function_arn
137+
span_attributes[OpenTelemetry::SemanticConventions::Resource::CLOUD_ACCOUNT_ID] = context.invoked_function_arn.split(':')[4]
138+
139+
if event['requestContext']
140+
request_attributes = event['version'] == '2.0' ? v2_proxy_attributes(event) : v1_proxy_attributes(event)
141+
request_attributes[OpenTelemetry::SemanticConventions::Trace::FAAS_TRIGGER] = 'http'
142+
span_attributes.merge!(request_attributes)
143+
end
144+
145+
if event['Records']
146+
trigger_attributes = trigger_type_attributes(event)
147+
span_attributes.merge!(trigger_attributes)
148+
end
149+
150+
span_attributes
151+
rescue StandardError => e
152+
OpenTelemetry.logger.error("aws-lambda instrumentation exception occurred while preparing span attributes: #{e.message}")
153+
{}
154+
end
155+
156+
# sqs spec for lambda: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/faas/aws-lambda.md#sqs
157+
# current there is no spec for 'aws:sns', 'aws:s3' and 'aws:dynamodb'
158+
def trigger_type_attributes(event)
159+
attributes = {}
160+
case event['Records'].dig(0, 'eventSource')
161+
when 'aws:sqs'
162+
attributes[OpenTelemetry::SemanticConventions::Trace::FAAS_TRIGGER] = 'pubsub'
163+
attributes[OpenTelemetry::SemanticConventions::Trace::MESSAGING_OPERATION] = 'process'
164+
attributes[OpenTelemetry::SemanticConventions::Trace::MESSAGING_SYSTEM] = 'AmazonSQS'
165+
end
166+
attributes
167+
end
168+
end
169+
end
170+
end
171+
end

0 commit comments

Comments
 (0)