Guide · Kinesis Firehose Lambda Transformation

Kinesis Firehose Lambda Transformation for MCP Servers — Base64 Encoding, ProcessingResult Codes, Newline Delimiters

The single most common source of silent data loss in Kinesis Firehose pipelines is a Lambda transformation that returns raw strings instead of base64-encoded output. Firehose sends records to the transformation Lambda base64-encoded, and requires base64-encoded records back — if your Lambda returns a plain UTF-8 string, Firehose silently routes every record to the error prefix without logging a clear error message. Two other non-obvious requirements: each transformed record must end with a newline character — Firehose concatenates all records from a batch into a single S3 object with no separator, so without the newline every S3 file is one continuous blob unparseable by Athena; and the output record count must equal the input record count — if your Lambda returns fewer records than it received (even intentionally), Firehose treats the discrepancy as a processing failure and retries the entire batch.

TL;DR

Firehose transformation Lambda receives a batch of up to 3 MB / 100 records. Each record's data field is base64-encoded. Decode, transform, re-encode as base64, and append a newline to the decoded payload before re-encoding. Return one output record per input record. Use result: "Ok" for successful transforms, result: "Dropped" to silently discard a record (it will not appear in S3 or the error prefix), and result: "ProcessingFailed" to route a record to the error prefix. Lambda timeout can be up to 5 minutes. Firehose retries a failed Lambda invocation up to 3 times before routing the entire batch to the error prefix.

The Firehose transformation contract

Firehose calls your Lambda synchronously with a batch of records. The event shape is fixed:

// Input event from Firehose
interface FirehoseTransformEvent {
  invocationId: string;
  deliveryStreamArn: string;
  region: string;
  records: Array<{
    recordId: string;                    // Opaque ID — must be echoed back in output
    approximateArrivalTimestamp: number; // Epoch milliseconds
    data: string;                        // BASE64-encoded record payload
    kinesisRecordMetadata?: {            // Present when source is Kinesis Data Streams
      sequenceNumber: string;
      subsequenceNumber: number;
      partitionKey: string;
      shardId: string;
      approximateArrivalTimestamp: number;
    };
  }>;
}

// Output event your Lambda must return
interface FirehoseTransformResult {
  records: Array<{
    recordId: string;   // Must match the input recordId exactly
    result: 'Ok' | 'Dropped' | 'ProcessingFailed';
    data: string;       // BASE64-encoded transformed payload (required for 'Ok' and 'ProcessingFailed')
  }>;
}

Three invariants Firehose enforces on the output:

  1. The output must contain exactly one record per input record (by recordId). Missing a recordId causes Firehose to treat the batch as failed.
  2. The recordId in each output record must exactly match the recordId from the corresponding input record.
  3. The total size of all transformed records in the response must not exceed 6 MB. If the transformation inflates records beyond this limit, use ProcessingFailed for oversized records and handle them separately.

Complete transformation Lambda example

A production-grade transformation Lambda for AliveMCP probe results: normalizes field names, adds a processing timestamp, discards test probe events, and routes malformed records to the error prefix:

import type { FirehoseTransformationEvent, FirehoseTransformationResult,
               FirehoseTransformationResultRecord } from 'aws-lambda';

export const handler = async (
  event: FirehoseTransformationEvent
): Promise => {
  const records: FirehoseTransformationResultRecord[] = event.records.map(record => {
    try {
      // Step 1: Decode base64 input
      const payload = Buffer.from(record.data, 'base64').toString('utf-8');

      // Step 2: Parse JSON
      const probe = JSON.parse(payload);

      // Step 3: Drop test probes (result: Dropped — no error prefix, no S3 delivery)
      if (probe.isTest === true || probe.serverSlug?.startsWith('test-')) {
        return {
          recordId: record.recordId,
          result: 'Dropped',
          data: record.data,  // data field required even for Dropped records (AWS quirk)
        };
      }

      // Step 4: Normalize and enrich
      const normalized = {
        serverSlug: probe.serverSlug,
        tenantId: probe.tenantId ?? 'public',
        status: probe.status,                          // 'healthy' | 'degraded' | 'down'
        responseTimeMs: probe.responseTimeMs ?? null,
        errorCode: probe.errorCode ?? null,
        probedAt: probe.probedAt,
        processedAt: new Date().toISOString(),         // Add processing timestamp
        schemaVersion: 2,                              // Version your schema
      };

      // Step 5: Re-encode as base64 — MUST append '\n' before encoding
      // Without the newline, S3 files are one continuous blob with no record boundaries
      const transformedPayload = JSON.stringify(normalized) + '\n';
      const transformedData = Buffer.from(transformedPayload, 'utf-8').toString('base64');

      return {
        recordId: record.recordId,
        result: 'Ok',
        data: transformedData,
      };

    } catch (err) {
      // Step 6: Route unparseable or schema-invalid records to error prefix
      console.error('Transform failed for record', record.recordId, err);
      return {
        recordId: record.recordId,
        result: 'ProcessingFailed',
        data: record.data,  // Original base64 data — preserved in error prefix for debugging
      };
    }
  });

  return { records };
};

