Logging
See what your agent did, per run: ctx.log records structured logs that Duraton captures, keeps durable under replay, and shows against the run.
ctx.log records structured, leveled logs from inside a workflow. Unlike a bare console.log - which
stays on the runner's stdout, invisible to Duraton - ctx.log lines flow to Duraton, persist
durably with the run, and are readable per run via the API.
Logging a line
ctx.log is callable (info level) and has one method per level:
const ticketCreated = defineWorkflow<TicketData>({
name: "ticket.created",
handler: async (ctx) => {
ctx.log.info("ticket received", { ticketId: ctx.event.data.ticketId });
const triage = await ctx.step.run("triage", async () => {
ctx.log.info("triaging ticket", { priority: "high" });
return triageTicket(ctx.event.data);
});
ctx.log.warn("triaged, refunding next", { refundId: triage.id });
},
});async def handle_ticket(ctx: StepContext) -> object:
ctx.log.info("ticket received", {"ticketId": ctx.event.data["ticketId"]})
async def triage_step():
ctx.log.info("triaging ticket", {"priority": "high"})
return triage_ticket(ctx.event.data)
triage = await ctx.step.run("triage", triage_step)
ctx.log.warn("triaged, refunding next", {"refundId": triage["id"]})
ticket_created = define_workflow("ticket.created", handle_ticket)var ticketCreated = duraton.DefineWorkflow(duraton.WorkflowDefinition{
Name: "ticket.created",
Handler: func(c *duraton.Context) (any, error) {
ticket, err := duraton.EventData[Ticket](c)
if err != nil {
return nil, err
}
c.Log.Info("ticket received", map[string]any{"ticketId": ticket.ID})
triage, err := duraton.Run(c, "triage", func() (Triage, error) {
c.Log.Info("triaging ticket", map[string]any{"priority": "high"})
return triageTicket(ticket)
})
if err != nil {
return nil, err
}
c.Log.Warn("triaged, refunding next", map[string]any{"refundId": triage.ID})
return nil, nil
},
})| Call | Level |
|---|---|
ctx.log(message, fields?) | info |
ctx.log.debug(message, fields?) | debug |
ctx.log.info(message, fields?) | info |
ctx.log.warn(message, fields?) | warn |
ctx.log.error(message, fields?) | error |
fields is an optional object of structured context. It is stored as JSON, so prefer structured
fields over interpolating values into the message.
Durable under replay
A handler re-runs from the top on every pass (durable
execution), so ctx.log is replay-aware:
- A handler-level log (outside any step) re-emits on every pass, but Duraton gives it a stable identity per attempt and records it exactly once.
- A log inside a
step.runonly executes on the pass where that step runs. A step that retries records its logs once per attempt, so you can see what each attempt did:
await ctx.step.run("call-upstream", async () => {
ctx.log.info("calling upstream", { attempt: ctx.attempt });
const res = await callUpstream();
if (!res.ok) {
ctx.log.warn("upstream failed, will retry");
throw new Error("upstream error");
}
return res;
});async def call_upstream_step():
ctx.log.info("calling upstream", {"attempt": ctx.attempt})
res = await call_upstream()
if not res.ok:
ctx.log.warn("upstream failed, will retry")
raise Exception("upstream error")
return res
await ctx.step.run("call-upstream", call_upstream_step)_, err := duraton.Run(c, "call-upstream", func() (*Response, error) {
c.Log.Info("calling upstream", map[string]any{"attempt": c.Attempt})
res, err := callUpstream()
if err != nil {
return nil, err
}
if !res.OK {
c.Log.Warn("upstream failed, will retry", nil)
return nil, fmt.Errorf("upstream error")
}
return res, nil
})
if err != nil {
return nil, err
}Redaction
Field values under sensitive key names (password, token, secret, authorization, and similar)
are masked to [redacted] before anything is persisted.
Redaction is keyed on field names, so prefer putting sensitive values in named fields rather than
inlining them into the free-text message.
Reading logs back
Fetch a run's logs oldest-first:
curl "$DURATON_URL/runs/<id>/logs"Each line carries its level, message, fields, scope (the step name, or @root for a
handler-level log), and attempt. See the Runs API for pagination and the
full response shape.
Limits
Each pass caps how many lines it ships so a pathologically chatty handler cannot overrun the 1 MiB wire-message limit; beyond the cap, a single line records how many were dropped. Logs are part of a run's data and are removed with the run.
See the logging example running end to end in Examples.
Retries & failure handling
Survive a flaky call without losing the run - only the failing step retries, and onFailure handlers plus replay cover the ones that run out.
Realtime
Watch a run as it happens instead of polling: runs.watch tails status transitions and logs over a durable, resumable per-run timeline.