From a6b930b22524c32881e0710227d98478c8408337 Mon Sep 17 00:00:00 2001 From: xsalefter Date: Sat, 11 Jul 2026 15:00:59 +0700 Subject: [PATCH 1/5] add LogEntriesFilter.java --- .../osgi/bundles/logger/LogEntriesFilter.java | 202 +++++++++++ .../bundles/logger/TestLogEntriesFilter.java | 329 ++++++++++++++++++ 2 files changed, 531 insertions(+) create mode 100644 osgi-bundles/bundles/logger/src/main/java/org/killbill/billing/osgi/bundles/logger/LogEntriesFilter.java create mode 100644 osgi-bundles/bundles/logger/src/test/java/org/killbill/billing/osgi/bundles/logger/TestLogEntriesFilter.java diff --git a/osgi-bundles/bundles/logger/src/main/java/org/killbill/billing/osgi/bundles/logger/LogEntriesFilter.java b/osgi-bundles/bundles/logger/src/main/java/org/killbill/billing/osgi/bundles/logger/LogEntriesFilter.java new file mode 100644 index 000000000..36b5b359b --- /dev/null +++ b/osgi-bundles/bundles/logger/src/main/java/org/killbill/billing/osgi/bundles/logger/LogEntriesFilter.java @@ -0,0 +1,202 @@ +/* + * Copyright 2020-2026 Equinix, Inc + * Copyright 2014-2026 The Billing Project, LLC + * + * The Billing Project 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.killbill.billing.osgi.bundles.logger; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.jooby.Request; +import org.killbill.billing.ObjectType; +import org.killbill.billing.plugin.api.PluginTenantContext; +import org.killbill.billing.util.api.RecordIdApi; +import org.killbill.billing.util.callcontext.TenantContext; +import org.killbill.commons.utils.annotation.VisibleForTesting; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.annotation.Nonnull; + +// http://localhost:8080/killbill-osgi-logger/ -> no filter applied +// +// http://localhost:8080/killbill-osgi-logger/?accountId -> no filter applied +// http://localhost:8080/killbill-osgi-logger/?accountId= -> no filter applied +// http://localhost:8080/killbill-osgi-logger/?accountId=somename -> filter by accountId. Return if matched. If nothing found, then do not return anything (heartbeat) +// +// http://localhost:8080/killbill-osgi-logger/?userToken -> no filter +// http://localhost:8080/killbill-osgi-logger/?userToken= -> no filter +// http://localhost:8080/killbill-osgi-logger/?userToken=someToken -> filter by userToken. Return if matched. If nothing found, then do not return anything (heartbeat) +// +// http://localhost:8080/killbill-osgi-logger/?accountId&userToken -> no filter applied +// http://localhost:8080/killbill-osgi-logger/?accountId=&userToken -> no filter applied +// http://localhost:8080/killbill-osgi-logger/?accountId&userToken= -> no filter applied +// http://localhost:8080/killbill-osgi-logger/?accountId=&userToken= -> no filter applied +// +// http://localhost:8080/killbill-osgi-logger/?accountId=somename&userToken -> filter by accountId only. Return if matched. If nothing found, then do not return anything (heartbeat) +// http://localhost:8080/killbill-osgi-logger/?accountId=somename&userToken= -> filter by accountId only. Return if matched. If nothing found, then do not return anything (heartbeat) +// +// http://localhost:8080/killbill-osgi-logger/?accountId&userToken=someToken -> filter by userToken only. Return if matched. If nothing found, then do not return anything (heartbeat) +// http://localhost:8080/killbill-osgi-logger/?accountId=&userToken=someToken -> filter by userToken only. Return if matched. If nothing found, then do not return anything (heartbeat) +// +// http://localhost:8080/killbill-osgi-logger/?accountId=somename&userToken=someToken -> filter by accountId OR userToken: +// - if by accountId found, return it. +// - if by accountId not found but by userToken found, return it. +// - If nothing found, then do not return anything (heartbeat) +class LogEntriesFilter { + + private static final Logger logger = LoggerFactory.getLogger(LogEntriesFilter.class); + + private final AccountRecordIdValue accountRecordId; + private final Optional userToken; + private boolean noResult; + + LogEntriesFilter(final Request request, final RecordIdApi recordIdApi) { + // resolveAccountRecordId() very likely call to db. Doing this at apply() time will cause it called every + // SSE calls. + this.accountRecordId = resolveAccountRecordId(request, recordIdApi); + this.userToken = getNonBlankParam(request, "userToken"); + this.noResult = true; + } + + public boolean hasNoResult() { + return noResult; + } + + /** + * Filters entries against the resolved criteria. + * Returns matching entries; if no filter is active, returns all. + */ + public Iterable apply(final Iterable entries) { + // Reset per drain cycle so heartbeat logic in the handler works correctly + noResult = true; + + if (!accountRecordId.isRequested() && userToken.isEmpty()) { + noResult = false; + return entries; + } + + final List result = new ArrayList<>(); + for (final LogEntryJson entry : entries) { + if (match(entry)) { + result.add(entry); + noResult = false; + } + } + return result; + } + + /** + * OR logic: entry passes if it matches ANY active filter. + * If no filter is active, all entries pass. + */ + @VisibleForTesting + boolean match(final LogEntryJson entry) { + if (!accountRecordId.isRequested() && userToken.isEmpty()) { + return true; + } + if (accountRecordId.matches(entry.getAccountRecordId())) { + return true; + } + return userToken.isPresent() && userToken.get().equals(entry.getUserToken()); + } + + /** + * Resolves the {@code accountId} query param to an internal record ID. + * Returns empty if param is missing or blank. + * Returns a sentinel value if param is provided but unresolvable (invalid UUID or account not found), + * so the filter stays active and matches nothing. + */ + @VisibleForTesting + static AccountRecordIdValue resolveAccountRecordId(final Request request, final RecordIdApi recordIdApi) { + final Optional param = getNonBlankParam(request, "accountId"); + logger.debug("param.isPresent(): {}, value: {} ", param.isPresent(), param.orElse("")); + if (param.isEmpty()) { + return AccountRecordIdValue.empty(); + } + + try { + final UUID accountId = UUID.fromString(param.get()); + logger.debug("accountId UUID constructed"); + final TenantContext context = new PluginTenantContext(accountId, null); + final Long recordId = recordIdApi.getRecordId(accountId, ObjectType.ACCOUNT, context); + logger.debug("recordId: {}", recordId); + if (recordId != null && recordId > 0L) { + return AccountRecordIdValue.exist(recordId.toString()); + } + } catch (final IllegalArgumentException ignored) { + // Invalid UUID format + logger.info("receive invalid UUID as request parameter"); + } + + // accountId was provided but accountRecordId unresolvable. keep filter active with a value that never matches + return AccountRecordIdValue.notMatch(); + } + + /** + * Returns the param value only if it is present and non-blank. + * Treats missing, empty, or whitespace-only values as "not provided". + */ + @VisibleForTesting + static Optional getNonBlankParam(final Request request, final String name) { + final Optional value = request.param(name).toOptional(); + if (value.isPresent() && !value.get().isBlank()) { + return value; + } + return Optional.empty(); + } + + static class AccountRecordIdValue { + + enum State { + EMPTY, // SSE request parameter not set + NOT_MATCH, // accountId not found + EXIST + } + + private final State state; + private final String value; + + AccountRecordIdValue(final State state, final String value) { + this.state = state; + this.value = value; + } + + static AccountRecordIdValue empty() { + return new AccountRecordIdValue(State.EMPTY, null); + } + + static AccountRecordIdValue notMatch() { + return new AccountRecordIdValue(State.NOT_MATCH, null); + } + + static AccountRecordIdValue exist(@Nonnull final String value) { + return new AccountRecordIdValue(State.EXIST, value); + } + + /** True when the client provided a non-blank accountId param. */ + boolean isRequested() { + return state != State.EMPTY; + } + + /** True only when the accountId was resolved and its record ID equals the given value. */ + boolean matches(final String entryAccountRecordId) { + return state == State.EXIST && value.equals(entryAccountRecordId); + } + } +} diff --git a/osgi-bundles/bundles/logger/src/test/java/org/killbill/billing/osgi/bundles/logger/TestLogEntriesFilter.java b/osgi-bundles/bundles/logger/src/test/java/org/killbill/billing/osgi/bundles/logger/TestLogEntriesFilter.java new file mode 100644 index 000000000..f6d92d2e6 --- /dev/null +++ b/osgi-bundles/bundles/logger/src/test/java/org/killbill/billing/osgi/bundles/logger/TestLogEntriesFilter.java @@ -0,0 +1,329 @@ +/* + * Copyright 2020-2026 Equinix, Inc + * Copyright 2014-2026 The Billing Project, LLC + * + * The Billing Project 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.killbill.billing.osgi.bundles.logger; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.jooby.Mutant; +import org.jooby.Request; +import org.killbill.billing.ObjectType; +import org.killbill.billing.util.api.RecordIdApi; +import org.killbill.commons.utils.collect.Iterables; +import org.mockito.Mockito; +import org.osgi.service.log.LogService; +import org.testng.Assert; +import org.testng.annotations.Test; + +public class TestLogEntriesFilter { + + // -- match() tests -- + + @Test(groups = "fast") + public void testMatchNoFilters() { + final LogEntriesFilter filter = buildFilter(null, null); + Assert.assertTrue(filter.match(makeEntry("msg", "token-1", "42"))); + } + + @Test(groups = "fast") + public void testMatchByUserTokenOnly() { + final LogEntriesFilter filter = buildFilter(null, "token-1"); + Assert.assertTrue(filter.match(makeEntry("msg", "token-1", "42"))); + Assert.assertFalse(filter.match(makeEntry("msg", "other", "42"))); + } + + @Test(groups = "fast") + public void testMatchByAccountRecordIdOnly() { + final UUID accountId = UUID.randomUUID(); + final LogEntriesFilter filter = buildFilterWithAccount(accountId, 42L, null); + Assert.assertTrue(filter.match(makeEntry("msg", "token-1", "42"))); + Assert.assertFalse(filter.match(makeEntry("msg", "token-1", "99"))); + } + + @Test(groups = "fast") + public void testMatchBothFiltersOr() { + final UUID accountId = UUID.randomUUID(); + final LogEntriesFilter filter = buildFilterWithAccount(accountId, 42L, "token-1"); + // Both match + Assert.assertTrue(filter.match(makeEntry("msg", "token-1", "42"))); + // accountRecordId matches only + Assert.assertTrue(filter.match(makeEntry("msg", "wrong", "42"))); + // userToken matches only + Assert.assertTrue(filter.match(makeEntry("msg", "token-1", "99"))); + // Neither matches + Assert.assertFalse(filter.match(makeEntry("msg", "wrong", "99"))); + } + + @Test(groups = "fast") + public void testMatchEntryWithNullFields() { + final LogEntriesFilter noFilter = buildFilter(null, null); + Assert.assertTrue(noFilter.match(makeEntry("msg", null, null))); + + final LogEntriesFilter tokenFilter = buildFilter(null, "any"); + Assert.assertFalse(tokenFilter.match(makeEntry("msg", null, null))); + + final UUID accountId = UUID.randomUUID(); + final LogEntriesFilter accountFilter = buildFilterWithAccount(accountId, 42L, null); + Assert.assertFalse(accountFilter.match(makeEntry("msg", null, null))); + } + + // -- getNonBlankParam() tests -- + + @Test(groups = "fast") + public void testGetNonBlankParamPresent() { + final Request request = mockRequestWithParam("userToken", "abc"); + Assert.assertEquals(LogEntriesFilter.getNonBlankParam(request, "userToken"), Optional.of("abc")); + } + + @Test(groups = "fast") + public void testGetNonBlankParamEmpty() { + final Request request = mockRequestWithParam("userToken", ""); + Assert.assertEquals(LogEntriesFilter.getNonBlankParam(request, "userToken"), Optional.empty()); + } + + @Test(groups = "fast") + public void testGetNonBlankParamBlank() { + final Request request = mockRequestWithParam("userToken", " "); + Assert.assertEquals(LogEntriesFilter.getNonBlankParam(request, "userToken"), Optional.empty()); + } + + @Test(groups = "fast") + public void testGetNonBlankParamMissing() { + final Request request = mockRequestWithParam("userToken", null); + Assert.assertEquals(LogEntriesFilter.getNonBlankParam(request, "userToken"), Optional.empty()); + } + + // -- resolveAccountRecordId() tests -- + + @Test(groups = "fast") + public void testResolveAccountRecordIdValid() { + final UUID accountId = UUID.randomUUID(); + final Request request = mockRequestWithParam("accountId", accountId.toString()); + final RecordIdApi recordIdApi = Mockito.mock(RecordIdApi.class); + Mockito.when(recordIdApi.getRecordId(Mockito.eq(accountId), Mockito.eq(ObjectType.ACCOUNT), Mockito.any())) + .thenReturn(42L); + + final LogEntriesFilter.AccountRecordIdValue result = LogEntriesFilter.resolveAccountRecordId(request, recordIdApi); + Assert.assertTrue(result.isRequested()); + Assert.assertTrue(result.matches("42")); + Assert.assertFalse(result.matches("99")); + } + + @Test(groups = "fast") + public void testResolveAccountRecordIdNotFound() { + final UUID accountId = UUID.randomUUID(); + final Request request = mockRequestWithParam("accountId", accountId.toString()); + final RecordIdApi recordIdApi = Mockito.mock(RecordIdApi.class); + Mockito.when(recordIdApi.getRecordId(Mockito.eq(accountId), Mockito.eq(ObjectType.ACCOUNT), Mockito.any())) + .thenReturn(null); + + final LogEntriesFilter.AccountRecordIdValue result = LogEntriesFilter.resolveAccountRecordId(request, recordIdApi); + Assert.assertTrue(result.isRequested(), "Filter should be active for provided but unresolvable accountId"); + Assert.assertFalse(result.matches("42"), "Unresolvable accountId should never match"); + } + + @Test(groups = "fast") + public void testResolveAccountRecordIdZero() { + final UUID accountId = UUID.randomUUID(); + final Request request = mockRequestWithParam("accountId", accountId.toString()); + final RecordIdApi recordIdApi = Mockito.mock(RecordIdApi.class); + Mockito.when(recordIdApi.getRecordId(Mockito.eq(accountId), Mockito.eq(ObjectType.ACCOUNT), Mockito.any())) + .thenReturn(0L); + + final LogEntriesFilter.AccountRecordIdValue result = LogEntriesFilter.resolveAccountRecordId(request, recordIdApi); + Assert.assertTrue(result.isRequested()); + Assert.assertFalse(result.matches("0")); + } + + @Test(groups = "fast") + public void testResolveAccountRecordIdInvalidUuid() { + final Request request = mockRequestWithParam("accountId", "not-a-uuid"); + final RecordIdApi recordIdApi = Mockito.mock(RecordIdApi.class); + + final LogEntriesFilter.AccountRecordIdValue result = LogEntriesFilter.resolveAccountRecordId(request, recordIdApi); + Assert.assertTrue(result.isRequested(), "Filter should be active for invalid UUID"); + Assert.assertFalse(result.matches("42")); + Mockito.verifyNoInteractions(recordIdApi); + } + + @Test(groups = "fast") + public void testResolveAccountRecordIdBlankParam() { + final Request request = mockRequestWithParam("accountId", ""); + final RecordIdApi recordIdApi = Mockito.mock(RecordIdApi.class); + + final LogEntriesFilter.AccountRecordIdValue result = LogEntriesFilter.resolveAccountRecordId(request, recordIdApi); + Assert.assertFalse(result.isRequested(), "Blank param means not requested"); + Mockito.verifyNoInteractions(recordIdApi); + } + + // -- apply() tests -- + + @Test(groups = "fast") + public void testApplyNoFilter() { + final Request request = mockRequestWithParams(null, null); + final RecordIdApi recordIdApi = Mockito.mock(RecordIdApi.class); + final LogEntriesFilter filter = new LogEntriesFilter(request, recordIdApi); + + final List entries = List.of( + makeEntry("msg1", "token-a", "10"), + makeEntry("msg2", null, null)); + + final Iterable result = filter.apply(entries); + Assert.assertSame(result, entries, "No filter should return same iterable"); + Assert.assertFalse(filter.hasNoResult()); + } + + @Test(groups = "fast") + public void testApplyFilterByUserToken() { + final Request request = mockRequestWithParams(null, "my-token"); + final RecordIdApi recordIdApi = Mockito.mock(RecordIdApi.class); + final LogEntriesFilter filter = new LogEntriesFilter(request, recordIdApi); + + final List entries = List.of( + makeEntry("match", "my-token", null), + makeEntry("skip", "other", null), + makeEntry("skip2", null, null)); + + final List result = Iterables.toUnmodifiableList(filter.apply(entries)); + Assert.assertEquals(result.size(), 1); + Assert.assertEquals(result.get(0).getMessage(), "match"); + Assert.assertFalse(filter.hasNoResult()); + } + + @Test(groups = "fast") + public void testApplyFilterNothingMatches() { + final Request request = mockRequestWithParams(null, "non-existent"); + final RecordIdApi recordIdApi = Mockito.mock(RecordIdApi.class); + final LogEntriesFilter filter = new LogEntriesFilter(request, recordIdApi); + + final List entries = List.of( + makeEntry("msg1", "other", null), + makeEntry("msg2", null, null)); + + final List result = Iterables.toUnmodifiableList(filter.apply(entries)); + Assert.assertTrue(result.isEmpty()); + Assert.assertTrue(filter.hasNoResult()); + } + + @Test(groups = "fast") + public void testApplyHasNoResultResetsPerCycle() { + final Request request = mockRequestWithParams(null, "my-token"); + final RecordIdApi recordIdApi = Mockito.mock(RecordIdApi.class); + final LogEntriesFilter filter = new LogEntriesFilter(request, recordIdApi); + + // Cycle 1: match + filter.apply(List.of(makeEntry("match", "my-token", null))); + Assert.assertFalse(filter.hasNoResult()); + + // Cycle 2: no match — hasNoResult must reset to true + filter.apply(List.of(makeEntry("skip", "other", null))); + Assert.assertTrue(filter.hasNoResult()); + + // Cycle 3: match again + filter.apply(List.of(makeEntry("match2", "my-token", null))); + Assert.assertFalse(filter.hasNoResult()); + } + + @Test(groups = "fast") + public void testApplyOrLogicBothFilters() { + final UUID accountId = UUID.randomUUID(); + final Request request = mockRequestWithParams(accountId.toString(), "my-token"); + final RecordIdApi recordIdApi = Mockito.mock(RecordIdApi.class); + Mockito.when(recordIdApi.getRecordId(Mockito.eq(accountId), Mockito.eq(ObjectType.ACCOUNT), Mockito.any())) + .thenReturn(42L); + + final LogEntriesFilter filter = new LogEntriesFilter(request, recordIdApi); + + final List entries = List.of( + makeEntry("both match", "my-token", "42"), + makeEntry("token only", "my-token", "99"), + makeEntry("account only", "other", "42"), + makeEntry("neither", "other", "99")); + + final List result = Iterables.toUnmodifiableList(filter.apply(entries)); + Assert.assertEquals(result.size(), 3); + Assert.assertEquals(result.get(0).getMessage(), "both match"); + Assert.assertEquals(result.get(1).getMessage(), "token only"); + Assert.assertEquals(result.get(2).getMessage(), "account only"); + Assert.assertFalse(filter.hasNoResult()); + } + + @Test(groups = "fast") + public void testApplyUnresolvableAccountIdMatchesNothing() { + final UUID unknownAccountId = UUID.randomUUID(); + final Request request = mockRequestWithParams(unknownAccountId.toString(), null); + final RecordIdApi recordIdApi = Mockito.mock(RecordIdApi.class); + Mockito.when(recordIdApi.getRecordId(Mockito.eq(unknownAccountId), Mockito.eq(ObjectType.ACCOUNT), Mockito.any())) + .thenReturn(null); + + final LogEntriesFilter filter = new LogEntriesFilter(request, recordIdApi); + + final List entries = List.of( + makeEntry("msg1", "token", "42"), + makeEntry("msg2", null, null)); + + // Unresolvable accountId → sentinel keeps filter active → nothing matches + final List result = Iterables.toUnmodifiableList(filter.apply(entries)); + Assert.assertTrue(result.isEmpty()); + Assert.assertTrue(filter.hasNoResult()); + } + + // -- Helpers -- + + private static LogEntryJson makeEntry(final String message, final String userToken, final String accountRecordId) { + return new LogEntryJson(null, LogService.LOG_INFO, "test.Logger", message, userToken, null, accountRecordId, null); + } + + private static LogEntriesFilter buildFilter(final String accountId, final String userToken) { + final Request request = mockRequestWithParams(accountId, userToken); + final RecordIdApi recordIdApi = Mockito.mock(RecordIdApi.class); + return new LogEntriesFilter(request, recordIdApi); + } + + private static LogEntriesFilter buildFilterWithAccount(final UUID accountId, final Long recordId, final String userToken) { + final Request request = mockRequestWithParams(accountId.toString(), userToken); + final RecordIdApi recordIdApi = Mockito.mock(RecordIdApi.class); + Mockito.when(recordIdApi.getRecordId(Mockito.eq(accountId), Mockito.eq(ObjectType.ACCOUNT), Mockito.any())) + .thenReturn(recordId); + return new LogEntriesFilter(request, recordIdApi); + } + + private static Request mockRequestWithParam(final String name, final String value) { + final Request request = Mockito.mock(Request.class); + final Mutant mutant = Mockito.mock(Mutant.class); + Mockito.when(mutant.toOptional()).thenReturn(value == null ? Optional.empty() : Optional.of(value)); + Mockito.when(request.param(name)).thenReturn(mutant); + return request; + } + + private static Request mockRequestWithParams(final String accountId, final String userToken) { + final Request request = Mockito.mock(Request.class); + + final Mutant accountMutant = Mockito.mock(Mutant.class); + Mockito.when(accountMutant.toOptional()).thenReturn(accountId == null ? Optional.empty() : Optional.of(accountId)); + Mockito.when(request.param("accountId")).thenReturn(accountMutant); + + final Mutant tokenMutant = Mockito.mock(Mutant.class); + Mockito.when(tokenMutant.toOptional()).thenReturn(userToken == null ? Optional.empty() : Optional.of(userToken)); + Mockito.when(request.param("userToken")).thenReturn(tokenMutant); + + return request; + } +} From 62d32ac142b726b172e854106df674e47271d26f Mon Sep 17 00:00:00 2001 From: xsalefter Date: Sat, 11 Jul 2026 15:01:50 +0700 Subject: [PATCH 2/5] add filter functionality via query parameter to LogsSseHandler --- .../osgi/bundles/logger/LogsSseHandler.java | 31 +- .../bundles/logger/TestLogsSseHandler.java | 581 ++++++++++++++++++ 2 files changed, 605 insertions(+), 7 deletions(-) create mode 100644 osgi-bundles/bundles/logger/src/test/java/org/killbill/billing/osgi/bundles/logger/TestLogsSseHandler.java diff --git a/osgi-bundles/bundles/logger/src/main/java/org/killbill/billing/osgi/bundles/logger/LogsSseHandler.java b/osgi-bundles/bundles/logger/src/main/java/org/killbill/billing/osgi/bundles/logger/LogsSseHandler.java index c2e90d36a..a3d381dc5 100644 --- a/osgi-bundles/bundles/logger/src/main/java/org/killbill/billing/osgi/bundles/logger/LogsSseHandler.java +++ b/osgi-bundles/bundles/logger/src/main/java/org/killbill/billing/osgi/bundles/logger/LogsSseHandler.java @@ -30,7 +30,9 @@ import org.jooby.Request; import org.jooby.Sse; import org.jooby.funzy.Throwing; +import org.killbill.billing.util.api.RecordIdApi; import org.killbill.commons.concurrent.Executors; +import org.killbill.commons.utils.collect.Iterables; public class LogsSseHandler implements Sse.Handler, Closeable { @@ -38,10 +40,12 @@ public class LogsSseHandler implements Sse.Handler, Closeable { private static final long PERIOD_MS = 1000; private final LogEntriesManager logEntriesManager; + private final RecordIdApi recordIdApi; private final ScheduledExecutorService scheduledExecutorService; - public LogsSseHandler(final LogEntriesManager logEntriesManager) { + public LogsSseHandler(final LogEntriesManager logEntriesManager, final RecordIdApi recordIdApi) { this.logEntriesManager = logEntriesManager; + this.recordIdApi = recordIdApi; this.scheduledExecutorService = Executors.newSingleThreadScheduledExecutor("LogsSseHandler"); } @@ -57,22 +61,35 @@ public void handle(final Request req, final Sse sse) { final UUID cacheId = UUID.fromString(sse.id()); logEntriesManager.subscribe(cacheId, lastEventId); + final LogEntriesFilter entriesFilter = new LogEntriesFilter(req, this.recordIdApi); + final AtomicReference lastLogId = new AtomicReference<>(); final ScheduledFuture future = scheduledExecutorService.scheduleAtFixedRate(new Runnable() { @Override public void run() { final Iterable logEntries = logEntriesManager.drain(cacheId); - if (!logEntries.iterator().hasNext()) { - // In case we have nothing to send, send a heartbeat to verify the client is still around - // That way, we can more quickly cleanup our subscriptions - // Note that we set the id as the last log id, so that we can easily resume - sse.event("heartbeat").id(lastLogId.get()).send(); + if (Iterables.isEmpty(logEntries)) { + sendHeartbeat(); } else { - for (final LogEntryJson logEntryJson : logEntries) { + for (final LogEntryJson logEntryJson : entriesFilter.apply(logEntries)) { sse.event(logEntryJson).id(logEntryJson.getId()).send(); lastLogId.set(logEntryJson.getId()); } + // Filtered clients may never match any entry. Without a periodic write, + // proxies/load balancers may kill idle connections and server won't detect dead peers. + if (entriesFilter.hasNoResult()) { + sendHeartbeat(); + } + } + } + + void sendHeartbeat() { + // send heartbeat. ID, if any, is the last log id so resume works correctly. + final Sse.Event heartbeat = sse.event("heartbeat"); + if (lastLogId.get() != null) { + heartbeat.id(lastLogId.get()); } + heartbeat.send(); } }, INITIAL_DELAY_MS, PERIOD_MS, TimeUnit.MILLISECONDS); diff --git a/osgi-bundles/bundles/logger/src/test/java/org/killbill/billing/osgi/bundles/logger/TestLogsSseHandler.java b/osgi-bundles/bundles/logger/src/test/java/org/killbill/billing/osgi/bundles/logger/TestLogsSseHandler.java new file mode 100644 index 000000000..dec3b2c22 --- /dev/null +++ b/osgi-bundles/bundles/logger/src/test/java/org/killbill/billing/osgi/bundles/logger/TestLogsSseHandler.java @@ -0,0 +1,581 @@ +/* + * Copyright 2020-2026 Equinix, Inc + * Copyright 2014-2026 The Billing Project, LLC + * + * The Billing Project 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.killbill.billing.osgi.bundles.logger; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.jooby.Env; +import org.jooby.Jooby; +import org.jooby.jetty.Jetty; +import org.jooby.json.Jackson; +import org.killbill.billing.ObjectType; +import org.killbill.billing.util.api.RecordIdApi; +import org.osgi.service.log.LogService; +import org.mockito.Mockito; +import org.testng.Assert; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import com.google.inject.Binder; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; +import com.typesafe.config.ConfigValueFactory; + +public class TestLogsSseHandler { + + private static final UUID ACCOUNT_ID = UUID.randomUUID(); + private static final Long ACCOUNT_RECORD_ID = 42L; + + private Jooby app; + private LogEntriesManager logEntriesManager; + private LogsSseHandler logsSseHandler; + private RecordIdApi recordIdApi; + private int port; + + @BeforeMethod(groups = "slow") + public void setUp() throws Exception { + port = findFreePort(); + logEntriesManager = new LogEntriesManager(); + recordIdApi = Mockito.mock(RecordIdApi.class); + Mockito.when(recordIdApi.getRecordId(Mockito.eq(ACCOUNT_ID), Mockito.eq(ObjectType.ACCOUNT), Mockito.any())) + .thenReturn(ACCOUNT_RECORD_ID); + logsSseHandler = new LogsSseHandler(logEntriesManager, recordIdApi); + + final Config testConfig = ConfigFactory.empty("test-config") + .withValue("server.join", ConfigValueFactory.fromAnyRef(false)) + .withValue("application.port", ConfigValueFactory.fromAnyRef(port)) + .withValue("server.module", ConfigValueFactory.fromAnyRef(Jetty.class.getName())); + + app = new Jooby() {{ + use(new Jackson()); + sse("/", logsSseHandler); + }}; + + app.use(new Jooby.Module() { + @Override + public void configure(final Env env, final Config config, final Binder binder) { + } + + @Override + public Config config() { + return testConfig; + } + }); + + app.start(); + } + + @AfterMethod(groups = "slow") + public void tearDown() throws Exception { + if (logsSseHandler != null) { + logsSseHandler.close(); + } + if (app != null) { + app.stop(); + } + } + + @Test(groups = "slow") + public void testReceivesLogEntriesBeforeConnect() throws Exception { + recordErrorEvent("com.acme.MyService", "Something broke", "user-token-123", "1", "2"); + recordInfoEvent("com.acme.OtherService", "All good here", null, null, null); + + final HttpResponse response = connectSse(); + final ExecutorService reader = Executors.newSingleThreadExecutor(); + try { + final Future future = reader.submit(() -> { + final StringBuilder sb = new StringBuilder(); + try (final BufferedReader br = new BufferedReader(new InputStreamReader(response.body()))) { + String line; + while ((line = br.readLine()) != null) { + sb.append(line).append('\n'); + if (sb.toString().contains("Something broke") && sb.toString().contains("All good here")) { + break; + } + } + } + return sb.toString(); + }); + + final String sseOutput = future.get(10, TimeUnit.SECONDS); + + Assert.assertTrue(sseOutput.contains("Something broke"), "Expected 'Something broke' in SSE output"); + Assert.assertTrue(sseOutput.contains("All good here"), "Expected 'All good here' in SSE output"); + Assert.assertTrue(sseOutput.contains("ERROR"), "Expected 'ERROR' level in SSE output"); + Assert.assertTrue(sseOutput.contains("INFO"), "Expected 'INFO' level in SSE output"); + Assert.assertTrue(sseOutput.contains("user-token-123"), "Expected userToken in SSE output"); + Assert.assertFalse(sseOutput.contains("I never recorded in event")); + } finally { + reader.shutdownNow(); + response.body().close(); + } + } + + @Test(groups = "slow") + public void testReceivesLiveLogEntries() throws Exception { + final HttpResponse response = connectSse(); + Thread.sleep(1000); // wait for SSE subscription to be established + + recordWarningEvent("com.acme.LiveService", "Live event arrived", null, null, null); + + final ExecutorService reader = Executors.newSingleThreadExecutor(); + try { + final Future future = reader.submit(() -> { + final StringBuilder sb = new StringBuilder(); + try (final BufferedReader br = new BufferedReader(new InputStreamReader(response.body()))) { + String line; + while ((line = br.readLine()) != null) { + sb.append(line).append('\n'); + if (sb.toString().contains("Live event arrived")) { + break; + } + } + } + return sb.toString(); + }); + + final String sseOutput = future.get(10, TimeUnit.SECONDS); + + Assert.assertTrue(sseOutput.contains("Live event arrived"), "Expected live event in SSE output"); + Assert.assertTrue(sseOutput.contains("WARNING"), "Expected 'WARNING' level in SSE output"); + } finally { + reader.shutdownNow(); + response.body().close(); + } + } + + @Test(groups = "slow") + public void testFilterByAccountId() throws Exception { + recordErrorEvent("com.acme.ServiceA", "Matching account event", null, null, ACCOUNT_RECORD_ID.toString()); + recordErrorEvent("com.acme.ServiceB", "Other account event", null, null, "999"); + recordInfoEvent("com.acme.ServiceC", "No account event", null, null, null); + + final HttpResponse response = connectSse(Map.of("accountId", ACCOUNT_ID.toString())); + final ExecutorService reader = Executors.newSingleThreadExecutor(); + try { + final Future future = reader.submit(() -> { + final StringBuilder sb = new StringBuilder(); + try (final BufferedReader br = new BufferedReader(new InputStreamReader(response.body()))) { + String line; + while ((line = br.readLine()) != null) { + sb.append(line).append('\n'); + if (sb.toString().contains("Matching account event")) { + break; + } + } + } + return sb.toString(); + }); + + final String sseOutput = future.get(10, TimeUnit.SECONDS); + + Assert.assertTrue(sseOutput.contains("Matching account event")); + Assert.assertFalse(sseOutput.contains("Other account event")); + Assert.assertFalse(sseOutput.contains("No account event")); + } finally { + reader.shutdownNow(); + response.body().close(); + } + } + + @Test(groups = "slow") + public void testFilterByUserToken() throws Exception { + recordErrorEvent("com.acme.ServiceA", "Token match event", "my-token", null, null); + recordErrorEvent("com.acme.ServiceB", "Other token event", "other-token", null, null); + recordInfoEvent("com.acme.ServiceC", "No token event", null, null, null); + + final HttpResponse response = connectSse(Map.of("userToken", "my-token")); + final ExecutorService reader = Executors.newSingleThreadExecutor(); + try { + final Future future = reader.submit(() -> { + final StringBuilder sb = new StringBuilder(); + try (final BufferedReader br = new BufferedReader(new InputStreamReader(response.body()))) { + String line; + while ((line = br.readLine()) != null) { + sb.append(line).append('\n'); + if (sb.toString().contains("Token match event")) { + break; + } + } + } + return sb.toString(); + }); + + final String sseOutput = future.get(10, TimeUnit.SECONDS); + + Assert.assertTrue(sseOutput.contains("Token match event")); + Assert.assertFalse(sseOutput.contains("Other token event")); + Assert.assertFalse(sseOutput.contains("No token event")); + } finally { + reader.shutdownNow(); + response.body().close(); + } + } + + @Test(groups = "slow") + public void testFilterByAccountIdOrUserToken() throws Exception { + // OR logic: entry passes if it matches accountId OR userToken + recordErrorEvent("com.acme.ServiceA", "Account match", null, null, ACCOUNT_RECORD_ID.toString()); + recordErrorEvent("com.acme.ServiceB", "Token match", "my-token", null, "999"); + recordInfoEvent("com.acme.ServiceC", "Neither match", "other-token", null, "888"); + + final HttpResponse response = connectSse(Map.of( + "accountId", ACCOUNT_ID.toString(), + "userToken", "my-token")); + final ExecutorService reader = Executors.newSingleThreadExecutor(); + try { + final Future future = reader.submit(() -> { + final StringBuilder sb = new StringBuilder(); + try (final BufferedReader br = new BufferedReader(new InputStreamReader(response.body()))) { + String line; + while ((line = br.readLine()) != null) { + sb.append(line).append('\n'); + if (sb.toString().contains("Account match") && sb.toString().contains("Token match")) { + break; + } + } + } + return sb.toString(); + }); + + final String sseOutput = future.get(10, TimeUnit.SECONDS); + + Assert.assertTrue(sseOutput.contains("Account match"), "Expected account-matched entry (OR)"); + Assert.assertTrue(sseOutput.contains("Token match"), "Expected token-matched entry (OR)"); + Assert.assertFalse(sseOutput.contains("Neither match"), "Should not contain unrelated entry"); + } finally { + reader.shutdownNow(); + response.body().close(); + } + } + + @Test(groups = "slow") + public void testHeartbeatSentWhenAllEntriesFilteredOut() throws Exception { + // Record entries that will NOT match the filter + recordErrorEvent("com.acme.ServiceA", "Unrelated event", "other-token", null, "999"); + recordInfoEvent("com.acme.ServiceB", "Another unrelated", null, null, null); + + final HttpResponse response = connectSse(Map.of("userToken", "non-existent-token")); + final ExecutorService reader = Executors.newSingleThreadExecutor(); + try { + final Future future = reader.submit(() -> { + final StringBuilder sb = new StringBuilder(); + try (final BufferedReader br = new BufferedReader(new InputStreamReader(response.body()))) { + String line; + while ((line = br.readLine()) != null) { + sb.append(line).append('\n'); + if (sb.toString().contains("heartbeat")) { + break; + } + } + } + return sb.toString(); + }); + + final String sseOutput = future.get(10, TimeUnit.SECONDS); + + Assert.assertTrue(sseOutput.contains("heartbeat"), "Expected heartbeat when no entries match filter"); + Assert.assertFalse(sseOutput.contains("Unrelated event"), "Filtered entries should not appear"); + Assert.assertFalse(sseOutput.contains("Another unrelated"), "Filtered entries should not appear"); + } finally { + reader.shutdownNow(); + response.body().close(); + } + } + + @Test(groups = "slow") + public void testFullSseLifecycle() throws Exception { + // -- Step 1-3: Connect without filter or Last-Event-ID. Read initial entries. -- + recordInfoEvent("com.acme.Init", "Entry one", null, null, null); + recordInfoEvent("com.acme.Init", "Entry two", null, null, null); + + String lastReceivedId; + try (final SseClientForTesting session = SseClientForTesting.connect(port)) { + final String output = session.readUntil("Entry one", "Entry two"); + Assert.assertTrue(output.contains("Entry one")); + Assert.assertTrue(output.contains("Entry two")); + + // Extract the last event id from the SSE stream for reconnection later + lastReceivedId = extractLastEventId(output); + Assert.assertNotNull(lastReceivedId, "Should have received at least one event id"); + } + + // -- Step 4: Server adds entry with userToken -- + recordErrorEvent("com.acme.Auth", "Token event", "request-abc", null, null); + + // -- Step 5: Filter by non-matching userToken -> heartbeat only -- + try (final SseClientForTesting session = SseClientForTesting.connect(port, Map.of("userToken", "non-existent"))) { + final String output = session.readUntil("heartbeat"); + Assert.assertTrue(output.contains("heartbeat")); + Assert.assertFalse(output.contains("Token event")); + Assert.assertFalse(output.contains("Entry one")); + } + + // -- Step 6: Filter by matching userToken -> match -- + try (final SseClientForTesting session = SseClientForTesting.connect(port, Map.of("userToken", "request-abc"))) { + final String output = session.readUntil("Token event"); + Assert.assertTrue(output.contains("Token event")); + Assert.assertFalse(output.contains("Entry one"), "Non-matching entries should be filtered"); + } + + // -- Step 7: Server adds entry with accountRecordId -- + recordWarningEvent("com.acme.Billing", "Account event", null, null, ACCOUNT_RECORD_ID.toString()); + + // -- Step 8a: Filter by non-matching accountId -> heartbeat only -- + final UUID wrongAccountId = UUID.randomUUID(); + try (final SseClientForTesting session = SseClientForTesting.connect(port, Map.of("accountId", wrongAccountId.toString()))) { + final String output = session.readUntil("heartbeat"); + Assert.assertTrue(output.contains("heartbeat")); + Assert.assertFalse(output.contains("Account event")); + } + + // -- Step 8b: Filter by matching accountId -> match -- + try (final SseClientForTesting session = SseClientForTesting.connect(port, Map.of("accountId", ACCOUNT_ID.toString()))) { + final String output = session.readUntil("Account event"); + Assert.assertTrue(output.contains("Account event")); + Assert.assertFalse(output.contains("Entry one"), "Non-matching entries should be filtered"); + } + + // -- Step 9: Filter with both userToken AND accountId (OR logic) -> either match passes -- + try (final SseClientForTesting session = SseClientForTesting.connect(port, Map.of( + "accountId", ACCOUNT_ID.toString(), + "userToken", "request-abc"))) { + final String output = session.readUntil("Token event", "Account event"); + Assert.assertTrue(output.contains("Token event"), "userToken-matched entry should pass (OR)"); + Assert.assertTrue(output.contains("Account event"), "accountRecordId-matched entry should pass (OR)"); + Assert.assertFalse(output.contains("Entry one"), "Non-matching entries should be filtered"); + } + + // -- Step 10: Filter with no match -> heartbeat only -- + try (final SseClientForTesting session = SseClientForTesting.connect(port, Map.of("userToken", "totally-unknown"))) { + final String output = session.readUntil("heartbeat"); + Assert.assertTrue(output.contains("heartbeat")); + Assert.assertFalse(output.contains("Token event")); + Assert.assertFalse(output.contains("Account event")); + Assert.assertFalse(output.contains("Entry one")); + } + + // -- Step 11: Server adds more entries -- + recordInfoEvent("com.acme.New", "Fresh entry after resume point", null, null, null); + + // -- Step 12: Reconnect with Last-Event-ID (no filter) -> only entries after that ID -- + try (final SseClientForTesting session = SseClientForTesting.connect(port, Map.of(), lastReceivedId)) { + final String output = session.readUntil("Fresh entry after resume point"); + Assert.assertTrue(output.contains("Fresh entry after resume point"), "New entry should be received"); + Assert.assertTrue(output.contains("Token event"), "Entry added after resume point should be received"); + Assert.assertTrue(output.contains("Account event"), "Entry added after resume point should be received"); + Assert.assertFalse(output.contains("Entry one"), "Entry before Last-Event-ID should not be received"); + Assert.assertFalse(output.contains("Entry two"), "Entry before Last-Event-ID should not be received"); + } + } + + // -- Helpers -- + + private void recordInfoEvent(final String loggerName, + final String message, + final String userToken, + final String tenantRecordId, + final String accountRecordId) { + recordEvent(LogService.LOG_INFO, loggerName, message, userToken, tenantRecordId, accountRecordId); + } + + private void recordWarningEvent(final String loggerName, + final String message, + final String userToken, + final String tenantRecordId, + final String accountRecordId) { + recordEvent(LogService.LOG_WARNING, loggerName, message, userToken, tenantRecordId, accountRecordId); + } + + private void recordErrorEvent(final String loggerName, + final String message, + final String userToken, + final String tenantRecordId, + final String accountRecordId) { + recordEvent(LogService.LOG_ERROR, loggerName, message, userToken, tenantRecordId, accountRecordId); + } + + private void recordEvent(final int level, + final String loggerName, + final String message, + final String userToken, + final String tenantRecordId, + final String accountRecordId) { + logEntriesManager.recordEvent(new LogEntryJson(null, level, loggerName, message, userToken, tenantRecordId, accountRecordId, null)); + } + + private HttpResponse connectSse(final Map queryParams) throws Exception { + final StringBuilder uriBuilder = new StringBuilder("http://localhost:").append(port).append("/"); + if (!queryParams.isEmpty()) { + final String query = queryParams.entrySet().stream() + .map(e -> e.getKey() + "=" + e.getValue()) + .collect(Collectors.joining("&")); + uriBuilder.append("?").append(query); + } + + final HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + + final HttpRequest request = HttpRequest.newBuilder(URI.create(uriBuilder.toString())) + .header("Accept", "text/event-stream") + .GET() + .build(); + + final HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + Assert.assertEquals(response.statusCode(), 200); + return response; + } + + private HttpResponse connectSse() throws Exception { + return connectSse(Map.of()); + } + + private static int findFreePort() throws Exception { + try (final ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + /** + * Extracts the last "id:" value from raw SSE output. + * SSE format: "id:some-uuid\n" + */ + private static String extractLastEventId(final String sseOutput) { + String lastId = null; + for (final String line : sseOutput.split("\n")) { + if (line.startsWith("id:")) { + lastId = line.substring(3).trim(); + } + } + return lastId; + } +} + + +/** + * Lightweight SSE client for tests. Each instance represents one connection lifecycle: + * connect → read → close. + */ +class SseClientForTesting implements AutoCloseable { + + private final HttpResponse response; + private final ExecutorService reader; + + private SseClientForTesting(final HttpResponse response) { + this.response = response; + this.reader = Executors.newSingleThreadExecutor(); + } + + public static SseClientForTesting connect(final int port) throws Exception { + return connect(port, Map.of(), null); + } + + public static SseClientForTesting connect(final int port, final Map queryParams) throws Exception { + return connect(port, queryParams, null); + } + + public static SseClientForTesting connect(final int port, + final Map queryParams, + final String lastEventId) throws Exception { + final StringBuilder uriBuilder = new StringBuilder("http://localhost:").append(port).append("/"); + if (!queryParams.isEmpty()) { + final String query = queryParams.entrySet().stream() + .map(e -> e.getKey() + "=" + e.getValue()) + .collect(Collectors.joining("&")); + uriBuilder.append("?").append(query); + } + + final HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(URI.create(uriBuilder.toString())) + .header("Accept", "text/event-stream") + .GET(); + + if (lastEventId != null) { + requestBuilder.header("Last-Event-ID", lastEventId); + } + + final HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + + final HttpResponse response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() != 200) { + throw new IllegalStateException("SSE connection failed with status " + response.statusCode()); + } + + return new SseClientForTesting(response); + } + + /** + * Reads the SSE stream until all expected strings are found, or times out. + */ + public String readUntil(final String... expectedContents) throws Exception { + return readUntil(10, expectedContents); + } + + /** + * Reads the SSE stream until all expected strings are found, or times out. + */ + public String readUntil(final int timeoutSeconds, final String... expectedContents) throws Exception { + final Future future = reader.submit(() -> { + final StringBuilder sb = new StringBuilder(); + try (final BufferedReader br = new BufferedReader(new InputStreamReader(response.body()))) { + String line; + while ((line = br.readLine()) != null) { + sb.append(line).append('\n'); + if (containsAll(sb.toString(), expectedContents)) { + break; + } + } + } + return sb.toString(); + }); + return future.get(timeoutSeconds, TimeUnit.SECONDS); + } + + @Override + public void close() throws Exception { + reader.shutdownNow(); + response.body().close(); + } + + private static boolean containsAll(final String content, final String... expected) { + for (final String s : expected) { + if (!content.contains(s)) { + return false; + } + } + return true; + } +} From b3826165e4c1bb7f8bbae295648aa09d8b5d1221 Mon Sep 17 00:00:00 2001 From: xsalefter Date: Sat, 11 Jul 2026 15:03:54 +0700 Subject: [PATCH 3/5] call super.start(), make sure killbillAPI created to construct RecordIdApi --- .../org/killbill/billing/osgi/bundles/logger/Activator.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/osgi-bundles/bundles/logger/src/main/java/org/killbill/billing/osgi/bundles/logger/Activator.java b/osgi-bundles/bundles/logger/src/main/java/org/killbill/billing/osgi/bundles/logger/Activator.java index 26989abdd..8b29c77cc 100644 --- a/osgi-bundles/bundles/logger/src/main/java/org/killbill/billing/osgi/bundles/logger/Activator.java +++ b/osgi-bundles/bundles/logger/src/main/java/org/killbill/billing/osgi/bundles/logger/Activator.java @@ -44,6 +44,7 @@ public class Activator extends KillbillActivatorBase { @Override public void start(final BundleContext context) throws Exception { + super.start(context); loggerFactory = new KillbillLoggerFactory(context.getBundle()); logEntriesManager = new LogEntriesManager(); killbillLogListener = new KillbillLogWriter(logEntriesManager, loggerFactory); @@ -58,7 +59,7 @@ public void start(final BundleContext context) throws Exception { registrar = new OSGIKillbillRegistrar(); final PluginApp pluginApp = new PluginAppBuilder(PLUGIN_NAME).build(); - logsSseHandler = new LogsSseHandler(logEntriesManager); + logsSseHandler = new LogsSseHandler(logEntriesManager, killbillAPI.getRecordIdApi()); pluginApp.sse("/", logsSseHandler); final HttpServlet httpServlet = PluginApp.createServlet(pluginApp); registerServlet(context, httpServlet); From a63d87f13490750916218ef546cc352a4b63d2e2 Mon Sep 17 00:00:00 2001 From: xsalefter Date: Sat, 11 Jul 2026 15:04:34 +0700 Subject: [PATCH 4/5] add dependency to com.typesafe:config and jakarta.annotation-api --- osgi-bundles/bundles/logger/pom.xml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/osgi-bundles/bundles/logger/pom.xml b/osgi-bundles/bundles/logger/pom.xml index 8c08033a1..c2c1d9c09 100644 --- a/osgi-bundles/bundles/logger/pom.xml +++ b/osgi-bundles/bundles/logger/pom.xml @@ -44,6 +44,20 @@ com.google.code.findbugs jsr305 + + + com.google.inject + guice + + + + com.typesafe + config + + + jakarta.annotation + jakarta.annotation-api + jakarta.servlet jakarta.servlet-api From e7d5835267a8b44279fe14889be2793439b0cfed Mon Sep 17 00:00:00 2001 From: xsalefter Date: Sat, 11 Jul 2026 15:05:29 +0700 Subject: [PATCH 5/5] add simple demo for easy SSE manual testing independently --- osgi-bundles/bundles/logger/html-sse-demo.md | 168 +++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 osgi-bundles/bundles/logger/html-sse-demo.md diff --git a/osgi-bundles/bundles/logger/html-sse-demo.md b/osgi-bundles/bundles/logger/html-sse-demo.md new file mode 100644 index 000000000..dac631a8a --- /dev/null +++ b/osgi-bundles/bundles/logger/html-sse-demo.md @@ -0,0 +1,168 @@ +### HTML SSE Demo + +1. Build the project: `mvn clean install -pl osgi-bundles/bundles/logger` +2. Copy the JAR to `platform` plugin directory +3. Copy HTML content below to `` +4. Run this using `python3 -m http.server 3000 -d ` or `npx serve -l 3000 ` + +```html + + + + + SSE Logger Client + + + +

SSE Logger Client

+
+
+ + +
+
+ + +
+
+ + +
+ + + ● Disconnected +
+
+ + + + + +``` \ No newline at end of file