Skip to content

Feature request: Model keySchemaMetadata / valueSchemaMetadata in the Parser Kafka schemas #5868

Description

@hidingmyname

Use case

When a Kafka event source mapping uses a schema registry, Lambda adds keySchemaMetadata and valueSchemaMetadata (dataFormat, schemaId) to each record (Lambda docs: Kafka event payload). I parse MSK / self-managed Kafka events with KafkaMskEventSchema / KafkaSelfManagedEventSchema and then need the schema ID and format for downstream deserialization and record-keeping. After parsing, both fields are gone from the result and from the inferred type.

Location (aws-powertools/powertools-lambda-typescript @ default branch faa8220):

Current code:

const KafkaRecordSchema = z.object({
  topic: z.string(),
  partition: z.number(),
  offset: z.number(),
  timestamp: z.number(),
  timestampType: z.string(),
  key: z
    .string()
    .transform((key) => {
      return Buffer.from(key, 'base64').toString();
    })
    .optional(),
  value: z.string().transform((value) => {
    return Buffer.from(value, 'base64').toString();
  }),
  headers: z.array(
    z.record(
      z.string(),
      // Lambda sends header bytes as signed Java bytes, so bytes >= 128 arrive negative.
      // Buffer.from masks each number to a byte, and decoding matches @aws-lambda-powertools/kafka.
      z.array(z.number().int().min(-128).max(255)).transform((value) => {
        return Buffer.from(value).toString('utf8');
      })
    )
  ),
});

Problem: KafkaRecordSchema does not declare keySchemaMetadata / valueSchemaMetadata, and the z.object default strips undeclared keys, so both fields are dropped from the parsed record and are absent from the inferred type.

Reproduction:

import { KafkaMskEventSchema } from '@aws-lambda-powertools/parser/schemas/kafka';

const event = {
  eventSource: 'aws:kafka',
  eventSourceArn: 'arn:aws:kafka:us-east-1:123456789012:cluster/demo/abcd',
  bootstrapServers: 'b-1.demo:9092',
  records: {
    'mytopic-0': [{
      topic: 'mytopic', partition: 0, offset: 15,
      timestamp: 1545084650987, timestampType: 'CREATE_TIME',
      key: 'cmVjb3JkS2V5', value: 'eyJrZXkiOiJ2YWx1ZSJ9', headers: [],
      valueSchemaMetadata: { dataFormat: 'AVRO', schemaId: '1234' },
      keySchemaMetadata: { dataFormat: 'AVRO', schemaId: '5678' },
    }],
  },
};

const record = KafkaMskEventSchema.parse(event).records['mytopic-0'][0];
console.log(record.valueSchemaMetadata); // undefined (and not part of the inferred type)
Vitest output from the equivalent unit test
 FAIL  tests/unit/schema/kafkaSchemaMetadata.test.ts > Schema: Kafka schema metadata > preserves valueSchemaMetadata when parsing a Kafka MSK event
AssertionError: expected { topic: 'mytopic', …(7) } to have property "valueSchemaMetadata"
 FAIL  tests/unit/schema/kafkaSchemaMetadata.test.ts > Schema: Kafka schema metadata > preserves keySchemaMetadata when parsing a Kafka MSK event
AssertionError: expected { topic: 'mytopic', …(7) } to have property "keySchemaMetadata"
 FAIL  tests/unit/schema/kafkaSchemaMetadata.test.ts > Schema: Kafka schema metadata > preserves schema metadata when parsing a self-managed Kafka event
AssertionError: expected { topic: 'mytopic', …(7) } to have property "valueSchemaMetadata"

A fourth case (event without metadata) passed. Node.js v24.22.1.

Environment: tested on main at 219d973 with Node.js v24.22.1; the schema file is byte-identical on current main faa8220.

Solution/User Experience

Already implemented in aws-powertools/powertools-lambda-python

PR: aws-powertools/powertools-lambda-python#6821 — "feat(kafka): New Kafka utility" (merged 2025-06-18T18:10:41Z, merge commit e920f08519d).

This PR introduced Python's Kafka utility; the part relevant here is the Parser model change, which added optional keySchemaMetadata / valueSchemaMetadata to KafkaRecordModel (still present today: models/kafka.py#L64-L73). The rest of the PR (deserializers, consumer records, data-class changes) is unrelated to Parser.

How powertools-lambda-python implemented it:

--- a/aws_lambda_powertools/utilities/parser/models/kafka.py
+++ b/aws_lambda_powertools/utilities/parser/models/kafka.py
@@ -8,6 +8,11 @@
 SERVERS_DELIMITER = ","
 
 
+class KafkaRecordSchemaMetadata(BaseModel):
+    dataFormat: str
+    schemaId: str
+
+
 class KafkaRecordModel(BaseModel):
     topic: str
     partition: int
@@ -17,6 +22,8 @@ class KafkaRecordModel(BaseModel):
     key: Optional[bytes] = None
     value: Union[str, Type[BaseModel]]
     headers: List[Dict[str, bytes]]
+    keySchemaMetadata: Optional[KafkaRecordSchemaMetadata] = None
+    valueSchemaMetadata: Optional[KafkaRecordSchemaMetadata] = None
 
     # key is optional; only decode if not None
     @field_validator("key", mode="before")

Suggested fix

