Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 45 additions & 7 deletions TELEMETRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ use the query recipes below to find the failing turn and next query.
| ----------------------------------- | ----------------------------- | ------------------------- | --------------------- |
| `event_id` | captured Sentry error | failed Slack reply | open event |
| `gen_ai.conversation.id` | Slack thread/run conversation | Slack footer, logs, spans | query trace/logs |
| `app.ai.turn.id` | one durable Junior turn | logs, agent/tool spans | query turn |
| `trace_id` | end-to-end trace | errors, logs, spans | open trace |
| `span_id` | one span in a trace | logs, spans | inspect span |
| `messaging.message.conversation_id` | Slack thread | logs, spans | thread logs |
Expand All @@ -34,6 +35,14 @@ use the query recipes below to find the failing turn and next query.
| `gen_ai.tool.name` | tool name | tool spans/logs | tool failures |
| `app.credential.provider` | auth provider | auth logs | auth/resume search |

## Semantic Conventions

Use OpenTelemetry GenAI attributes for the concepts the specification defines:
operation, agent identity, conversation ID, request model, token usage, and
errors. OpenTelemetry does not currently define a durable agent-turn ID,
cumulative agent-step count, or cumulative turn runtime, so Junior records
those gaps under `app.ai.turn.*` rather than inventing new `gen_ai.*` fields.

## Query Recipes

Conversation timeline from a Slack thread, footer link, or conversation ID.
Expand All @@ -52,6 +61,21 @@ fields=timestamp,level,event.name,trace_id,span_id,error.type,exception.message
sort=timestamp
```

One durable turn across execution slices and child model/tool spans.

```text
dataset=spans query='app.ai.turn.id:"<turn_id>"'
fields=timestamp,trace,span.op,span.description,span.duration,gen_ai.conversation.id,gen_ai.request.model,app.ai.turn.slice_id,app.ai.turn.step_count,app.ai.turn.runtime_ms,app.ai.turn.state,error.type
sort=timestamp
```

Observed completed-turn step and cumulative-runtime distributions.

```text
dataset=spans query='span.op:chat.turn app.ai.turn.state:completed'
fields=count(),p50(app.ai.turn.step_count),p90(app.ai.turn.step_count),p95(app.ai.turn.step_count),p99(app.ai.turn.step_count),max(app.ai.turn.step_count),p50(app.ai.turn.runtime_ms),p90(app.ai.turn.runtime_ms),p95(app.ai.turn.runtime_ms),p99(app.ai.turn.runtime_ms),max(app.ai.turn.runtime_ms)
```

Trace log history after opening a Sentry event or trace.

```text
Expand Down Expand Up @@ -84,6 +108,14 @@ fields=timestamp,trace,gen_ai.conversation.id,gen_ai.tool.name,gen_ai.tool.call.
sort=-gen_ai.tool.call.result.size
```

System budgets exceeded while admitting or running work.

```text
dataset=logs query='event.name:system.budget.exceeded'
fields=timestamp,event.name,gen_ai.conversation.id,app.ai.turn.id,app.budget.name,app.budget.outcome,app.budget.value,app.budget.limit
sort=-timestamp
```

Search tool volume, truncation, and raw output size.

```text
Expand Down Expand Up @@ -135,6 +167,8 @@ Spans: `chat.turn`, `chat.reply`, `chat.slash_command`,
`chat.app_home_opened`, `chat.app_home_disconnect`

Attributes: `trace_id`, `span_id`, `gen_ai.conversation.id`,
`app.ai.turn.id`, `app.ai.turn.state`, `app.ai.turn.step_count`,
`app.ai.turn.runtime_ms`,
`messaging.message.conversation_id`, `messaging.destination.name`,
`app.slack.reply_stage`, `app.slack.error_code`, `app.slack.api_error`

Expand All @@ -145,6 +179,7 @@ The turn timed out, returned no useful answer, or used unexpected reasoning.
Events: `agent.message.received`, `agent.message.generated`,
`agent.turn.timed_out`,
`agent.turn.provider_error`, `agent.turn.execution.failed`,
`system.budget.exceeded`,
`agent.turn.empty_output.retrying`,
`agent.turn.empty_output.exhausted`, `assistant.reply.generation.failed`,
`guardian.action_review.retrying`
Expand All @@ -153,15 +188,18 @@ Spans: `ai.generate_assistant_reply`, `ai.chat_completion`,
`chat.route_thinking`, `gen_ai.invoke_agent`, `gen_ai.chat`

