"""Lambda that listens to DynamoDB events and saves changes as json to S3. For each event (contains batch of changes) there will be json file saved to S3. This lambda was developed to use with Python 3. """ import copy import datetime import logging import os import time import uuid import boto3 import simplejson as json # noqa import config # noqa import const # noqa from connectors import sentry # noqa from util import upload_to_s3 # noqa s3 = boto3.client('s3') boto3.resource('dynamodb', region_name=config.AWS_REGION) deserializer = boto3.dynamodb.types.TypeDeserializer() LOGGING_LEVEL = os.environ.get('LOGGING_LEVEL', logging.INFO) log = logging.getLogger() log.setLevel(LOGGING_LEVEL) # For logging purposes only. Since lambda is reusing the same container # multiple times, we can roughly calculate the number of retries # when the processing fails. retry_count = 0 TERRITORIES_MAP = [ const.OPCODE_ADD, const.OPCODE_CONFLICT_CREATED, const.OPCODE_CONFLICT_RESOLVED ] TERRITORIES_LIST = [ const.OPCODE_REMOVE, const.OPCODE_UNLOCK, const.OPCODE_LOCK ] def lambda_handler(event, context): """Extract changed rows from DynamoDB event and write them to S3 bucket. Args: event (dict): Lambda event data context (dict): Lambda invocation context data """ try: global retry_count data = parse_dynamo_events(event) if not data: log.info('No valid events parsed.') return json_data = json.dumps(data) s3_key = make_s3_key() log.info('Saving to S3: %s', s3_key) upload_to_s3(s3, config.S3_BUCKET_NAME, s3_key, json_data) retry_count = 0 log.info('Successfully saved to S3: %s', s3_key) return {'created_filename': s3_key} except Exception as ex: try: retry_count += 1 log.exception( 'Failed to process dynamoDB batch. Attempt: %s', retry_count) sentry.sentry_client.captureException() except: # noqa log.exception('Failed to capture exception to Sentry.') finally: # Workaround to avoid possible failures during captureException() # call. See details in comments to this ticket: # https://jira.theorchard.com/browse/MR-3329 # An exception must always be raised, that will trigger lambda # retry mechanism. log.error('Reraising the error to be reprocessed by lambda.') raise ex def parse_dynamo_events(dynamo_events): """Extract data from DynamoDB events and return it as list. Args: dynamo_events (dict): events received from DynamoDB Returns: list: dicts with events """ result = [] isrcs = set() for event in dynamo_events['Records']: parsed_event = parse_single_dynamo_event(event) if parsed_event: result.append(parsed_event) isrcs.add(parsed_event['isrc']) log.info('ISRCs received: %s', ','.join(isrcs)) return result def parse_single_dynamo_event(dynamo_event): """Make dict of single dynamodb event payload without type declarations. Args: dynamo_event (dict): single dynamodb event Returns: dict: data from received event without type declarations """ # Sometimes we do not have NewImage, please see the link below # https://sentry.io/the-orchard/lambda-dynamo_audit_to_s3/issues/571769685 if not dynamo_event['dynamodb'].get('NewImage'): log.warning('NewImage was not found in dynamo event:\n {}'.format( dynamo_event['dynamodb']['Keys']['isrc']['S'])) return None dynamo_event_copy = copy.deepcopy(dynamo_event) result = { 'isrc': dynamo_event_copy['dynamodb']['Keys']['isrc']['S'], 'data': {} } event_payload = { key: deserializer.deserialize(value) for key, value in dynamo_event_copy['dynamodb']['NewImage'].items()} result['timestamp'] = event_payload['timestamp'] / 1000 if event_payload['opcode'] in TERRITORIES_MAP: territories_dict = { territory: int(tuid) for territory, tuid in event_payload['territories'].items()} else: territories_dict = { territory: None for territory in event_payload['territories']} result['data']['territories'] = territories_dict result['data']['correlation_id'] = event_payload.get('correlation_id') result['data']['opcode'] = event_payload.get('opcode') result['data']['user'] = event_payload.get('user') result['data']['lock_reason'] = event_payload.get('lock_reason') result['data']['source'] = event_payload.get('source') result['data']['conflict'] = event_payload.get('conflict') return result def make_s3_key(): """Generate directory and file names for S3 bucket. This lambda is supposed to run every day, so naming will be following: directory name will be current date in YYYY-MM-DD format, file name will be timestamp of current UTC time Returns: str: path to file where event data should be saved """ directory_name = datetime.date.today().strftime('%Y-%m-%d') timestamp = time.mktime(datetime.datetime.utcnow().timetuple()) name = str(timestamp) + str(uuid.uuid4()) filename = '.'.join([name, 'json']) s3_key = os.path.join( config.AUDIT_S3_BUCKET_FOLDER, directory_name, filename) return s3_key