Logging & observability
Your tools and workflow steps log with plain
console.*. The runtime records the timeline of every run on its own: each step, model call, tool call, connection, approval and guardrail is a row in your box's Postgres, and Studio draws it as a waterfall. Every line you print is captured, stamped with the run and the step that printed it, kept for 30 days in the same Postgres, and sent to your own stack when you set one environment variable. You wire no tracer and no exporter in your code.
The run timeline is automatic
You instrument nothing to get the timeline. A run is one chat turn of a deep agent or one execution of a durable workflow. As it goes, the runtime writes one row per thing that happened, and Studio shows each row as one bar:
- Workflow steps. Each
'use step'function is a checkpoint. It appears on the run with its timing, its result and, if it failed and retried, each attempt. This is the core observability primitive of a workflow: the event-log replay model means the run is its list of steps. - Agent turns, tool calls and subagents. Every turn in a session, every tool the model invoked during it and every subagent it delegated to, with inputs, outputs and timing. A subagent's own tools hang off it.
- Model calls. Each call a turn makes to a model, and each call through
stackbone.aiinside a workflow step, is its own row with the model, the provider, the latency and the input and output tokens. A model call made with your own client runs inside its step, so the runtime records the step's timing but not the call's tokens. - Connections, approvals and guardrails. A connector call through Stackbone Connect, a pause for a person and a guardrail verdict each get a row with what went in and what came out.
There is no span processor to register and no exporter to configure in your code. If something is missing from a trace, add a new kind of step rather than a second tracer.
Runs that pause
A workflow that hits requestApproval() or a
durable hook stops and shows on the timeline as interrupted / pending while
it waits for a human. When the decision arrives, the run resumes from exactly
that checkpoint and the rest of the steps appear. Read the timeline to see where
a long-lived run is parked.
Log from a tool or a workflow step
You write code in two places: a deep-agent tool, and the steps of a durable
workflow. In both, use console.*. The runtime knows which run and which step
is executing, so you never thread a run id through your code.
A tool, defined inline in deep-agents/<name>/index.ts:
import { tool } from '@langchain/core/tools';
import { stackbone, z } from '@stackbone/sdk';
const lookupOrder = tool(
async ({ orderId }: { orderId: string }) => {
console.info('looking up order', { orderId });
const rows = await stackbone.database.select().from(orders).where(eq(orders.id, orderId));
if (rows.length === 0) {
console.warn('order not found', { orderId });
}
return rows[0] ?? null;
},
{
name: 'lookup_order',
description: 'Look up an order by id.',
schema: z.object({ orderId: z.string() }),
},
);A durable workflow step in workflows/<name>.workflow.ts:
async function notifyPartner(partner: string) {
'use step';
console.warn('partner rate limited, backing off', { partner });
// ... do work ...
}tool(...) comes from the upstream
LangChain package; 'use step'
is a
Workflow SDK directive.
The stackbone handle is the ambient client,
the same object on every surface, no construction required.
Both lines land in Studio under Logs stamped with their run: the first with
the agent turn that ran the tool, the second with the workflow run and the
notifyPartner step. Open that step in the run's trace and you see the line
next to the step's input and output.
Pass fields as an object, not in the message
Put the variable data in an object next to the message. The fields stay
fields, so Studio filters on orderId instead of searching a formatted
string:
console.info('email sent', { to, templateId });is more useful later than interpolating the same value into the string:
console.info('email sent to ' + to); // no structured `to` to filter onKeep the message a short, stable phrase. Pass an Error as its own argument
and its type, message and stack are kept as exception.* fields rather than
flattened into text.
Log inside steps, not in the workflow body
A workflow function replays from its event log every time a step completes, so
a console.log in the workflow body itself runs again on every replay and
prints once per replay. Those lines are marked as coming from the workflow body
and Studio collapses the repeats, but the honest place for a log line is inside
a 'use step' function or a tool, which runs once.
Levels
console.debug, console.info, console.warn and console.error map to the
debug, info, warn and error levels; console.log is info. Studio and
the CLI filter on the level, so use warn and error for what you will want
to find in an incident and keep progress chatter at info or debug.
If you already log with pino
The capture works by patching console, so it reaches everything written
through console and nothing written below it. A logger that writes straight to
the file descriptor is below it. pino is the common case: it calls
process.stdout.write itself, so its lines arrive at the box's stdout as raw
text with no run, no level, no fields and no masking pass. They are not
scrubbed, not correlated and not stored as rows you can filter.
Point pino at the SDK's destination and every line it writes enters the same pipeline as everything else, keeping the level and the fields pino already built:
import pino from 'pino';
import { createPinoDestination } from '@stackbone/sdk';
const log = pino({ level: 'debug' }, createPinoDestination());
log.info({ orderId }, 'looking up order');With no runtime around (a unit test, a script) the destination falls through to
process.stdout, which is pino's own default, so wiring it up never silently
mutes your logger. The same applies to any other logger you point at the
destination: it takes a { write(chunk) } sink, which is the slot pino's
destination argument fills.
What happens to a line
Everything the box process emits goes through one pipeline before it is
stored: your console.* lines, the SDK's own structured lines and the
runtime's diagnostics. The same code runs under stackbone dev, in a
self-hosted box and in a deployed one.
| The pipeline... | So that... |
|---|---|
| stamps each line with the run and the step that printed it | opening a failed step in Studio shows exactly what it printed. The step id comes from the 'use step' function that is executing, so a workflow line carries both ids. A tool running inside an agent chat turn carries that turn's run and no step id yet, so those lines group at the run. A line printed outside any run (a scheduled job, boot output) is stored with no run and the name of what produced it, so it stays filterable. |
| masks secrets | an API key printed by mistake never reaches the store or the export. The values of your workspace's own secrets are masked exactly; bearer tokens, key prefixes and credentials inside URLs are masked by pattern. Nothing else is rewritten: an email address stays searchable. |
| caps size and volume | a talkative loop degrades your logs instead of filling the disk. An oversized line is cut and marked as truncated. Past a hard number of lines per run the rest are dropped and counted, with one marker line saying how many. |
| writes in batches and never blocks | a database hiccup costs you log lines, never a run. When the database was unreachable, one marker line names the interval and how many lines were lost, so a gap in the record is declared instead of looking like silence. |
| keeps lines for 30 days | you can investigate a complaint that arrived late. Configurable between 7 and 90 days in the workspace settings, with a storage budget in gigabytes that deletes the oldest day early. Both keys are accepted from an owner or an admin only, so the seat that writes the log lines cannot shorten the window they live in. One hourly job enforces both and shows its last run on the Recurring jobs screen. |
| serves a history beside the live tail | you find the line without exporting the table. Filters by level, run, time range and substring; text search always inside a time range. |
puts raw log reading behind logs:read |
the read-only seats do not see the rawest data in the product. owner, admin and member read logs; viewer and approver keep runs, with inputs, outputs and steps. |
Every line is scoped to the box it came from. One installation is one box with one Postgres, so "which workspace" is answered by which box you are looking at. After the window, a day is deleted. The compliance layer around that (a daily archive into your own bucket, deletion on request and legal hold) is designed and not built yet; the Observability feature page describes it with that marked.
Send logs and traces to your own stack
You keep the canonical copy in the box. Getting a copy out is a separate, optional step, and it never blocks a run. A collector that is down costs you a dashboard, not the evidence, and the export failure count shows on the settings screen.
A deployed box always writes to standard output. It emits the same scrubbed
lines as NDJSON, so Promtail, Alloy, Vector, Fluent Bit or your cloud's agent
pick them up like any other container. Under stackbone dev the
terminal keeps showing your original console.* line instead, so the local
console stays readable.
Set an endpoint and OTLP export turns on. The box pushes logs and traces over OTLP/HTTP with a JSON body to the collector you already run. The standard variables apply, on the container that runs the box:
| Variable | What it does |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
Base URL. The box appends /v1/logs and /v1/traces. This is the whole switch: with no endpoint set, nothing is exported and the box keeps its only copy. |
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT |
Full URL for logs, used as is. Set this when your backend has its own path, such as Loki's /otlp/v1/logs. On its own it turns on logs and leaves traces off, because a logs URL says nothing about where spans go. |
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT |
Full URL for traces, used as is. |
OTEL_EXPORTER_OTLP_HEADERS |
Headers for the collector's auth, as key1=value1,key2=value2. |
OTEL_EXPORTER_OTLP_PROTOCOL |
Only http/json is supported. Unset means http/json; any other value (the specification's own default, http/protobuf, included) logs one warning at boot and disables export entirely, rather than exporting something you did not ask for. |
OTEL_SERVICE_NAME |
The service.name on every record. Unset, the box uses the agent's own name, so a collector never fills up with OTel's unknown_service. |
OTEL_RESOURCE_ATTRIBUTES |
Extra resource attributes, as key=value pairs. The box adds stackbone.installation_id, stackbone.organization_id and the environment under both keys: deployment.environment.name, the current one, and deployment.environment, which older Datadog Agents and most shipped dashboards still read. Yours are applied last and may override any of them. |
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT |
Whether prompts and completions ride on a span at all. NO_CONTENT unless you set SPAN_ONLY or SPAN_AND_EVENT, and a value the box does not recognise reads as NO_CONTENT too: a typo must never be taken as consent to export somebody's prompts. |
These are deployment settings, not workspace settings: whoever runs the
container owns the collector. Loki (3.0 and later) and any OpenTelemetry
Collector accept this export directly; the Datadog Agent takes OTLP logs over
HTTP with otlp_config.logs.enabled. Datadog's agentless intake is not a
documented target for the JSON encoding the box sends, so put an Agent or a
Collector in front of it.
When a run finishes, the box exports it as one trace: one root span for the run, and one span per timeline row under it, nested the way the timeline nests. The trace id is the run id with its dashes stripped, so the id you copy from Studio is the one you paste into your APM's search box. Log lines exported over OTLP carry the trace id and the span id of their step, so opening a slow step in your APM shows what it printed.
The root span is invoke_workflow <name> for a workflow and invoke_agent <name> for a chat turn, and it carries the run's status, its trigger and the
run's folded token totals. Each row under it reads like this:
| The timeline row | The span it becomes |
|---|---|
| Model call | chat <model>, with gen_ai.provider.name, gen_ai.request.model and gen_ai.usage.input_tokens / .output_tokens |
| Tool call | execute_tool <tool>, with gen_ai.tool.name |
| Agent turn or subagent | invoke_agent <agent>, with gen_ai.agent.name |
| Workflow step | invoke_workflow <step>, with gen_ai.workflow.name |
| Connector call | connection <name>, tagged openinference.span.kind=TOOL |
| Approval or hook | hook <name>, tagged openinference.span.kind=CHAIN |
| Guardrail | guardrail <name>, tagged openinference.span.kind=GUARDRAIL |
The first four use the OpenTelemetry GenAI conventions, so an LLM observability
backend renders models and token counts without an exporter per vendor. The
last three have no GenAI operation (the conventions model model calls, tools
and agents, and a connector is none of them), so they carry one borrowed
OpenInference key that says what they are instead of arriving as unlabelled
spans. A row that failed carries error.type, and a row that was still open
when the run ended is exported with its last known status rather than held
back.
Every span of a chat run carries the same gen_ai.conversation.id, the session
id, so a ten-turn conversation is ten traces your backend groups as one thing.
Payloads stay in the box unless you say otherwise. A span carries the names,
the timings, the token counts and the status, and never the prompt or the
completion. Set OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT to
SPAN_ONLY or SPAN_AND_EVENT to put the messages on the span as
gen_ai.input.messages and gen_ai.output.messages. They go through the same
masking pass as a log line and are truncated past a few thousand characters,
because a trace is not a payload store.
You can also continue a trace you already started. Send a W3C traceparent
header on
POST /api/workflows/:name/start and the run joins that trace instead of
opening one of its own: every span the run exports carries your trace id, the
run's root span hangs off the span you sent, and the run's log lines carry the
same trace id. A malformed header is ignored and the run starts normally. The
header is stored on the run, so a run that pauses for a human decision and
resumes later still exports under your trace. POST /api/workflows/:name/chat
and the agent chat routes do not honour the header yet. A run started through
them is exported as the root of its own trace, with the trace id equal to the
run id.
Inspecting runs from the shell
You do not need Studio open to read what happened. The CLI inspects the same runs and the same log store:
# List recent runs, newest first.
stackbone runs list
# Inspect one run: status, steps, timing, errors.
stackbone runs get <run-id>
# Live-tail logs across the installation.
stackbone logs tail --follow
# Tail only the lines for one run.
stackbone logs tail --run <run-id> --follow
# A window, filtered.
stackbone logs tail --since 15m --limit 200 --level warn --q "rate limited"
# List the workflows in the workspace.
stackbone workflows liststackbone logs tail takes --run, --level, --q (substring) and
--trace-id as server-side filters, and --since, --until and --limit on
the client; --agent <id> picks the installation when it is not the local one.
stackbone runs retry <run-id> and stackbone runs cancel <run-id> act on a run
once you have found it.
Local development
stackbone dev runs the same runtime, the same run records and the same log
pipeline as a deployed box: the lines go to the local Postgres the emulator
already runs, and Studio's Logs screen reads them. Two things differ on
purpose. Your terminal keeps showing the original console.* line rather than
JSON, and no OTLP endpoint is set by default, so the dev stack stays at its
three containers. Point OTEL_EXPORTER_OTLP_ENDPOINT at your own collector to
see the export path work locally with the same switch a deployed box uses.
Where to go next
- Observability: the same picture for operators, with the retention statement and the compliance layer that is still to come.
stackbone.ai: the model calls that appear as steps on the timeline, with their token usage.- Agents & sessions: turns, runs and the session model your tool logs are attributed to.
- Workflows:
'use step'checkpoints, replay, and how a run is reconstructed from its steps. - Human approvals:
requestApproval()and how a paused run reads on the timeline. - CLI reference:
stackbone runs,stackbone logs tailandstackbone workflows.