Streamlining NDJSON Processing with Kinesis Firehose Dynamic Partitioning
Use Kinesis Data Firehose dynamic partitioning to write NDJSON (Newline Delimited JSON) to S3 without a transformation Lambda function.
Kinesis Data Firehose introduced support for dynamic partitioning in 2021. It can extract partition keys and append record delimiters, eliminating the need for a Lambda function that only converts records to NDJSON (Newline Delimited JSON).
Configuration
The dynamic partitioning configuration lives in the CloudFormation template below.
AWSTemplateFormatVersion: 2010-09-09Description: Kinesis Data Firehose streaming NDJSON sample with dynamic partitioningResources: KinesisFirehoseDeliveryStream: Type: AWS::KinesisFirehose::DeliveryStream Properties: DeliveryStreamName: ndjson-firehose DeliveryStreamType: DirectPut ExtendedS3DestinationConfiguration: BucketARN: !GetAtt S3Bucket.Arn BufferingHints: IntervalInSeconds: 60 DynamicPartitioningConfiguration: Enabled: true Prefix: "success/user_id=!{partitionKeyFromQuery:user_id}/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/hour=!{timestamp:HH}/" ErrorOutputPrefix: "error/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/hour=!{timestamp:HH}/type=!{firehose:error-output-type}/" ProcessingConfiguration: Enabled: true Processors: - Type: AppendDelimiterToRecord Parameters: - ParameterName: Delimiter ParameterValue: '\\n' - Type: MetadataExtraction Parameters: - ParameterName: MetadataExtractionQuery ParameterValue: '{user_id: .user_id}' - ParameterName: JsonParsingEngine ParameterValue: JQ-1.6 RoleARN: !GetAtt IAMRoleKinesisFirehose.Arn
S3Bucket: Type: AWS::S3::Bucket Properties: BucketName: !Sub ${AWS::Region}-${AWS::AccountId}-ndjson-s3 BucketEncryption: ServerSideEncryptionConfiguration: - ServerSideEncryptionByDefault: SSEAlgorithm: AES256 PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true
IAMRoleKinesisFirehose: Type: AWS::IAM::Role Properties: RoleName: ndjson-firehose-role AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: firehose.amazonaws.com Action: sts:AssumeRole MaxSessionDuration: 3600 Policies: - PolicyName: policy1 PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - glue:GetTable - glue:GetTableVersion - glue:GetTableVersions Resource: - !Sub arn:aws:glue:${AWS::Region}:${AWS::AccountId}:catalog - !Sub arn:aws:glue:${AWS::Region}:${AWS::AccountId}:database/%FIREHOSE_POLICY_TEMPLATE_PLACEHOLDER% - !Sub arn:aws:glue:${AWS::Region}:${AWS::AccountId}:table/%FIREHOSE_POLICY_TEMPLATE_PLACEHOLDER%/%FIREHOSE_POLICY_TEMPLATE_PLACEHOLDER% - Effect: Allow Action: - s3:AbortMultipartUpload - s3:GetBucketLocation - s3:GetObject - s3:ListBucket - s3:ListBucketMultipartUploads - s3:PutObject Resource: - !GetAtt S3Bucket.Arn - !Sub ${S3Bucket.Arn}/* - Effect: Allow Action: - lambda:InvokeFunction - lambda:GetFunctionConfiguration Resource: !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:%FIREHOSE_POLICY_TEMPLATE_PLACEHOLDER% - Effect: Allow Action: - kms:GenerateDataKey - kms:Decrypt Resource: - !Sub arn:aws:kms:${AWS::Region}:${AWS::AccountId}:key/%FIREHOSE_POLICY_TEMPLATE_PLACEHOLDER% Condition: StringEquals: kms:ViaService: !Sub s3.${AWS::Region}.amazonaws.com StringLike: kms:EncryptionContext:aws:s3:arn: - arn:aws:s3:::%FIREHOSE_POLICY_TEMPLATE_PLACEHOLDER%/* - arn:aws:s3:::%FIREHOSE_POLICY_TEMPLATE_PLACEHOLDER% - Effect: Allow Action: - logs:PutLogEvents Resource: - !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:%FIREHOSE_POLICY_TEMPLATE_PLACEHOLDER%:log-stream:*Deploy the stack:
aws cloudformation deploy \ --template-file stack.yaml \ --stack-name firehose-ndjson-sample \ --s3-bucket <YOUR_S3_BUCKET> \ --s3-prefix firehose-ndjson-sample/$(date +%Y/%m/%d/%H) \ --capabilities CAPABILITY_NAMED_IAMTesting
As described in the official documentation, data blobs must be Base64-encoded before they are sent.
The data blob, which is base64-encoded when the blob is serialized. The maximum size of the data blob, before base64-encoding, is 1,000 KiB.
Encode your sample JSON as follows:
$ echo -n '{"user_id": 1, "message": "Hello"}' | base64eyJ1c2VyX2lkIjogMSwgIm1lc3NhZ2UiOiAiSGVsbG8ifQ==
$ echo -n '{"user_id": 1, "message": "World"}' | base64eyJ1c2VyX2lkIjogMSwgIm1lc3NhZ2UiOiAiV29ybGQifQ==The encoded records can then be sent to the Firehose stream via the AWS CLI:
echo '{ "DeliveryStreamName": "ndjson-firehose", "Records": [ {"Data": "eyJ1c2VyX2lkIjogMSwgIm1lc3NhZ2UiOiAiSGVsbG8ifQ=="}, {"Data": "eyJ1c2VyX2lkIjogMSwgIm1lc3NhZ2UiOiAiV29ybGQifQ=="} ]}' > input.jsonaws firehose put-record-batch --cli-input-json file://~/input.jsonCheck the objects in your S3 bucket:
$ aws s3 ls --recursive s3://<AWS_REGION>-<AWS_ACCOUNT>-ndjson-s3/success/2022-07-26 23:52:47 69 success/user_id=1/year=2022/month=07/day=26/hour=14/ndjson-firehose-4-2022-07-26-14-50-22-b95618c0-e518-3b66-b06c-693b059cc751
$ aws s3 cp s3://<AWS_REGION>-<AWS_ACCOUNT>-ndjson-s3/success/user_id=1/year=2022/month=07/day=26/hour=14/ndjson-firehose-4-2022-07-26-14-50-22-b95618c0-e518-3b66-b06c-693b059cc751 ./download: s3://<AWS_REGION>-<AWS_ACCOUNT>-ndjson-s3/success/user_id=1/year=2022/month=07/day=26/hour=14/ndjson-firehose-4-2022-07-26-14-50-22-b95618c0-e518-3b66-b06c-693b059cc751 to ./ndjson-firehose-4-2022-07-26-14-50-22-b95618c0-e518-3b66-b06c-693b059cc751
$ cat -n ndjson-firehose-4-2022-07-26-14-50-22-b95618c0-e518-3b66-b06c-693b059cc751 1 {"user_id": 1, "message": "Hello"} 2 {"user_id": 1, "message": "World"}Cleaning Up
Remove the resources provisioned by this example with:
aws s3 rm --recursive s3://<AWS_REGION>-<AWS_ACCOUNT>-ndjson-s3/aws cloudformation delete-stack --stack-name firehose-ndjson-sampleConclusion
Enabling dynamic partitioning on a Firehose delivery stream produced partitioned, newline-delimited JSON in S3 without a transformation Lambda in the pipeline.
DynamicPartitioningConfiguration: Enabled: true combined with the MetadataExtraction processor is what lets the Prefix template reference !{partitionKeyFromQuery:user_id} directly, pulling the partition key out of each record’s JSON body via the JQ query {user_id: .user_id} rather than requiring a Lambda function to compute it beforehand. Pairing that with the AppendDelimiterToRecord processor’s \\n delimiter covers the other half of what a transformation Lambda used to be needed for — producing clean NDJSON output.
If MetadataExtractionQuery does not match the record structure, Firehose writes the record under the error prefix. Check the error/ path in S3 alongside success/ when sending real traffic for the first time.
Related posts
Configuring Record Separators for Kinesis Firehose in AWS IoT Core
Configure a record separator in an IoT Core topic rule's Firehose action so that records stored in S3 are separated by newlines.
Querying S3 Logs with Athena and Kinesis Data Firehose
Deliver logs to S3 with Kinesis Data Firehose and query them in Athena, using custom prefixes to register partitions automatically.
Streaming OPC UA Data to Kinesis via SiteWise Edge Gateway
Bridging OPC UA telemetry to Kinesis Data Streams through SiteWise Edge Gateway and a custom Greengrass component.
Partitioning Kinesis Records with AWS IoT Payload Values
Use a customer ID from an IoT topic payload as the Kinesis partition key to preserve record order for each customer.
Sign in with Slack Using Cognito User Pools and OIDC
Federating Cognito user pools with Slack over OIDC and wiring "Sign in with Slack" into a Next.js app with Amplify.
