-
Notifications
You must be signed in to change notification settings - Fork 495
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
Merged
singhpk234
merged 47 commits into
apache:main
from
adnanhemani:ahemani/cloudwatch_event_listener
Sep 3, 2025
Merged
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 06eaca4
cleanup
adnanhemani 1515fbb
spotlessapply
adnanhemani 854501b
Added unit test with LocalStack
adnanhemani c1c94b2
typo
adnanhemani ab3c5f9
spotlessapply
adnanhemani ab9ccbe
Merge remote-tracking branch 'origin/main' into ahemani/cloudwatch_ev…
adnanhemani a641136
recompile from main
adnanhemani 5a355d1
first revision change, based on review from @eric-maynard
adnanhemani bab4439
merge from origin/main
adnanhemani 04c310a
spotlessapply
adnanhemani d4b44ff
Merge branch 'main' into ahemani/cloudwatch_event_listener
adnanhemani 4d0554a
injected securitycontext and callcontext
adnanhemani cc715ad
todo
adnanhemani 518aaaa
modify test
adnanhemani 8758255
first draft of revision
adnanhemani f3f62a0
resolve comments from @eric-maynard and @snazy
adnanhemani d21dabc
refactor into separate package
adnanhemani 9054511
typo
adnanhemani 828760a
revising comments from @eric-maynard
adnanhemani ae79600
Merge branch 'main' into ahemani/cloudwatch_event_listener
adnanhemani 9d47684
spotlessapply
adnanhemani 025de74
revision on review from @singhpk234
adnanhemani e4ec3f8
resolve conflicts
adnanhemani 491ea3a
resolve conflicts, pt. 2
adnanhemani d453660
spotlessapply
adnanhemani f89b0ae
spotlessapply again
adnanhemani e5c02b7
address comments from @RussellSpitzer
adnanhemani 4f8a15b
merge from main
adnanhemani 1305321
prior to manual test
adnanhemani 27f28f4
addressing comments from @snazy
adnanhemani 6b42071
Merge remote-tracking branch 'origin/main' into ahemani/cloudwatch_ev…
adnanhemani ec2bee8
merge from main
adnanhemani 69b7feb
spotlesscheck
adnanhemani e8b5e93
documentation updates
adnanhemani 3030d6a
review comments from @RussellSpitzer
adnanhemani b9abab6
removed mocked tests, as per review from @RussellSpitzer
adnanhemani 4c91c57
Address comments from @RussellSpitzer and merge from main
adnanhemani b0c6160
typo
adnanhemani 44fad9a
spotlessapply
adnanhemani e8447a9
fix docstrings
adnanhemani 688ec97
refactor
adnanhemani 360cb91
use awaitility
adnanhemani 7c09d9b
Add negative case testing
adnanhemani 5e34884
spotlessapply
adnanhemani e2ed743
Revision based on comments from @eric-maynard and @singhpk234
adnanhemani a870aea
Addressing comments from @singhpk234
adnanhemani File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
28 changes: 28 additions & 0 deletions
28
.../service/src/main/java/org/apache/polaris/service/quarkus/events/AwsCloudwatchConfig.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
29 changes: 29 additions & 0 deletions
29
service/common/src/main/java/org/apache/polaris/service/events/AfterCatalogCreatedEvent.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 {} |
228 changes: 228 additions & 0 deletions
228
...ce/common/src/main/java/org/apache/polaris/service/events/AwsCloudWatchEventListener.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 { | ||
|
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<>(); | ||
|
adnanhemani marked this conversation as resolved.
Outdated
|
||
| private CloudWatchLogsClient client; | ||
|
adnanhemani marked this conversation as resolved.
Outdated
|
||
| private volatile String sequenceToken; | ||
|
|
||
| private volatile boolean running = true; | ||
|
|
||
| ExecutorService executorService; | ||
|
adnanhemani marked this conversation as resolved.
Outdated
|
||
|
|
||
| private Future<?> backgroundTask; | ||
|
adnanhemani marked this conversation as resolved.
Outdated
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")); | ||
|
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<>(); | ||
|
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))); | ||
|
adnanhemani marked this conversation as resolved.
Outdated
|
||
|
|
||
| sendToCloudWatch(transformedEvents); | ||
| } catch (Exception e) { | ||
|
eric-maynard marked this conversation as resolved.
Outdated
|
||
| LOGGER.error("Error writing logs to CloudWatch: {}", e.getMessage()); | ||
| LOGGER.error("Events not logged: {}", transformedEvents); | ||
|
eric-maynard marked this conversation as resolved.
Outdated
|
||
| queue.addAll(drainedEvents); | ||
|
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); | ||
|
adnanhemani marked this conversation as resolved.
Outdated
|
||
|
|
||
| synchronized (this) { | ||
|
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(); | ||
|
eric-maynard marked this conversation as resolved.
Outdated
|
||
| } | ||
| } | ||
| } | ||
|
|
||
| private void ensureLogGroupAndStream() { | ||
| try { | ||
| client.createLogGroup(CreateLogGroupRequest.builder().logGroupName(logGroup).build()); | ||
| } catch (ResourceAlreadyExistsException ignored) { | ||
| } | ||
|
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()); | ||
| } | ||
| } | ||
|
adnanhemani marked this conversation as resolved.
Outdated
|
||
| if (client != null) { | ||
| client.close(); | ||
| } | ||
| } | ||
|
|
||
| private record EventAndTimestamp(String event, long timestamp) {} | ||
|
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); | ||
|
eric-maynard marked this conversation as resolved.
Outdated
|
||
| json.put("realm", callContext.getRealmContext().getRealmIdentifier()); | ||
| json.put("event_type", event.getClass().getSimpleName()); | ||
| queue.add( | ||
|
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()); | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.