feat(events): restructure kafka module and add publisher

Signed-off-by: Jonas Beck <dev@jonasbeck.dk>
This commit is contained in:
Jonas Beck
2025-12-08 12:37:02 +01:00
parent 17799815dc
commit 805c48d23d
28 changed files with 2504 additions and 1063 deletions
+344 -151
View File
@@ -13,7 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { HumanDuration } from '@backstage/types';
export interface Config {
@@ -25,179 +24,373 @@ export interface Config {
kafka?: {
/**
* Configuration for KafkaConsumingEventPublisher
*
* Supports multiple named instances as a record where each key is a unique name
* for the Kafka consumer configuration.
*/
kafkaConsumingEventPublisher?: {
/**
* (Required) Client ID used by Backstage to identify when connecting to the Kafka cluster.
*/
clientId: string;
/**
* (Required) List of brokers in the Kafka cluster to connect to.
*/
brokers: string[];
/**
* Optional SSL connection parameters to connect to the cluster. Passed directly to Node tls.connect.
* See https://nodejs.org/dist/latest-v8.x/docs/api/tls.html#tls_tls_createsecurecontext_options
*/
ssl?:
| {
ca?: string[];
/** @visibility secret */
key?: string;
cert?: string;
rejectUnauthorized?: boolean;
}
| boolean;
/**
* Optional SASL connection parameters.
*/
sasl?: {
mechanism: 'plain' | 'scram-sha-256' | 'scram-sha-512';
username: string;
/** @visibility secret */
password: string;
};
/**
* Optional retry connection parameters.
*/
retry: {
[name: string]: {
/**
* (Optional) Maximum wait time for a retry
* Default: 30000 ms.
* (Required) Client ID used by Backstage to identify when connecting to the Kafka cluster.
*/
maxRetryTime: HumanDuration | string;
clientId: string;
/**
* (Required) List of brokers in the Kafka cluster to connect to.
*/
brokers: string[];
/**
* Optional SSL connection parameters to connect to the cluster. Passed directly to Node tls.connect.
* See https://nodejs.org/dist/latest-v8.x/docs/api/tls.html#tls_tls_createsecurecontext_options
*/
ssl?:
| {
ca?: string[];
/** @visibility secret */
key?: string;
cert?: string;
rejectUnauthorized?: boolean;
}
| boolean;
/**
* Optional SASL connection parameters.
*/
sasl?: {
mechanism: 'plain' | 'scram-sha-256' | 'scram-sha-512';
username: string;
/** @visibility secret */
password: string;
};
/**
* (Optional) Initial value used to calculate the retry (This is still randomized following the randomization factor)
* Default: 300 ms.
* Optional retry connection parameters.
*/
initialRetryTime: HumanDuration | string;
/**
* (Optional) Randomization factor
* Default: 0.2.
*/
factor: number;
/**
* (Optional) Exponential factor
* Default: 2.
*/
multiplier: number;
/**
* (Optional) Max number of retries per call
* Default: 5.
*/
retries: number;
};
/**
* (Optional) Timeout for authentication requests.
* Default: 10000 ms.
*/
authenticationTimeout: HumanDuration | string;
/**
* (Optional) Time to wait for a successful connection.
* Default: 1000 ms.
*/
connectionTimeout: HumanDuration | string;
/**
* (Optional) Time to wait for a successful request.
* Default: 30000 ms.
*/
requestTimeout: HumanDuration | string;
/**
* (Optional) The request timeout can be disabled by setting enforceRequestTimeout to false.
* Default: true
*/
enforceRequestTimeout: boolean;
/**
* Contains a object per topic for which an Kafka queue
* should be used as source of events.
*/
topics: Array<{
/**
* (Required) The Backstage topic to publish to
*/
topic: string;
/**
* (Required) KafkaConsumer-related configuration.
*/
kafka: {
retry?: {
/**
* (Required) The Kafka topics to subscribe to
*/
topics: string[];
/**
* (Required) The GroupId to be used by the topic consumers
*/
groupId: string;
/**
* (Optional) Timeout used to detect failures.
* The consumer sends periodic heartbeats to indicate its liveness to the broker.
* If no heartbeats are received by the broker before the expiration of this session timeout,
* then the broker will remove this consumer from the group and initiate a rebalance
* (Optional) Maximum wait time for a retry
* Default: 30000 ms.
*/
sessionTimeout: HumanDuration | string;
maxRetryTime?: HumanDuration | string;
/**
* (Optional) The maximum time that the coordinator will wait for each member to rejoin when rebalancing the group
* Default: 60000 ms.
* (Optional) Initial value used to calculate the retry (This is still randomized following the randomization factor)
* Default: 300 ms.
*/
rebalanceTimeout: HumanDuration | string;
initialRetryTime?: HumanDuration | string;
/**
* (Optional) The expected time between heartbeats to the consumer coordinator.
* Heartbeats are used to ensure that the consumer's session stays active.
* The value must be set lower than session timeout
* Default: 3000 ms.
* (Optional) Randomization factor
* Default: 0.2.
*/
heartbeatInterval: HumanDuration | string;
factor?: number;
/**
* (Optional) The period of time after which we force a refresh of metadata
* even if we haven't seen any partition leadership changes to proactively discover any new brokers or partitions
* Default: 300000 ms (5 minutes).
* (Optional) Exponential factor
* Default: 2.
*/
metadataMaxAge: HumanDuration | string;
multiplier?: number;
/**
* (Optional) The maximum amount of data per-partition the server will return.
* This size must be at least as large as the maximum message size the server allows
* or else it is possible for the producer to send messages larger than the consumer can fetch.
* If that happens, the consumer can get stuck trying to fetch a large message on a certain partition
* Default: 1048576 (1MB)
* (Optional) Max number of retries per call
* Default: 5.
*/
maxBytesPerPartition: number;
/**
* (Optional) Minimum amount of data the server should return for a fetch request, otherwise wait up to maxWaitTime for more data to accumulate.
* Default: 1
*/
minBytes: number;
/**
* (Optional) Maximum amount of bytes to accumulate in the response. Supported by Kafka >= 0.10.1.0
* Default: 10485760 (10MB)
*/
maxBytes: number;
/**
* (Optional) The maximum amount of time the server will block before answering the fetch request
* if there isnt sufficient data to immediately satisfy the requirement given by minBytes
* Default: 5000
*/
maxWaitTime: HumanDuration | string;
retries?: number;
};
}>;
/**
* (Optional) Timeout for authentication requests.
* Default: 10000 ms.
*/
authenticationTimeout?: HumanDuration | string;
/**
* (Optional) Time to wait for a successful connection.
* Default: 1000 ms.
*/
connectionTimeout?: HumanDuration | string;
/**
* (Optional) Time to wait for a successful request.
* Default: 30000 ms.
*/
requestTimeout?: HumanDuration | string;
/**
* (Optional) The request timeout can be disabled by setting enforceRequestTimeout to false.
* Default: true
*/
enforceRequestTimeout?: boolean;
/**
* Contains an object per topic for which a Kafka queue
* should be used as source of events.
*/
topics: Array<{
/**
* (Required) The Backstage topic to publish to
*/
topic: string;
/**
* (Required) KafkaConsumer-related configuration.
*/
kafka: {
/**
* (Required) The Kafka topics to subscribe to
*/
topics: string[];
/**
* (Required) The GroupId to be used by the topic consumers
*/
groupId: string;
/**
* (Optional) Timeout used to detect failures.
* The consumer sends periodic heartbeats to indicate its liveness to the broker.
* If no heartbeats are received by the broker before the expiration of this session timeout,
* then the broker will remove this consumer from the group and initiate a rebalance
* Default: 30000 ms.
*/
sessionTimeout?: HumanDuration | string;
/**
* (Optional) The maximum time that the coordinator will wait for each member to rejoin when rebalancing the group
* Default: 60000 ms.
*/
rebalanceTimeout?: HumanDuration | string;
/**
* (Optional) The expected time between heartbeats to the consumer coordinator.
* Heartbeats are used to ensure that the consumer's session stays active.
* The value must be set lower than session timeout
* Default: 3000 ms.
*/
heartbeatInterval?: HumanDuration | string;
/**
* (Optional) The period of time after which we force a refresh of metadata
* even if we haven't seen any partition leadership changes to proactively discover any new brokers or partitions
* Default: 300000 ms (5 minutes).
*/
metadataMaxAge?: HumanDuration | string;
/**
* (Optional) The maximum amount of data per-partition the server will return.
* This size must be at least as large as the maximum message size the server allows
* or else it is possible for the producer to send messages larger than the consumer can fetch.
* If that happens, the consumer can get stuck trying to fetch a large message on a certain partition
* Default: 1048576 (1MB)
*/
maxBytesPerPartition?: number;
/**
* (Optional) Minimum amount of data the server should return for a fetch request, otherwise wait up to maxWaitTime for more data to accumulate.
* Default: 1
*/
minBytes?: number;
/**
* (Optional) Maximum amount of bytes to accumulate in the response. Supported by Kafka >= 0.10.1.0
* Default: 10485760 (10MB)
*/
maxBytes?: number;
/**
* (Optional) The maximum amount of time the server will block before answering the fetch request
* if there isn't sufficient data to immediately satisfy the requirement given by minBytes
* Default: 5000
*/
maxWaitTime?: HumanDuration | string;
};
}>;
};
};
/**
* Configuration for KafkaPublishingEventConsumer
*
* Supports multiple named instances as a record where each key is a unique name
* for the Kafka producer configuration.
*/
kafkaPublishingEventConsumer?: {
[name: string]: {
/**
* (Required) Client ID used by Backstage to identify when connecting to the Kafka cluster.
*/
clientId: string;
/**
* (Required) List of brokers in the Kafka cluster to connect to.
*/
brokers: string[];
/**
* Optional SSL connection parameters to connect to the cluster. Passed directly to Node tls.connect.
* See https://nodejs.org/dist/latest-v8.x/docs/api/tls.html#tls_tls_createsecurecontext_options
*/
ssl?:
| {
ca?: string[];
/** @visibility secret */
key?: string;
cert?: string;
rejectUnauthorized?: boolean;
}
| boolean;
/**
* Optional SASL connection parameters.
*/
sasl?: {
mechanism: 'plain' | 'scram-sha-256' | 'scram-sha-512';
username: string;
/** @visibility secret */
password: string;
};
/**
* Optional retry connection parameters.
*/
retry?: {
/**
* (Optional) Maximum wait time for a retry
* Default: 30000 ms.
*/
maxRetryTime?: HumanDuration | string;
/**
* (Optional) Initial value used to calculate the retry (This is still randomized following the randomization factor)
* Default: 300 ms.
*/
initialRetryTime?: HumanDuration | string;
/**
* (Optional) Randomization factor
* Default: 0.2.
*/
factor?: number;
/**
* (Optional) Exponential factor
* Default: 2.
*/
multiplier?: number;
/**
* (Optional) Max number of retries per call
* Default: 5.
*/
retries?: number;
};
/**
* (Optional) Timeout for authentication requests.
* Default: 10000 ms.
*/
authenticationTimeout?: HumanDuration | string;
/**
* (Optional) Time to wait for a successful connection.
* Default: 1000 ms.
*/
connectionTimeout?: HumanDuration | string;
/**
* (Optional) Time to wait for a successful request.
* Default: 30000 ms.
*/
requestTimeout?: HumanDuration | string;
/**
* (Optional) The request timeout can be disabled by setting enforceRequestTimeout to false.
* Default: true
*/
enforceRequestTimeout?: boolean;
/**
* Contains an object per topic for which a Kafka queue
* should be used as destination for events.
*/
topics: Array<{
/**
* (Required) The Backstage topic to consume from
*/
topic: string;
/**
* (Required) KafkaProducer-related configuration.
*/
kafka: {
/**
* (Required) The Kafka topic to publish to
*/
topic: string;
/**
* (Optional) Allow topic creation when querying metadata for non-existent topics.
* Default: true
*/
allowAutoTopicCreation?: boolean;
/**
* (Optional) The period of time after which we force a refresh of metadata
* even if we haven't seen any partition leadership changes to proactively discover any new brokers or partitions
* Default: 300000 ms (5 minutes).
*/
metadataMaxAge?: HumanDuration | string;
/**
* (Optional) The maximum amount of time in ms that the transaction coordinator will wait for a transaction status update
* from the producer before proactively aborting the ongoing transaction.
* If this value is larger than the `transaction.max.timeout.ms`` setting in the broker, the request will fail with a `InvalidTransactionTimeout` error
* Default: 60000 ms.
*/
transactionTimeout?: HumanDuration | string;
/**
* (Optional) Experimental. If enabled producer will ensure each message is written exactly once. Acks must be set to -1 ("all").
* Retries will default to MAX_SAFE_INTEGER.
* Default: false.
*/
idempotent?: boolean;
/**
* (Optional) Max number of requests that may be in progress at any time. If falsey then no limit.
* Default: null.
*/
maxInFlightRequests?: number;
/**
* Optional retry connection parameters.
*/
retry?: {
/**
* (Optional) Maximum wait time for a retry
* Default: 30000 ms.
*/
maxRetryTime?: HumanDuration | string;
/**
* (Optional) Initial value used to calculate the retry (This is still randomized following the randomization factor)
* Default: 300 ms.
*/
initialRetryTime?: HumanDuration | string;
/**
* (Optional) Randomization factor
* Default: 0.2.
*/
factor?: number;
/**
* (Optional) Exponential factor
* Default: 2.
*/
multiplier?: number;
/**
* (Optional) Max number of retries per call
* Default: 5.
*/
retries?: number;
};
};
}>;
};
};
};
};
@@ -0,0 +1,163 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { KafkaConsumingEventPublisher } from './KafkaConsumingEventPublisher';
import { Kafka } from 'kafkajs';
import { ConfigReader } from '@backstage/config';
import { mockServices } from '@backstage/backend-test-utils';
jest.mock('kafkajs');
describe('KafkaConsumingEventPublisher', () => {
const mockLogger = mockServices.logger.mock();
const mockEvents = mockServices.events.mock();
const mockConsumer = {
connect: jest.fn(),
disconnect: jest.fn(),
subscribe: jest.fn(),
run: jest.fn(),
};
const mockKafkaClient = {
consumer: jest.fn().mockReturnValue(mockConsumer),
} as unknown as Kafka;
jest.mocked(Kafka).mockImplementation(() => mockKafkaClient);
const mockConfig = new ConfigReader({
events: {
modules: {
kafka: {
kafkaConsumingEventPublisher: {
dev: {
clientId: 'backstage-events',
brokers: ['kafka1:9092', 'kafka2:9092'],
topics: [
{
topic: 'backstage-topic',
kafka: {
topics: ['test-topic'],
groupId: 'test-group',
},
},
],
},
},
},
},
},
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should create instances from config', () => {
const consumers = KafkaConsumingEventPublisher.fromConfig({
config: mockConfig,
events: mockEvents,
logger: mockLogger,
});
expect(consumers).toBeInstanceOf(Array);
expect(consumers).toHaveLength(1);
expect(consumers[0]).toBeInstanceOf(KafkaConsumingEventPublisher);
});
it('should return empty array when no config', () => {
const consumers = KafkaConsumingEventPublisher.fromConfig({
config: new ConfigReader({}),
events: mockEvents,
logger: mockLogger,
});
expect(consumers).toEqual([]);
});
it('should start all consumers', async () => {
const consumers = KafkaConsumingEventPublisher.fromConfig({
config: mockConfig,
events: mockEvents,
logger: mockLogger,
});
expect(consumers).toHaveLength(1);
await consumers[0].start();
expect(mockConsumer.connect).toHaveBeenCalled();
expect(mockConsumer.subscribe).toHaveBeenCalledWith({
topics: ['test-topic'],
});
expect(mockConsumer.run).toHaveBeenCalled();
});
it('should shutdown all consumers', async () => {
const consumers = KafkaConsumingEventPublisher.fromConfig({
config: mockConfig,
events: mockEvents,
logger: mockLogger,
});
expect(consumers).toHaveLength(1);
await consumers[0].shutdown();
expect(mockConsumer.disconnect).toHaveBeenCalled();
});
it('should handle multiple consumer configs', () => {
const multiConsumerConfig = new ConfigReader({
events: {
modules: {
kafka: {
kafkaConsumingEventPublisher: {
dev: {
clientId: 'backstage-events',
brokers: ['kafka1:9092'],
topics: [
{
topic: 'topic1',
kafka: {
topics: ['kafka-topic-1'],
groupId: 'group1',
},
},
{
topic: 'topic2',
kafka: {
topics: ['kafka-topic-2'],
groupId: 'group2',
},
},
],
},
},
},
},
},
});
const consumers = KafkaConsumingEventPublisher.fromConfig({
config: multiConsumerConfig,
events: mockEvents,
logger: mockLogger,
});
expect(consumers).toHaveLength(1);
expect(mockKafkaClient.consumer).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,109 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LoggerService } from '@backstage/backend-plugin-api';
import { EventsService } from '@backstage/plugin-events-node';
import { Consumer, Kafka } from 'kafkajs';
import {
KafkaConsumerConfig,
KafkaConsumingEventPublisherConfig,
readConsumerConfig,
} from './config';
import { Config } from '@backstage/config';
import { loggerServiceAdapter } from '../utils/LoggerServiceAdapter';
import { convertHeadersToMetadata } from '../utils/kafkaTransformers';
type KafkaConsumer = {
consumer: Consumer;
config: KafkaConsumerConfig;
};
/**
* This class subscribes to Kafka topics and publishes events received to the registered subscriber.
* The message payload will be used as the event payload and passed to the subscribers.
*/
export class KafkaConsumingEventPublisher {
private readonly kafkaConsumers: KafkaConsumer[];
private readonly logger: LoggerService;
static fromConfig(env: {
config: Config;
events: EventsService;
logger: LoggerService;
}): KafkaConsumingEventPublisher[] {
const configs = readConsumerConfig(env.config);
return configs.map(
kafkaConfig =>
new KafkaConsumingEventPublisher(env.logger, env.events, kafkaConfig),
);
}
private constructor(
logger: LoggerService,
private readonly events: EventsService,
config: KafkaConsumingEventPublisherConfig,
) {
this.logger = logger.child({
class: KafkaConsumingEventPublisher.prototype.constructor.name,
instance: config.instance,
});
const kafka = new Kafka({
...config.kafkaConfig,
logCreator: loggerServiceAdapter(this.logger),
});
this.kafkaConsumers = config.kafkaConsumerConfigs.map(consumerConfig => ({
consumer: kafka.consumer(consumerConfig.consumerConfig),
config: consumerConfig,
}));
}
async start(): Promise<void> {
await Promise.all(
this.kafkaConsumers.map(async ({ consumer, config }) => {
const consumerLogger = this.logger.child({
id: `events.kafka.publisher:${config.backstageTopic}`,
groupId: config.consumerConfig.groupId,
kafkaTopics: config.consumerSubscribeTopics.topics.toString(),
backstageTopic: config.backstageTopic,
});
try {
await consumer.connect();
await consumer.subscribe(config.consumerSubscribeTopics);
await consumer.run({
eachMessage: async ({ message }) => {
this.events.publish({
topic: config.backstageTopic,
eventPayload: JSON.parse(message.value?.toString()!),
metadata: convertHeadersToMetadata(message.headers),
});
},
});
} catch (error: any) {
consumerLogger.error('Kafka consumer connection failed', error);
}
}),
);
}
async shutdown(): Promise<void> {
await Promise.all(
this.kafkaConsumers.map(({ consumer }) => consumer.disconnect()),
);
}
}
@@ -0,0 +1,275 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfigReader } from '@backstage/config';
import { readConsumerConfig } from './config';
describe('readConsumerConfig', () => {
it('not configured', () => {
const publisherConfigs = readConsumerConfig(new ConfigReader({}));
expect(publisherConfigs).toEqual([]);
});
it('only required fields configured', () => {
const config = new ConfigReader({
events: {
modules: {
kafka: {
kafkaConsumingEventPublisher: {
dev: {
clientId: 'backstage-events',
brokers: ['kafka1:9092', 'kafka2:9092'],
topics: [
{
topic: 'fake1',
kafka: {
topics: ['topic-A'],
groupId: 'my-group',
},
},
{
topic: 'fake2',
kafka: {
topics: ['topic-B'],
groupId: 'my-group',
},
},
],
},
},
},
},
},
});
const publisherConfigs = readConsumerConfig(config);
expect(publisherConfigs).toBeDefined();
expect(Array.isArray(publisherConfigs)).toBe(true);
expect(publisherConfigs).toHaveLength(1);
const devConfig = publisherConfigs[0];
expect(devConfig.instance).toBe('dev');
expect(devConfig.kafkaConsumerConfigs.length).toBe(2);
expect(devConfig.kafkaConfig.clientId).toEqual('backstage-events');
expect(devConfig.kafkaConfig.brokers).toEqual([
'kafka1:9092',
'kafka2:9092',
]);
expect(devConfig.kafkaConsumerConfigs).toEqual([
{
backstageTopic: 'fake1',
consumerConfig: {
groupId: 'my-group',
},
consumerSubscribeTopics: {
topics: ['topic-A'],
},
},
{
backstageTopic: 'fake2',
consumerConfig: {
groupId: 'my-group',
},
consumerSubscribeTopics: {
topics: ['topic-B'],
},
},
]);
});
it('all fields configured', () => {
const config = new ConfigReader({
events: {
modules: {
kafka: {
kafkaConsumingEventPublisher: {
dev: {
clientId: 'backstage-events',
brokers: ['kafka1:9092', 'kafka2:9092'],
ssl: true,
sasl: {
mechanism: 'plain',
username: 'username',
password: 'password',
},
retry: {
maxRetryTime: { milliseconds: 20000 },
initialRetryTime: { milliseconds: 200 },
factor: '0.4',
multiplier: '4',
retries: '10',
},
authenticationTimeout: { milliseconds: 20000 },
connectionTimeout: { milliseconds: 1500 },
requestTimeout: { milliseconds: 20000 },
enforceRequestTimeout: false,
topics: [
{
topic: 'fake1',
kafka: {
topics: ['topic-A'],
groupId: 'my-group',
sessionTimeout: { milliseconds: 20000 },
rebalanceTimeout: { milliseconds: 50000 },
heartbeatInterval: { milliseconds: 2000 },
metadataMaxAge: { milliseconds: 400000 },
maxBytesPerPartition: 50000,
minBytes: 2,
maxBytes: 500000,
maxWaitTime: { milliseconds: 4000 },
},
},
{
topic: 'fake2',
kafka: {
topics: ['topic-B'],
groupId: 'my-group',
},
},
],
},
},
},
},
},
});
const publisherConfigs = readConsumerConfig(config);
expect(publisherConfigs).toBeDefined();
expect(Array.isArray(publisherConfigs)).toBe(true);
expect(publisherConfigs).toHaveLength(1);
const devConfig = publisherConfigs[0];
expect(devConfig.instance).toBe('dev');
// Client configuration
expect(devConfig.kafkaConfig.clientId).toEqual('backstage-events');
expect(devConfig.kafkaConfig.brokers).toEqual([
'kafka1:9092',
'kafka2:9092',
]);
expect(devConfig.kafkaConfig.ssl).toBeTruthy();
expect(devConfig.kafkaConfig.sasl).toStrictEqual({
mechanism: 'plain',
username: 'username',
password: 'password',
});
expect(devConfig.kafkaConfig.authenticationTimeout).toBe(20000);
expect(devConfig.kafkaConfig.connectionTimeout).toBe(1500);
expect(devConfig.kafkaConfig.requestTimeout).toBe(20000);
expect(devConfig.kafkaConfig.enforceRequestTimeout).toBeFalsy();
expect(devConfig.kafkaConfig.retry).toStrictEqual({
maxRetryTime: 20000,
initialRetryTime: 200,
factor: 0.4,
multiplier: 4,
retries: 10,
});
// Consumer configuration
expect(devConfig.kafkaConsumerConfigs.length).toBe(2);
expect(devConfig.kafkaConsumerConfigs).toEqual([
{
backstageTopic: 'fake1',
consumerConfig: {
groupId: 'my-group',
sessionTimeout: 20000,
rebalanceTimeout: 50000,
heartbeatInterval: 2000,
metadataMaxAge: 400000,
maxBytesPerPartition: 50000,
minBytes: 2,
maxBytes: 500000,
maxWaitTimeInMs: 4000,
},
consumerSubscribeTopics: {
topics: ['topic-A'],
},
},
{
backstageTopic: 'fake2',
consumerConfig: {
groupId: 'my-group',
},
consumerSubscribeTopics: {
topics: ['topic-B'],
},
},
]);
});
it('should handle HumanDuration and string values for durations and timeouts', () => {
const config = new ConfigReader({
events: {
modules: {
kafka: {
kafkaConsumingEventPublisher: {
dev: {
clientId: 'backstage-events',
brokers: ['kafka1:9092', 'kafka2:9092'],
retry: {
maxRetryTime: { seconds: 1 },
initialRetryTime: { minutes: 1 },
factor: 0.4,
multiplier: 4,
retries: 10,
},
authenticationTimeout: { hours: 1 },
connectionTimeout: { days: 1 },
topics: [],
requestTimeout: '1m',
},
},
},
},
},
});
const publisherConfigs = readConsumerConfig(config);
expect(publisherConfigs).toBeDefined();
expect(Array.isArray(publisherConfigs)).toBe(true);
expect(publisherConfigs).toHaveLength(1);
const devConfig = publisherConfigs[0];
expect(devConfig.instance).toBe('dev');
// Client configuration
expect(devConfig.kafkaConfig.clientId).toEqual('backstage-events');
expect(devConfig.kafkaConfig.brokers).toEqual([
'kafka1:9092',
'kafka2:9092',
]);
expect(devConfig.kafkaConfig.authenticationTimeout).toBe(3600000);
expect(devConfig.kafkaConfig.connectionTimeout).toBe(86400000);
expect(devConfig.kafkaConfig.requestTimeout).toBe(60000);
expect(devConfig.kafkaConfig.retry).toStrictEqual({
maxRetryTime: 1000,
initialRetryTime: 60000,
factor: 0.4,
multiplier: 4,
retries: 10,
});
// Consumer configuration
expect(devConfig.kafkaConsumerConfigs.length).toBe(0);
});
});
@@ -0,0 +1,91 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config } from '@backstage/config';
import { ConsumerConfig, ConsumerSubscribeTopics, KafkaConfig } from 'kafkajs';
import {
readKafkaConfig,
readOptionalHumanDurationInMs,
} from '../utils/config';
export interface KafkaConsumerConfig {
backstageTopic: string;
consumerConfig: ConsumerConfig;
consumerSubscribeTopics: ConsumerSubscribeTopics;
}
export interface KafkaConsumingEventPublisherConfig {
instance: string;
kafkaConfig: KafkaConfig;
kafkaConsumerConfigs: KafkaConsumerConfig[];
}
const CONFIG_PREFIX_PUBLISHER =
'events.modules.kafka.kafkaConsumingEventPublisher';
export const readConsumerConfig = (
config: Config,
): KafkaConsumingEventPublisherConfig[] => {
const publishers = config.getOptionalConfig(CONFIG_PREFIX_PUBLISHER);
return (
publishers?.keys()?.map(publisherKey => {
const publisherConfig = publishers.getConfig(publisherKey);
return {
instance: publisherKey,
kafkaConfig: readKafkaConfig(publisherConfig),
kafkaConsumerConfigs: publisherConfig
.getConfigArray('topics')
.map(topicConfig => {
return {
backstageTopic: topicConfig.getString('topic'),
consumerConfig: {
groupId: topicConfig.getString('kafka.groupId'),
sessionTimeout: readOptionalHumanDurationInMs(
topicConfig,
'kafka.sessionTimeout',
),
rebalanceTimeout: readOptionalHumanDurationInMs(
topicConfig,
'kafka.rebalanceTimeout',
),
heartbeatInterval: readOptionalHumanDurationInMs(
topicConfig,
'kafka.heartbeatInterval',
),
metadataMaxAge: readOptionalHumanDurationInMs(
topicConfig,
'kafka.metadataMaxAge',
),
maxBytesPerPartition: topicConfig.getOptionalNumber(
'kafka.maxBytesPerPartition',
),
minBytes: topicConfig.getOptionalNumber('kafka.minBytes'),
maxBytes: topicConfig.getOptionalNumber('kafka.maxBytes'),
maxWaitTimeInMs: readOptionalHumanDurationInMs(
topicConfig,
'kafka.maxWaitTime',
),
},
consumerSubscribeTopics: {
topics: topicConfig.getStringArray('kafka.topics'),
},
};
}),
};
}) ?? []
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { eventsModuleKafkaConsumingEventPublisher } from './module';
@@ -0,0 +1,122 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createServiceFactory } from '@backstage/backend-plugin-api';
import { mockServices, startTestBackend } from '@backstage/backend-test-utils';
import { eventsServiceRef } from '@backstage/plugin-events-node';
import { TestEventsService } from '@backstage/plugin-events-backend-test-utils';
import { eventsModuleKafkaConsumingEventPublisher } from './module';
import { KafkaConsumingEventPublisher } from './KafkaConsumingEventPublisher';
jest.mock('./KafkaConsumingEventPublisher');
describe('eventsModuleKafkaConsumingEventPublisher', () => {
it('should be correctly wired and set up', async () => {
const events = new TestEventsService();
const eventsServiceFactory = createServiceFactory({
service: eventsServiceRef,
deps: {},
async factory({}) {
return events;
},
});
const mockKafkaConsumingEventPublisher = {
start: jest.fn(),
shutdown: jest.fn(),
} as unknown as KafkaConsumingEventPublisher;
jest
.mocked(KafkaConsumingEventPublisher.fromConfig)
.mockReturnValue([mockKafkaConsumingEventPublisher]);
await startTestBackend({
features: [
eventsServiceFactory,
eventsModuleKafkaConsumingEventPublisher,
mockServices.rootConfig.factory({
data: {
events: {
modules: {
kafka: {
kafkaConsumingEventPublisher: {
dev: {
clientId: 'backstage-events',
brokers: ['kafka1:9092', 'kafka2:9092'],
topics: {
fake1: {
kafka: {
topics: ['topic-A'],
groupId: 'my-group',
},
},
fake2: {
kafka: {
topics: ['topic-B'],
groupId: 'my-group',
},
},
},
},
},
},
},
},
},
}),
],
});
// Verify that the Kafka consumer client was started
expect(mockKafkaConsumingEventPublisher.start).toHaveBeenCalled();
// Verify that the shutdown hook was registered
expect(mockKafkaConsumingEventPublisher.shutdown).not.toHaveBeenCalled();
});
it('should handle empty configuration gracefully', async () => {
const events = new TestEventsService();
const eventsServiceFactory = createServiceFactory({
service: eventsServiceRef,
deps: {},
async factory({}) {
return events;
},
});
jest.mocked(KafkaConsumingEventPublisher.fromConfig).mockReturnValue([]);
await startTestBackend({
features: [
eventsServiceFactory,
eventsModuleKafkaConsumingEventPublisher,
mockServices.rootConfig.factory({
data: {
events: {
modules: {
kafka: {
// No kafkaConsumingEventPublisher config
},
},
},
},
}),
],
});
// Verify that fromConfig was called but returned empty array
expect(KafkaConsumingEventPublisher.fromConfig).toHaveBeenCalled();
});
});
@@ -17,11 +17,11 @@ import {
coreServices,
createBackendModule,
} from '@backstage/backend-plugin-api';
import { KafkaConsumerClient } from '../publisher/KafkaConsumerClient';
import { eventsServiceRef } from '@backstage/plugin-events-node';
import { KafkaConsumingEventPublisher } from './KafkaConsumingEventPublisher';
/**
* Kafka module for the Events plugin.
* Reads messages off of Kafka topics and forwards them into the Backstage events system.
*
* @public
*/
@@ -34,22 +34,22 @@ export const eventsModuleKafkaConsumingEventPublisher = createBackendModule({
config: coreServices.rootConfig,
events: eventsServiceRef,
logger: coreServices.logger,
lifecycle: coreServices.lifecycle,
lifecycle: coreServices.rootLifecycle,
},
async init({ config, logger, events, lifecycle }) {
const kafka = KafkaConsumerClient.fromConfig({
const consumers = KafkaConsumingEventPublisher.fromConfig({
config,
events,
logger,
});
if (!kafka) {
return;
}
lifecycle.addStartupHook(async () => {
await Promise.all(consumers.map(consumer => consumer.start()));
});
await kafka.start();
lifecycle.addShutdownHook(async () => await kafka.shutdown());
lifecycle.addShutdownHook(async () => {
await Promise.all(consumers.map(consumer => consumer.shutdown()));
});
},
});
},
@@ -0,0 +1,106 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { KafkaPublishingEventConsumer } from './KafkaPublishingEventConsumer';
import { Kafka } from 'kafkajs';
import { mockServices } from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
jest.mock('kafkajs');
describe('KafkaPublishingEventConsumer', () => {
const mockLogger = mockServices.logger.mock();
const mockEvents = mockServices.events.mock();
const mockProducer = {
connect: jest.fn(),
disconnect: jest.fn(),
send: jest.fn(),
};
const mockKafkaClient = {
producer: jest.fn().mockReturnValue(mockProducer),
} as unknown as Kafka;
jest.mocked(Kafka).mockImplementation(() => mockKafkaClient);
const mockConfig = new ConfigReader({
events: {
modules: {
kafka: {
kafkaPublishingEventConsumer: {
dev: {
clientId: 'backstage-events',
brokers: ['kafka1:9092'],
topics: [
{
topic: 'backstage-topic',
kafka: {
topic: 'kafka-topic',
allowAutoTopicCreation: true,
},
},
],
},
},
},
},
},
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should create instances from config', () => {
const consumers = KafkaPublishingEventConsumer.fromConfig({
config: mockConfig,
events: mockEvents,
logger: mockLogger,
});
expect(consumers).toHaveLength(1);
expect(consumers[0]).toBeInstanceOf(KafkaPublishingEventConsumer);
});
it('should start the consumer and subscribe to events', async () => {
const consumers = KafkaPublishingEventConsumer.fromConfig({
config: mockConfig,
events: mockEvents,
logger: mockLogger,
});
await consumers[0].start();
expect(mockProducer.connect).toHaveBeenCalled();
expect(mockEvents.subscribe).toHaveBeenCalledWith({
id: 'kafka:publisher:backstage-topic',
topics: ['backstage-topic'],
onEvent: expect.any(Function),
});
});
it('should shutdown the producer', async () => {
const consumers = KafkaPublishingEventConsumer.fromConfig({
config: mockConfig,
events: mockEvents,
logger: mockLogger,
});
await consumers[0].shutdown();
expect(mockProducer.disconnect).toHaveBeenCalled();
});
});
@@ -0,0 +1,115 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LoggerService } from '@backstage/backend-plugin-api';
import { EventParams, EventsService } from '@backstage/plugin-events-node';
import { Kafka, Producer } from 'kafkajs';
import {
KafkaPublisherConfig,
KafkaPublishingEventConsumerConfig,
readPublisherConfig,
} from './config';
import { Config } from '@backstage/config';
import { loggerServiceAdapter } from '../utils/LoggerServiceAdapter';
import { payloadToBuffer } from '../utils/kafkaTransformers';
type KafkaPublisher = {
producer: Producer;
config: KafkaPublisherConfig;
};
/**
* This class subscribes to Backstage internal events and publishes them to Kafka topics.
* The internal event payload will be serialized and sent to the configured Kafka topic.
*/
export class KafkaPublishingEventConsumer {
private readonly kafkaPublishers: KafkaPublisher[];
private readonly logger: LoggerService;
static fromConfig(env: {
config: Config;
events: EventsService;
logger: LoggerService;
}): KafkaPublishingEventConsumer[] {
const configs = readPublisherConfig(env.config);
return configs.map(
kafkaConfig =>
new KafkaPublishingEventConsumer(env.logger, env.events, kafkaConfig),
);
}
private constructor(
logger: LoggerService,
private readonly events: EventsService,
config: KafkaPublishingEventConsumerConfig,
) {
this.logger = logger.child({
class: KafkaPublishingEventConsumer.prototype.constructor.name,
instance: config.instance,
});
const kafka = new Kafka({
...config.kafkaConfig,
logCreator: loggerServiceAdapter(this.logger),
});
this.kafkaPublishers = config.kafkaPublisherConfigs.map(
publisherConfig => ({
producer: kafka.producer(publisherConfig.producerConfig),
config: publisherConfig,
}),
);
}
async start(): Promise<void> {
await Promise.all(
this.kafkaPublishers.map(async ({ producer, config }) => {
try {
await producer.connect();
this.events.subscribe({
id: `kafka:publisher:${config.backstageTopic}`,
topics: [config.backstageTopic],
onEvent: async (params: EventParams) => {
await producer.send({
topic: config.kafkaTopic,
messages: [
{
value: payloadToBuffer(params.eventPayload),
},
],
});
},
});
this.logger.info(
`Subscribed to EventService, publishing events to external topic: ${config.backstageTopic}`,
);
} catch (error: any) {
this.logger.error(
`Kafka producer connection failed for topic ${config.backstageTopic}`,
error,
);
}
}),
);
}
async shutdown(): Promise<void> {
await Promise.all(
this.kafkaPublishers.map(({ producer }) => producer.disconnect()),
);
}
}
@@ -0,0 +1,360 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfigReader } from '@backstage/config';
import { readPublisherConfig } from './config';
describe('readPublisherConfig', () => {
it('not configured', () => {
const publisherConfigs = readPublisherConfig(new ConfigReader({}));
expect(publisherConfigs).toEqual([]);
});
it('only required fields configured', () => {
const config = new ConfigReader({
events: {
modules: {
kafka: {
kafkaPublishingEventConsumer: {
dev: {
clientId: 'backstage-events',
brokers: ['kafka1:9092', 'kafka2:9092'],
topics: [
{
topic: 'fake1',
kafka: {
topic: 'topic-A',
},
},
{
topic: 'fake2',
kafka: {
topic: 'topic-B',
},
},
],
},
},
},
},
},
});
const publisherConfigs = readPublisherConfig(config);
expect(publisherConfigs).toBeDefined();
expect(Array.isArray(publisherConfigs)).toBe(true);
expect(publisherConfigs).toHaveLength(1);
const devConfig = publisherConfigs[0];
expect(devConfig.instance).toBe('dev');
expect(devConfig.kafkaPublisherConfigs.length).toBe(2);
expect(devConfig.kafkaConfig.clientId).toEqual('backstage-events');
expect(devConfig.kafkaConfig.brokers).toEqual([
'kafka1:9092',
'kafka2:9092',
]);
expect(devConfig.kafkaPublisherConfigs).toEqual([
{
backstageTopic: 'fake1',
kafkaTopic: 'topic-A',
producerConfig: {
allowAutoTopicCreation: undefined,
metadataMaxAge: undefined,
transactionTimeout: undefined,
idempotent: undefined,
maxInFlightRequests: undefined,
retry: {},
},
},
{
backstageTopic: 'fake2',
kafkaTopic: 'topic-B',
producerConfig: {
allowAutoTopicCreation: undefined,
metadataMaxAge: undefined,
transactionTimeout: undefined,
idempotent: undefined,
maxInFlightRequests: undefined,
retry: {},
},
},
]);
});
it('all fields configured', () => {
const config = new ConfigReader({
events: {
modules: {
kafka: {
kafkaPublishingEventConsumer: {
dev: {
clientId: 'backstage-events',
brokers: ['kafka1:9092', 'kafka2:9092'],
ssl: true,
sasl: {
mechanism: 'plain',
username: 'username',
password: 'password',
},
retry: {
maxRetryTime: { milliseconds: 20000 },
initialRetryTime: { milliseconds: 200 },
factor: '0.4',
multiplier: '4',
retries: '10',
},
authenticationTimeout: { milliseconds: 20000 },
connectionTimeout: { milliseconds: 1500 },
requestTimeout: { milliseconds: 20000 },
enforceRequestTimeout: false,
topics: [
{
topic: 'fake1',
kafka: {
topic: 'topic-A',
allowAutoTopicCreation: true,
metadataMaxAge: { milliseconds: 400000 },
transactionTimeout: { milliseconds: 30000 },
idempotent: true,
maxInFlightRequests: 5,
retry: {
maxRetryTime: { milliseconds: 15000 },
initialRetryTime: { milliseconds: 100 },
factor: '0.2',
multiplier: '2',
retries: '5',
},
},
},
{
topic: 'fake2',
kafka: {
topic: 'topic-B',
},
},
],
},
},
},
},
},
});
const publisherConfigs = readPublisherConfig(config);
expect(publisherConfigs).toBeDefined();
expect(Array.isArray(publisherConfigs)).toBe(true);
expect(publisherConfigs).toHaveLength(1);
const devConfig = publisherConfigs[0];
expect(devConfig.instance).toBe('dev');
// Client configuration
expect(devConfig.kafkaConfig.clientId).toEqual('backstage-events');
expect(devConfig.kafkaConfig.brokers).toEqual([
'kafka1:9092',
'kafka2:9092',
]);
expect(devConfig.kafkaConfig.ssl).toBeTruthy();
expect(devConfig.kafkaConfig.sasl).toStrictEqual({
mechanism: 'plain',
username: 'username',
password: 'password',
});
expect(devConfig.kafkaConfig.authenticationTimeout).toBe(20000);
expect(devConfig.kafkaConfig.connectionTimeout).toBe(1500);
expect(devConfig.kafkaConfig.requestTimeout).toBe(20000);
expect(devConfig.kafkaConfig.enforceRequestTimeout).toBeFalsy();
expect(devConfig.kafkaConfig.retry).toStrictEqual({
maxRetryTime: 20000,
initialRetryTime: 200,
factor: 0.4,
multiplier: 4,
retries: 10,
});
// Publisher configuration
expect(devConfig.kafkaPublisherConfigs.length).toBe(2);
expect(devConfig.kafkaPublisherConfigs).toEqual([
{
backstageTopic: 'fake1',
kafkaTopic: 'topic-A',
producerConfig: {
allowAutoTopicCreation: true,
metadataMaxAge: 400000,
transactionTimeout: 30000,
idempotent: true,
maxInFlightRequests: 5,
retry: {
maxRetryTime: 15000,
initialRetryTime: 100,
factor: 0.2,
multiplier: 2,
retries: 5,
},
},
},
{
backstageTopic: 'fake2',
kafkaTopic: 'topic-B',
producerConfig: {
allowAutoTopicCreation: undefined,
metadataMaxAge: undefined,
transactionTimeout: undefined,
idempotent: undefined,
maxInFlightRequests: undefined,
retry: {},
},
},
]);
});
it('should handle HumanDuration and string values for durations and timeouts', () => {
const config = new ConfigReader({
events: {
modules: {
kafka: {
kafkaPublishingEventConsumer: {
dev: {
clientId: 'backstage-events',
brokers: ['kafka1:9092', 'kafka2:9092'],
retry: {
maxRetryTime: { seconds: 1 },
initialRetryTime: { minutes: 1 },
factor: 0.4,
multiplier: 4,
retries: 10,
},
authenticationTimeout: { hours: 1 },
connectionTimeout: { days: 1 },
topics: [
{
topic: 'fake1',
kafka: {
topic: 'topic-A',
metadataMaxAge: { seconds: 300 },
transactionTimeout: '30s',
},
},
],
requestTimeout: '1m',
},
},
},
},
},
});
const publisherConfigs = readPublisherConfig(config);
expect(publisherConfigs).toBeDefined();
expect(Array.isArray(publisherConfigs)).toBe(true);
expect(publisherConfigs).toHaveLength(1);
const devConfig = publisherConfigs[0];
expect(devConfig.instance).toBe('dev');
// Client configuration
expect(devConfig.kafkaConfig.clientId).toEqual('backstage-events');
expect(devConfig.kafkaConfig.brokers).toEqual([
'kafka1:9092',
'kafka2:9092',
]);
expect(devConfig.kafkaConfig.authenticationTimeout).toBe(3600000);
expect(devConfig.kafkaConfig.connectionTimeout).toBe(86400000);
expect(devConfig.kafkaConfig.requestTimeout).toBe(60000);
expect(devConfig.kafkaConfig.retry).toStrictEqual({
maxRetryTime: 1000,
initialRetryTime: 60000,
factor: 0.4,
multiplier: 4,
retries: 10,
});
// Publisher configuration
expect(devConfig.kafkaPublisherConfigs.length).toBe(1);
expect(
devConfig.kafkaPublisherConfigs[0].producerConfig.metadataMaxAge,
).toBe(300000);
expect(
devConfig.kafkaPublisherConfigs[0].producerConfig.transactionTimeout,
).toBe(30000);
});
it('should handle multiple instances', () => {
const config = new ConfigReader({
events: {
modules: {
kafka: {
kafkaPublishingEventConsumer: {
dev: {
clientId: 'backstage-dev',
brokers: ['kafka-dev:9092'],
topics: [
{
topic: 'dev-topic',
kafka: {
topic: 'kafka-dev-topic',
},
},
],
},
prod: {
clientId: 'backstage-prod',
brokers: ['kafka-prod1:9092', 'kafka-prod2:9092'],
topics: [
{
topic: 'prod-topic',
kafka: {
topic: 'kafka-prod-topic',
},
},
],
},
},
},
},
},
});
const publisherConfigs = readPublisherConfig(config);
expect(publisherConfigs).toBeDefined();
expect(Array.isArray(publisherConfigs)).toBe(true);
expect(publisherConfigs).toHaveLength(2);
const devConfig = publisherConfigs.find(c => c.instance === 'dev')!;
expect(devConfig.kafkaConfig.clientId).toBe('backstage-dev');
expect(devConfig.kafkaConfig.brokers).toEqual(['kafka-dev:9092']);
expect(devConfig.kafkaPublisherConfigs).toHaveLength(1);
expect(devConfig.kafkaPublisherConfigs[0].backstageTopic).toBe('dev-topic');
const prodConfig = publisherConfigs.find(c => c.instance === 'prod')!;
expect(prodConfig.kafkaConfig.clientId).toBe('backstage-prod');
expect(prodConfig.kafkaConfig.brokers).toEqual([
'kafka-prod1:9092',
'kafka-prod2:9092',
]);
expect(prodConfig.kafkaPublisherConfigs).toHaveLength(1);
expect(prodConfig.kafkaPublisherConfigs[0].backstageTopic).toBe(
'prod-topic',
);
});
});
@@ -0,0 +1,82 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config } from '@backstage/config';
import {
readKafkaConfig,
readOptionalHumanDurationInMs,
readRetryConfig,
} from '../utils/config';
import { KafkaConfig, ProducerConfig } from 'kafkajs';
export interface KafkaPublisherConfig {
backstageTopic: string;
kafkaTopic: string;
producerConfig: ProducerConfig;
}
export interface KafkaPublishingEventConsumerConfig {
instance: string;
kafkaConfig: KafkaConfig;
kafkaPublisherConfigs: KafkaPublisherConfig[];
}
const CONFIG_PREFIX_PUBLISHER =
'events.modules.kafka.kafkaPublishingEventConsumer';
export const readPublisherConfig = (
config: Config,
): KafkaPublishingEventConsumerConfig[] => {
const publishers = config.getOptionalConfig(CONFIG_PREFIX_PUBLISHER);
return (
publishers?.keys()?.map(publisherKey => {
const publisherConfig = publishers.getConfig(publisherKey);
return {
instance: publisherKey,
kafkaConfig: readKafkaConfig(publisherConfig),
kafkaPublisherConfigs: publisherConfig
.getConfigArray('topics')
.map(topicConfig => {
return {
backstageTopic: topicConfig.getString('topic'),
kafkaTopic: topicConfig.getString('kafka.topic'),
producerConfig: {
allowAutoTopicCreation: topicConfig.getOptionalBoolean(
'kafka.allowAutoTopicCreation',
),
metadataMaxAge: readOptionalHumanDurationInMs(
topicConfig,
'kafka.metadataMaxAge',
),
transactionTimeout: readOptionalHumanDurationInMs(
topicConfig,
'kafka.transactionTimeout',
),
idempotent: topicConfig.getOptionalBoolean('kafka.idempotent'),
maxInFlightRequests: topicConfig.getOptionalNumber(
'kafka.maxInFlightRequests',
),
retry: readRetryConfig(
topicConfig.getOptionalConfig('kafka.retry'),
),
},
};
}),
};
}) ?? []
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { eventsModuleKafkaPublishingEventConsumer } from './module';
@@ -0,0 +1,122 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createServiceFactory } from '@backstage/backend-plugin-api';
import { mockServices, startTestBackend } from '@backstage/backend-test-utils';
import { eventsServiceRef } from '@backstage/plugin-events-node';
import { TestEventsService } from '@backstage/plugin-events-backend-test-utils';
import { eventsModuleKafkaPublishingEventConsumer } from './module';
import { KafkaPublishingEventConsumer } from './KafkaPublishingEventConsumer';
jest.mock('./KafkaPublishingEventConsumer');
describe('eventsModuleKafkaPublishingEventConsumer', () => {
it('should be correctly wired and set up', async () => {
const events = new TestEventsService();
const eventsServiceFactory = createServiceFactory({
service: eventsServiceRef,
deps: {},
async factory({}) {
return events;
},
});
const mockKafkaPublishingEventConsumer = {
start: jest.fn(),
shutdown: jest.fn(),
} as unknown as KafkaPublishingEventConsumer;
jest
.mocked(KafkaPublishingEventConsumer.fromConfig)
.mockReturnValue([mockKafkaPublishingEventConsumer]);
await startTestBackend({
features: [
eventsServiceFactory,
eventsModuleKafkaPublishingEventConsumer,
mockServices.rootConfig.factory({
data: {
events: {
modules: {
kafka: {
kafkaPublishingEventConsumer: {
dev: {
clientId: 'backstage-events',
brokers: ['kafka1:9092', 'kafka2:9092'],
topics: [
{
topic: 'fake1',
kafka: {
topic: 'topic-A',
},
},
{
topic: 'fake2',
kafka: {
topic: 'topic-B',
},
},
],
},
},
},
},
},
},
}),
],
});
// Verify that the Kafka publishing event consumer was started
expect(mockKafkaPublishingEventConsumer.start).toHaveBeenCalled();
// Verify that the shutdown hook was registered (but not called yet)
expect(mockKafkaPublishingEventConsumer.shutdown).not.toHaveBeenCalled();
});
it('should handle empty configuration gracefully', async () => {
const events = new TestEventsService();
const eventsServiceFactory = createServiceFactory({
service: eventsServiceRef,
deps: {},
async factory({}) {
return events;
},
});
jest.mocked(KafkaPublishingEventConsumer.fromConfig).mockReturnValue([]);
await startTestBackend({
features: [
eventsServiceFactory,
eventsModuleKafkaPublishingEventConsumer,
mockServices.rootConfig.factory({
data: {
events: {
modules: {
kafka: {
// No kafkaPublishingEventConsumer config
},
},
},
},
}),
],
});
// Verify that fromConfig was called but returned empty array
expect(KafkaPublishingEventConsumer.fromConfig).toHaveBeenCalled();
});
});
@@ -0,0 +1,56 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
coreServices,
createBackendModule,
} from '@backstage/backend-plugin-api';
import { eventsServiceRef } from '@backstage/plugin-events-node';
import { KafkaPublishingEventConsumer } from './KafkaPublishingEventConsumer';
/**
* Reads internal Backstage events and forwards them to Kafka topics.
*
* @public
*/
export const eventsModuleKafkaPublishingEventConsumer = createBackendModule({
pluginId: 'events',
moduleId: 'kafka-publishing-event-consumer',
register(env) {
env.registerInit({
deps: {
config: coreServices.rootConfig,
events: eventsServiceRef,
logger: coreServices.logger,
lifecycle: coreServices.rootLifecycle,
},
async init({ config, logger, events, lifecycle }) {
const consumers = KafkaPublishingEventConsumer.fromConfig({
config,
events,
logger,
});
lifecycle.addStartupHook(async () => {
await Promise.all(consumers.map(consumer => consumer.start()));
});
lifecycle.addShutdownHook(async () => {
await Promise.all(consumers.map(consumer => consumer.shutdown()));
});
},
});
},
});
@@ -13,14 +13,22 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createBackendFeatureLoader } from '@backstage/backend-plugin-api';
import { eventsModuleKafkaConsumingEventPublisher } from './KafkaConsumingEventPublisher';
import { eventsModuleKafkaPublishingEventConsumer } from './KafkaPublishingEventConsumer';
/**
* The module "kafka" for the Backstage backend plugin "events"
* adding an Kafka-based publisher,
* receiving events from an Kafka topic and passing it to the
* internal event broker.
* adding Kafka-based event handling:
* - Consumer: receives events from Kafka topics and passes them to the internal event broker
* - Publisher: receives internal events and publishes them to Kafka topics
*
* @packageDocumentation
*/
export { eventsModuleKafkaConsumingEventPublisher as default } from './service/eventsModuleKafkaConsumingEventPublisher';
export default createBackendFeatureLoader({
*loader() {
yield eventsModuleKafkaConsumingEventPublisher;
yield eventsModuleKafkaPublishingEventConsumer;
},
});
@@ -1,132 +0,0 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { KafkaConsumerClient } from './KafkaConsumerClient';
import { ConfigReader } from '@backstage/config';
import { KafkaConsumingEventPublisher } from './KafkaConsumingEventPublisher';
import { mockServices } from '@backstage/backend-test-utils';
jest.mock('kafkajs');
jest.mock('./KafkaConsumingEventPublisher');
describe('KafkaConsumerClient', () => {
const mockLogger = mockServices.logger.mock();
const mockEvents = mockServices.events.mock();
const mockConfig = new ConfigReader({
events: {
modules: {
kafka: {
kafkaConsumingEventPublisher: {
clientId: 'backstage-events',
brokers: ['kafka1:9092', 'kafka2:9092'],
topics: [
{
topic: 'fake1',
kafka: {
topics: ['topic-A'],
groupId: 'my-group',
},
},
{
topic: 'fake2',
kafka: {
topics: ['topic-B'],
groupId: 'my-group',
},
},
],
},
},
},
},
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should create an instance from config', () => {
const client = KafkaConsumerClient.fromConfig({
config: mockConfig,
events: mockEvents,
logger: mockLogger,
});
expect(client).toBeInstanceOf(KafkaConsumerClient);
});
it('should not create an instance from config', () => {
const client = KafkaConsumerClient.fromConfig({
config: new ConfigReader({}),
events: mockEvents,
logger: mockLogger,
});
expect(client).toBeUndefined();
});
it('should create a consumer for each topic from config', () => {
KafkaConsumerClient.fromConfig({
config: mockConfig,
events: mockEvents,
logger: mockLogger,
});
expect(KafkaConsumingEventPublisher.fromConfig).toHaveBeenCalledTimes(2);
});
it('should start all consumers', async () => {
const mockConsumer = {
start: jest.fn().mockResolvedValue(undefined),
};
(KafkaConsumingEventPublisher.fromConfig as jest.Mock).mockReturnValue(
mockConsumer,
);
const client = KafkaConsumerClient.fromConfig({
config: mockConfig,
events: mockEvents,
logger: mockLogger,
});
expect(client).toBeDefined();
await client?.start();
expect(mockConsumer.start).toHaveBeenCalled();
});
it('should shutdown all consumers', async () => {
const mockConsumer = {
shutdown: jest.fn().mockResolvedValue(undefined),
};
(KafkaConsumingEventPublisher.fromConfig as jest.Mock).mockReturnValue(
mockConsumer,
);
const client = KafkaConsumerClient.fromConfig({
config: mockConfig,
events: mockEvents,
logger: mockLogger,
});
expect(client).toBeDefined();
await client?.shutdown();
expect(mockConsumer.shutdown).toHaveBeenCalled();
});
});
@@ -1,77 +0,0 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LoggerService } from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import { EventsService } from '@backstage/plugin-events-node';
import { Kafka } from 'kafkajs';
import { KafkaEventSourceConfig, readConfig } from './config';
import { KafkaConsumingEventPublisher } from './KafkaConsumingEventPublisher';
import { loggerServiceAdapter } from './LoggerServiceAdapter';
/**
* KafkaConsumerClient
*
* This class creates the Kafka client that will be used to create the KafkaConsumingEventPublisher
*/
export class KafkaConsumerClient {
private readonly kafka: Kafka;
private readonly consumers: KafkaConsumingEventPublisher[];
static fromConfig(options: {
config: Config;
events: EventsService;
logger: LoggerService;
}): KafkaConsumerClient | undefined {
const kafkaConfig = readConfig(options.config);
if (!kafkaConfig) {
options.logger.info(
'Kafka consumer not configured, skipping initialization',
);
return undefined;
}
return new KafkaConsumerClient(options.logger, options.events, kafkaConfig);
}
private constructor(
logger: LoggerService,
events: EventsService,
config: KafkaEventSourceConfig,
) {
this.kafka = new Kafka({
...config.kafkaConfig,
logCreator: loggerServiceAdapter(logger),
});
this.consumers = config.kafkaConsumerConfigs.map(consumerConfig =>
KafkaConsumingEventPublisher.fromConfig({
kafkaClient: this.kafka,
config: consumerConfig,
logger,
events,
}),
);
}
async start(): Promise<void> {
this.consumers.map(async consumer => await consumer.start());
}
async shutdown(): Promise<void> {
this.consumers.map(async consumer => await consumer.shutdown());
}
}
@@ -1,92 +0,0 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { KafkaConsumingEventPublisher } from './KafkaConsumingEventPublisher';
import { Kafka } from 'kafkajs';
import { KafkaConsumerConfig } from './config';
import { mockServices } from '@backstage/backend-test-utils';
jest.mock('kafkajs');
describe('KafkaConsumingEventPublisher', () => {
const mockLogger = mockServices.logger.mock();
const mockEvents = mockServices.events.mock();
const mockConsumer = {
connect: jest.fn(),
disconnect: jest.fn(),
subscribe: jest.fn(),
run: jest.fn(),
};
const mockKafkaClient = {
consumer: jest.fn().mockReturnValue(mockConsumer),
} as unknown as Kafka;
const kafkaConsumerConfig: KafkaConsumerConfig = {
consumerConfig: {
groupId: 'test-group',
},
consumerSubscribeTopics: {
topics: ['test-topic'],
},
backstageTopic: 'backstage-topic',
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should create an instance from config', () => {
const consumer = KafkaConsumingEventPublisher.fromConfig({
kafkaClient: mockKafkaClient,
config: kafkaConsumerConfig,
events: mockEvents,
logger: mockLogger,
});
expect(consumer).toBeInstanceOf(KafkaConsumingEventPublisher);
});
it('should start the consumer', async () => {
const consumer = KafkaConsumingEventPublisher.fromConfig({
kafkaClient: mockKafkaClient,
config: kafkaConsumerConfig,
events: mockEvents,
logger: mockLogger,
});
await consumer.start();
expect(mockConsumer.connect).toHaveBeenCalled();
expect(mockConsumer.subscribe).toHaveBeenCalledWith(
kafkaConsumerConfig.consumerSubscribeTopics,
);
expect(mockConsumer.run).toHaveBeenCalled();
});
it('should shutdown the consumer', async () => {
const consumer = KafkaConsumingEventPublisher.fromConfig({
kafkaClient: mockKafkaClient,
config: kafkaConsumerConfig,
events: mockEvents,
logger: mockLogger,
});
await consumer.shutdown();
expect(mockConsumer.disconnect).toHaveBeenCalled();
});
});
@@ -1,109 +0,0 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LoggerService } from '@backstage/backend-plugin-api';
import { EventParams, EventsService } from '@backstage/plugin-events-node';
import { Consumer, ConsumerSubscribeTopics, IHeaders, Kafka } from 'kafkajs';
import { KafkaConsumerConfig } from './config';
type EventMetadata = EventParams['metadata'];
/**
* This class subscribes to Kafka topics and publishes events received to the registered subscriber.
* The message payload will be used as the event payload and passed to the subscribers.
*/
export class KafkaConsumingEventPublisher {
private readonly kafkaConsumer: Consumer;
private readonly consumerSubscribeTopics: ConsumerSubscribeTopics;
private readonly backstageTopic: string;
private readonly logger: LoggerService;
static fromConfig(env: {
kafkaClient: Kafka;
config: KafkaConsumerConfig;
events: EventsService;
logger: LoggerService;
}): KafkaConsumingEventPublisher {
return new KafkaConsumingEventPublisher(
env.kafkaClient,
env.logger,
env.events,
env.config,
);
}
private readonly events: EventsService;
private constructor(
kafkaClient: Kafka,
logger: LoggerService,
events: EventsService,
config: KafkaConsumerConfig,
) {
this.events = events;
this.kafkaConsumer = kafkaClient.consumer(config.consumerConfig);
this.consumerSubscribeTopics = config.consumerSubscribeTopics;
this.backstageTopic = config.backstageTopic;
const id = `events.kafka.publisher:${this.backstageTopic}`;
this.logger = logger.child({
class: KafkaConsumingEventPublisher.prototype.constructor.name,
groupId: config.consumerConfig.groupId,
kafkaTopics: config.consumerSubscribeTopics.topics.toString(),
backstageTopic: config.backstageTopic,
taskId: id,
});
}
async start(): Promise<void> {
try {
await this.kafkaConsumer.connect();
await this.kafkaConsumer.subscribe(this.consumerSubscribeTopics);
await this.kafkaConsumer.run({
eachMessage: async ({ message }) => {
this.events.publish({
topic: this.backstageTopic,
eventPayload: JSON.parse(message.value?.toString()!),
metadata: this.convertHeadersToMetadata(message.headers),
});
},
});
} catch (error: any) {
this.logger.error('Kafka consumer connection failed ', error);
}
}
async shutdown(): Promise<void> {
await this.kafkaConsumer.disconnect();
}
private convertHeadersToMetadata = (
headers: IHeaders | undefined,
): EventParams['metadata'] => {
if (!headers) return undefined;
const metadata: EventMetadata = {};
Object.entries(headers).forEach(([key, value]) => {
// If value is an array use toString() on all values converting any Buffer types to valid strings
if (Array.isArray(value)) metadata[key] = value.map(v => v.toString());
// Always return the values using toString() to catch all Buffer types that should be converted to strings
else metadata[key] = value?.toString();
});
return metadata;
};
}
@@ -1,250 +0,0 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfigReader } from '@backstage/config';
import { readConfig } from './config';
describe('readConfig', () => {
it('not configured', () => {
const publisherConfigs = readConfig(new ConfigReader({}));
expect(publisherConfigs).toBeUndefined();
});
it('only required fields configured', () => {
const config = new ConfigReader({
events: {
modules: {
kafka: {
kafkaConsumingEventPublisher: {
clientId: 'backstage-events',
brokers: ['kafka1:9092', 'kafka2:9092'],
topics: [
{
topic: 'fake1',
kafka: {
topics: ['topic-A'],
groupId: 'my-group',
},
},
{
topic: 'fake2',
kafka: {
topics: ['topic-B'],
groupId: 'my-group',
},
},
],
},
},
},
},
});
const publisherConfigs = readConfig(config);
expect(publisherConfigs).toBeDefined();
expect(publisherConfigs?.kafkaConsumerConfigs.length).toBe(2);
expect(publisherConfigs?.kafkaConfig.clientId).toEqual('backstage-events');
expect(publisherConfigs?.kafkaConfig.brokers).toEqual([
'kafka1:9092',
'kafka2:9092',
]);
expect(publisherConfigs?.kafkaConsumerConfigs[0].backstageTopic).toEqual(
'fake1',
);
expect(
publisherConfigs?.kafkaConsumerConfigs[0].consumerConfig.groupId,
).toEqual('my-group');
expect(
publisherConfigs?.kafkaConsumerConfigs[0].consumerSubscribeTopics.topics,
).toEqual(['topic-A']);
});
it('all fields configured', () => {
const config = new ConfigReader({
events: {
modules: {
kafka: {
kafkaConsumingEventPublisher: {
clientId: 'backstage-events',
brokers: ['kafka1:9092', 'kafka2:9092'],
ssl: true,
sasl: {
mechanism: 'plain',
username: 'username',
password: 'password',
},
retry: {
maxRetryTime: { milliseconds: 20000 },
initialRetryTime: { milliseconds: 200 },
factor: '0.4',
multiplier: '4',
retries: '10',
},
authenticationTimeout: { milliseconds: 20000 },
connectionTimeout: { milliseconds: 1500 },
requestTimeout: { milliseconds: 20000 },
enforceRequestTimeout: false,
topics: [
{
topic: 'fake1',
kafka: {
topics: ['topic-A'],
groupId: 'my-group',
sessionTimeout: { milliseconds: 20000 },
rebalanceTimeout: { milliseconds: 50000 },
heartbeatInterval: { milliseconds: 2000 },
metadataMaxAge: { milliseconds: 400000 },
maxBytesPerPartition: 50000,
minBytes: 2,
maxBytes: 500000,
maxWaitTime: { milliseconds: 4000 },
},
},
{
topic: 'fake2',
kafka: {
topics: ['topic-B'],
groupId: 'my-group',
},
},
],
},
},
},
},
});
const publisherConfigs = readConfig(config);
expect(publisherConfigs).toBeDefined();
// Client configuration
expect(publisherConfigs?.kafkaConfig.clientId).toEqual('backstage-events');
expect(publisherConfigs?.kafkaConfig.brokers).toEqual([
'kafka1:9092',
'kafka2:9092',
]);
expect(publisherConfigs?.kafkaConfig.ssl).toBeTruthy();
expect(publisherConfigs?.kafkaConfig.sasl).toStrictEqual({
mechanism: 'plain',
username: 'username',
password: 'password',
});
expect(publisherConfigs?.kafkaConfig.authenticationTimeout).toBe(20000);
expect(publisherConfigs?.kafkaConfig.connectionTimeout).toBe(1500);
expect(publisherConfigs?.kafkaConfig.requestTimeout).toBe(20000);
expect(publisherConfigs?.kafkaConfig.enforceRequestTimeout).toBeFalsy();
expect(publisherConfigs?.kafkaConfig.retry).toStrictEqual({
maxRetryTime: 20000,
initialRetryTime: 200,
factor: 0.4,
multiplier: 4,
retries: 10,
});
// Consumer configuration
expect(publisherConfigs?.kafkaConsumerConfigs.length).toBe(2);
expect(publisherConfigs?.kafkaConsumerConfigs[0].backstageTopic).toEqual(
'fake1',
);
expect(
publisherConfigs?.kafkaConsumerConfigs[0].consumerConfig.groupId,
).toEqual('my-group');
expect(
publisherConfigs?.kafkaConsumerConfigs[0].consumerSubscribeTopics.topics,
).toEqual(['topic-A']);
expect(
publisherConfigs?.kafkaConsumerConfigs[0].consumerConfig.sessionTimeout,
).toBe(20000);
expect(
publisherConfigs?.kafkaConsumerConfigs[0].consumerConfig.rebalanceTimeout,
).toBe(50000);
expect(
publisherConfigs?.kafkaConsumerConfigs[0].consumerConfig
.heartbeatInterval,
).toBe(2000);
expect(
publisherConfigs?.kafkaConsumerConfigs[0].consumerConfig.metadataMaxAge,
).toBe(400000);
expect(
publisherConfigs?.kafkaConsumerConfigs[0].consumerConfig
.maxBytesPerPartition,
).toBe(50000);
expect(
publisherConfigs?.kafkaConsumerConfigs[0].consumerConfig.minBytes,
).toBe(2);
expect(
publisherConfigs?.kafkaConsumerConfigs[0].consumerConfig.maxBytes,
).toBe(500000);
expect(
publisherConfigs?.kafkaConsumerConfigs[0].consumerConfig.maxWaitTimeInMs,
).toBe(4000);
});
it('should handle HumanDuration and string values for durations and timeouts', () => {
const config = new ConfigReader({
events: {
modules: {
kafka: {
kafkaConsumingEventPublisher: {
clientId: 'backstage-events',
brokers: ['kafka1:9092', 'kafka2:9092'],
retry: {
maxRetryTime: { seconds: 1 },
initialRetryTime: { minutes: 1 },
factor: 0.4,
multiplier: 4,
retries: 10,
},
authenticationTimeout: { hours: 1 },
connectionTimeout: { days: 1 },
topics: [],
requestTimeout: '1m',
},
},
},
},
});
const publisherConfigs = readConfig(config);
expect(publisherConfigs).toBeDefined();
// Client configuration
expect(publisherConfigs?.kafkaConfig.clientId).toEqual('backstage-events');
expect(publisherConfigs?.kafkaConfig.brokers).toEqual([
'kafka1:9092',
'kafka2:9092',
]);
expect(publisherConfigs?.kafkaConfig.authenticationTimeout).toBe(3600000);
expect(publisherConfigs?.kafkaConfig.connectionTimeout).toBe(86400000);
expect(publisherConfigs?.kafkaConfig.requestTimeout).toBe(60000);
expect(publisherConfigs?.kafkaConfig.retry).toStrictEqual({
maxRetryTime: 1000,
initialRetryTime: 60000,
factor: 0.4,
multiplier: 4,
retries: 10,
});
// Consumer configuration
expect(publisherConfigs?.kafkaConsumerConfigs.length).toBe(0);
});
});
@@ -1,153 +0,0 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config, readDurationFromConfig } from '@backstage/config';
import { durationToMilliseconds } from '@backstage/types';
import { ConsumerConfig, ConsumerSubscribeTopics, KafkaConfig } from 'kafkajs';
export interface KafkaConsumerConfig {
backstageTopic: string;
consumerConfig: ConsumerConfig;
consumerSubscribeTopics: ConsumerSubscribeTopics;
}
export interface KafkaEventSourceConfig {
kafkaConfig: KafkaConfig;
kafkaConsumerConfigs: KafkaConsumerConfig[];
}
const CONFIG_PREFIX_PUBLISHER =
'events.modules.kafka.kafkaConsumingEventPublisher';
/**
* Reads an optional HumanDuration from the config and returns the value in milliseconds if the key is defined.
*
* @param config - The configuration object to read from.
* @param key - The key to look up in the configuration.
* @returns The duration in milliseconds, or undefined if the key is not defined.
*/
const readOptionalHumanDurationInMs = (
config: Config,
key: string,
): number | undefined => {
const humanDuration = config.has(key)
? readDurationFromConfig(config, { key })
: undefined;
if (!humanDuration) return undefined;
return durationToMilliseconds(humanDuration);
};
export const readConfig = (
config: Config,
): KafkaEventSourceConfig | undefined => {
const kafkaConfig = config.getOptionalConfig(CONFIG_PREFIX_PUBLISHER);
if (!kafkaConfig) {
return undefined;
}
const clientId = kafkaConfig.getString('clientId');
const brokers = kafkaConfig.getStringArray('brokers');
const authenticationTimeout = readOptionalHumanDurationInMs(
kafkaConfig,
'authenticationTimeout',
);
const connectionTimeout = readOptionalHumanDurationInMs(
kafkaConfig,
'connectionTimeout',
);
const requestTimeout = readOptionalHumanDurationInMs(
kafkaConfig,
'requestTimeout',
);
const enforceRequestTimeout = kafkaConfig.getOptionalBoolean(
'enforceRequestTimeout',
);
const ssl = kafkaConfig.getOptional('ssl') as KafkaConfig['ssl'];
const sasl = kafkaConfig.getOptional('sasl') as KafkaConfig['sasl'];
const retry: KafkaConfig['retry'] = {
maxRetryTime: readOptionalHumanDurationInMs(
kafkaConfig,
'retry.maxRetryTime',
),
initialRetryTime: readOptionalHumanDurationInMs(
kafkaConfig,
'retry.initialRetryTime',
),
factor: kafkaConfig.getOptionalNumber('retry.factor'),
multiplier: kafkaConfig.getOptionalNumber('retry.multiplier'),
retries: kafkaConfig.getOptionalNumber('retry.retries'),
};
const kafkaConsumerConfigs: KafkaConsumerConfig[] = kafkaConfig
.getConfigArray('topics')
.map(topic => {
return {
backstageTopic: topic.getString('topic'),
consumerConfig: {
groupId: topic.getString('kafka.groupId'),
sessionTimeout: readOptionalHumanDurationInMs(
topic,
'kafka.sessionTimeout',
),
rebalanceTimeout: readOptionalHumanDurationInMs(
topic,
'kafka.rebalanceTimeout',
),
heartbeatInterval: readOptionalHumanDurationInMs(
topic,
'kafka.heartbeatInterval',
),
metadataMaxAge: readOptionalHumanDurationInMs(
topic,
'kafka.metadataMaxAge',
),
maxBytesPerPartition: topic.getOptionalNumber(
'kafka.maxBytesPerPartition',
),
minBytes: topic.getOptionalNumber('kafka.minBytes'),
maxBytes: topic.getOptionalNumber('kafka.maxBytes'),
maxWaitTimeInMs: readOptionalHumanDurationInMs(
topic,
'kafka.maxWaitTime',
),
},
consumerSubscribeTopics: {
topics: topic.getStringArray('kafka.topics'),
},
};
});
return {
kafkaConfig: {
clientId,
brokers,
ssl,
sasl,
authenticationTimeout,
connectionTimeout,
requestTimeout,
enforceRequestTimeout,
retry,
},
kafkaConsumerConfigs,
};
};
@@ -1,85 +0,0 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createServiceFactory } from '@backstage/backend-plugin-api';
import { mockServices, startTestBackend } from '@backstage/backend-test-utils';
import { eventsServiceRef } from '@backstage/plugin-events-node';
import { TestEventsService } from '@backstage/plugin-events-backend-test-utils';
import { eventsModuleKafkaConsumingEventPublisher } from './eventsModuleKafkaConsumingEventPublisher';
import { KafkaConsumerClient } from '../publisher/KafkaConsumerClient';
jest.mock('../publisher/KafkaConsumerClient');
describe('eventsModuleKafkaConsumingEventPublisher', () => {
it('should be correctly wired and set up', async () => {
const events = new TestEventsService();
const eventsServiceFactory = createServiceFactory({
service: eventsServiceRef,
deps: {},
async factory({}) {
return events;
},
});
const mockKafkaConsumerClient = {
start: jest.fn(),
shutdown: jest.fn(),
};
(KafkaConsumerClient.fromConfig as jest.Mock).mockReturnValue(
mockKafkaConsumerClient,
);
await startTestBackend({
features: [
eventsServiceFactory,
eventsModuleKafkaConsumingEventPublisher,
mockServices.rootConfig.factory({
data: {
events: {
modules: {
kafka: {
kafkaConsumingEventPublisher: {
clientId: 'backstage-events',
brokers: ['kafka1:9092', 'kafka2:9092'],
topics: {
fake1: {
kafka: {
topics: ['topic-A'],
groupId: 'my-group',
},
},
fake2: {
kafka: {
topics: ['topic-B'],
groupId: 'my-group',
},
},
},
},
},
},
},
},
}),
],
});
// Verify that the Kafka consumer client was started
expect(mockKafkaConsumerClient.start).toHaveBeenCalled();
// Verify that the shutdown hook was registered
expect(mockKafkaConsumerClient.shutdown).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,235 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfigReader } from '@backstage/config';
import { readRetryConfig, readKafkaConfig } from './config';
describe('readRetryConfig', () => {
it('should return empty object when config is undefined', () => {
const result = readRetryConfig(undefined);
expect(result).toEqual({});
});
it('should return empty object when config is empty', () => {
const config = new ConfigReader({});
const result = readRetryConfig(config);
expect(result).toEqual({});
});
it('should read retry configuration with milliseconds', () => {
const config = new ConfigReader({
maxRetryTime: { milliseconds: 20000 },
initialRetryTime: { milliseconds: 200 },
factor: 0.4,
multiplier: 4,
retries: 10,
});
const result = readRetryConfig(config);
expect(result).toEqual({
maxRetryTime: 20000,
initialRetryTime: 200,
factor: 0.4,
multiplier: 4,
retries: 10,
});
});
it('should read retry configuration with string values', () => {
const config = new ConfigReader({
maxRetryTime: { seconds: 20 },
initialRetryTime: { minutes: 1 },
factor: '0.4',
multiplier: '4',
retries: '10',
});
const result = readRetryConfig(config);
expect(result).toEqual({
maxRetryTime: 20000,
initialRetryTime: 60000,
factor: 0.4,
multiplier: 4,
retries: 10,
});
});
it('should handle HumanDuration values', () => {
const config = new ConfigReader({
maxRetryTime: { hours: 1 },
initialRetryTime: { days: 1 },
});
const result = readRetryConfig(config);
expect(result).toEqual({
maxRetryTime: 3600000,
initialRetryTime: 86400000,
factor: undefined,
multiplier: undefined,
retries: undefined,
});
});
it('should handle partial configuration', () => {
const config = new ConfigReader({
maxRetryTime: { milliseconds: 15000 },
retries: 5,
});
const result = readRetryConfig(config);
expect(result).toEqual({
maxRetryTime: 15000,
initialRetryTime: undefined,
factor: undefined,
multiplier: undefined,
retries: 5,
});
});
});
describe('readKafkaConfig', () => {
it('should read minimal kafka configuration', () => {
const config = new ConfigReader({
clientId: 'test-client',
brokers: ['kafka1:9092', 'kafka2:9092'],
});
const result = readKafkaConfig(config);
expect(result).toEqual({
clientId: 'test-client',
brokers: ['kafka1:9092', 'kafka2:9092'],
authenticationTimeout: undefined,
connectionTimeout: undefined,
requestTimeout: undefined,
enforceRequestTimeout: undefined,
ssl: undefined,
sasl: undefined,
retry: {},
});
});
it('should read full kafka configuration with all optional fields', () => {
const config = new ConfigReader({
clientId: 'backstage-events',
brokers: ['kafka1:9092', 'kafka2:9092'],
authenticationTimeout: { milliseconds: 20000 },
connectionTimeout: { milliseconds: 1500 },
requestTimeout: { milliseconds: 20000 },
enforceRequestTimeout: false,
ssl: true,
sasl: {
mechanism: 'plain',
username: 'username',
password: 'password',
},
retry: {
maxRetryTime: { milliseconds: 20000 },
initialRetryTime: { milliseconds: 200 },
factor: 0.4,
multiplier: 4,
retries: 10,
},
});
const result = readKafkaConfig(config);
expect(result).toEqual({
clientId: 'backstage-events',
brokers: ['kafka1:9092', 'kafka2:9092'],
authenticationTimeout: 20000,
connectionTimeout: 1500,
requestTimeout: 20000,
enforceRequestTimeout: false,
ssl: true,
sasl: {
mechanism: 'plain',
username: 'username',
password: 'password',
},
retry: {
maxRetryTime: 20000,
initialRetryTime: 200,
factor: 0.4,
multiplier: 4,
retries: 10,
},
});
});
it('should handle HumanDuration values for timeouts', () => {
const config = new ConfigReader({
clientId: 'test-client',
brokers: ['kafka:9092'],
authenticationTimeout: { hours: 1 },
connectionTimeout: { days: 1 },
requestTimeout: { minutes: 5 },
});
const result = readKafkaConfig(config);
expect(result.authenticationTimeout).toBe(3600000);
expect(result.connectionTimeout).toBe(86400000);
expect(result.requestTimeout).toBe(300000);
});
it('should handle complex SSL configuration', () => {
const config = new ConfigReader({
clientId: 'secure-client',
brokers: ['secure-kafka:9093'],
ssl: {
rejectUnauthorized: false,
ca: 'ca-certificate',
key: 'client-key',
cert: 'client-cert',
},
});
const result = readKafkaConfig(config);
expect(result.ssl).toEqual({
rejectUnauthorized: false,
ca: 'ca-certificate',
key: 'client-key',
cert: 'client-cert',
});
});
it('should handle complex SASL configuration', () => {
const config = new ConfigReader({
clientId: 'sasl-client',
brokers: ['sasl-kafka:9094'],
sasl: {
mechanism: 'scram-sha-256',
username: 'kafka-user',
password: 'kafka-password',
authorizationIdentity: 'authz-user',
},
});
const result = readKafkaConfig(config);
expect(result.sasl).toEqual({
mechanism: 'scram-sha-256',
username: 'kafka-user',
password: 'kafka-password',
authorizationIdentity: 'authz-user',
});
});
});
@@ -0,0 +1,84 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config, readDurationFromConfig } from '@backstage/config';
import { durationToMilliseconds } from '@backstage/types';
import { KafkaConfig, RetryOptions } from 'kafkajs';
/**
* Reads an optional HumanDuration from the config and returns the value in milliseconds if the key is defined.
*
* @param config - The configuration object to read from.
* @param key - The key to look up in the configuration.
* @returns The duration in milliseconds, or undefined if the key is not defined.
*/
export const readOptionalHumanDurationInMs = (
config: Config,
key: string,
): number | undefined => {
const humanDuration = config.has(key)
? readDurationFromConfig(config, { key })
: undefined;
if (!humanDuration) return undefined;
return durationToMilliseconds(humanDuration);
};
/**
* Reads retry configuration options from the provided config object.
*
* @param config - The configuration object to read retry options from, or undefined.
* @returns A RetryOptions object with optional retry settings, or an empty object if config is undefined.
*/
export const readRetryConfig = (config: Config | undefined): RetryOptions => {
if (!config) {
return {};
}
return {
maxRetryTime: readOptionalHumanDurationInMs(config, 'maxRetryTime'),
initialRetryTime: readOptionalHumanDurationInMs(config, 'initialRetryTime'),
factor: config.getOptionalNumber('factor'),
multiplier: config.getOptionalNumber('multiplier'),
retries: config.getOptionalNumber('retries'),
};
};
/**
* Reads Kafka configuration from the provided config object.
*
* @param config - The configuration object containing Kafka settings.
* @returns A KafkaConfig object with all necessary Kafka connection and authentication settings.
*/
export const readKafkaConfig = (config: Config): KafkaConfig => {
return {
clientId: config.getString('clientId'),
brokers: config.getStringArray('brokers'),
authenticationTimeout: readOptionalHumanDurationInMs(
config,
'authenticationTimeout',
),
connectionTimeout: readOptionalHumanDurationInMs(
config,
'connectionTimeout',
),
requestTimeout: readOptionalHumanDurationInMs(config, 'requestTimeout'),
enforceRequestTimeout: config.getOptionalBoolean('enforceRequestTimeout'),
ssl: config.getOptional('ssl') as KafkaConfig['ssl'],
sasl: config.getOptional('sasl') as KafkaConfig['sasl'],
retry: readRetryConfig(config.getOptionalConfig('retry')),
};
};
@@ -0,0 +1,137 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { convertHeadersToMetadata, payloadToBuffer } from './kafkaTransformers';
describe('kafka-transformers', () => {
describe('convertHeadersToMetadata', () => {
it('should return undefined when headers is undefined', () => {
const result = convertHeadersToMetadata(undefined);
expect(result).toBeUndefined();
});
it('should convert string headers to metadata', () => {
const headers = {
'content-type': 'application/json',
'user-id': '12345',
};
const result = convertHeadersToMetadata(headers);
expect(result).toEqual({
'content-type': 'application/json',
'user-id': '12345',
});
});
it('should convert Buffer headers to string metadata', () => {
const headers = {
'content-type': Buffer.from('application/json'),
'correlation-id': Buffer.from('abc-123'),
};
const result = convertHeadersToMetadata(headers);
expect(result).toEqual({
'content-type': 'application/json',
'correlation-id': 'abc-123',
});
});
it('should convert array headers to string array metadata', () => {
const headers = {
tags: ['tag1', 'tag2'],
'buffer-tags': [Buffer.from('tag3'), Buffer.from('tag4')],
'mixed-tags': ['tag5', Buffer.from('tag6')],
};
const result = convertHeadersToMetadata(headers);
expect(result).toEqual({
tags: ['tag1', 'tag2'],
'buffer-tags': ['tag3', 'tag4'],
'mixed-tags': ['tag5', 'tag6'],
});
});
it('should handle mixed header types', () => {
const headers = {
'string-header': 'value',
'buffer-header': Buffer.from('buffer-value'),
'array-header': ['item1', Buffer.from('item2')],
'undefined-header': undefined,
};
const result = convertHeadersToMetadata(headers);
expect(result).toEqual({
'string-header': 'value',
'buffer-header': 'buffer-value',
'array-header': ['item1', 'item2'],
'undefined-header': undefined,
});
});
it('should handle empty headers object', () => {
const headers = {};
const result = convertHeadersToMetadata(headers);
expect(result).toEqual({});
});
});
describe('payloadToBuffer', () => {
it('should return the same Buffer when payload is already a Buffer', () => {
const originalBuffer = Buffer.from('test data');
const result = payloadToBuffer(originalBuffer);
expect(result).toBe(originalBuffer);
expect(Buffer.isBuffer(result)).toBe(true);
});
it('should convert string to Buffer', () => {
const payload = 'hello world';
const result = payloadToBuffer(payload);
expect(Buffer.isBuffer(result)).toBe(true);
expect(result.toString()).toBe('hello world');
});
it('should convert object to JSON Buffer', () => {
const payload = { name: 'John', age: 30 };
const result = payloadToBuffer(payload);
expect(Buffer.isBuffer(result)).toBe(true);
expect(JSON.parse(result.toString())).toEqual(payload);
});
it('should convert array to JSON Buffer', () => {
const payload = [1, 2, 3, 'test'];
const result = payloadToBuffer(payload);
expect(Buffer.isBuffer(result)).toBe(true);
expect(JSON.parse(result.toString())).toEqual(payload);
});
it('should convert primitives to JSON Buffer', () => {
expect(payloadToBuffer(42).toString()).toBe('42');
expect(payloadToBuffer(true).toString()).toBe('true');
expect(payloadToBuffer(null).toString()).toBe('null');
});
});
});
@@ -0,0 +1,49 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EventParams } from '@backstage/plugin-events-node';
import { IHeaders } from 'kafkajs';
type EventMetadata = EventParams['metadata'];
export const convertHeadersToMetadata = (
headers: IHeaders | undefined,
): EventMetadata => {
if (!headers) return undefined;
const metadata: EventMetadata = {};
Object.entries(headers).forEach(([key, value]) => {
// If value is an array use toString() on all values converting any Buffer types to valid strings
if (Array.isArray(value)) metadata[key] = value.map(v => v.toString());
// Always return the values using toString() to catch all Buffer types that should be converted to strings
else metadata[key] = value?.toString();
});
return metadata;
};
export const payloadToBuffer = (payload: unknown): Buffer => {
if (Buffer.isBuffer(payload)) {
return payload;
}
if (typeof payload === 'string') {
return Buffer.from(payload, 'utf8'); // More explicit encoding
}
// Convert to JSON string then encode
return Buffer.from(JSON.stringify(payload), 'utf8');
};