Key implementation notes:

Batch size, timeout, and concurrency

Firehose controls the batch size and invocation frequency — your Lambda does not control when it is called:

ParameterLimitConfigured where
Max records per Lambda invocation100 recordsFirehose delivery stream (BufferingHints)
Max payload size per invocation3 MBFirehose internal limit (not configurable)
Max response size6 MBFirehose internal limit (not configurable)
Lambda function timeoutUp to 5 minutesLambda function configuration
Firehose transform timeout5 minutesFirehose internal (matches Lambda max)
Lambda concurrencyScales with Firehose throughputLambda reserved concurrency (optional cap)
Retry attempts on Lambda error3Not configurable in Firehose v1

Concurrency model: Firehose invokes the transformation Lambda synchronously and waits for each batch to complete before delivering the transformed records to S3. Multiple Firehose shards (when source is KDS) may invoke the Lambda in parallel — one concurrent Lambda invocation per Firehose shard. For AliveMCP with 1 KDS shard, you will have at most 1 concurrent transformation Lambda invocation at any moment.

If Lambda concurrency is exhausted (because another workload has consumed all reserved concurrency), Firehose receives a throttling error and retries the batch. After 3 retries, the batch routes to the error prefix. Set reserved concurrency on the transformation Lambda if other workloads in the same account could exhaust the account concurrency limit.

ProcessingResult codes: when to use each

Result codeS3 deliveryError prefixCloudWatch metricWhen to use
OkYes — transformed dataNoDeliveryToS3.Records incrementedSuccessful transform
DroppedNoNoNo dedicated metricIntentional discard: test events, health checks, duplicate suppression
ProcessingFailedNoYes — original data preservedDeliveryToS3.ProcessingFailedRecords incrementedUnrecoverable record: schema violation, oversized, unparseable

Dropped is the right choice for events you deliberately do not want to archive — test probes, internal health checks, rate-limiter synthetic requests. Unlike ProcessingFailed, dropped records do not appear in your error prefix and do not trigger alerts. Use ProcessingFailed only for records that should have been archivable but encountered an unrecoverable error — these records in the error prefix are your audit trail for data quality issues.

Retry behavior and error routing

When the transformation Lambda invocation fails — either a Lambda error (unhandled exception, timeout, out-of-memory) or a throttling error — Firehose retries the entire batch up to 3 times with exponential backoff before routing all records in that batch to the error prefix. The retry duration is controlled by the DurationInSeconds field on RetryOptions (0 to 7,200 seconds; default 300 seconds).

// Configure retry duration in the delivery stream
ProcessingConfiguration: {
  Enabled: true,
  Processors: [
    {
      Type: 'Lambda',
      Parameters: [
        {
          ParameterName: 'LambdaArn',
          ParameterValue: 'arn:aws:lambda:us-east-1:123456789012:function:firehose-transform:$LATEST',
        },
        {
          ParameterName: 'BufferSizeInMBs',
          ParameterValue: '3',   // Max 3 MB per Lambda invocation
        },
        {
          ParameterName: 'BufferIntervalInSeconds',
          ParameterValue: '60',  // Invoke Lambda at least every 60 seconds
        },
        {
          ParameterName: 'NumberOfRetries',
          ParameterValue: '3',   // 3 retries before error prefix routing
        },
        {
          ParameterName: 'RoleArn',
          ParameterValue: 'arn:aws:iam::123456789012:role/firehose-lambda-invoke-role',
        },
      ],
    },
  ],
}

The retry behavior is per-batch: if 99 out of 100 records in a batch transform successfully but 1 causes an exception, the entire batch of 100 records is retried. This makes idempotent transforms critical — a record that was successfully transformed in a prior attempt and is now being re-processed must produce the same output. The processedAt timestamp in the example above would differ between retries; use probedAt from the source record as the authoritative timestamp if deduplication matters.

Common failure modes

SymptomCauseFix
All records in error prefix, no S3 deliveryLambda returning raw string instead of base64-encoded dataAdd Buffer.from(payload, 'utf-8').toString('base64') before returning
S3 files are one continuous blob (Athena parse error)Missing newline delimiter before base64 re-encodingAppend \n to the string before encoding: JSON.stringify(obj) + '\n'
Entire batch routes to error prefix on single bad recordUnhandled exception escapes the handlerWrap per-record logic in try/catch; return ProcessingFailed for individual errors
Lambda timeout on large batchesTransform logic too slow for 3 MB / 100 records in 5 minutesReduce BufferSizeInMBs and BufferIntervalInSeconds to reduce batch size
Missing records in S3 outputLambda returning fewer output records than input recordsReturn exactly one record per input record; use Dropped instead of omitting records
DeliveryToS3.ProcessingFailedRecords > 0 alarm firingLegitimate schema-invalid records in source streamCheck error prefix; if systematic, fix producer schema; if one-off, acceptable — monitor for spikes