Redouble AI Nucleo documentation
Part 8 · Seeing what happened

Logs, traces, metrics and cost

ai.redouble.nucleo.harness.observability · nucleo-core

The events of the previous pages are raw facts: this job started, that one failed, this model call returned so many tokens. What operators work with is different: log lines they can read and filter, traces that show where a slow request spent its time, metrics on a dashboard, and what a run cost. This package turns the one into the other. Each of its classes is an observer, subscribed to the dispatcher's message bus like an observer of your own, and like every observer it runs on its own queue, so none of them slows a job.

The observers here are:

What runs without code of yours

In a Spring Boot or a Quarkus application, the integration subscribes two of them when the dispatcher starts: an EventLogger, so every job shows up in the application's log, and a CostLedger, which it also registers as the dispatcher's spend gate so caps are enforced. Its NucleoRuntime bean hands you both, through dispatcher() and ledger() (Spring Boot, Quarkus). The other observers you subscribe yourself, where the application starts.

A plain Java program subscribes whatever it wants after starting the dispatcher:

JobDispatcher dispatcher = JobDispatcher.getInstance();
dispatcher.start();
dispatcher.subscribe(new EventLogger(), JobEvent.class);
dispatcher.subscribe(new SystemHealthReporter(), JobEvent.class);

All EventLogger instances are equal, so subscribing a second one where the integration has already subscribed the first changes nothing, apart from a warning in the log.

Logging

EventLogger writes one line per event: the workflow id, the job id and its state, then the event's message, colored so that one workflow's lines stand out from another's. A failure is followed by its stack trace, unless its error is one written for the model to read or a child job's failure whose trace that child's own line already printed. It leaves out the limiter events, which come by the thousand; SystemHealthReporter summarizes those.

Each line goes to the logger of the job it is about, named after the job's class plus one segment for the kind of event, and each kind has its level:

SegmentEventLevel
failureFailureEventWARN
lifecycleany other LifecycleEventINFO
retryRetryEventDEBUG
workflowWorkflowTerminationEventINFO
notificationUserNotificationEventINFO and SUCCESS at DEBUG, WARNING at WARN, ERROR at ERROR
streamContentStreamEventDEBUG
progressany other ProgressEventDEBUG
systemSystemEventINFO
heartbeatHeartbeatEventINFO
eventany other eventINFO

So at the usual INFO level the log shows every job starting and ending, and every failure; the steps inside a run, its progress, the tools a thinker starts and the retries, are at DEBUG. You choose what you see the way you do for any logger. Set the demo's agent, ai.redouble.demo.DemoAgent, to DEBUG and you see all of its own lines, the tools it starts and completes among them; set ai.redouble.demo.DemoAgent.progress alone and you see its progress lines only; set a package and it covers every job class under it. The model calls and the tools an agent runs are jobs of their own and log under their own classes: a model call under ai.redouble.nucleo.tools.thinking.LLMCall, so its retries arrive on ai.redouble.nucleo.tools.thinking.LLMCall.retry. Events that belong to no job log under the event's own class plus the segment.

While a line is written, the logging context (the MDC) holds the event's workflow id under the key workflowId (EventLogger.MDC_WORKFLOW_ID). A logging backend can use it to print the workflow on every line, or to lower the level for one workflow alone, as Logback's DynamicThresholdFilter does, while every other workflow stays at the configured level.

Watching one workflow on the console

StreamingLogObserver prints what one workflow's jobs stream and report: the text a model streams, printed as it arrives, and each progress report as Progress: N% followed by its message. It is meant for development, where you watch one run in a terminal:

Identifiable workflow = Job.workflow("you", "orders");
dispatcher.observeWorkflow(new StreamingLogObserver<>(workflow.getWorkflowId()), JobEvent.class);

It unsubscribes itself when the workflow completes. Another constructor takes an inactivity timeout, after which the bus removes it, and two flags that turn the streamed text and the progress lines on and off.

The state of the whole runtime

