Observability API#
Everything importable from janux/observability, plus the instrumentation.ts convention that turns it on.
Nothing here runs until an app registers something. With no tracer and no error handler, every call in this module is a branch on a module-level undefined — an uninstrumented app pays nothing, which is the constraint the whole design is built around.
For a working setup, see the Sentry recipe.
instrumentation.ts#
src/instrumentation.ts is loaded and its register() awaited before the server serves its first request — in janux dev and in janux start alike. That ordering is the point: an SDK that patches globals has to do it before the code it means to observe is loaded.
// src/instrumentation.ts
import { otelTracer, setOnError, setTracer } from 'janux/observability';
import { trace } from '@opentelemetry/api';
export async function register() {
await startYourSdk();
setTracer(otelTracer(trace.getTracer('janux')));
setOnError((error, info) => reportSomewhere(error, info));
}register() may be async, and it is awaited. A register() that throws — or a file that fails to import, or one that exports no register() — is reported and the app serves anyway: observability is how you find out the app is broken, so it must never be the reason it is.
setTracer(tracer)#
Registers the process-wide tracer. Pass undefined to turn tracing off again.
import { setTracer } from 'janux/observability';
setTracer({
span: async (name, attributes, run) => {
console.log(name, attributes);
return run({ setAttributes: () => {}, recordError: () => {} });
},
});A JanuxTracer has one method. It is callback-shaped rather than start/end so that parenting is the tracer's job: otelTracer maps it straight onto OpenTelemetry's startActiveSpan, and the trace gets its shape from OTel's context propagation rather than from bookkeeping in Janux.
isTracing()#
true once a tracer is registered. The framework uses it to skip work that only a trace would consume (resolving a route pattern, for one); an app rarely needs it.
withSpan(name, attributes, run)#
Runs run inside a span, or just runs it when nothing is registered. attributes is a thunk precisely so an uninstrumented app never builds an object it would throw away.
import { withSpan } from 'janux/observability';
export const importOrders = api({
description: 'Bulk import',
run: ({ input }) => withSpan('shop.import', () => ({ 'shop.rows': input.rows.length }), async (span) => {
const result = await load(input.rows);
span.setAttributes({ 'shop.skipped': result.skipped });
return result;
}),
});Fail-open, in both directions: a tracer that throws before the work runs is bypassed (the work still runs, once), and a tracer that throws after the work succeeded does not turn a served page into a 500. An error from run is recorded on the span and rethrown unchanged.
otelTracer(tracer)#
Wraps an OpenTelemetry tracer as a JanuxTracer. Anything that speaks OTLP — Sentry, Datadog, Grafana, a bare collector — is reached this way.
import { otelTracer, setTracer } from 'janux/observability';
import { trace } from '@opentelemetry/api';
setTracer(otelTracer(trace.getTracer('janux')));@opentelemetry/api is not a dependency of Janux. The adapter describes the tracer structurally, so an app that never instruments never installs an SDK, and one that does already has the real package for its exporter.
The janux.* attributes#
The set no other framework can emit, because no other framework has the answers.
| Attribute | On | Value |
|---|---|---|
janux.route |
janux.request, janux.render |
The route pattern (/orders/[id]), never the URL — a span per order id is a cardinality bomb |
janux.island |
janux.island |
The island's name |
janux.intent |
janux.intent, janux.api |
The tool name, exactly as the audit trail writes it: cart.checkout, api.shop.checkout |
janux.guard |
janux.intent, janux.api |
The resolved guard: auto, confirm or forbidden |
janux.origin |
janux.intent, janux.api |
human or agent |
janux.proposal.id |
proposal spans | Ties an agent's request to the human approval that ran it, across two requests |
janux.cost.usd |
chat {model} |
What the turn cost, when the agent declared its prices |
The spans#
| Span | Opened by |
|---|---|
janux.request |
Every request, before routing |
janux.render |
The SSR document render |
janux.island |
Each island. A suspended island's span covers what it contributed to this flush — its fallback; the deferred content arrives on its own chunk, after the span closed |
janux.intent |
The component invocation pipeline |
janux.intent.execute |
The approved run of a proposed intent |
janux.api |
The api() invocation pipeline, and the proposal an agent's confirm call parks |
janux.api.execute |
The approved run of a proposed api() call |
janux.proposal.approve |
POST /_janux/approve — the human act |
invoke_agent janux |
One agent turn: every round and every tool hangs off it |
chat {model} |
One round of the loop |
GenAI semantic conventions#
The agent spans follow the OpenTelemetry GenAI conventions, so a Janux copilot lands in the same dashboards as every other instrumented LLM caller: gen_ai.operation.name, gen_ai.provider.name, gen_ai.request.model, gen_ai.response.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens and error.type.
gen_ai.input.messages and gen_ai.output.messages are not emitted. They are opt-in in the spec and stay off here: the transcript is the one field guaranteed to contain whatever the user typed.
Token cost comes from defineAgent({ cost }), in USD per million tokens — the unit every provider publishes:
import { defineAgent } from '@janux/agent';
export default defineAgent({
model: 'anthropic/claude-fable-5',
cost: { input: 3, output: 15 },
});Janux ships no price table. A bundled one is wrong the week after it is written.
setOnError(handler)#
The app's global error sink — the other half of _500.tsx. That page is what the visitor sees; this is what the operator sees, and it fires for the failures no page can catch.
import { setOnError } from 'janux/observability';
setOnError((error, info) => {
if (info.level === 'warning') return logger.warn(info.phase, error);
logger.error(info.phase, error, { route: info.route, intent: info.intent, origin: info.origin });
});info.phase is where the framework was: ssr (a page render), invocation (the pipeline every intent and api() call goes through), agent, instrumentation (the app's own instrumentation.ts) or observability (the reporting machinery itself — a broken exporter). info.level separates a handled warning from a failure.
A refusal is not an error. invalid_input, forbidden and the rest of JanuxIntentError are the pipeline working; routing them here would drown the signal in every mistyped agent call.
One failure can produce two reports, on purpose: an api() that throws during SSR is reported once by the invocation pipeline (which knows which tool broke) and once by the render (which knows which page died with it). They are different facts, and an operator wants both.
With no handler registered, failures go to the console exactly as they always did. A handler that throws is contained — reporting a failure must never become a second failure.
reportError(error, info) / reportWarning(message, info)#
The framework's own reporting calls, exported so app code and adapters can use the same channel instead of a stray console.warn.
import { reportWarning } from 'janux/observability';
if (!queue.durable) reportWarning('running with an in-memory queue', { phase: 'invocation' });setPiiFilter(filter)#
Span attributes leave the process. Route paths carry ids, error messages quote the input that broke, and an exporter ships all of it to a third party — so every string attribute is scrubbed on the way out, by default.
The default filter is deliberately conservative, because destroying signal costs debuggability:
- emails →
[email] - phone numbers only in international format (leading
+) →[phone]; bare digit runs (ids, timestamps, amounts) are left alone data:URLs and long base64 runs → a length marker
import { defaultPiiFilter, setPiiFilter } from 'janux/observability';
setPiiFilter((value) => defaultPiiFilter(value).replace(/cus_\w+/g, '[customer]'));Pass undefined to restore the default. This one fails closed, unlike everything else here: a filter that throws redacts the value rather than letting the raw one through, because the raw value is the thing being protected.