Guide · Kinesis Firehose Dynamic Partitioning

Kinesis Firehose Dynamic Partitioning for MCP Servers — JQ Expressions, Partition Keys, S3 Prefix Routing

Without dynamic partitioning, every record Firehose delivers lands in a single date-stamped S3 prefix — a queryable blob of all your MCP probe results mixed together. Dynamic partitioning routes each record to a separate S3 prefix based on values extracted from the record itself: you can split probe results by tenantId, toolName, serverSlug, or any combination, so Athena queries on a specific tenant only scan that tenant's files. Three facts that bite teams: dynamic partitioning cannot be disabled after enabling — you must recreate the delivery stream to remove it; the buffer interval applies per active partition, not globally — a partition receiving one record per day will wait the full buffer interval (up to 900 seconds) before flushing, creating up to 15-minute latency on low-traffic prefixes; and inline parsing requires the source records to be valid JSON — binary or non-JSON records must go through a Lambda preprocessor before dynamic partitioning can extract keys.

TL;DR

Enable dynamic partitioning when you create the Firehose delivery stream (cannot be enabled later on an existing stream without recreation). Use inline JQ-style expressions like .tenantId to extract partition keys from JSON probe result records. Reference the key in the S3 prefix as !{partitionKeyFromQuery:tenantId}/!{timestamp:yyyy/MM/dd/HH}. Set a multi-record deaggregation mode of NONE if your producer sends raw PutRecord calls; set DELIMITED if records are newline-delimited. Maximum 50 partition keys per delivery stream. Monitor PartitionCount — if it approaches 500 active partitions, buffering overhead increases cost substantially.

What dynamic partitioning solves

The default Firehose S3 delivery uses a single prefix with optional datetime components: probe-results/!{timestamp:yyyy/MM/dd/HH}/. Every record — regardless of which MCP endpoint it describes or which tenant owns it — lands in the same hourly files. An Athena query asking "show me all probe failures for tenant XYZ in the last 7 days" must scan every record from every tenant for every hour over 7 days. At 5 million probe records per day across 1,000 tenants, that is a full 35 million record scan for what is conceptually a ~35,000 record query.

Dynamic partitioning routes each record to its own S3 key at ingest time. The same query scans only the files under probe-results/tenant=XYZ/, reducing data scanned by 1,000×. This is especially valuable for AliveMCP where probe results naturally segment by serverSlug (one per MCP endpoint) and status (healthy/degraded/down), and where tenants on paid plans expect query isolation.

Enabling dynamic partitioning

Dynamic partitioning is configured at delivery stream creation time. It cannot be enabled on an existing stream — you must create a new stream or recreate the existing one. The following CloudFormation / IaC excerpt shows the relevant parameters:

// AWS SDK v3 — CreateDeliveryStream with dynamic partitioning
import { FirehoseClient, CreateDeliveryStreamCommand } from '@aws-sdk/client-firehose';

const client = new FirehoseClient({ region: 'us-east-1' });

await client.send(new CreateDeliveryStreamCommand({
  DeliveryStreamName: 'alivemcp-probe-results',
  DeliveryStreamType: 'KinesisStreamAsSource',
  KinesisStreamSourceConfiguration: {
    KinesisStreamARN: 'arn:aws:kinesis:us-east-1:123456789012:stream/alivemcp-results',
    RoleARN: 'arn:aws:iam::123456789012:role/firehose-kinesis-role',
  },
  ExtendedS3DestinationConfiguration: {
    BucketARN: 'arn:aws:s3:::alivemcp-probe-archive',
    RoleARN: 'arn:aws:iam::123456789012:role/firehose-s3-role',

    // Prefix uses dynamic partition keys + timestamp
    Prefix: 'probe-results/tenant=!{partitionKeyFromQuery:tenantId}/tool=!{partitionKeyFromQuery:toolName}/!{timestamp:yyyy/MM/dd/HH}/',
    ErrorOutputPrefix: 'probe-results-errors/!{firehose:error-output-type}/!{timestamp:yyyy/MM/dd/HH}/',

    BufferingHints: {
      SizeInMBs: 64,
      IntervalInSeconds: 60,
    },

    // Enable dynamic partitioning
    DynamicPartitioningConfiguration: {
      Enabled: true,
      RetryOptions: {
        DurationInSeconds: 300,  // Retry failed partition key extractions for 5 min
      },
    },

    // Inline parsing: extract partition keys from JSON records
    ProcessingConfiguration: {
      Enabled: true,
      Processors: [
        {
          Type: 'MetadataExtraction',
          Parameters: [
            {
              ParameterName: 'MetadataExtractionQuery',
              // JQ-style: extract tenantId and toolName from the JSON record
              ParameterValue: '{tenantId: .tenantId, toolName: .toolName}',
            },
            {
              ParameterName: 'JsonParsingEngine',
              ParameterValue: 'JQ-1.6',
            },
          ],
        },
        // Multi-record deaggregation: required for dynamic partitioning
        {
          Type: 'AppendDelimiterToRecord',
          Parameters: [
            { ParameterName: 'Delimiter', ParameterValue: '\\n' },
          ],
        },
      ],
    },
  },
}));

