airfoil 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cafc414d07d58d199bc9058066119516cf27810402bac47c7c7cb253ebee85a3
4
+ data.tar.gz: c694dd87b5a2f228b03701580c33c0063751a077c3d23c410e230d6702b8937a
5
+ SHA512:
6
+ metadata.gz: 685294eccb81796e1b4338ff99cbd849d8997fde13cc2437e0839b8134bfcb9b99ecf630bd462c6fa7aa3a03a102e689ad54b1147849e868c96e5aa74758f3d9
7
+ data.tar.gz: b0a8f3e5314f1006b8fb5dc85b2955ead9b7f089798e44afab0f96801bc3dfe67d4579669f09f48620d274af09dac919059a8c77323a7b4240571c45ea98d990
data/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # Airfoil
2
+ _Enough structure to get our Lambda handlers in the air._
3
+
4
+ Airfoil is curated middleware stack that abstracts away common infrastructure
5
+ needed for Lambda handler functions.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'airfoil'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle install
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install airfoil
22
+
23
+ ## Usage
24
+
25
+ You can instantiate a new handler with an Airfoil stack like so:
26
+
27
+ ```ruby
28
+ require_relative "config/environment"
29
+
30
+ STACK = Airfoil.create_stack do |b|
31
+ # Custom middleware and handlers go here
32
+ b.use Airfoil::Middleware::FunctionName, DbReset, "db-reset"
33
+ b.use Airfoil::Middleware::FunctionName, UpdateUpcomingRenewals, "update-upcoming-renewals"
34
+ end
35
+
36
+ def handler(event:, context:)
37
+ STACK.call({event: event, context: context})
38
+ end
39
+ ```
40
+
41
+ Airfoil also includes a stack of middleware that you can add to or customize to
42
+ suit your specific needs. Your own middleware can inherit from the base class and
43
+ implement their own behavior like so:
44
+
45
+ ```ruby
46
+ class MyResolverMiddleware < Airfoil::Middleware::Base
47
+ def call(env)
48
+ ApplicationResolver.handle(env[:event], env[:context])
49
+ end
50
+ end
51
+ ```
52
+
53
+ Existing middleware include:
54
+
55
+ - `FunctionName` - dispatch any calls made to a specific Lambda function (by name) to a specified handler class
56
+ - `LogEvent` - log AWS events in a pretty format
57
+ - `SentryCatcher` - catch exceptions and report them to Sentry, including context
58
+ - `SentryMonitoring` - instrument your function code for Sentry's performance monitoring
59
+ - `SetRequestId` - set the `AWS_REQUEST_ID` environment variable for your function code
60
+
61
+ ## Development
62
+
63
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
64
+
65
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
@@ -0,0 +1,9 @@
1
+ require "logger"
2
+
3
+ module Airfoil
4
+ class CloudwatchFormatter < Logger::Formatter
5
+ def call(severity, datetime, progname, msg)
6
+ "#{severity} RequestId: #{ENV["AWS_REQUEST_ID"]} #{msg}\n"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ # The built-in Middleware gem logger truncates the event so instead
2
+ # we have our own logging middleware to print the event and context.
3
+ # Here, we reopen the class and ignoring the partial event to cut down on log chatter.
4
+ class ::Middleware::Logger
5
+ def way_in_message name, env
6
+ " %s has been called with: %s" % [name, env[:context].function_name]
7
+ end
8
+
9
+ # Default to omitting middleware logging
10
+ def write msg
11
+ if ENV["MIDDLEWARE_LOGGING_ENABLED"] == "true"
12
+ @write_to.add(::Logger::INFO, msg.slice(0, 255).strip!, @middleware_name)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ module Airfoil
2
+ module Middleware
3
+ class Base
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ require "airfoil/middleware/base"
2
+
3
+ module Airfoil
4
+ module Middleware
5
+ class Database < Airfoil::Middleware::Base
6
+ def call(env)
7
+ # TODO: This explicitly makes a connection, which we may want to re-evaluate at some point
8
+ ActiveRecord::Base.connection_pool.with_connection do |conn|
9
+ conn.enable_query_cache!
10
+ @app.call(env)
11
+ ensure
12
+ conn.disable_query_cache!
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,23 @@
1
+ require "datadog/lambda"
2
+ require "datadog/tracing"
3
+ require "airfoil/middleware/base"
4
+
5
+ module Airfoil
6
+ module Middleware
7
+ class Datadog < Base
8
+ # See the Airfoil railtie for config loaded at Rails load-time
9
+ # Note: Rails loads prior to Airfoil because of the inclusion of `require_relative "config/environment"`
10
+ # in the engine lambda handler
11
+ def call(env)
12
+ event, context = env.values_at(:event, :context)
13
+
14
+ ::Datadog::Lambda.wrap(event, context) do
15
+ @app.call(env)
16
+ rescue => err
17
+ ::Datadog::Tracing.active_span&.set_error(err)
18
+ raise err
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,35 @@
1
+ require "airfoil/middleware/base"
2
+
3
+ module Airfoil
4
+ module Middleware
5
+ class FunctionName < Middleware::Base
6
+ def initialize(app, handler_class, *function_names_to_match)
7
+ super(app)
8
+ @handler_class = handler_class
9
+ @function_names_to_match = function_names_to_match
10
+ end
11
+
12
+ def call(env)
13
+ context = env[:context]
14
+
15
+ if handles? context
16
+ @handler_class.handle(env[:event], context)
17
+ else
18
+ @app.call(env)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def handles?(context)
25
+ @function_names_to_match.include?(canonicalize_function_name(context.function_name))
26
+ end
27
+
28
+ # Strip off the function suffix if present to allow for terraform/terratest to invoke lambdas with unique names that route to the same function
29
+ # e.g. finalize-submission-testrun89YD36 => finalize-submission
30
+ def canonicalize_function_name(function_name)
31
+ function_name.sub(/-testrun[a-zA-Z0-9]*$/, "")
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,71 @@
1
+ require "dry-monads"
2
+ require "active_support"
3
+ require "active_support/core_ext/hash/except"
4
+ require "airfoil/middleware/base"
5
+
6
+ module Airfoil
7
+ module Middleware
8
+ class LogEvent < Base
9
+ include Dry::Monads[:maybe]
10
+
11
+ def initialize(app, logger)
12
+ super(app)
13
+ @logger = logger
14
+ end
15
+
16
+ def call(env)
17
+ event = env[:event]
18
+
19
+ logged_data = {
20
+ "identity" => log_identity(event),
21
+ "event" => log_event(event)
22
+ }.to_json
23
+
24
+ @logger.info(logged_data)
25
+ result = @app.call(env)
26
+ # Log the full result instead of the truncated version the middleware outputs
27
+ @logger.info({result: result}.to_json)
28
+ result
29
+ end
30
+
31
+ private
32
+
33
+ # Clear unwanted properties from our log output
34
+ def clean_event(event)
35
+ unwanted_fields = %w[identity]
36
+ event.except(*unwanted_fields)
37
+ end
38
+
39
+ def log_event(event)
40
+ return event if event.is_a?(String)
41
+
42
+ # Remove the full identity from our event output
43
+ if event.is_a?(Array)
44
+ event.map { |e| clean_event(e) }
45
+ else
46
+ clean_event(event)
47
+ end
48
+ end
49
+
50
+ def log_identity(event)
51
+ identity = case event
52
+ in [e, *rest]
53
+ e.dig("identity")
54
+ in Hash
55
+ event.dig("identity")
56
+ else
57
+ nil
58
+ end
59
+
60
+ Maybe(identity).bind { |i|
61
+ Some({
62
+ groups: i.dig("groups"),
63
+ source_ip: i.dig("sourceIp"),
64
+ sub: i.dig("sub"),
65
+ auth_time: i.dig("claims", "auth_time")
66
+ })
67
+ }.value_or(nil)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,19 @@
1
+ require "airfoil/middleware/base"
2
+
3
+ module Airfoil
4
+ module Middleware
5
+ class LoggerTagging < Base
6
+ def initialize(app, logger)
7
+ super(app)
8
+ @logger = logger
9
+ end
10
+
11
+ def call(env)
12
+ context = env[:context]
13
+ @logger.tagged(ENV["_X_AMZN_TRACE_ID"], context.aws_request_id) do
14
+ @app.call(env)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,40 @@
1
+ require "sentry-ruby"
2
+ require "airfoil/middleware/base"
3
+
4
+ module Airfoil
5
+ module Middleware
6
+ class SentryCatcher < Base
7
+ def initialize(app, logger)
8
+ super(app)
9
+ @logger = logger
10
+ end
11
+
12
+ def call(env)
13
+ @app.call(env)
14
+ rescue => err
15
+ Sentry.with_scope do |scope|
16
+ context = env[:context]
17
+ event = env[:event]
18
+
19
+ scope.set_extras(
20
+ function_name: context.function_name,
21
+ function_version: context.function_version,
22
+ invoked_function_arn: context.invoked_function_arn,
23
+ memory_limit_in_mb: context.memory_limit_in_mb,
24
+ aws_request_id: context.aws_request_id,
25
+ log_group_name: context.log_group_name,
26
+ log_stream_name: context.log_stream_name,
27
+ identity: context.identity,
28
+ event: event
29
+ )
30
+
31
+ Sentry.capture_exception(err)
32
+ @logger.error(err)
33
+ raise err
34
+ end
35
+ end
36
+ end
37
+
38
+ class RequestError < StandardError; end
39
+ end
40
+ end
@@ -0,0 +1,42 @@
1
+ require "sentry-ruby"
2
+ require "airfoil/middleware/base"
3
+
4
+ module Airfoil
5
+ module Middleware
6
+ class SentryMonitoring < Base
7
+ def call(env)
8
+ event, context = env.values_at(:event, :context)
9
+ sentry_trace_id = get_first_instance(event, "sentry_trace_id")
10
+ identity = get_first_instance(event, "identity")
11
+
12
+ options = {name: context.function_name, op: "handler"}
13
+ options[:transaction] = Sentry::Transaction.from_sentry_trace(sentry_trace_id, **options) if sentry_trace_id.present?
14
+
15
+ Sentry.set_user(username: identity.dig("username"), ip_address: identity.dig("source_ip", 0)) if identity.present?
16
+ transaction = Sentry.start_transaction(**options)
17
+ # Add transaction to the global scope so it is accessible throughout the app
18
+ Sentry.get_current_hub&.configure_scope do |scope|
19
+ scope.set_span(transaction)
20
+ end
21
+
22
+ result = @app.call(env)
23
+ transaction.finish if transaction.present?
24
+
25
+ result
26
+ end
27
+
28
+ private
29
+
30
+ def get_first_instance(event, key)
31
+ case event
32
+ in [e, *rest]
33
+ e.dig(key)
34
+ in Hash
35
+ event.dig(key)
36
+ else
37
+ nil
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,13 @@
1
+ require "airfoil/middleware/base"
2
+
3
+ module Airfoil
4
+ module Middleware
5
+ class SetRequestId < Base
6
+ def call(env)
7
+ context = env[:context]
8
+ ENV["AWS_REQUEST_ID"] = context.aws_request_id if context.present?
9
+ @app.call(env)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,37 @@
1
+ require_relative "function_name"
2
+
3
+ module Airfoil
4
+ module Middleware
5
+ class StepFunction < FunctionName
6
+ def initialize(app, handler_class, *function_names_to_match, retried_exceptions: [])
7
+ super(app, handler_class, *function_names_to_match)
8
+ @retried_exceptions = retried_exceptions.map(&:to_s)
9
+ end
10
+
11
+ def call(env)
12
+ context = env[:context]
13
+
14
+ ignore_exceptions(context) do
15
+ super
16
+ end
17
+ end
18
+
19
+ def ignore_exceptions(context)
20
+ disable_exceptions
21
+ result = yield
22
+ enable_exceptions
23
+
24
+ result
25
+ end
26
+
27
+ def disable_exceptions
28
+ @before = Sentry.configuration.excluded_exceptions
29
+ Sentry.configuration.excluded_exceptions += @retried_exceptions
30
+ end
31
+
32
+ def enable_exceptions
33
+ Sentry.configuration.excluded_exceptions = @before
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,33 @@
1
+ require "airfoil/cloudwatch_formatter"
2
+ require "datadog/lambda"
3
+
4
+ module Airfoil
5
+ class Railtie < Rails::Railtie
6
+ # Format logs for consistent parsing by Cloudwatch
7
+ config.log_formatter = Airfoil::CloudwatchFormatter.new
8
+ config.datadog_enabled = ENV.fetch("DATADOG_ENABLED", Rails.env.production?).to_s == "true"
9
+
10
+ initializer "airfoil.datadog" do
11
+ if Rails.configuration.datadog_enabled
12
+ require "ddtrace/auto_instrument"
13
+
14
+ ::Datadog::Lambda.configure_apm do |c|
15
+ c.env = ENV.fetch("SENTRY_ENVIRONMENT", Rails.env).dasherize
16
+ # downscasing first ensures we don't attempt to snake case things that don't already have dashes
17
+ c.service = ENV.fetch("AWS_LAMBDA_FUNCTION_NAME", "brokersuite").downcase.underscore
18
+
19
+ # Set trace rate via DD_TRACE_SAMPLE_RATE
20
+ c.tracing.enabled = true
21
+ c.tracing.instrument :aws
22
+ c.tracing.instrument :faraday
23
+ c.tracing.instrument :rest_client
24
+ c.tracing.instrument :httpclient
25
+ c.tracing.instrument :http
26
+ c.tracing.instrument :rails
27
+ end
28
+
29
+ Rails.logger.debug "=====DATADOG LOADED (RAILTIE)====="
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airfoil
4
+ VERSION = '0.1.2'
5
+ end
data/lib/airfoil.rb ADDED
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "middleware"
4
+
5
+ require_relative "airfoil/version"
6
+ require_relative "airfoil/middleware/database"
7
+ require_relative "airfoil/middleware/datadog"
8
+ require_relative "airfoil/middleware/function_name"
9
+ require_relative "airfoil/middleware/log_event"
10
+ require_relative "airfoil/middleware/logger_tagging"
11
+ require_relative "airfoil/middleware/sentry_catcher"
12
+ require_relative "airfoil/middleware/sentry_monitoring"
13
+ require_relative "airfoil/middleware/set_request_id"
14
+ require_relative "airfoil/middleware/step_function"
15
+ require_relative "airfoil/logger_patch"
16
+ require_relative "airfoil/railtie" if defined?(Rails::Railtie)
17
+
18
+ module Airfoil
19
+ class << self
20
+ def create_stack
21
+ # ensure that STDOUT streams are synchronous so we don't lose logs
22
+ $stdout.sync = true
23
+
24
+ Signal.trap("TERM") do
25
+ # We can't use the Rails logger here as the logger is not available in the trap context
26
+ puts "Received SIGTERM, shutting down gracefully..." # rubocop:disable Rails/Output
27
+ end
28
+
29
+ ::Middleware::Builder.new { |b|
30
+ b.use Middleware::LoggerTagging, Rails.logger
31
+ b.use Middleware::SetRequestId
32
+ b.use Middleware::Datadog
33
+ b.use Middleware::SentryCatcher, Rails.logger
34
+ b.use Middleware::SentryMonitoring
35
+ b.use Middleware::LogEvent, Rails.logger
36
+ yield b
37
+ }.inject_logger(Rails.logger)
38
+ end
39
+ end
40
+ end
metadata ADDED
@@ -0,0 +1,170 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: airfoil
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2
5
+ platform: ruby
6
+ authors:
7
+ - Highwing Engineering
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-08-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: datadog-lambda
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: dry-monads
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: ibsciss-middleware
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 0.4.2
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 0.4.2
55
+ - !ruby/object:Gem::Dependency
56
+ name: railties
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: sentry-ruby
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: aws_lambda_ric
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ description:
126
+ email:
127
+ - engineering@highwing.io
128
+ executables: []
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - README.md
133
+ - lib/airfoil.rb
134
+ - lib/airfoil/cloudwatch_formatter.rb
135
+ - lib/airfoil/logger_patch.rb
136
+ - lib/airfoil/middleware/base.rb
137
+ - lib/airfoil/middleware/database.rb
138
+ - lib/airfoil/middleware/datadog.rb
139
+ - lib/airfoil/middleware/function_name.rb
140
+ - lib/airfoil/middleware/log_event.rb
141
+ - lib/airfoil/middleware/logger_tagging.rb
142
+ - lib/airfoil/middleware/sentry_catcher.rb
143
+ - lib/airfoil/middleware/sentry_monitoring.rb
144
+ - lib/airfoil/middleware/set_request_id.rb
145
+ - lib/airfoil/middleware/step_function.rb
146
+ - lib/airfoil/railtie.rb
147
+ - lib/airfoil/version.rb
148
+ homepage:
149
+ licenses: []
150
+ metadata: {}
151
+ post_install_message:
152
+ rdoc_options: []
153
+ require_paths:
154
+ - lib
155
+ required_ruby_version: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '3.2'
160
+ required_rubygems_version: !ruby/object:Gem::Requirement
161
+ requirements:
162
+ - - ">="
163
+ - !ruby/object:Gem::Version
164
+ version: '0'
165
+ requirements: []
166
+ rubygems_version: 3.4.17
167
+ signing_key:
168
+ specification_version: 4
169
+ summary: Enough structure to get our Lambda handlers in the air
170
+ test_files: []