Add both fields as optional objects on KafkaRecordSchema, shared by the MSK and self-managed schemas, as powertools-lambda-python does for its Parser model. The shape mirrors the SchemaMetadata type in @aws-lambda-powertools/kafka (types.ts#L18-L30), and events without a schema registry parse as before:

diff --git a/packages/parser/src/schemas/kafka.ts b/packages/parser/src/schemas/kafka.ts
--- a/packages/parser/src/schemas/kafka.ts
+++ b/packages/parser/src/schemas/kafka.ts
@@ -1,6 +1,14 @@
 import { z } from 'zod';
 import type { KafkaMskEvent, KafkaSelfManagedEvent } from '../types/schema.js';
 
+/**
+ * Zod schema for the schema registry metadata of a Kafka record key or value.
+ */
+const KafkaSchemaMetadataSchema = z.object({
+  dataFormat: z.string(),
+  schemaId: z.string().optional(),
+});
+
 /**
  * Zod schema for a Kafka record from an Kafka event.
  */
@@ -29,6 +37,8 @@ const KafkaRecordSchema = z.object({
       })
     )
   ),
+  keySchemaMetadata: KafkaSchemaMetadataSchema.optional(),
+  valueSchemaMetadata: KafkaSchemaMetadataSchema.optional(),
 });
 
 const KafkaBaseEventSchema = z.object({

Verified on 219d973 (Node.js v24.22.1, Vitest 5.0.5): the test above fails without this patch and passes with it; no other test results change.

I can submit a PR for this.

Alternative solutions

  • Extend the exported KafkaRecordSchema with the two fields and rebuild the MSK / self-managed event schemas in user code: works, but every user re-implements the same documented fields and has to keep the event wrappers in sync.
  • Use @aws-lambda-powertools/kafka's consumer instead of Parser: fine for deserialization, but not when the goal is Parser-based validation of the event.

Acknowledgment

Acknowledgment

Related issues checked

Checked: hits concern the Kafka consumer's tombstone handling (#4988, #5016), Kafka header decoding (#5830, #5839) and exporting record schema building blocks (#2811) — none add these fields to Parser.

Activity

  1. powertools-for-aws-oss-automation commented on Oct 30, 2026

    @powertools-for-aws-oss-automation

    Thanks for opening your first issue here! We'll come back to you as soon as we can.
    In the meantime, check out our GitHub Discussions: Join the conversation

  2. dreamorosi commented on Oct 30, 2026

    @dreamorosi
    Contributor

    Hi @hidingmyname, thanks for this one too. Confirmed: keySchemaMetadata and valueSchemaMetadata are documented in the Lambda Kafka event payload when schema validation is configured on the event source mapping, and KafkaRecordSchema currently strips them. Powertools for AWS Lambda (Python) models both fields in its Parser, as you pointed out.

    Your proposed fix looks good to me, including keeping schemaId optional. Python requires it, but our own @aws-lambda-powertools/kafka treats it as optional (see SchemaMetadata in packages/kafka/src/types/types.ts and the schemaId === undefined branch in the Protobuf deserializer), and the Parser should not reject events that the Kafka utility accepts. Please mention this difference from Python in the PR description.

    I've assigned the issue to you. Please add these tests to the existing describe block in packages/parser/tests/unit/schema/kafka.test.ts, using its existing rawEvent:

    it.each([
      { eventSource: 'aws:kafka', schema: KafkaMskEventSchema },
      { eventSource: 'SelfManagedKafka', schema: KafkaSelfManagedEventSchema },
    ])(
      'preserves the key and value schema metadata ($eventSource)',
      ({ eventSource, schema }) => {
        // Prepare
        const event = { ...structuredClone(rawEvent), eventSource };
        event.records['mytopic-0'][0].keySchemaMetadata = {
          dataFormat: 'AVRO',
          schemaId: '5678',
        };
        event.records['mytopic-0'][0].valueSchemaMetadata = {
          dataFormat: 'AVRO',
          schemaId: 'a1b2c3d4-5678-90ab-cdef-EXAMPLE11111',
        };
    
        // Act
        const result = schema.parse(event);
    
        // Assess
        expect(result.records['mytopic-0'][0]).toEqual(
          expect.objectContaining({
            keySchemaMetadata: { dataFormat: 'AVRO', schemaId: '5678' },
            valueSchemaMetadata: {
              dataFormat: 'AVRO',
              schemaId: 'a1b2c3d4-5678-90ab-cdef-EXAMPLE11111',
            },
          })
        );
      }
    );
    
    it('parses schema metadata without a schemaId', () => {
      // Prepare
      const event = structuredClone(rawEvent);
      event.records['mytopic-0'][0].valueSchemaMetadata = {
        dataFormat: 'PROTOBUF',
      };
    
      // Act
      const result = KafkaMskEventSchema.parse(event);
    
      // Assess
      expect(result.records['mytopic-0'][0].valueSchemaMetadata).toStrictEqual({
        dataFormat: 'PROTOBUF',
      });
    });
    
    it('returns an issue when the schema metadata has no dataFormat', () => {
      // Prepare
      const event = structuredClone(rawEvent);
      (
        event.records['mytopic-0'][0] as Record<string, unknown>
      ).valueSchemaMetadata = { schemaId: '1234' };
    
      // Act
      const result = KafkaMskEventSchema.safeParse(event);
    
      // Assess
      expect(result.success).toBe(false);
      expect(result.error?.issues).toEqual([
        expect.objectContaining({
          path: ['records', 'mytopic-0', 0, 'valueSchemaMetadata', 'dataFormat'],
        }),
      ]);
    });

    All four currently fail on main, and the schema change from your proposal should make them pass. KafkaMskEvent and KafkaSelfManagedEvent should stay inferred from the schema, so no separate type change is needed, and please don't add or modify other tests unless coverage drops below 100%.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

parserThis item relates to the Parser Utility

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions