Skip to content

AWS CloudWatch Event Sink Implementation #1965

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 37 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2e07dde
Add AWS CloudWatch integration through Event Listener
adnanhemani Jun 27, 2025
06eaca4
cleanup
adnanhemani Jun 27, 2025
1515fbb
spotlessapply
adnanhemani Jun 27, 2025
854501b
Added unit test with LocalStack
adnanhemani Jul 12, 2025
c1c94b2
typo
adnanhemani Jul 12, 2025
ab3c5f9
spotlessapply
adnanhemani Jul 12, 2025
ab9ccbe
Merge remote-tracking branch 'origin/main' into ahemani/cloudwatch_ev…
adnanhemani Jul 12, 2025
a641136
recompile from main
adnanhemani Jul 12, 2025
5a355d1
first revision change, based on review from @eric-maynard
adnanhemani Jul 16, 2025
bab4439
merge from origin/main
adnanhemani Jul 16, 2025
04c310a
spotlessapply
adnanhemani Jul 16, 2025
d4b44ff
Merge branch 'main' into ahemani/cloudwatch_event_listener
adnanhemani Jul 16, 2025
4d0554a
injected securitycontext and callcontext
adnanhemani Jul 17, 2025
cc715ad
todo
adnanhemani Jul 17, 2025
518aaaa
modify test
adnanhemani Jul 17, 2025
8758255
first draft of revision
adnanhemani Jul 20, 2025
f3f62a0
resolve comments from @eric-maynard and @snazy
adnanhemani Jul 21, 2025
d21dabc
refactor into separate package
adnanhemani Jul 21, 2025
9054511
typo
adnanhemani Jul 21, 2025
828760a
revising comments from @eric-maynard
adnanhemani Jul 22, 2025
ae79600
Merge branch 'main' into ahemani/cloudwatch_event_listener
adnanhemani Jul 22, 2025
9d47684
spotlessapply
adnanhemani Jul 22, 2025
025de74
revision on review from @singhpk234
adnanhemani Aug 4, 2025
e4ec3f8
resolve conflicts
adnanhemani Aug 4, 2025
491ea3a
resolve conflicts, pt. 2
adnanhemani Aug 4, 2025
d453660
spotlessapply
adnanhemani Aug 4, 2025
f89b0ae
spotlessapply again
adnanhemani Aug 4, 2025
e5c02b7
address comments from @RussellSpitzer
adnanhemani Aug 6, 2025
4f8a15b
merge from main
adnanhemani Aug 6, 2025
1305321
prior to manual test
adnanhemani Aug 13, 2025
27f28f4
addressing comments from @snazy
adnanhemani Aug 13, 2025
6b42071
Merge remote-tracking branch 'origin/main' into ahemani/cloudwatch_ev…
adnanhemani Aug 13, 2025
ec2bee8
merge from main
adnanhemani Aug 13, 2025
69b7feb
spotlesscheck
adnanhemani Aug 13, 2025
e8b5e93
documentation updates
adnanhemani Aug 13, 2025
3030d6a
review comments from @RussellSpitzer
adnanhemani Aug 13, 2025
b9abab6
removed mocked tests, as per review from @RussellSpitzer
adnanhemani Aug 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ jakarta-validation-api = { module = "jakarta.validation:jakarta.validation-api",
jakarta-ws-rs-api = { module = "jakarta.ws.rs:jakarta.ws.rs-api", version = "4.0.0" }
javax-servlet-api = { module = "javax.servlet:javax.servlet-api", version = "4.0.1" }
junit-bom = { module = "org.junit:junit-bom", version = "5.13.4" }
localstack = { module = "org.testcontainers:localstack", version = "1.19.7" }
logback-classic = { module = "ch.qos.logback:logback-classic", version = "1.5.18" }
micrometer-bom = { module = "io.micrometer:micrometer-bom", version = "1.15.3" }
microprofile-fault-tolerance-api = { module = "org.eclipse.microprofile.fault-tolerance:microprofile-fault-tolerance-api", version = "4.1.2" }
Expand Down
4 changes: 4 additions & 0 deletions runtime/service/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ dependencies {
implementation("software.amazon.awssdk:sts")
implementation("software.amazon.awssdk:iam-policy-builder")
implementation("software.amazon.awssdk:s3")
implementation("software.amazon.awssdk:cloudwatchlogs")
implementation("software.amazon.awssdk:apache-client") {
exclude("commons-logging", "commons-logging")
}
Expand Down Expand Up @@ -142,6 +143,9 @@ dependencies {
testImplementation("io.quarkus:quarkus-rest-client")
testImplementation("io.quarkus:quarkus-rest-client-jackson")
testImplementation("io.rest-assured:rest-assured")
testImplementation(libs.localstack)
testImplementation("org.testcontainers:testcontainers")
testImplementation(project(":polaris-container-spec-helper"))

testImplementation(libs.threeten.extra)
testImplementation(libs.hawkular.agent.prometheus.scraper)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
@ConfigMapping(prefix = "polaris.event-listener")
public interface PolarisEventListenerConfiguration {
/**
* The type of the event listener to use. Must be a registered {@link
* org.apache.polaris.service.events.PolarisEventListener} identifier.
* The type of the event listener to use. Must be a registered {@link PolarisEventListener}
* identifier.
*/
String type();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*/

package org.apache.polaris.service.events.jsonEventListener;

import java.util.HashMap;
import org.apache.polaris.service.events.AfterTableRefreshedEvent;
import org.apache.polaris.service.events.PolarisEventListener;

/**
* Abstract base class from which all event sinks that output events in JSON format can extend.
*
* <p>This class provides a common framework for transforming Polaris events into JSON format and
* sending them to various destinations. Concrete implementations should override the {@link
* #transformAndSendEvent(HashMap)} method to define how the JSON event data should be transmitted
* or stored.
*/
public abstract class JsonEventListener extends PolarisEventListener {
protected abstract void transformAndSendEvent(HashMap<String, Object> properties);

@Override
public void onAfterTableRefreshed(AfterTableRefreshedEvent event) {
HashMap<String, Object> properties = new HashMap<>();
properties.put("event_type", event.getClass().getSimpleName());
properties.put("table_identifier", event.tableIdentifier().toString());
transformAndSendEvent(properties);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*/

package org.apache.polaris.service.events.jsonEventListener.aws.cloudwatch;

/** Configuration interface for AWS CloudWatch event listener settings. */
public interface AwsCloudWatchConfiguration {
String awsCloudwatchlogGroup();

String awsCloudwatchlogStream();

String awsCloudwatchRegion();

boolean synchronousMode();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*/

package org.apache.polaris.service.events.jsonEventListener.aws.cloudwatch;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.smallrye.common.annotation.Identifier;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.SecurityContext;
import java.time.Clock;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import org.apache.polaris.core.context.CallContext;
import org.apache.polaris.service.events.jsonEventListener.JsonEventListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsAsyncClient;
import software.amazon.awssdk.services.cloudwatchlogs.model.CreateLogGroupRequest;
import software.amazon.awssdk.services.cloudwatchlogs.model.CreateLogGroupResponse;
import software.amazon.awssdk.services.cloudwatchlogs.model.CreateLogStreamRequest;
import software.amazon.awssdk.services.cloudwatchlogs.model.CreateLogStreamResponse;
import software.amazon.awssdk.services.cloudwatchlogs.model.InputLogEvent;
import software.amazon.awssdk.services.cloudwatchlogs.model.PutLogEventsRequest;
import software.amazon.awssdk.services.cloudwatchlogs.model.PutLogEventsResponse;
import software.amazon.awssdk.services.cloudwatchlogs.model.ResourceAlreadyExistsException;

@ApplicationScoped
@Identifier("aws-cloudwatch")
public class AwsCloudWatchEventListener extends JsonEventListener {
private static final Logger LOGGER = LoggerFactory.getLogger(AwsCloudWatchEventListener.class);
private final ObjectMapper objectMapper = new ObjectMapper();

private CloudWatchLogsAsyncClient client;

private final String logGroup;
private final String logStream;
private final Region region;
private final boolean synchronousMode;
private final Clock clock;

@Inject CallContext callContext;

@Context SecurityContext securityContext;

@Inject
public AwsCloudWatchEventListener(AwsCloudWatchConfiguration config, Clock clock) {
this.logStream = config.awsCloudwatchlogStream();
this.logGroup = config.awsCloudwatchlogGroup();
this.region = Region.of(config.awsCloudwatchRegion());
this.synchronousMode = config.synchronousMode();
this.clock = clock;
}

@PostConstruct
void start() {
this.client = createCloudWatchAsyncClient();
ensureLogGroupAndStream();
}

protected CloudWatchLogsAsyncClient createCloudWatchAsyncClient() {
return CloudWatchLogsAsyncClient.builder().region(region).build();
}

private void ensureLogGroupAndStream() {
try {
CompletableFuture<CreateLogGroupResponse> future =
client.createLogGroup(CreateLogGroupRequest.builder().logGroupName(logGroup).build());
future.join();
} catch (CompletionException e) {
if (e.getCause() instanceof ResourceAlreadyExistsException) {
LOGGER.debug("Log group {} already exists", logGroup);
} else {
throw e;
}
}

try {
CompletableFuture<CreateLogStreamResponse> future =
client.createLogStream(
CreateLogStreamRequest.builder()
.logGroupName(logGroup)
.logStreamName(logStream)
.build());
future.join();
} catch (CompletionException e) {
if (e.getCause() instanceof ResourceAlreadyExistsException) {
LOGGER.debug("Log stream {} already exists", logStream);
} else {
throw e;
}
}
}

@PreDestroy
void shutdown() {
if (client != null) {
client.close();
}
}

@Override
protected void transformAndSendEvent(HashMap<String, Object> properties) {
properties.put("realm", callContext.getRealmContext().getRealmIdentifier());
properties.put("principal", securityContext.getUserPrincipal().getName());
// TODO: Add request ID when it is available
String eventAsJson;
try {
eventAsJson = objectMapper.writeValueAsString(properties);
} catch (JsonProcessingException e) {
LOGGER.error("Error processing event into JSON string: ", e);
return;
}
InputLogEvent inputLogEvent = createLogEvent(eventAsJson, getCurrentTimestamp());
PutLogEventsRequest.Builder requestBuilder =
PutLogEventsRequest.builder()
.logGroupName(logGroup)
.logStreamName(logStream)
.logEvents(List.of(inputLogEvent));
CompletableFuture<PutLogEventsResponse> future =
client
.putLogEvents(requestBuilder.build())
.whenComplete(
(resp, err) -> {
if (err != null) {
LOGGER.error(
"Error writing log to CloudWatch. Event: {}, Error: ", inputLogEvent, err);
}
});
if (synchronousMode) {
future.join();
}
}

private long getCurrentTimestamp() {
return clock.millis();
}

private InputLogEvent createLogEvent(String eventAsJson, long timestamp) {
return InputLogEvent.builder().message(eventAsJson).timestamp(timestamp).build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*/
package org.apache.polaris.service.quarkus.events.jsonEventListener.aws.cloudwatch;

import io.quarkus.runtime.annotations.StaticInitSafe;
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
import io.smallrye.config.WithName;
import jakarta.enterprise.context.ApplicationScoped;
import org.apache.polaris.service.events.jsonEventListener.aws.cloudwatch.AwsCloudWatchConfiguration;

/**
* Quarkus-specific configuration interface for AWS CloudWatch event listener integration.
*
* <p>This interface extends the base {@link AwsCloudWatchConfiguration} and provides
* Quarkus-specific configuration mappings for AWS CloudWatch logging functionality.
*/
@StaticInitSafe
@ConfigMapping(prefix = "polaris.event-listener.aws-cloudwatch")
@ApplicationScoped
public interface QuarkusAwsCloudWatchConfiguration extends AwsCloudWatchConfiguration {

/**
* Returns the AWS CloudWatch log group name for event logging.
*
* <p>The log group is a collection of log streams that share the same retention, monitoring, and
* access control settings. If not specified, defaults to "polaris-cloudwatch-default-group".
*
* <p>Configuration property: {@code polaris.event-listener.aws-cloudwatch.log-group}
*
* @return an Optional containing the log group name, or empty if not configured
*/
@WithName("log-group")
@WithDefault("polaris-cloudwatch-default-group")
@Override
String awsCloudwatchlogGroup();

/**
* Returns the AWS CloudWatch log stream name for event logging.
*
* <p>A log stream is a sequence of log events that share the same source. Each log stream belongs
* to one log group. If not specified, defaults to "polaris-cloudwatch-default-stream".
*
* <p>Configuration property: {@code polaris.event-listener.aws-cloudwatch.log-stream}
*
* @return an Optional containing the log stream name, or empty if not configured
*/
@WithName("log-stream")
@WithDefault("polaris-cloudwatch-default-stream")
@Override
String awsCloudwatchlogStream();

/**
* Returns the AWS region where CloudWatch logs should be sent.
*
* <p>This specifies the AWS region for the CloudWatch service endpoint. The region must be a
* valid AWS region identifier. If not specified, defaults to "us-east-1".
*
* <p>Configuration property: {@code polaris.event-listener.aws-cloudwatch.region}
*
* @return an Optional containing the AWS region, or empty if not configured
*/
@WithName("region")
@WithDefault("us-east-1")
@Override
String awsCloudwatchRegion();

/**
* Returns the synchronous mode setting for CloudWatch logging.
*
* <p>When set to "true", log events are sent to CloudWatch synchronously, which may impact
* application performance but ensures immediate delivery. When set to "false" (default), log
* events are sent asynchronously for better performance.
*
* <p>Configuration property: {@code polaris.event-listener.aws-cloudwatch.synchronous-mode}
*
* @return a boolean value indicating the synchronous mode setting
*/
@WithName("synchronous-mode")
@WithDefault("false")
@Override
boolean synchronousMode();
}
Loading