OCIロギングサービス用Appender

Google Cloud PlatformからOracle Cloud Infrastructureに乗り換えつつありますが、ログ周りでAppenderが提供されていないので自分で書いてみました。

## 参考情報
- [GCPで使っていたAppender](https://github.com/googleapis/java-logging-logback/blob/main/src/main/java/com/google/cloud/logging/logback/LoggingAppender.java)
- [AWSのCloud Watch用のAppender](https://github.com/j256/cloudwatch-logback-appender/blob/master/src/main/java/com/j256/cloudwatchlogbackappender/CloudWatchAppender.java)

最初はこれらを参考にSlf4j用にAppenderを書いていましたが途中からLog4j2用にする必要があり改めて以下を参考にしました。
- [AWS Cloud Watch Log4j2用](https://github.com/sensefly-sa/log4j-cloudwatch-appender/blob/master/src/main/java/io/sensefly/logging/log4j/CloudWatchAppender.java)

AbstractAppenderをextendsしPluginとして作成することがキモで、ログ出力のタイミングで呼ばれるappendでの処理についてはほとんど同じ感じでいけそうです。

## OCI SDKの認証方法
OCI SDKの認証方法はOCI以外の環境からも使用するのでAPIキーベース認証としました。
秘密キーなどをどのように設定するかJava SDKの多くのサンプルでは~/.oci/configから読み込むように実装されていますがサーバレス環境で使いづらいのでlog4j2.xmlに設定するようにしました。

```log4j2.xml
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
  <Appenders>
    <OciLoggingAppender
        name="OCI"
        logSource="[ログ中のsourceに設定される文字列]"
        logId="[OCIコンソールで作成したカスタムログのOCID]"
        tenantId="[カスタムログを作成したテナントのOCID]"
        userId="[ログを書き込むユーザのOCID]"
        fingerprint="[ログを書き込むユーザに追加したAPIキーのフィンガープリント]"
        region="[カスタムログを作成したテナントのリージョン]"
        privateKey="[ログを書き込むユーザに追加したAPIキーの秘密キー]"
        >
      <PatternLayout pattern="%d{yyyy/MM/dd HH:mm:ss.SSS} %-5level - %msg%n" />
    </OciLoggingAppender>
  </Appenders>
  <Loggers>
    <Root level="info">
      <AppenderRef ref="OCI" />
    </Root>
  </Loggers>
</Configuration>
```
regionは東京であれば以下のようになります。
```
region="ap-tokyo-1"
```
privateKeyにはダウンロードしたPEM形式の文字列を"\n"で結合して１行にして設定します。
```
privateKey="-----BEGIN PRIVATE KEY-----\n...\n-----END PRI\VATE KEY-----"
```

## 実装
```OCILoggingAppender.java
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;

import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.core.Appender;
import org.apache.logging.log4j.core.Core;
import org.apache.logging.log4j.core.Filter;
import org.apache.logging.log4j.core.Layout;
import org.apache.logging.log4j.core.LogEvent;
import org.apache.logging.log4j.core.appender.AbstractAppender;
import org.apache.logging.log4j.core.config.plugins.Plugin;
import org.apache.logging.log4j.core.config.plugins.PluginAttribute;
import org.apache.logging.log4j.core.config.plugins.PluginElement;
import org.apache.logging.log4j.core.config.plugins.PluginFactory;
import org.apache.logging.log4j.core.config.plugins.validation.constraints.Required;
import org.apache.logging.log4j.core.impl.ThrowableProxy;
import org.json.JSONObject;

import com.oracle.bmc.Region;
import com.oracle.bmc.auth.AuthenticationDetailsProvider;
import com.oracle.bmc.auth.SimpleAuthenticationDetailsProvider;
import com.oracle.bmc.auth.StringPrivateKeySupplier;
import com.oracle.bmc.loggingingestion.LoggingClient;
import com.oracle.bmc.loggingingestion.model.LogEntry;
import com.oracle.bmc.loggingingestion.model.LogEntryBatch;
import com.oracle.bmc.loggingingestion.model.PutLogsDetails;
import com.oracle.bmc.loggingingestion.requests.PutLogsRequest;
import com.oracle.bmc.loggingingestion.responses.PutLogsResponse;

@Plugin(name="OciLoggingAppender", category=Core.CATEGORY_NAME, elementType=Appender.ELEMENT_TYPE)
public class OciLoggingAppender extends AbstractAppender {

    private LoggingClient logging;
    private Layout<? extends Serializable> layout;
    private String logId;
    private String logSource = "application.log";
    private String logType = "custom.application";
    private String subject = "custom.logging";

    private BlockingQueue<LogEvent> loggingEventQueue;
    private Thread loggingWriterThread;
    private Boolean interrupted = false;

    private static final String LOG_SPEC_VERSION = "1.0";
    private static final int INITIAL_WAIT_TIME_MILLIS = 0;
    private static final int MAX_BATCH_SIZE = 128;
    private static final int MAX_BATCH_TIME_MILLIS = 5000;
    private static final int MAX_QUEUE_WAIT_TIME_MILLIS = 100;
    private static final int INTERNAL_QUEUE_SIZE = 8192;
    private static final int PUT_REQUEST_RETRY_COUNT = 2;

    protected OciLoggingAppender(String name, Filter filter, Layout<? extends Serializable> layout, String logId, String logSource, String logType, String subject, String tenantId, String userId, String fingerprint, String privateKey, String region) {
        super(name, filter, layout);
        this.logId = logId;
        if (logSource != null) {
            this.logSource = logSource;
        }
        if (logType != null) {
            this.logType = logType;
        }
        if (subject != null) {
            this.subject = subject;
        }
        final AuthenticationDetailsProvider authenticationDetailsProvider =
            SimpleAuthenticationDetailsProvider.builder()
            .tenantId(tenantId)
            .userId(userId)
            .fingerprint(fingerprint)
            .privateKeySupplier(new StringPrivateKeySupplier(privateKey))
            .region(Region.valueOf(region))
            .build();
        logging = LoggingClient.builder().build(authenticationDetailsProvider);
        loggingEventQueue = new ArrayBlockingQueue<LogEvent>(INTERNAL_QUEUE_SIZE);
        loggingWriterThread = new Thread(new LoggingWriter(), getClass().getSimpleName());
        loggingWriterThread.setDaemon(true);
        loggingWriterThread.start();
    }

    @PluginFactory
    public static OciLoggingAppender createAppender(@PluginAttribute(value = "name", defaultString = "OciLoggingAppender") String name,
                                                    @PluginElement("Filter") final Filter filter,
                                                    @PluginElement("Layout") Layout<? extends Serializable> layout,
                                                    @PluginAttribute("logId") @Required(message = "LogId is required") String logId,
                                                    @PluginAttribute("logSource") String logSource,
                                                    @PluginAttribute("logType") String logType,
                                                    @PluginAttribute("subject") String subject,
                                                    @PluginAttribute("tenantId") @Required(message = "TenantId is required") String tenantId,
                                                    @PluginAttribute("userId") @Required(message = "UserId is required") String userId,
                                                    @PluginAttribute("fingerprint") @Required(message = "Fingerprint is required") String fingerprint,
                                                    @PluginAttribute("privateKey") @Required(message = "PrivateKey is required") String privateKey,
                                                    @PluginAttribute("region") @Required(message = "Region is required") String region) {
        privateKey = privateKey.replaceAll("\\\\n", "\n");
        return new OciLoggingAppender(name, filter, layout, logId, logSource, logType, subject, tenantId, userId, fingerprint, privateKey, region);
    }

    @Override
    public synchronized void stop() {
        if (!isStarted()) {
            return;
        }
        interrupted = true;
        try {
            loggingWriterThread.join(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        if (logging != null) {
            try {
                logging.close();
            } catch (Exception ex) {
                // ignore
            }
        }
        logging = null;
        super.stop();
    }

	@Override
	public void append(LogEvent event) {
        if (logging == null) {
            return;
        }
        if (loggingEventQueue == null) {
            return;
        }
        try {
            loggingEventQueue.offer(event.toImmutable(), MAX_QUEUE_WAIT_TIME_MILLIS, TimeUnit.MILLISECONDS);
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
        }
	}

    private class LoggingWriter implements Runnable {
        @Override
        public void run() {
            try {
                Thread.sleep(INITIAL_WAIT_TIME_MILLIS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }

            List<LogEvent> events = new ArrayList<>(MAX_BATCH_SIZE);
            Thread thread = Thread.currentThread();
            while (!interrupted) {
                long batchTimeout = System.currentTimeMillis() + MAX_BATCH_TIME_MILLIS;
                while (!interrupted) {
                    long timeoutMillis = batchTimeout - System.currentTimeMillis();
                    if (timeoutMillis < 0) {
                        break;
                    }
                    LogEvent loggingEvent;
                    try {
                        loggingEvent = loggingEventQueue.poll(timeoutMillis, TimeUnit.MILLISECONDS);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        break;
                    }
                    if (loggingEvent == null) {
                        break;
                    }
                    events.add(loggingEvent);
                    if (events.size() >= MAX_BATCH_SIZE) {
                        break;
                    }
                }
                if (!events.isEmpty()) {
                    writeEvents(events);
                    events.clear();
                }
            }
            events.clear();
            while (true) {
                LogEvent event = loggingEventQueue.poll();
                if (event == null) {
                    break;
                }
                events.add(event);
                if (events.size() >= MAX_BATCH_SIZE) {
                    writeEvents(events);
                    events.clear();
                }
            }
            if (!events.isEmpty()) {
                writeEvents(events);
                events.clear();
            }
        }

        private void writeEvents(List<LogEvent> events) {
            LogEntry.Builder logEntryBuilder = LogEntry.builder();
            List<LogEntry> logEntries = new ArrayList<>();
            for (LogEvent event : events) {
                StringBuilder payload = new StringBuilder().append(new String(getLayout().toByteArray(event), StandardCharsets.UTF_8)).append('\n');
                writeStack(event.getThrownProxy(), "", payload);
                JSONObject json = new JSONObject();
                json.put("message", payload.toString());
                json.put("logLevel", event.getLevel());
                logEntries.add(logEntryBuilder
                               .data(json.toString())
                               .id(UUID.randomUUID().toString())
                               .time(new Date(event.getTimeMillis()))
                               .build());
            }
            if (logEntries.size() > 0) {
                putLogs(logId, logSource, logType, subject, logEntries);
            }
        }

        private void writeStack(ThrowableProxy throwProxy, String prefix, StringBuilder payload) {
            if (throwProxy == null) {
                return;
            }
            payload.append(prefix).append(throwProxy.getClass().getName()).append(": ").append(throwProxy.getMessage()).append('\n');
            //throwProxy.
            StackTraceElement[] trace = throwProxy.getStackTrace();
            if (trace == null) {
                trace = new StackTraceElement[0];
            }
            int commonFrames = throwProxy.getCommonElementCount();
            int printFrames = trace.length - commonFrames;
            for (int i = 0; i < printFrames; i++) {
                payload.append("    ").append(trace[i]).append('\n');
            }
            if (commonFrames != 0) {
                payload.append("    ... ").append(commonFrames).append(" common frames elided\n");
            }
            writeStack(throwProxy.getCauseProxy(), "caused by: ", payload);
        }

        private PutLogsResponse putLogs(String logId, String logSource, String logType, String subject, List<LogEntry> logEntries) {
            LogEntryBatch.Builder logEntryBatchBuilder = LogEntryBatch.builder();
            LogEntryBatch logEntryBatch = logEntryBatchBuilder
                .entries(logEntries)
                .source(logSource)
                .type(logType)
                .subject(subject)
                .build();
            PutLogsDetails putLogsDetails = PutLogsDetails.builder()
                .specversion(LOG_SPEC_VERSION)
                .logEntryBatches(new ArrayList<>(Arrays.asList(logEntryBatch)))
                .build();
            PutLogsRequest putLogsRequest = PutLogsRequest.builder()
                .logId(logId)
                .putLogsDetails(putLogsDetails)
                .build();
            PutLogsResponse response = logging.putLogs(putLogsRequest);
            return response;
        }
    }
}
```

## 課題
5000ミリ秒間隔でまとめ書きしているのでこの間にプログラム終了するとキューイングされているログが出力されません。
とりあえずカッコ悪いですが終了前に6000ミリ秒sleepさせています。
```
    Thread.sleep(6000);
```