Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
47 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
4c91c57
Address comments from @RussellSpitzer and merge from main
adnanhemani Aug 22, 2025
b0c6160
typo
adnanhemani Aug 22, 2025
44fad9a
spotlessapply
adnanhemani Aug 22, 2025
e8447a9
fix docstrings
adnanhemani Aug 25, 2025
688ec97
refactor
adnanhemani Aug 25, 2025
360cb91
use awaitility
adnanhemani Aug 25, 2025
7c09d9b
Add negative case testing
adnanhemani Aug 28, 2025
5e34884
spotlessapply
adnanhemani Aug 28, 2025
e2ed743
Revision based on comments from @eric-maynard and @singhpk234
adnanhemani Sep 1, 2025
a870aea
Addressing comments from @singhpk234
adnanhemani Sep 3, 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.1" }
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.1" }
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/defaults/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ polaris.secrets-manager.type=in-memory
polaris.file-io.type=default

polaris.event-listener.type=no-op
# polaris.event-listener.type=aws-cloudwatch
# polaris.event-listener.aws-cloudwatch.log-group=test-group
# polaris.event-listener.aws-cloudwatch.log-stream=test-stream
# polaris.event-listener.aws-cloudwatch.region=us-west-2
Comment thread
adnanhemani marked this conversation as resolved.
Outdated

polaris.log.request-id-header-name=Polaris-Request-Id
# polaris.log.mdc.aid=polaris
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* 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;

