Observability with Sentry#
Sentry is an OpenTelemetry backend, so wiring it up is the same two lines that wire up Datadog, Grafana or a bare OTLP collector. What is not the same is what Janux puts in the trace: the guard that decided, and whether a human or an agent was asking.
Install#
bun add @sentry/node @opentelemetry/api@opentelemetry/api is not a dependency of Janux — the framework describes a tracer structurally, so an app that never instruments never installs an SDK. Here you are instrumenting, so you install it.
src/instrumentation.ts#
The convention: Janux imports this file and awaits register() before the server serves its first request, in janux dev and janux start alike.
import { otelTracer, setOnError, setPiiFilter, setTracer, defaultPiiFilter } from 'janux/observability';
import { trace } from '@opentelemetry/api';
import * as Sentry from '@sentry/node';
export async function register() {
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0.1,
skipOpenTelemetrySetup: false,
});
setTracer(otelTracer(trace.getTracer('janux')));
// The other half of _500.tsx: the page is what the visitor sees, this is
// what you see. Warnings are expected conditions — keep them out of Issues.
setOnError((error, info) => {
if (info.level === 'warning') return Sentry.logger.warn(String(error), { ...info });
Sentry.captureException(error, { tags: { phase: info.phase, origin: info.origin }, extra: { ...info } });
});
// Your own identifiers, on top of the default redaction.
setPiiFilter((value) => defaultPiiFilter(value).replace(/cus_\w+/g, '[customer]'));
}That is the whole integration. No Sentry.wrapHandler, no per-route middleware: the spans come from the framework's own pipelines, which is why they know things a generic HTTP instrumentation cannot.
If Sentry is down, misconfigured, or its SDK throws on init, the app still serves. Fail-open is a hard constraint, not a best effort — a page that already rendered is never turned into a 500 by an exporter that failed while flushing.
Pricing the agent#
Token counts ride on every model turn automatically. The price does not — Janux ships no price table, because a bundled one is wrong the week after it is written:
import { defineAgent } from '@janux/agent';
export default defineAgent({
model: 'anthropic/claude-fable-5',
cost: { input: 3, output: 15 }, // USD per MILLION tokens
});That puts janux.cost.usd on each chat {model} span, alongside the gen_ai.usage.* counts.
The trace you get#
An agent asks to check out. The tool is guarded confirm, so nothing runs: a proposal is parked, the human approves it in the UI, and the call executes. Three requests, one story, tied together by janux.proposal.id:
janux.request GET / 28ms
http.request.method = GET
janux.route = /
└─ janux.render 26ms
│ janux.route = /
└─ janux.island 9ms
janux.island = cart
janux.request POST /_janux/agent 1.4s
├─ janux.render 7ms
│ │ janux.route = / ← the turn renders the page to know its tools
│ └─ janux.island 3ms
│ janux.island = cart
└─ invoke_agent janux 1.4s
│ gen_ai.operation.name = invoke_agent
│ gen_ai.provider.name = anthropic
├─ chat claude-fable-5 1.1s
│ gen_ai.operation.name = chat
│ gen_ai.request.model = claude-fable-5
│ gen_ai.response.model = claude-fable-5-20260501
│ gen_ai.usage.input_tokens = 1200
│ gen_ai.usage.output_tokens = 300
│ janux.cost.usd = 0.0081
└─ janux.api 2ms
janux.intent = api.shop.checkout
janux.guard = confirm ← nothing ran
janux.origin = agent
janux.proposal.id = prop_api_9f2c…
janux.request POST /_janux/approve 34ms
└─ janux.proposal.approve 33ms
│ janux.origin = human ← the human said yes
│ janux.proposal.id = prop_api_9f2c…
└─ janux.api.execute 31ms
janux.intent = api.shop.checkout
janux.origin = agent ← still on the agent's behalf
janux.proposal.id = prop_api_9f2c…Read it top to bottom and you have the answer to the question every audit asks: who asked for this, what stopped it, who let it through, and what did it cost? The origin split on the last two spans is the load-bearing detail — the approval is human, the execution is still the agent's call, and a trace that folded them together could not tell you the difference.
The exact span names, attributes and their cardinality rules are in the observability reference.
Querying it#
Because janux.route is the route pattern and never the URL, latency groups the way you want it to:
janux.guard = confirm AND janux.origin = agent— every time an agent asked for something dangerous.janux.proposal.id = X— the full life of one proposal, across the requests that proposed and approved it.span.name = "janux.render" GROUP BY janux.route— SSR cost per route, not per visitor.sum(janux.cost.usd) GROUP BY gen_ai.request.model— what the copilot spent this week.
Sampling and cost#
tracesSampleRate is Sentry's, and it applies to everything — including the agentic spans that are the reason you are here. A common shape is to sample page traffic lightly and keep every guarded invocation, using Sentry's tracesSampler and the fact that only some spans carry janux.guard.
An uninstrumented app is unaffected either way: with no instrumentation.ts, no tracer is registered and the framework never builds a single attribute object.