Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ jobs:
cmake -B build \
-DCMAKE_TOOLCHAIN_FILE="$VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake" \
-DCMAKE_BUILD_TYPE=Release \
-DVCPKG_MANIFEST_FEATURES=tests \
-DGO_PLUGIN_LOG_ABSL=ON \
-DGO_PLUGIN_BUILD_TESTS=ON \
-DGO_PLUGIN_BUILD_EXAMPLES=ON \
-G Ninja

- name: Build
Expand Down
24 changes: 20 additions & 4 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.20)
project(go-plugin-cpp VERSION 0.1.0 LANGUAGES CXX)
project(go-plugin-cpp VERSION 0.2.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
Expand All @@ -9,16 +9,27 @@ find_package(gRPC CONFIG REQUIRED)
find_package(Protobuf CONFIG REQUIRED)
find_package(OpenSSL REQUIRED)

# Explicit, not detected: the installed package's dependencies should not vary
# with whatever happened to be present when it was built.
option(GO_PLUGIN_LOG_ABSL "Build the Abseil logging bridge" OFF)

if(GO_PLUGIN_LOG_ABSL)
find_package(absl CONFIG REQUIRED)
endif()

add_subdirectory(src)

option(GO_PLUGIN_BUILD_TESTS "Build tests" ON)
# gtest is a manifest feature, so a consumer of the library never installs it.
option(GO_PLUGIN_BUILD_TESTS "Build tests" OFF)
if(GO_PLUGIN_BUILD_TESTS)
find_package(GTest CONFIG REQUIRED)
enable_testing()
add_subdirectory(tests)
endif()

option(GO_PLUGIN_BUILD_EXAMPLES "Build examples" ON)
# The only thing here that generates protobuf code, so with it off the library
# needs neither protoc nor grpc_cpp_plugin.
option(GO_PLUGIN_BUILD_EXAMPLES "Build examples" OFF)
if(GO_PLUGIN_BUILD_EXAMPLES)
add_subdirectory(example)
endif()
Expand All @@ -38,7 +49,12 @@ write_basic_package_version_file(
COMPATIBILITY SameMajorVersion
)

install(TARGETS go_plugin
set(GO_PLUGIN_INSTALL_TARGETS go_plugin)
if(GO_PLUGIN_LOG_ABSL)
list(APPEND GO_PLUGIN_INSTALL_TARGETS go_plugin_log_absl)
endif()

install(TARGETS ${GO_PLUGIN_INSTALL_TARGETS}
Comment thread
devgianlu marked this conversation as resolved.
EXPORT go_plugin-targets
ARCHIVE DESTINATION lib
LIBRARY DESTINATION lib
Expand Down
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,56 @@ This library handles:
configured automatically.
- **Handshake line output** – writes the correctly formatted line to stdout so the host can connect.
- **Health-check service** – the built-in gRPC health-check service is registered automatically.
- **Logging the host can read** – see below.

## Logging

A host parses a plugin's standard error as hclog JSON and reads nothing else. A line in any other shape reaches the
host's logs as one opaque string at the host's own level, with the plugin's severity and fields buried inside it — so
a plugin error cannot surface as an error, and nothing downstream can filter on a field.

`go_plugin::log` writes that format. It depends on nothing but the standard library:

```cpp
#include "go_plugin/log.hpp"

go_plugin::log::Info("sink opened", {{"rate", 48000}, {"path", pipe_path}});
go_plugin::log::Error("write failed", {{"error", strerror(errno)}});
```

### Bridging an existing logging library

A plugin that already logs through a library keeps its call sites and installs a bridge. Each backend is a separate
target, so a plugin links only the one it uses:

| Backend | Target | Header | Build with | Install with |
|---------|--------|--------|------------|--------------|
| Abseil (`LOG`/`VLOG`) | `go_plugin::go_plugin_log_absl` | `go_plugin/log_absl.hpp` | `-DGO_PLUGIN_LOG_ABSL=ON` | `go_plugin::log::InstallAbslBridge()` |

```cpp
absl::InitializeLog();
go_plugin::log::InstallAbslBridge(); // LOG(WARNING) now reaches the host as a warning
```

Abseil has no debug or trace severity of its own — they exist only as `VLOG` verbosities — so the bridge maps `VLOG(1)`
to debug and `VLOG(2)` and above to trace, and it stops Abseil writing its own copy of each line to standard error.

To add another backend, translate its records into `go_plugin::log::Submit` and add a target beside the Abseil one;
nothing in the core changes.

### Logging from a C library

A C library that writes its own diagnostics can be routed through the same path rather than left to print unattributed
text. FFmpeg, for example, takes a callback, which lets the library's own name travel as a field instead of a pointer
address that makes every line unique:

```cpp
av_log_set_level(AV_LOG_WARNING);
av_log_set_callback([](void *avcl, int level, const char *fmt, va_list args) {
// format into a buffer, then:
go_plugin::log::Write(LevelFor(level), text, {{"avclass", av_default_item_name(avcl)}});
});
```

## Building

Expand All @@ -37,6 +87,20 @@ cmake -B build \
cmake --build build -j
```

That builds the library alone. The tests and the example are opt-in, so a consumer installs neither gtest nor the
protobuf code generators they need:

```bash
cmake -B build \
-DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \
-DCMAKE_BUILD_TYPE=Release \
-DVCPKG_MANIFEST_FEATURES=tests \
-DGO_PLUGIN_LOG_ABSL=ON \
-DGO_PLUGIN_BUILD_TESTS=ON \
-DGO_PLUGIN_BUILD_EXAMPLES=ON
cmake --build build -j
```

### Running the tests

```bash
Expand Down
7 changes: 7 additions & 0 deletions cmake/go_plugin-config.cmake.in
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ find_dependency(gRPC CONFIG REQUIRED)
find_dependency(OpenSSL REQUIRED)
find_dependency(Protobuf CONFIG REQUIRED)

# The Abseil bridge's exported target names absl:: libraries, so they have to
# exist by the time the export is read — a consumer that never links the bridge
# would otherwise fail on find_package(go_plugin) alone.
if(@GO_PLUGIN_LOG_ABSL@)
find_dependency(absl CONFIG REQUIRED)
endif()

include("${CMAKE_CURRENT_LIST_DIR}/go_plugin-targets.cmake")

check_required_components(go_plugin)
101 changes: 101 additions & 0 deletions include/go_plugin/log.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#pragma once

#include <chrono>
#include <functional>
#include <initializer_list>
#include <string>
#include <string_view>

namespace go_plugin::log {

/**
* The only five severities go-plugin understands. Anything else leaves the host
* unable to tell the level and it files the line at its own.
*/
enum class Level { Trace, Debug, Info, Warn, Error };

class Field {
public:
Field(std::string_view key, std::string_view value);
Field(std::string_view key, const char* value);
Field(std::string_view key, const std::string& value);
Field(std::string_view key, bool value);
Field(std::string_view key, int value);
Field(std::string_view key, long long value);
Field(std::string_view key, unsigned long long value);
Field(std::string_view key, double value);

const std::string& key() const { return key_; }
const std::string& value() const { return value_; }

/** Whether the value is written as a JSON literal rather than a string. */
bool literal() const { return literal_; }

private:
Field(std::string_view key, std::string value, bool literal);

std::string key_;
std::string value_;
bool literal_ = false;
};

struct Record {
Level level = Level::Info;
std::string_view message;
const Field* fields = nullptr;
std::size_t field_count = 0;
std::chrono::system_clock::time_point timestamp;
};

/** Receives every record. The default encodes it and writes it to stderr. */
using Sink = std::function<void(const Record&)>;

/** Pass nullptr to restore the default. */
void SetSink(Sink sink);

/** Governs Write only, not Submit. Info by default. */
void SetLevel(Level min);
Level GetLevel();
bool Enabled(Level level);

void Write(Level level, std::string_view message, std::initializer_list<Field> fields = {});

inline void Trace(std::string_view message, std::initializer_list<Field> fields = {}) {
Write(Level::Trace, message, fields);
}
inline void Debug(std::string_view message, std::initializer_list<Field> fields = {}) {
Write(Level::Debug, message, fields);
}
inline void Info(std::string_view message, std::initializer_list<Field> fields = {}) {
Write(Level::Info, message, fields);
}
inline void Warn(std::string_view message, std::initializer_list<Field> fields = {}) {
Write(Level::Warn, message, fields);
}
inline void Error(std::string_view message, std::initializer_list<Field> fields = {}) {
Write(Level::Error, message, fields);
}

/**
* The seam a backend adapter sits on, so a library that already knows a line's
* time, severity and origin does not lose them to a second timestamp.
*
* SetLevel is deliberately not applied: the record comes from a library that
* has already decided to emit it, and dropping it again here would lose what a
* plugin meant to say.
*/
void Submit(const Record& record);

/** Renders a record in the host's format, without the trailing newline. */
std::string Encode(const Record& record);

/**
* Exactly six fractional digits, and an offset written either as "Z" or with a
* colon. A timestamp in any other shape makes the host reject the whole line
* and report it as unparsed text at its own level.
*/
std::string FormatTimestamp(std::chrono::system_clock::time_point tp);

std::string_view LevelName(Level level);

} // namespace go_plugin::log
32 changes: 32 additions & 0 deletions include/go_plugin/log_absl.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#pragma once

namespace go_plugin::log {

struct AbslBridgeOptions {
/**
* Abseil has no debug or trace severity of its own — they exist only as
* VLOG verbosities — so the mapping has to be stated. VLOG at or above this
* verbosity is reported as trace, below it as debug.
*/
int trace_from_verbosity = 2;

/** Carry Abseil's source file and line as a `caller` field. */
bool include_caller = true;

/**
* Stop Abseil writing its own copy of every line. Left on, each line
* reaches the host twice: once as unparsed text, once in the format it can
* read.
*/
bool silence_absl_stderr = true;
};

/**
* Routes Abseil's LOG() and VLOG() through the format the host parses, so a
* plugin keeps its own severity without touching a call site.
*
* Call after absl::InitializeLog(). Only the first call installs a sink.
*/
void InstallAbslBridge(const AbslBridgeOptions& options = {});

} // namespace go_plugin::log
19 changes: 18 additions & 1 deletion src/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
add_library(go_plugin STATIC
log.cpp
server.cpp
tls.cpp
)
Expand All @@ -12,12 +13,28 @@ target_include_directories(go_plugin
target_link_libraries(go_plugin
PUBLIC
gRPC::grpc++
gRPC::grpc++_reflection
protobuf::libprotobuf
OpenSSL::SSL
OpenSSL::Crypto
)

# One target per backend, so a consumer links only the one it uses.
if(GO_PLUGIN_LOG_ABSL)
add_library(go_plugin_log_absl STATIC log_absl.cpp)
target_link_libraries(go_plugin_log_absl
PUBLIC
go_plugin
absl::log
absl::log_sink
absl::log_sink_registry
absl::log_entry
absl::log_globals
absl::log_severity
absl::time
)
target_compile_options(go_plugin_log_absl PRIVATE -Wall -Wextra -Wpedantic)
endif()

target_compile_options(go_plugin PRIVATE
-Wall -Wextra -Wpedantic
)
Loading
Loading