public interface AwsCloudwatchConfig {
String logGroup();

String logStream();

String region();
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,29 @@

import io.quarkus.runtime.annotations.StaticInitSafe;
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithName;
import java.util.Optional;
import org.apache.polaris.service.events.EventListenerConfiguration;
import org.apache.polaris.service.events.PolarisEventListener;

@StaticInitSafe
@ConfigMapping(prefix = "polaris.event-listener")
public interface QuarkusPolarisEventListenerConfiguration {
public interface QuarkusPolarisEventListenerConfiguration extends EventListenerConfiguration {
/**
* 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();

@WithName("aws-cloudwatch.log-group")
@Override
Optional<String> awsCloudwatchlogGroup();
Comment thread
adnanhemani marked this conversation as resolved.
Outdated

@WithName("aws-cloudwatch.log-stream")
@Override
Optional<String> awsCloudwatchlogStream();

@WithName("aws-cloudwatch.region")
@Override
Optional<String> awsCloudwatchRegion();
}
3 changes: 3 additions & 0 deletions service/common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,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(platform(libs.azuresdk.bom))
implementation("com.azure:azure-core")
Expand All @@ -94,6 +95,8 @@ dependencies {
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation(libs.assertj.core)
testImplementation(libs.mockito.core)
testImplementation(libs.localstack)
testImplementation("org.testcontainers:testcontainers")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")

testImplementation(libs.logback.classic)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@
import org.apache.polaris.service.admin.api.PolarisPrincipalsApiService;
import org.apache.polaris.service.config.RealmEntityManagerFactory;
import org.apache.polaris.service.config.ReservedProperties;
import org.apache.polaris.service.events.AfterCatalogCreatedEvent;
import org.apache.polaris.service.events.PolarisEventListener;
import org.apache.polaris.service.types.PolicyIdentifier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -97,6 +99,7 @@ public class PolarisServiceImpl
private final UserSecretsManagerFactory userSecretsManagerFactory;
private final CallContext callContext;
private final ReservedProperties reservedProperties;
private final PolarisEventListener polarisEventListener;

@Inject
public PolarisServiceImpl(
Expand All @@ -105,13 +108,15 @@ public PolarisServiceImpl(
UserSecretsManagerFactory userSecretsManagerFactory,
PolarisAuthorizer polarisAuthorizer,
CallContext callContext,
ReservedProperties reservedProperties) {
ReservedProperties reservedProperties,
PolarisEventListener polarisEventListener) {
this.entityManagerFactory = entityManagerFactory;
this.metaStoreManagerFactory = metaStoreManagerFactory;
this.userSecretsManagerFactory = userSecretsManagerFactory;
this.polarisAuthorizer = polarisAuthorizer;
this.callContext = callContext;
this.reservedProperties = reservedProperties;
this.polarisEventListener = polarisEventListener;
// FIXME: This is a hack to set the current context for downstream calls.
CallContext.setCurrentContext(callContext);
}
Expand Down Expand Up @@ -150,6 +155,8 @@ public Response createCatalog(
validateExternalCatalog(catalog);
Catalog newCatalog = new CatalogEntity(adminService.createCatalog(request)).asCatalog();
LOGGER.info("Created new catalog {}", newCatalog);
polarisEventListener.onAfterCatalogCreated(
new AfterCatalogCreatedEvent(newCatalog), callContext);
return Response.status(Response.Status.CREATED).build();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* 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;

import org.apache.polaris.core.admin.model.Catalog;

/**
* Emitted after Polaris creates a catalog (internal or external). This is not emitted if there's an
* exception while created.
*
* @param catalog The catalog that was created
*/
public record AfterCatalogCreatedEvent(Catalog catalog) implements PolarisEvent {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/*
* 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;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.annotations.VisibleForTesting;
import io.smallrye.common.annotation.Identifier;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import org.apache.polaris.core.context.CallContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.cloudwatchlogs.CloudWatchLogsClient;
import software.amazon.awssdk.services.cloudwatchlogs.model.CreateLogGroupRequest;
import software.amazon.awssdk.services.cloudwatchlogs.model.CreateLogStreamRequest;
import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogStreamsRequest;
import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogStreamsResponse;
import software.amazon.awssdk.services.cloudwatchlogs.model.InputLogEvent;
import software.amazon.awssdk.services.cloudwatchlogs.model.InvalidSequenceTokenException;
import software.amazon.awssdk.services.cloudwatchlogs.model.LogStream;
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 PolarisEventListener {
Comment thread
adnanhemani marked this conversation as resolved.
Outdated
private static final Logger LOGGER = LoggerFactory.getLogger(AwsCloudWatchEventListener.class);
private final ObjectMapper objectMapper = new ObjectMapper();
private static final int MAX_BATCH_SIZE = 10_000;
private static final int MAX_WAIT_MS = 5000;

private final BlockingQueue<EventAndTimestamp> queue = new LinkedBlockingQueue<>();
Comment thread
adnanhemani marked this conversation as resolved.
Outdated
private CloudWatchLogsClient client;
Comment thread
adnanhemani marked this conversation as resolved.
Outdated
private volatile String sequenceToken;

private volatile boolean running = true;

ExecutorService executorService;
Comment thread
adnanhemani marked this conversation as resolved.
Outdated

private Future<?> backgroundTask;
Comment thread
adnanhemani marked this conversation as resolved.
Outdated
Comment thread
adnanhemani marked this conversation as resolved.
Outdated

private final String logGroup;
private final String logStream;
private final Region region;

@Inject
public AwsCloudWatchEventListener(
EventListenerConfiguration config, ExecutorService executorService) {
this.executorService = executorService;

this.logStream = config.awsCloudwatchlogStream().orElse("polaris-cloudwatch-default-stream");
this.logGroup = config.awsCloudwatchlogGroup().orElse("polaris-cloudwatch-default-group");
this.region = Region.of(config.awsCloudwatchRegion().orElse("us-east-1"));
Comment thread
eric-maynard marked this conversation as resolved.
Outdated
}

@PostConstruct
void start() {
this.client = createCloudWatchClient();
ensureLogGroupAndStream();
backgroundTask = executorService.submit(this::processQueue);
}

protected CloudWatchLogsClient createCloudWatchClient() {
return CloudWatchLogsClient.builder().region(region).build();
}

private void processQueue() {
while (running || !queue.isEmpty()) {
drainQueue();
}
}

@VisibleForTesting
public void drainQueue() {
List<EventAndTimestamp> drainedEvents = new ArrayList<>();
List<InputLogEvent> transformedEvents = new ArrayList<>();
Comment thread
eric-maynard marked this conversation as resolved.
Outdated
try {
EventAndTimestamp first = queue.poll(MAX_WAIT_MS, TimeUnit.MILLISECONDS);

if (first != null) {
drainedEvents.add(first);
queue.drainTo(drainedEvents, MAX_BATCH_SIZE - 1);
} else {
return;
}

drainedEvents.forEach(event -> transformedEvents.add(createLogEvent(event)));
Comment thread
adnanhemani marked this conversation as resolved.
Outdated

sendToCloudWatch(transformedEvents);
} catch (Exception e) {
Comment thread
eric-maynard marked this conversation as resolved.
Outdated
LOGGER.error("Error writing logs to CloudWatch: {}", e.getMessage());
LOGGER.error("Events not logged: {}", transformedEvents);
Comment thread
eric-maynard marked this conversation as resolved.
Outdated
queue.addAll(drainedEvents);
Comment thread
adnanhemani marked this conversation as resolved.
Outdated
}
}

private InputLogEvent createLogEvent(EventAndTimestamp eventAndTimestamp) {
return InputLogEvent.builder()
.message(eventAndTimestamp.event)
.timestamp(eventAndTimestamp.timestamp)
.build();
}

private void sendToCloudWatch(List<InputLogEvent> events) {
events.sort(Comparator.comparingLong(InputLogEvent::timestamp));

PutLogEventsRequest.Builder requestBuilder =
PutLogEventsRequest.builder()
.logGroupName(logGroup)
.logStreamName(logStream)
.logEvents(events);
Comment thread
adnanhemani marked this conversation as resolved.
Outdated

synchronized (this) {
Comment thread
adnanhemani marked this conversation as resolved.
Outdated
if (sequenceToken != null) {
requestBuilder.sequenceToken(sequenceToken);
}

try {
PutLogEventsResponse response = client.putLogEvents(requestBuilder.build());
sequenceToken = response.nextSequenceToken();
} catch (InvalidSequenceTokenException e) {
sequenceToken = getSequenceToken();
requestBuilder.sequenceToken(sequenceToken);
PutLogEventsResponse retryResponse = client.putLogEvents(requestBuilder.build());
sequenceToken = retryResponse.nextSequenceToken();
Comment thread
eric-maynard marked this conversation as resolved.
Outdated
}
}
}

private void ensureLogGroupAndStream() {
try {
client.createLogGroup(CreateLogGroupRequest.builder().logGroupName(logGroup).build());
} catch (ResourceAlreadyExistsException ignored) {
}
Comment thread
eric-maynard marked this conversation as resolved.
Outdated

try {
client.createLogStream(
CreateLogStreamRequest.builder().logGroupName(logGroup).logStreamName(logStream).build());
} catch (ResourceAlreadyExistsException ignored) {
}

sequenceToken = getSequenceToken();
}

private String getSequenceToken() {
DescribeLogStreamsResponse response =
client.describeLogStreams(
DescribeLogStreamsRequest.builder()
.logGroupName(logGroup)
.logStreamNamePrefix(logStream)
.build());

return response.logStreams().stream()
.filter(s -> logStream.equals(s.logStreamName()))
.map(LogStream::uploadSequenceToken)
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
}

@PreDestroy
void shutdown() {
running = false;
if (backgroundTask != null) {
try {
backgroundTask.get(10, TimeUnit.SECONDS);
} catch (Exception e) {
LOGGER.error("Error waiting for background logging task to finish: {}", e.getMessage());
}
}
Comment thread
adnanhemani marked this conversation as resolved.
Outdated
if (client != null) {
client.close();
}
}

private record EventAndTimestamp(String event, long timestamp) {}
Comment thread
eric-maynard marked this conversation as resolved.
Outdated

private long getCurrentTimestamp(CallContext callContext) {
return callContext.getPolarisCallContext().getClock().millis();
}

// Event overrides below
@Override
public void onAfterCatalogCreated(AfterCatalogCreatedEvent event, CallContext callContext) {
try {
Map<String, Object> json = objectMapper.convertValue(event.catalog(), Map.class);
Comment thread
eric-maynard marked this conversation as resolved.
Outdated
json.put("realm", callContext.getRealmContext().getRealmIdentifier());
json.put("event_type", event.getClass().getSimpleName());
queue.add(
Comment thread
adnanhemani marked this conversation as resolved.
Outdated
new EventAndTimestamp(
objectMapper.writeValueAsString(json), getCurrentTimestamp(callContext)));
} catch (JsonProcessingException e) {
LOGGER.error("Error processing event into JSON string: {}", e.getMessage());
}
}
}
Loading
Loading