Attributes: `gen_ai.operation.name`, `gen_ai.request.model`,
`gen_ai.response.finish_reasons`, `app.ai.outcome`,
`app.ai.reasoning_effort`, `app.ai.model_profile`, `gen_ai.usage.input_tokens`,
`gen_ai.usage.output_tokens`, `gen_ai.usage.input_tokens.cached`,
`gen_ai.usage.input_tokens.cache_write`, `app.ai.reasoning_tokens`,
`gen_ai.agent.name`, `gen_ai.conversation.id`, `gen_ai.response.finish_reasons`,
`gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`,
`app.ai.turn.id`, `app.ai.turn.step_count`, `app.ai.turn.runtime_ms`,
`app.ai.outcome`,
`app.ai.reasoning_effort`, `app.ai.model_profile`,
`gen_ai.usage.cache_read.input_tokens`,
`gen_ai.usage.cache_creation.input_tokens`,
`gen_ai.usage.reasoning.output_tokens`, `app.ai.reasoning_tokens`,
`app.ai.empty_output.attempt`, `app.ai.provider_error.kind`,
`app.guardian.review_attempt`,
`app.ai.cost.input_usd`, `app.ai.cost.output_usd`,
`app.ai.cost.cache_read_usd`, `app.ai.cost.cache_write_usd`,
`app.ai.cost.total_usd`
`app.cost.input_usd`, `app.cost.output_usd`, `app.cost.cache_read_usd`,
`app.cost.cache_write_usd`, `app.cost.total_usd`

### Tools, MCP, And Sandbox

Expand Down
9 changes: 8 additions & 1 deletion TERMINOLOGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ Canonical words used across Junior's code and documentation.
normal ordering and waits when a turn is already active.
- **Turn**: one request-to-final-response cycle. It may span multiple runs and
execution slices; one model invocation is not a turn.
- **Agent step**: one model response attempt inside a turn, including any tool
calls and results that follow that response. A turn may contain many agent
steps across runs and execution slices.
- **System budget**: one configured resource boundary checked against current
runtime usage. Exceeding a capacity budget queues work; exceeding a turn
budget stops the turn.
- **Run**: one bounded attempt to advance a turn. A later run may resume the
same turn after a pause, yield, or recoverable failure.
- **Execution slice**: one serverless invocation segment of a run.
Expand Down Expand Up @@ -57,7 +63,8 @@ Canonical words used across Junior's code and documentation.

## Naming Guidance

- Use `turn`, `run`, and `slice` only with the meanings above.
- Use `turn`, `agent step`, `system budget`, `run`, and `slice` only with the
meanings above.
- Use `message` for platform chat content. Use `user_message`,
`assistant_message`, and `tool_result` for replayable agent history.
- Use `agent history item` when referring to those three native event types as
Expand Down
28 changes: 28 additions & 0 deletions packages/docs/src/content/docs/reference/config-and-env.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,34 @@ for its own turn. Set it to `steer` to preserve collaborative steering across
actors. In `follow_up` mode, a user can start one message with `!!` to steer the
active turn explicitly.

## System protections

These budgets are parsed into `botConfig.budgets` at startup, checked through
the shared `checkBudgets()` decision path, and shown on the dashboard's
**System** page.

Junior's private internal budget registry owns each budget's environment name,
default limit, unit, display copy, outcome, runtime stage, and measurement
function. Nitro configuration contains only the resulting numeric limits;
budget functions stay in the bundled runtime and are never serialized.

| Variable | Default | Protection |
| ------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------------- |
| `JUNIOR_MAX_ACTIVE_CONVERSATIONS` | `100` | Maximum conversations holding execution leases globally. Additional conversations remain queued. |
| `JUNIOR_MAX_ACTIVE_CONVERSATIONS_PER_USER` | `5` | Maximum active conversations for one platform-scoped user when a stable user ID is available. |
| `JUNIOR_MAX_STEPS_PER_TURN` | `500` | Maximum agent steps in one turn across model responses, tool calls, retries, and resumes. |
| `JUNIOR_MAX_TURN_RUNTIME_MS` | `21600000` | Maximum cumulative active runtime for one turn across timeout, yield, and recovery resumes (6 hours). |

