Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
16 changes: 16 additions & 0 deletions crates/core/src/logging/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ use sink::log_level_filter;
pub(crate) use format::format_event_for_test;

static LOGGER_LIFECYCLE_LOCK: Mutex<()> = Mutex::new(());
static DEFAULT_LOGGING_RUNTIME: Mutex<Option<LoggingRuntime>> = Mutex::new(None);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

fn lock_logger_lifecycle() -> MutexGuard<'static, ()> {
LOGGER_LIFECYCLE_LOCK
Expand Down Expand Up @@ -170,6 +171,21 @@ pub fn init_logging(config: &LoggingConfig) -> Result<LoggingRuntime> {
LoggingRuntime::configure(config.clone())
}

/// Installs and retains the default process-wide logging runtime for a language binding.
///
/// Configuration is resolved from the supported logging environment variables, with built-in
/// defaults when none are present. Repeated initialization in the same linked runtime is a no-op.
#[doc(hidden)]
pub fn initialize_default_logging() -> Result<()> {
let mut runtime = DEFAULT_LOGGING_RUNTIME.lock().map_err(|error| {
FlowError::Internal(format!("default logging runtime lock poisoned: {error}"))
})?;
if runtime.is_none() {
*runtime = Some(LoggingRuntime::configure_from_environment()?);
Comment thread
willkill07 marked this conversation as resolved.
Resolved
}
Ok(())
}