Key configuration decisions in the above:

JQ expression syntax and limits

Firehose inline parsing uses JQ 1.6 syntax to extract partition keys from JSON records. The expression must return an object where each key becomes a partition key name:

Use caseJQ expressionNotes
Single tenant ID{tenantId: .tenantId}Simple field extraction
Nested field{region: .endpoint.awsRegion}Dot-path into nested JSON objects
Status normalization{status: (.status | ascii_downcase)}JQ filters transform values
Multiple keys{tenantId: .tenantId, toolName: .toolName}Up to 50 keys per stream
Conditional key{tier: (if .isPaid then "paid" else "free" end)}if/then/else supported in JQ 1.6
Default fallback{tenantId: (.tenantId // "unknown")}// operator returns fallback if left side is null/false

Hard constraints on dynamic partition keys:

Buffer semantics per partition

This is the most misunderstood aspect of dynamic partitioning. The buffer hints (SizeInMBs and IntervalInSeconds) apply per active partition, not globally. A Firehose stream with 1,000 active tenant partitions receiving 1 record per tenant per minute will flush each partition's buffer every 60 seconds — even though the global record rate may be high enough to flush a single-partition stream every second.

Implications for AliveMCP:

Partition traffic levelBuffer behaviorS3 object sizeDaily PUT count
High (500 rec/min, 2 KB each)Flushes every 60s (time limit)~1 MB per file1,440
Medium (50 rec/min, 2 KB each)Flushes every 60s (time limit)~100 KB per file1,440
Low (1 rec/min, 2 KB each)Flushes every 60s (time limit)~2 KB per file1,440
Very low (1 rec/hour)Flushes every 900s max (interval)~2 KB per file96

Recommendation: partition by coarse-grained keys first (e.g., tier = paid/free, then date), and add fine-grained per-tenant partitioning only for paid tiers with sufficient traffic to justify the S3 file fragmentation cost.

Multi-record deaggregation

When dynamic partitioning is enabled, Firehose must deaggregate multi-record batches before extracting partition keys — because different records in the same batch may belong to different partitions. The AppendDelimiterToRecord processor handles this by ensuring each record is newline-terminated before the MetadataExtraction processor runs.

If you are using KPL (Kinesis Producer Library) aggregation to batch records at the producer, you must add the Decompression and KPL deaggregation processors before MetadataExtraction:

// If producer uses KPL aggregation, add KPL deaggregation first
Processors: [
  {
    Type: 'RecordDeAggregation',
    Parameters: [
      { ParameterName: 'SubRecordType', ParameterValue: 'DELIMITED' },
    ],
  },
  {
    Type: 'MetadataExtraction',
    Parameters: [
      { ParameterName: 'MetadataExtractionQuery', ParameterValue: '{tenantId: .tenantId, toolName: .toolName}' },
      { ParameterName: 'JsonParsingEngine', ParameterValue: 'JQ-1.6' },
    ],
  },
  {
    Type: 'AppendDelimiterToRecord',
    Parameters: [{ ParameterName: 'Delimiter', ParameterValue: '\\n' }],
  },
]

Processor ordering matters: deaggregation must precede metadata extraction, which must precede the delimiter appender. Reversing the order causes the metadata extractor to see the aggregated record envelope rather than the individual JSON records, causing all keys to fail extraction and route to the error prefix.

S3 prefix expression reference

Firehose prefix expressions support three variable types:

Variable typeSyntaxExample resolved value
Extracted partition key!{partitionKeyFromQuery:keyName}tenant-abc123
Timestamp (UTC)!{timestamp:yyyy/MM/dd/HH}2026/10/30/14
Error output type!{firehose:error-output-type}processing-failed
Random string!{firehose:random-string}8-character alphanumeric token for uniqueness

The !{timestamp:} format uses Java DateTimeFormatter patterns. Common patterns:

Use Hive-compatible prefixes (key=value/ format) when registering the S3 location as an Athena table — it enables Athena partition projection, eliminating the need to run MSCK REPAIR TABLE after each day's data arrives.

Common failure modes

SymptomCauseFix
All records routed to error prefixJQ expression fails for every record (syntax error or field missing)Validate JQ expression against a sample record; add // "unknown" fallback for nullable fields
Cannot enable dynamic partitioning on existing streamDynamic partitioning is immutable after stream creationCreate new delivery stream; update producer to write to new stream name; drain old stream before deleting
S3 files not parseable by Athena (one giant blob)Missing AppendDelimiterToRecord processorAdd newline delimiter processor after MetadataExtraction; update existing stream configuration
Unexpected high S3 PUT costsToo many low-traffic partitions each flushing independentlyReduce partition granularity; route low-traffic segments to a coarser-grained shared partition
Partition key value missing from S3 prefixMismatch between JQ output key name and prefix reference nameEnsure {keyName: ...} in JQ exactly matches !{partitionKeyFromQuery:keyName} in prefix — case-sensitive
KPL-aggregated records not partitioned correctlyMetadataExtraction runs before RecordDeAggregationReorder processors: RecordDeAggregation → MetadataExtraction → AppendDelimiterToRecord