Active-conversation budgets are admission controls, not rejection limits. Inbound
messages remain durable, and a conversation that cannot acquire a lease is
queued again after a short delay. The per-user limit applies when the inbound
source provides a stable user ID; the global limit still covers system work.
Step and runtime budgets stop one runaway turn instead of leaving it queued.
Those hard-budget hits are available from `/api/stats` under the `junior`
namespace and `budget_exceeded` metric. Every exceeded budget emits the shared
event `system.budget.exceeded` with its name, value, limit, and `queue` or
`stop` outcome. Stop outcomes are also captured as Sentry issues.

Model profile names are durable conversation bindings. Each later turn resolves
the stored name through current configuration; the exact model ID recorded when
an epoch opens is audit evidence, not a runtime pin. Changing a mapping retargets
Expand Down
15 changes: 15 additions & 0 deletions packages/junior-dashboard/src/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ export const dashboardConfigSchema = z
basePath: z.string(),
componentGallery: z.boolean(),
sentryConversationLinks: z.boolean(),
systemBudgets: z
.array(
z
.object({
description: z.string().min(1),
label: z.string().min(1),
limit: z.number().positive(),
name: z.string().min(1),
outcome: z.enum(["queue", "stop"]),
stage: z.enum(["conversation_admission", "turn"]),
unit: z.enum(["count", "milliseconds", "usd"]),
})
.strict(),
)
.optional(),
timeZone: z.string(),
})
.strict();
Expand Down
10 changes: 9 additions & 1 deletion packages/junior-dashboard/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import type {
PluginRouteApp,
} from "@sentry/junior-plugin-api";
import { pluginApiRouteRequestContextSchema } from "@sentry/junior-plugin-api";
import { dashboardConfigSchema, dashboardIdentitySchema } from "./api/schema";
import {
dashboardConfigSchema,
dashboardIdentitySchema,
type DashboardConfig,
} from "./api/schema";
import {
dashboardAvatarHeaderAsset,
dashboardClientAsset,
Expand Down Expand Up @@ -55,6 +59,7 @@ export interface JuniorDashboardOptions {

interface DashboardRuntimeOptions extends JuniorDashboardOptions {
pluginRoutes?: DashboardPluginRoute[];
systemBudgets?: NonNullable<DashboardConfig["systemBudgets"]>;
}

interface DashboardPluginRoute {
Expand Down Expand Up @@ -767,6 +772,9 @@ export function createDashboardApp(
basePath,
componentGallery: options.componentGallery === true,
sentryConversationLinks: hasSentryConversationLinks(),
...(options.systemBudgets
? { systemBudgets: options.systemBudgets }
: {}),
timeZone: dashboardTimeZone(),
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { ShieldCheck } from "lucide-react";

import type { DashboardConfig } from "../../types";
import { Card } from "../../components/layout/Card";
import { SectionIntro } from "../../components/layout/SectionIntro";

type SystemBudgetDescriptions = NonNullable<DashboardConfig["systemBudgets"]>;

function formatLimit(budget: SystemBudgetDescriptions[number]): string {
if (budget.unit === "milliseconds") {
const hours = budget.limit / (60 * 60 * 1000);
return Number.isInteger(hours)
? `${hours} hours`
: `${hours.toFixed(1)} hours`;
}
if (budget.unit === "usd") {
return `$${budget.limit.toLocaleString(undefined, {
maximumFractionDigits: 2,
})}`;
}
return budget.limit.toLocaleString();
}

/** Show the configured budgets that queue work or stop runaway turns. */
export function SystemBudgets(props: { budgets: SystemBudgetDescriptions }) {
return (
<section className="grid gap-4" aria-labelledby="system-budgets-title">
<SectionIntro
eyebrow="System protections"
id="system-budgets-title"
title="System budgets"
/>
<Card padding="none">
<div className="grid sm:grid-cols-2 xl:grid-cols-4">
{props.budgets.map((budget) => (
<div
className="border-b border-white/8 p-4 last:border-b-0 xl:border-r xl:border-b-0 xl:last:border-r-0"
key={budget.name}
>
<div className="flex items-center justify-between gap-2 text-emerald-200/70">
<span className="flex min-w-0 items-center gap-2">
<ShieldCheck
aria-hidden="true"
className="shrink-0"
size={14}
/>
<span className="font-mono text-[0.62rem] tracking-[0.08em] uppercase">
{budget.label}
</span>
</span>
<span className="shrink-0 rounded border border-white/10 px-1.5 py-0.5 font-mono text-[0.54rem] tracking-[0.08em] text-dashboard-text-muted uppercase">
{budget.outcome === "queue" ? "Queue" : "Stop"}
</span>
</div>
<div className="mt-3 font-display text-2xl text-dashboard-text">
{formatLimit(budget)}
</div>
<p className="mt-1 mb-0 text-xs leading-relaxed text-dashboard-text-muted">
{budget.description}
</p>
</div>
))}
</div>
</Card>
</section>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { PluginReports } from "./PluginReports";
import { SkillInventory } from "./SkillInventory";
import { SystemActivity } from "./SystemActivity";
import { SystemPageLayout } from "./SystemPageLayout";
import { SystemBudgets } from "./SystemBudgets";
import {
buildSystemPlugins,
normalizeSystemPath,
Expand Down Expand Up @@ -94,6 +95,9 @@ function OverviewSystemPage(props: {
eyebrow={`${agentNamePossessive()} engine room`}
title="System"
/>
{props.data.config.systemBudgets ? (
<SystemBudgets budgets={props.data.config.systemBudgets} />
) : null}
<SystemActivity
error={props.data.conversationStatsError}
range={props.range}
Expand Down
33 changes: 33 additions & 0 deletions packages/junior-dashboard/tests/system-budgets-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { createDashboardApp } from "../src/app";

describe("system budgets config", () => {
it("returns configured system budgets", async () => {
const app = createDashboardApp({
authRequired: false,
systemBudgets: [
{
description: "Stops runaway model and tool loops.",
label: "Agent steps per turn",
limit: 500,
name: "turn_steps",
outcome: "stop",
stage: "turn",
unit: "count",
},
],
});

await expect(
(await app.fetch(new Request("http://localhost/api/config"))).json(),
).resolves.toMatchObject({
systemBudgets: [
{
limit: 500,
name: "turn_steps",
outcome: "stop",
},
],
});
});
});
60 changes: 60 additions & 0 deletions packages/junior-dashboard/tests/system-budgets.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { SystemBudgets } from "../src/client/pages/system/SystemBudgets";

describe("system budgets", () => {
it("shows configured queue and stop budgets", () => {
const html = renderToStaticMarkup(
<SystemBudgets
budgets={[
{
description: "Queues additional conversations.",
label: "Active globally",
limit: 100,
name: "active_conversations_global",
outcome: "queue",
stage: "conversation_admission",
unit: "count",
},
{
description: "Stops runaway model and tool loops.",
label: "Agent steps per turn",
limit: 500,
name: "turn_steps",
outcome: "stop",
stage: "turn",
unit: "count",
},
{
description: "Stops work after the daily budget.",
label: "Daily spend",
limit: 250,
name: "daily_spend",
outcome: "stop",
stage: "turn",
unit: "usd",
},
{
description: "Cumulative active time across resumes.",
label: "Runtime per turn",
limit: 21_600_000,
name: "turn_runtime",
outcome: "stop",
stage: "turn",
unit: "milliseconds",
},
]}
/>,
);

expect(html).toContain("System budgets");
expect(html).toContain("Active globally");
expect(html).toContain("Agent steps per turn");
expect(html).toContain("Runtime per turn");
expect(html).toContain("Daily spend");
expect(html).toContain("$250");
expect(html).toContain("6 hours");
expect(html.match(/>Queue</g)).toHaveLength(1);
expect(html.match(/>Stop</g)).toHaveLength(3);
});
});
Loading