SystemHealthReporter prints a block every fifty events it receives. It lists every limit jobs are admitted under (Admission), fullest first, with how much of it is in use and how many jobs wait on it; a limit that is refusing work or has slowed itself down is marked and tinted. Then the memory gate's state, the running jobs in total and by kind (thinkers, tools, model calls and the rest), the workflows with a job running, and the dispatcher's queues with the number of jobs waiting in admission. It answers "why is nothing moving" at a glance: a limit at 100% with a crowd of waiters is where the jobs are.

Traces

OpenTelemetryObserver gives every job a span. The span opens when the job starts, closes when it ends, and hangs under the span of the job that submitted it, so an agent's trace shows each model call and each tool call inside it, with its duration. A failed job's span has status ERROR and the exception recorded. It needs io.opentelemetry:opentelemetry-api on the classpath, which nucleo-core leaves optional, and an OpenTelemetry SDK and exporter set up by the application:

Tracer tracer = openTelemetry.getTracer("my-app");
dispatcher.subscribe(new OpenTelemetryObserver(tracer, new DefaultSpanCustomizer()), JobEvent.class);

What goes on a span is decided by the SpanCustomizer you pass, which receives each event of a job together with its open span. DefaultSpanCustomizer sets the job's identity when it starts (job.id, job.parent_id, workflow.id, user.id, job.type, job.display_name, job.action, job.attempt), adds a span event for each progress message and for each limiter change while the job runs, and at the end sets the attempts, the duration and the job's model use (llm.calls, llm.input_tokens, llm.output_tokens, llm.cache_read_tokens, llm.cache_creation_tokens, llm.latency_ms, llm.models). To record something else, write your own customizer: it is one method, customize(event, span), usually a switch on the event.

Metrics

MicrometerObserver hands every event, with a Micrometer MeterRegistry, to a MeterCustomizer that decides what to record. It needs io.micrometer:micrometer-core, also optional in nucleo-core, and a registry from the application (Prometheus, Datadog or any other):

dispatcher.subscribe(new MicrometerObserver(registry, new DefaultMeterCustomizer()), JobEvent.class);

DefaultMeterCustomizer records:

No metric is tagged with a job id, since there would be one series per job. The job id is on the trace spans.

What a run costs

CostLedger prices every model call a finished job made, using the prices in the model catalog (The catalog, grades and the picker), and keeps the result per workflow, per model and per call. A failed job's calls count too, since a call that ran and failed still used its tokens. Once subscribed, it answers while the work runs:

A Cost is an amount and a currency, and Nucleo never converts between currencies: a deployment that pays one provider in dollars and another in euros sees two totals. A call on a model the catalog gives no price is counted with its tokens and marked unpriced, and never priced at a guess.

Capping what a workflow spends

A workflow is capped with cap(workflowId, cost) on a ledger registered as the dispatcher's spend gate (registerSpendGate, which the Spring Boot and Quarkus integrations do for you). From then on, before each job of that workflow is given anything, the ledger adds up what the workflow has spent, what its running jobs have reserved, and the most the new job's model calls can cost. When the sum would pass the cap, the job is refused with a SpendCapExceededException whose message names the cap, the spend, what is in flight and the reservation. Counting what is in flight is what stops a fan-out of a hundred jobs from all being admitted before the first one is priced.

A cap stops new work only: jobs already running finish. Under a cap, a job whose model has no price, or a price in a currency the workflow has no cap for, is refused as well, since a spend nobody can state cannot be held to a cap. uncap lifts the caps. Staying under the cap is as exact as the reservations: the provider's invoice can differ from the runtime's count of each call by a little, as the design essay explains.

Recording runs for good

Every observer here keeps what it knows in memory. A deployment that needs a record of every job and every model call subscribes its own observer to LifecycleEvent and writes the terminal events, with the model responses they carry, to its own store. None ships with the runtime. Heartbeats have an observer base of their own, Stethoscope (Heartbeats).

Example: A dollar cap on a workflow - a ledger subscribed and registered as the spend gate, a cap of one cent, and the run it refuses at admission.

How it works inside

The exact rules of each observer, of Cost and of the ledger, with the tests that hold them, and the inner workings of the limiter stream are in Inside observability, for those working on the runtime itself.

^