Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions bin/oncall/email-deliveries
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require 'active_support'
require 'active_support/core_ext/enumerable' # index_by
require 'active_support/core_ext/integer/time'
require 'optparse'
require 'terminal-table'

$LOAD_PATH.unshift(File.expand_path(File.join(__dir__, '../../lib')))
require 'reporting/cloudwatch_client'
require 'reporting/cloudwatch_query_quoting'

class EmailDeliveries
include Reporting::CloudwatchQueryQuoting

# @return [EmailDeliveries]
def self.parse!(argv: ARGV, out: STDOUT)
show_help = false

parser = OptionParser.new do |opts|
opts.banner = <<~EOM
Usage: #{$PROGRAM_NAME} uuid1 [uuid2...]

Looks up email deliveries by user UUID, within the last week

Options:
EOM

opts.on('--help', 'Show this help message') do
show_help = true
end
end

uuids = parser.order(argv)

if uuids.empty? || show_help
out.puts parser
exit 1
end

new(uuids: uuids)
end

attr_reader :uuids

def initialize(uuids:, progress_bar: true)
@uuids = uuids
@progress_bar = progress_bar
end

def progress_bar?
@progress_bar
end

def run(out: STDOUT)
results = query_data(uuids)

table = Terminal::Table.new
table << %w[user_id timestamp message_id events]
table << :separator

results.each do |result|
table << [
result.user_id,
result.timestamp,
result.message_id,
result.events.join(', '),
]
end

out.puts table
end

Result = Struct.new(
:user_id,
:timestamp,
:message_id,
:events,
keyword_init: true,
)

# @return [Array<Result>]
def query_data(uuids)
if progress_bar?
bar = ProgressBar.create(
title: 'Querying logs',
total: nil,
format: '[ %t ] %B %a',
output: STDERR,
)
thread = Thread.new do
loop do
sleep 0.1
bar.increment
end
end
end

event_log = cloudwatch_client('prod_/srv/idp/shared/log/events.log').fetch(
query: <<~EOS,
fields
@timestamp
, properties.user_id AS user_id
, properties.event_properties.ses_message_id AS ses_message_id
| filter name = 'Email Sent'
| filter properties.user_id IN #{quote(uuids)}
| limit 10000
EOS
from: 1.week.ago,
to: Time.now,
)

events_by_message_id = event_log.index_by { |event| event['ses_message_id'] }

message_id_filters = events_by_message_id.keys.map do |message_id|
"@message LIKE /#{message_id}/"
end.join(' OR ')

email_events = cloudwatch_client('/aws/lambda/SESAllEvents_Lambda').fetch(
query: <<~EOS,
fields
eventType AS event_type
| filter #{message_id_filters}
| parse '"messageId": "*"' as ses_message_id
| display @timestamp, event_type, ses_message_id
| limit 10000
EOS
from: 1.week.ago,
to: Time.now,
)

email_events.
group_by { |event| event['ses_message_id'] }.
map do |message_id, events|
Result.new(
user_id: events_by_message_id[message_id]['user_id'],
timestamp: events_by_message_id[message_id]['@timestamp'],
message_id: message_id,
events: events.sort_by { |e| e['@timestamp'] }.map { |e| e['event_type'] },
)
end
ensure
thread&.kill
bar&.stop
end


def cloudwatch_client(log_group_name)
Reporting::CloudwatchClient.new(
ensure_complete_logs: false,
slice_interval: nil,
progress: false,
log_group_name: log_group_name,
)
end
end

if $PROGRAM_NAME == __FILE__
EmailDeliveries.parse!.run
end
90 changes: 90 additions & 0 deletions spec/bin/oncall/email-deliveries_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
require 'rails_helper'
load Rails.root.join('bin/oncall/email-deliveries')
require 'tableparser'

RSpec.describe EmailDeliveries do
describe '.parse!' do
let(:out) { StringIO.new }
subject(:parse!) { EmailDeliveries.parse!(argv: argv, out: out) }

context 'with --help' do
let(:argv) { %w[--help] }

it 'prints help and exits uncleanly' do
expect(EmailDeliveries).to receive(:exit).with(1)

parse!

expect(out.string).to include('Usage:')
end
end

context 'with no arguments' do
let(:argv) { [] }

it 'prints help and exits uncleanly' do
expect(EmailDeliveries).to receive(:exit).with(1)

parse!

expect(out.string).to include('Usage:')
end
end

context 'with arguments' do
let(:argv) { %w[abc def] }

it 'returns an instance populated with UUIDs' do
expect(parse!.uuids).to eq(%w[abc def])
end
end
end

describe '#run' do
let(:instance) { EmailDeliveries.new(uuids: %w[abc123 def456], progress_bar: false) }
let(:stdout) { StringIO.new }
subject(:run) { instance.run(out: stdout) }

before do
allow(instance).to receive(:cloudwatch_client).
with('prod_/srv/idp/shared/log/events.log').
and_return(instance_double('Reporting::CloudwatchClient', fetch: events_log))

allow(instance).to receive(:cloudwatch_client).
with('/aws/lambda/SESAllEvents_Lambda').
and_return(instance_double('Reporting::CloudwatchClient', fetch: email_events))
end

# rubocop:disable Metrics/LineLength
let(:events_log) do
[
{ '@timestamp' => '2023-01-01 00:00:01', 'user_id' => 'abc123', 'ses_message_id' => 'message-1' },
{ '@timestamp' => '2023-01-01 00:00:02', 'user_id' => 'def456', 'ses_message_id' => 'message-2' },
]
end

let(:email_events) do
[
{ '@timestamp' => '2023-01-01 00:00:01', 'ses_message_id' => 'message-1', 'event_type' => 'Send' },
{ '@timestamp' => '2023-01-01 00:00:02', 'ses_message_id' => 'message-1', 'event_type' => 'Delivery' },
{ '@timestamp' => '2023-01-01 00:00:03', 'ses_message_id' => 'message-2', 'event_type' => 'Send' },
{ '@timestamp' => '2023-01-01 00:00:04', 'ses_message_id' => 'message-2', 'event_type' => 'Bounce' },
]
end
# rubocop:enable Metrics/LineLength

it 'prints a table of events by message ID' do
run

table = Tableparser.parse(stdout.string)

expect(table).to eq(
[
['user_id', 'timestamp', 'message_id', 'events'],
['abc123', '2023-01-01 00:00:01', 'message-1', 'Send, Delivery'],
['def456', '2023-01-01 00:00:02', 'message-2', 'Send, Bounce'],
],
)
end
end
end