Skip to content

Commit 0614d6a

Browse files
Logging
1 parent 8f2cf94 commit 0614d6a

6 files changed

Lines changed: 259 additions & 27 deletions

File tree

sdk-core/src/main/java/dev/restate/sdk/core/RequestProcessorImpl.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ private void onUserCodeResult(@Nullable Slice slice, @Nullable Throwable throwab
221221
try {
222222
if (throwable != null) {
223223
if (throwable instanceof TerminalException) {
224-
LOG.info("Invocation completed with terminal error", throwable);
224+
LOG.info("Invocation ended with terminal error", throwable);
225225
stateMachine.writeOutput((TerminalException) throwable);
226226
stateMachine.end();
227227
} else if (ExceptionUtils.containsAbortedExecutionException(throwable)) {
@@ -233,6 +233,7 @@ private void onUserCodeResult(@Nullable Slice slice, @Nullable Throwable throwab
233233
} else {
234234
stateMachine.writeOutput(Objects.requireNonNullElse(slice, Slice.EMPTY));
235235
stateMachine.end();
236+
LOG.info("Invocation ended successfully");
236237
}
237238
} catch (Throwable e) {
238239
// Error happened when trying to write the final bits
@@ -249,6 +250,7 @@ private void onUserCodeResult(@Nullable Slice slice, @Nullable Throwable throwab
249250

250251
private void startHandler() {
251252
state = State.RUNNING_HANDLER;
253+
LOG.info("Invocation started");
252254

253255
// Get vm input
254256
StateMachine.Input stateMachineInput = stateMachine.input();

sdk-core/src/main/java/dev/restate/sdk/core/legacy/State.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,6 @@ default void hitSuspended(Collection<NotificationId> awaitingOn, StateContext st
202202
}
203203

204204
default void end(StateContext stateContext) {
205-
LOG.info("Invocation ended");
206-
207205
stateContext.writeMessageOut(Protocol.EndMessage.getDefaultInstance());
208206
stateContext.getStateHolder().transition(new ClosedState());
209207

sdk-core/src/main/java/dev/restate/sdk/core/legacy/WaitingStartState.java

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,6 @@ public void onNewMessage(InvocationInput invocationInput, StateContext stateCont
4646
: null));
4747
stateContext.setEagerState(new EagerState(startMessage));
4848

49-
// Tracing and logging setup
50-
LOG.info("Start invocation");
51-
5249
// Execute state transition
5350
stateContext.getStateHolder().transition(new WaitingReplayEntriesState());
5451
}

sdk-core/src/main/java23/dev/restate/sdk/core/statemachine/ffm/FfmStateMachine.java

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -71,19 +71,9 @@ public final class FfmStateMachine implements StateMachine {
7171
// Load the native library BEFORE SharedCoreNative is class-initialized so its
7272
// SymbolLookup.loaderLookup() resolves the vm_* symbols.
7373
NativeLibraryLoader.ensureLoaded();
74-
SharedCoreNative.init(defaultLogLevel());
75-
}
76-
77-
private static int defaultLogLevel() {
78-
// 0 trace, 1 debug, 2 info, 3 warn, 4 error
79-
String level = System.getProperty("dev.restate.sharedcore.loglevel", "INFO");
80-
return switch (level.trim().toUpperCase(java.util.Locale.ROOT)) {
81-
case "TRACE" -> 0;
82-
case "DEBUG" -> 1;
83-
case "WARN" -> 3;
84-
case "ERROR" -> 4;
85-
default -> 2;
86-
};
74+
// Install the native tracing subscriber: filter at the host's configured level (so disabled
75+
// callsites short-circuit in the core) and forward surviving events to Log4j2 via an upcall.
76+
SharedCoreNative.init(NativeLogging.abiLevel(), NativeLogging.callbackStub());
8777
}
8878

8979
public FfmStateMachine(HeadersAccessor headersAccessor) {
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH
2+
//
3+
// This file is part of the Restate Java SDK,
4+
// which is released under the MIT license.
5+
//
6+
// You can find a copy of the license in file LICENSE in the root
7+
// directory of this repository or package, or at
8+
// https://github.com/restatedev/sdk-java/blob/main/LICENSE
9+
package dev.restate.sdk.core.statemachine.ffm;
10+
11+
import java.lang.foreign.Arena;
12+
import java.lang.foreign.FunctionDescriptor;
13+
import java.lang.foreign.Linker;
14+
import java.lang.foreign.MemorySegment;
15+
import java.lang.foreign.ValueLayout;
16+
import java.lang.invoke.MethodHandle;
17+
import java.lang.invoke.MethodHandles;
18+
import java.lang.invoke.MethodType;
19+
import java.nio.charset.StandardCharsets;
20+
import java.util.Locale;
21+
import org.apache.logging.log4j.Level;
22+
import org.apache.logging.log4j.LogManager;
23+
import org.apache.logging.log4j.Logger;
24+
import org.apache.logging.log4j.spi.StandardLevel;
25+
26+
/**
27+
* Bridges the native {@code restate-sdk-shared-core} tracing output into Log4j2.
28+
*
29+
* <p>At startup the FFM state machine installs a {@code tracing} subscriber in the native core (see
30+
* {@code init} in {@code rust/src/lib.rs}) parameterized with:
31+
*
32+
* <ul>
33+
* <li>a <b>max level</b> ({@link #abiLevel()}) derived from this class's Log4j2 logger, so the
34+
* core filters disabled events on its side — critically, its {@code #[instrument(level =
35+
* "trace")]} spans short-circuit before doing any work when trace is off;
36+
* <li>a <b>log callback</b> ({@link #callbackStub()}): an FFM upcall stub the core invokes for
37+
* every event that passes the filter.
38+
* </ul>
39+
*
40+
* <p>The callback is invoked synchronously on the same thread that made the downcall into the core,
41+
* so a native log line naturally inherits that thread's logging context (the {@code
42+
* restateInvocationId} / {@code restateInvocationTarget} MDC set by the endpoint handler) — a
43+
* Log4j2 pattern using {@code %X{restateInvocationId}} works unchanged.
44+
*
45+
* <p><b>Copy semantics.</b> The core hands the callback a borrowed {@code (ptr, len)} view over a
46+
* buffer that is valid only for the duration of the call. We therefore eagerly copy the bytes into
47+
* a JVM-heap {@code String} <b>during the call</b>: an async Log4j2 appender that retains the
48+
* message then holds a self-contained Java object, never a view over native memory that has since
49+
* been reused or freed.
50+
*/
51+
final class NativeLogging {
52+
53+
private NativeLogging() {}
54+
55+
/** Dedicated logger all native (shared-core) events are routed through. */
56+
static final String LOGGER_NAME = "dev.restate.sdk.core.StateMachine";
57+
58+
private static final Logger LOG = LogManager.getLogger(LOGGER_NAME);
59+
60+
/**
61+
* Optional override for the native max level, bypassing the Log4j2-derived value. Accepts {@code
62+
* TRACE|DEBUG|INFO|WARN|ERROR}.
63+
*/
64+
private static final String LEVEL_OVERRIDE_PROPERTY =
65+
"dev.restate.sdk.core.statemachine.ffm.FfmStateMachine.loglevel";
66+
67+
// ABI ordinal -> Log4j2 level. Mirrors AbiLogLevel in rust/src/lib.rs.
68+
private static final Level[] LEVELS = {
69+
Level.TRACE, Level.DEBUG, Level.INFO, Level.WARN, Level.ERROR
70+
};
71+
72+
private static final FunctionDescriptor CALLBACK_DESC =
73+
FunctionDescriptor.ofVoid(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.JAVA_LONG);
74+
75+
/**
76+
* Upcall entry point invoked by the native core for each tracing event. Must never throw across
77+
* the FFI boundary, so everything is wrapped in a catch-all.
78+
*
79+
* @param level the {@link #LEVELS} ordinal
80+
* @param msgPtr native pointer to UTF-8 bytes, valid only for this call
81+
* @param msgLen length in bytes of the message at {@code msgPtr}
82+
*/
83+
static void log(int level, MemorySegment msgPtr, long msgLen) {
84+
try {
85+
Level l = level >= 0 && level < LEVELS.length ? LEVELS[level] : Level.INFO;
86+
// The native side already filtered by max level; this guards against per-logger config that
87+
// is stricter than the max, and avoids the decode when the event would be dropped anyway.
88+
if (!LOG.isEnabled(l)) {
89+
return;
90+
}
91+
// Eagerly copy the native bytes into a JVM-heap String *now*, before returning. This is the
92+
// single copy that makes the whole scheme safe: the native buffer is valid only for this
93+
// call, so an async Log4j2 appender that retains the message must be handed a self-contained
94+
// Java object, never a view over native memory.
95+
byte[] bytes = msgPtr.reinterpret(msgLen).toArray(ValueLayout.JAVA_BYTE);
96+
LOG.log(l, new String(bytes, StandardCharsets.UTF_8));
97+
} catch (Throwable ignored) {
98+
// Swallow: an exception unwinding into the native caller across `extern "C"` is UB.
99+
}
100+
}
101+
102+
/**
103+
* Builds the upcall stub for {@link #log}. Uses {@link Arena#global()} (never freed): the native
104+
* core keeps only the raw function pointer, which the GC cannot see, so the stub must outlive the
105+
* process — an {@link Arena#ofAuto()} stub would be reclaimed as soon as the returned segment
106+
* becomes unreachable (right after {@code init} returns), and the next event would call freed
107+
* code.
108+
*/
109+
static MemorySegment callbackStub() {
110+
try {
111+
MethodHandle handle =
112+
MethodHandles.lookup()
113+
.findStatic(
114+
NativeLogging.class,
115+
"log",
116+
MethodType.methodType(void.class, int.class, MemorySegment.class, long.class));
117+
return Linker.nativeLinker().upcallStub(handle, CALLBACK_DESC, Arena.global());
118+
} catch (NoSuchMethodException | IllegalAccessException e) {
119+
throw new ExceptionInInitializerError(e);
120+
}
121+
}
122+
123+
/**
124+
* The max native tracing level as an {@code AbiLogLevel} ordinal (0 trace .. 4 error), taken from
125+
* the {@link #LEVEL_OVERRIDE_PROPERTY} system property when set, otherwise from the effective
126+
* Log4j2 level of {@link #LOGGER_NAME}. Passed to native {@code init} so the core does not even
127+
* produce events below it.
128+
*/
129+
static int abiLevel() {
130+
String override = System.getProperty(LEVEL_OVERRIDE_PROPERTY);
131+
if (override != null) {
132+
return switch (override.trim().toUpperCase(Locale.ROOT)) {
133+
case "TRACE" -> 0;
134+
case "DEBUG" -> 1;
135+
case "WARN" -> 3;
136+
case "ERROR" -> 4;
137+
default -> 2;
138+
};
139+
}
140+
return abiLevel(LOG.getLevel());
141+
}
142+
143+
private static int abiLevel(Level level) {
144+
if (level == null) {
145+
return 2; // INFO
146+
}
147+
// Log4j2 intLevel decreases with severity (TRACE=600 .. ERROR=200); a logger set to level L
148+
// permits L and everything more severe. Map that to the coarser native max level.
149+
int i = level.intLevel();
150+
if (i >= StandardLevel.TRACE.intLevel()) {
151+
return 0;
152+
} else if (i >= StandardLevel.DEBUG.intLevel()) {
153+
return 1;
154+
} else if (i >= StandardLevel.INFO.intLevel()) {
155+
return 2;
156+
} else if (i >= StandardLevel.WARN.intLevel()) {
157+
return 3;
158+
}
159+
return 4;
160+
}
161+
}

sdk-core/src/main/rust/src/lib.rs

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,28 +59,100 @@ use restate_sdk_shared_core::{
5959
};
6060
use std::borrow::Cow;
6161
use std::convert::Infallible;
62+
use std::ffi::c_void;
63+
use std::fmt::Write as _;
6264
use std::mem::MaybeUninit;
6365
use std::slice;
6466
use std::time::Duration;
67+
use tracing::field::{Field, Visit};
6568
use tracing::level_filters::LevelFilter;
66-
use tracing::Level;
69+
use tracing::{Event, Level, Subscriber};
70+
use tracing_subscriber::layer::{Context, Layer, SubscriberExt};
71+
use tracing_subscriber::registry::LookupSpan;
72+
use tracing_subscriber::util::SubscriberInitExt;
6773

6874
// =========================================================================
6975
// Init & logging
7076
// =========================================================================
7177

78+
/// Zero-copy log sink installed by the host: `(level, msg_ptr, msg_len)`. `level` is the
79+
/// [`AbiLogLevel`] ordinal; the `(ptr, len)` pair is a UTF-8 view into a Rust-owned, thread-local
80+
/// buffer that is valid **only for the synchronous duration of this call**. The callee must copy
81+
/// the bytes out before returning and must never retain the pointer — no allocation crosses the
82+
/// boundary and nothing is freed here (contrast the owned `Slice` results). It must also never
83+
/// unwind (the Java upcall swallows all exceptions), as unwinding across `extern "C"` is UB.
84+
type LogCallback = extern "C" fn(level: u32, msg_ptr: *const u8, msg_len: usize);
85+
86+
/// Install the process-global tracing subscriber and panic hook. `level` is the max [`AbiLogLevel`]
87+
/// to emit — set from the host's logging configuration so disabled callsites (e.g. the core's
88+
/// `#[instrument(level = "trace")]` spans) short-circuit before doing any work. Events that pass the
89+
/// filter are formatted into a thread-local buffer and forwarded to the host via [`LogCallback`]
90+
/// (zero-copy).
91+
///
92+
/// This must be called exactly once. `log_callback` must be non-null (the host always installs a
93+
/// sink), and a second call — or any pre-existing global subscriber — **panics**: silently ignoring
94+
/// it would hide a real wiring bug and leave logs going nowhere.
7295
#[no_mangle]
73-
pub unsafe extern "C" fn init(level: u32) {
96+
pub unsafe extern "C" fn init(level: u32, log_callback: *const c_void) {
7497
std::panic::set_hook(Box::new(|panic| {
7598
eprintln!("[restate-shared-core] core panicked: {panic}");
7699
}));
100+
assert!(
101+
!log_callback.is_null(),
102+
"init called with a null log callback"
103+
);
77104
let level: Level = AbiLogLevel::from(level).into();
78-
let _ = tracing_subscriber::fmt()
79-
.with_ansi(false)
80-
.with_writer(std::io::stderr)
81-
.with_max_level(LevelFilter::from_level(level))
82-
.with_target(level == Level::TRACE)
83-
.try_init();
105+
let max = LevelFilter::from_level(level);
106+
107+
// SAFETY: the host passed a non-null upcall stub with the `LogCallback` signature. On all
108+
// supported targets a data pointer and a C function pointer share a representation.
109+
let callback: LogCallback = std::mem::transmute(log_callback);
110+
// `.init()` (not `try_init`) panics if a global subscriber is already set — see the doc above.
111+
tracing_subscriber::registry()
112+
.with(JavaLogLayer { callback }.with_filter(max))
113+
.init();
114+
}
115+
116+
/// A `tracing` layer that forwards each event to the host [`LogCallback`]. The event's message and
117+
/// fields are rendered into a `String` and handed to the callback as a borrowed `(ptr, len)` view;
118+
/// the host copies the bytes out synchronously (which is what makes it safe against async log
119+
/// appenders), and the `String` is dropped as soon as the callback returns. Events are already
120+
/// filtered by max level, so allocating a fresh buffer per event is not on any hot path.
121+
struct JavaLogLayer {
122+
callback: LogCallback,
123+
}
124+
125+
impl<S> Layer<S> for JavaLogLayer
126+
where
127+
S: Subscriber + for<'a> LookupSpan<'a>,
128+
{
129+
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
130+
let level = AbiLogLevel::from(*event.metadata().level()) as u32;
131+
let mut msg = String::new();
132+
event.record(&mut MessageVisitor { buf: &mut msg });
133+
(self.callback)(level, msg.as_ptr(), msg.len());
134+
}
135+
}
136+
137+
/// Renders a `tracing` event into `buf` as `message key=value ...` (the message field verbatim,
138+
/// other fields as `key=value`). Span context is intentionally omitted — at INFO/DEBUG the core's
139+
/// trace-level spans don't exist, and per-invocation context (invocation id, target) already rides
140+
/// in the host's logging MDC via the same-thread upcall.
141+
struct MessageVisitor<'a> {
142+
buf: &'a mut String,
143+
}
144+
145+
impl Visit for MessageVisitor<'_> {
146+
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
147+
if !self.buf.is_empty() {
148+
self.buf.push(' ');
149+
}
150+
if field.name() == "message" {
151+
let _ = write!(self.buf, "{value:?}");
152+
} else {
153+
let _ = write!(self.buf, "{}={:?}", field.name(), value);
154+
}
155+
}
84156
}
85157

86158
#[repr(u32)]
@@ -104,6 +176,18 @@ impl From<u32> for AbiLogLevel {
104176
}
105177
}
106178

179+
impl From<Level> for AbiLogLevel {
180+
fn from(value: Level) -> Self {
181+
match value {
182+
Level::TRACE => AbiLogLevel::Trace,
183+
Level::DEBUG => AbiLogLevel::Debug,
184+
Level::INFO => AbiLogLevel::Info,
185+
Level::WARN => AbiLogLevel::Warn,
186+
Level::ERROR => AbiLogLevel::Error,
187+
}
188+
}
189+
}
190+
107191
impl From<AbiLogLevel> for Level {
108192
fn from(value: AbiLogLevel) -> Self {
109193
match value {

0 commit comments

Comments
 (0)