#[cfg(test)]
#[path = "../../tests/coverage/logging_tests.rs"]
mod tests;
9 changes: 9 additions & 0 deletions crates/ffi/nemo_relay.h
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,15 @@ typedef char *(*NemoRelayToolExecInterceptCb)(void *user_data,
*/
typedef char *(*NemoRelayToolExecCb)(void *user_data, const char *args_json);

/**
* Initializes the Go binding runtime and installs default operational logging.
*
* Logging configuration is resolved from `NEMO_RELAY_LOG`,
* `NEMO_RELAY_LOG_STDERR_FORMAT`, or `NEMO_RELAY_LOG_CONFIG_PATH`, with built-in defaults when
* none are set. Repeated initialization is a no-op.
*/
NemoRelayStatus nemo_relay_initialize_default_logging(void);

/**
* Run the registered tool request intercept chain on the given arguments.
*
Expand Down
16 changes: 16 additions & 0 deletions crates/ffi/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,22 @@ fn tokio_runtime() -> &'static Runtime {
})
}

/// Initializes the Go binding runtime and installs default operational logging.
///
/// Logging configuration is resolved from `NEMO_RELAY_LOG`,
/// `NEMO_RELAY_LOG_STDERR_FORMAT`, or `NEMO_RELAY_LOG_CONFIG_PATH`, with built-in defaults when
/// none are set. Repeated initialization is a no-op.
#[unsafe(no_mangle)]
pub extern "C" fn nemo_relay_initialize_default_logging() -> NemoRelayStatus {
clear_last_error();
let result = nemo_relay::shared_runtime::initialize_shared_runtime_binding("go")
.and_then(|()| nemo_relay::logging::initialize_default_logging());
match result {
Ok(()) => NemoRelayStatus::Ok,
Err(error) => status_from_error(&error),
}
}

fn block_on_sync_ffi<T, F>(future: F) -> FlowResult<T>
where
T: Send,
Expand Down
2 changes: 2 additions & 0 deletions crates/node/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ fn effective_scope_top(
fn init() {
initialize_shared_runtime_binding("node")
.expect("node runtime ownership initialization should succeed");
nemo_relay::logging::initialize_default_logging()
.expect("node operational logging initialization should succeed");
register_adaptive_component()
.expect("node adaptive plugin component registration should succeed");
register_pii_redaction_component()
Expand Down
42 changes: 42 additions & 0 deletions crates/node/tests/logging_tests.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const packageDirectory = fileURLToPath(new URL('..', import.meta.url));
const loggingEnvironmentNames = ['NEMO_RELAY_LOG', 'NEMO_RELAY_LOG_STDERR_FORMAT', 'NEMO_RELAY_LOG_CONFIG_PATH'];

function requireBinding(loggingEnvironment) {
const environment = { ...process.env };
for (const name of loggingEnvironmentNames) {
delete environment[name];
}
Object.assign(environment, loggingEnvironment);
return spawnSync(process.execPath, ['-e', "require('./index.js')"], {
cwd: packageDirectory,
encoding: 'utf8',
env: environment,
});
}

describe('operational logging', () => {
it('initializes from the logging environment', () => {
const result = requireBinding({
NEMO_RELAY_LOG: 'info',
NEMO_RELAY_LOG_STDERR_FORMAT: 'jsonl',
});

assert.equal(result.status, 0, result.stderr);
assert.match(result.stderr, /"event":"logging_initialized"/);
});

it('rejects an invalid logging environment', () => {
const result = requireBinding({ NEMO_RELAY_LOG: '' });

assert.notEqual(result.status, 0);
assert.match(result.stderr, /NEMO_RELAY_LOG must not be empty/);
});
});
5 changes: 5 additions & 0 deletions crates/python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> {
"failed to initialize NeMo Relay runtime ownership: {e}"
))
})?;
nemo_relay::logging::initialize_default_logging().map_err(|e| {
pyo3::exceptions::PyRuntimeError::new_err(format!(
"failed to initialize NeMo Relay operational logging: {e}"
))
})?;
register_adaptive_component().map_err(|e| {
pyo3::exceptions::PyRuntimeError::new_err(format!(
"failed to register adaptive plugin component: {e}"
Expand Down
13 changes: 8 additions & 5 deletions docs/reference/operational-logging.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Use the source that matches how Relay is launched:
| Use Case | Configuration Source |
| --- | --- |
| Run the Relay CLI with temporary settings | `--log-*` options |
| Configure a process without a Relay config file | `NEMO_RELAY_LOG*` environment variables |
| Configure a language binding or CLI process | `NEMO_RELAY_LOG*` environment variables |
| Reuse logging settings across runs | `[logging]` in TOML |
| Embed Relay in a Rust application | `LoggingConfig` and `LoggingRuntime` |

Expand All @@ -40,8 +40,10 @@ For CLI processes, Relay selects one source in this order:
3. `[logging]` in the resolved Relay `config.toml`
4. Built-in defaults

Sources are selected rather than merged. Rust applications explicitly choose
which `LoggingRuntime` initialization method to use and do not apply the CLI
Sources are selected rather than merged. Python, Node.js, and Go install a
process-lifetime `LoggingRuntime` when the binding loads, using environment
configuration or built-in defaults. Rust applications explicitly choose which
`LoggingRuntime` initialization method to use and do not apply the CLI
precedence rules.

## CLI Options
Expand All @@ -63,8 +65,9 @@ Do not combine `--log-config-path` with `--log-level` or

## Environment Variables

Set these variables for the CLI or a Rust application that initializes logging
with `LoggingRuntime::configure_from_environment()`:
Set these variables for a Python, Node.js, or Go process, the CLI, or a Rust
application that initializes logging with
`LoggingRuntime::configure_from_environment()`:

```bash
export NEMO_RELAY_LOG=debug
Expand Down
67 changes: 67 additions & 0 deletions go/nemo_relay/logging_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package nemo_relay

import (
"os"
"os/exec"
"strings"
"testing"
)

const loggingHelperEnvironment = "NEMO_RELAY_TEST_LOGGING_HELPER"

var loggingEnvironmentNames = map[string]struct{}{
"NEMO_RELAY_LOG": {},
"NEMO_RELAY_LOG_STDERR_FORMAT": {},
"NEMO_RELAY_LOG_CONFIG_PATH": {},
}

func loggingTestEnvironment(values ...string) []string {
environment := make([]string, 0, len(os.Environ())+len(values))
for _, value := range os.Environ() {
name, _, _ := strings.Cut(value, "=")
if _, isLoggingEnvironment := loggingEnvironmentNames[name]; !isLoggingEnvironment {
environment = append(environment, value)
}
}
return append(environment, values...)
}

func TestBindingLoggingEnvironment(t *testing.T) {
if os.Getenv(loggingHelperEnvironment) == "1" {
return
}

t.Run("initializes from environment", func(t *testing.T) {
command := exec.Command(os.Args[0], "-test.run=TestBindingLoggingEnvironment")
command.Env = loggingTestEnvironment(
loggingHelperEnvironment+"=1",
"NEMO_RELAY_LOG=info",
"NEMO_RELAY_LOG_STDERR_FORMAT=jsonl",
)
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("binding import failed: %v\n%s", err, output)
}
if !strings.Contains(string(output), `"event":"logging_initialized"`) {
t.Fatalf("logging initialization event missing from output:\n%s", output)
}
})

t.Run("rejects invalid environment", func(t *testing.T) {
command := exec.Command(os.Args[0], "-test.run=TestBindingLoggingEnvironment")
command.Env = loggingTestEnvironment(
loggingHelperEnvironment+"=1",
"NEMO_RELAY_LOG=",
)
output, err := command.CombinedOutput()
if err == nil {
t.Fatalf("binding initialization unexpectedly succeeded:\n%s", output)
}
if !strings.Contains(string(output), "NEMO_RELAY_LOG must not be empty") {
t.Fatalf("logging initialization error missing from output:\n%s", output)
}
})
}
7 changes: 7 additions & 0 deletions go/nemo_relay/nemo_relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ typedef struct NemoRelayLlmSanitizeResponseContext { uint32_t codec_kind; const
typedef void (*NemoRelayFreeFn)(void* user_data);

// Core API
extern int32_t nemo_relay_initialize_default_logging(void);
extern int32_t nemo_relay_get_handle(FfiScopeHandle** out);
extern int32_t nemo_relay_push_scope(const char* name, int32_t scope_type, const FfiScopeHandle* parent, uint32_t attributes, const char* data_json, const char* metadata_json, const char* input_json, const int64_t* timestamp_unix_micros, FfiScopeHandle** out);
extern int32_t nemo_relay_pop_scope(const FfiScopeHandle* handle, const char* output_json, const char* metadata_json, const int64_t* timestamp_unix_micros);
Expand Down Expand Up @@ -298,6 +299,12 @@ import (

const defaultServiceName = "nemo-relay"

func init() {
if err := checkStatus(C.nemo_relay_initialize_default_logging()); err != nil {
panic(fmt.Sprintf("failed to initialize NeMo Relay operational logging: %v", err))
}
}

func checkedValue[T any](status int32, value T) (T, error) {
if err := checkStatus(C.int32_t(status)); err != nil {
var zero T
Expand Down
43 changes: 43 additions & 0 deletions python/tests/test_logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

import os
import subprocess
import sys

_LOG_ENVIRONMENT = (
"NEMO_RELAY_LOG",
"NEMO_RELAY_LOG_STDERR_FORMAT",
"NEMO_RELAY_LOG_CONFIG_PATH",
)


def _import_nemo_relay(**logging_environment: str) -> subprocess.CompletedProcess[str]:
environment = os.environ.copy()
for name in _LOG_ENVIRONMENT:
environment.pop(name, None)
environment.update(logging_environment)
return subprocess.run(
[sys.executable, "-c", "import nemo_relay"],
check=False,
capture_output=True,
text=True,
env=environment,
)


def test_binding_initializes_logging_from_environment():
completed = _import_nemo_relay(
NEMO_RELAY_LOG="info",
NEMO_RELAY_LOG_STDERR_FORMAT="jsonl",
)

assert completed.returncode == 0, completed.stderr
assert '"event":"logging_initialized"' in completed.stderr


def test_binding_rejects_invalid_logging_environment():
completed = _import_nemo_relay(NEMO_RELAY_LOG="")

assert completed.returncode != 0
assert "NEMO_RELAY_LOG must not be empty" in completed.stderr
Loading