Watch Your LLM App Think
July 11, 2026 · View on GitHub
Put a hard latency budget on an LLM call and watch token counts, cost, and budget violations line up on one combined dashboard — no code changes to the app making the calls, and no SDK, log shipper, or external service in between.
Persona: anyone instrumenting an AI-powered feature who wants token/latency/cost visibility without adding a tracing SDK. Time: ~10 minutes.
What you'll need
- JDK 11 or newer on your PATH (the demo uses single-file source launch)
- BTrace 3.0 installed, including the bundled
btrace-llm-traceandbtrace-contractsextensions —bin/btraceandbin/btracexon your PATH (installation options) - Two terminal windows
Step 1 — Start the patient
This tutorial uses its own demo app instead of the shared DemoApp.java from the rest of the
series, because it needs something LLM-shaped to instrument. demo/LlmDemoApp.java
is a tiny "AI ticket summarizer" that calls a mocked OpenAI-client-shaped OpenAiClient.chat(String)
for every ticket — no real network calls, no dependencies. One deliberate defect is hidden inside:
roughly 1 call in 12 (~8%) "runs away" — the mocked model keeps generating well past its normal
reply length, which shows up later as both a token/cost spike and a blown latency budget.
Run it in terminal 1:
java LlmDemoApp.java
You should see (numbers will vary):
[demo] llm ticket summarizer running - stop with Ctrl+C
[demo] answered 5 tickets
[demo] answered 10 tickets
Step 2 — Find its PID
In terminal 2:
jps
You should see a line like 23456 LlmDemoApp. That's the <PID> for every command below.
Step 3 — See what the two extensions actually ask for
Before granting anything, check what btrace-llm-trace and btrace-contracts require. btracex inspect reads extension manifests directly — no running JVM needed:
btracex inspect btrace-llm-trace
You should see:
Extension: btrace-llm-trace
Version : 3.0.0-SNAPSHOT
Privileged: true
Required : [THREADS]
Services : io.btrace.llm.LlmTraceService
btracex inspect btrace-contracts
You should see:
Extension: btrace-contracts
Version : 3.0.0-SNAPSHOT
Privileged: true
Required : [THREADS]
Services : io.btrace.contracts.ContractService
What just happened? Neither extension declares a permission in source — there's no
package-info.javawith@ExtensionDescriptor(permissions = ...)in either module, unlikebtrace-metrics(see Tutorial 4).THREADSshows up anyway because BTrace's Gradle extension plugin computes an extension's permissions by statically scanning its compiled implementation for JDK APIs that imply one (PermissionScanner, part of theio.btrace.extensionplugin,scanPermissions = trueby default) — and bothLlmTraceServiceImplandContractServiceImpllean entirely onConcurrentHashMapandAtomicLongfor lock-free per-model/per-contract bookkeeping. The scanner tags any call intojava.util.concurrent.*asTHREADS, whether or not the extension actually starts a thread — so both extensions land in the same privileged tier asbtrace-metrics, for a different reason thanbtrace-metricsearns it.
Step 4 — Grant both extensions their permission
mkdir -p ~/.btrace
cat > ~/.btrace/permissions.properties <<'EOF'
allowExtensions=btrace-llm-trace,btrace-contracts
EOF
allowExtensions takes a comma-separated list of extension ids, so one line covers both. Since
you haven't attached BTrace to this LlmDemoApp process yet, the very next attach (Step 5) will be
the first one — exactly when this policy file gets read. (See Tutorial 4
for what it looks like when you skip this step, and the full btracex policy reference.)
Step 5 — Deploy the combined script
demo/LlmObservability.java injects both services at once —
LlmTraceService records tokens, latency, and cost per call; ContractService enforces a 500ms
latency budget on that same call:
@Injected private static LlmTraceService llm;
@Injected private static ContractService contracts;
@OnMethod(clazz = "OpenAiClient", method = "chat", location = @Location(Kind.RETURN))
public static void onChatReturn(@Return Object response, @Duration long durationNanos) {
Object usage = get(field("ChatCompletion", "usage"), response);
int promptTokens = 0;
int completionTokens = 0;
if (usage != null) {
promptTokens = getInt(field("Usage", "promptTokens"), usage);
completionTokens = getInt(field("Usage", "completionTokens"), usage);
}
llm.call("gpt-4o-mini")
.provider("openai")
.inputTokens(promptTokens)
.outputTokens(completionTokens)
.duration(durationNanos)
.record();
contracts.checkLatency("chat/latency", durationNanos, 500_000_000L);
}
What just happened?
field/get/getIntwalk the mocked response the same way Lesson 12 pullsTokenUsageout of a real LangChain4j response — but those three areio.btrace.core.BTraceUtilsmethods, not methods on the flat-DSLio.btrace.BTracefacade, so this script statically importsio.btrace.core.BTraceUtils.*instead ofio.btrace.BTrace.*. That's not a style choice: BTrace's verifier only lets a script call methods directly on itself, onBTraceUtils/BTrace, on an injectedExtensionsubtype, or on a type that lives in an injected service's own package. The demo'sChatCompletionandUsageclasses match none of those, so their fields have to be read reflectively rather than through a getter — the same restriction is why Lesson 12's own example reaches forfield/getinstead of callingresponse.getUsage()directly.
Deploy it:
btrace <PID> LlmObservability.java
Step 6 — Read the dashboard
You should see, every 10 seconds (numbers will vary):
=== LLM + Contracts Dashboard ===
=== LLM Trace Summary ===
Model: gpt-4o-mini (openai)
Calls: 40
Tokens: 4352 in / 10716 out (avg 108/267)
Latency: avg 528ms, min 151ms, max 3801ms
Est. cost: \$0.0071
--- Totals ---
Calls: 40
Tokens: 4352 in / 10716 out
Est. total cost: \$0.0071
=== Contract Summary ===
Contract: chat/latency
Checks: 40 | VIOLATIONS: 3 (7%)
Latency: avg 528ms, min 151ms, max 3801ms
Last: Latency 3336ms exceeded budget 500ms
--- Totals ---
Checks: 40
Violations: 3
==================================
What just happened? Both extensions saw the exact same 40 calls and durations, but they answer different questions about them.
llm.getSummary()isLlmTraceServiceImpl's per-model rollup: token counts, latency min/mean/max, and a cost estimate from a built-in pricing table (gpt-4o-miniis priced in it; an unrecognized model name would simply omit theEst. costline).contracts.getSummary()isContractServiceImpl's per-contract rollup: how many of those 40 calls blew the 500ms budget from Step 5, and the message from the most recent violation. Neither extension knows the other exists — the correlation is something you read off the dashboard: the ~8% of calls flagged asVIOLATIONSare the same calls draggingmaxup into seconds and inflatingTokens ... out/Est. costpast what 40 normal replies would cost. That's the point of injecting both from one script: a hard SLA number, and the token/cost story behind it, on one screen.
Step 7 — Clean up
Ctrl+C the btrace client to detach (probes disable immediately, same as every other tutorial in
this series), then Ctrl+C LlmDemoApp in terminal 1. If you want to leave your BTrace
installation in its original state:
rm ~/.btrace/permissions.properties
Troubleshooting
btracex inspect btrace-llm-trace(orbtrace-contracts) prints an error or nothing — it scans$BTRACE_HOME/extensions/and~/.btrace/extensions/(plus$BTRACE_EXT_PATHif set). ConfirmBTRACE_HOMEis set and both extensions are exploded under it — see Tutorial 4's Troubleshooting section for the same check againstbtrace-metrics.- The dashboard never appears — confirm
~/.btrace/permissions.propertiesexisted before your firstbtrace <PID> ...attach against thisLlmDemoAppprocess. The policy loads once per JVM lifetime; if you attached once already (with an earlier, unrelated script) before writing the policy file, start a freshLlmDemoAppand get a new PID. - No
VIOLATIONSline ever shows up — the runaway defect fires on roughly 1 call in 12 with a single worker thread, so a short window can miss it. Let the demo run for another@OnTimertick (10 seconds) or two.
Go deeper
- The concepts behind both services, plus a full LangChain4j-instrumented example for
btrace-llm-trace: BTrace Tutorial, Lesson 12 — AI/LLM Application Observability ContractService's full API (call-rate limits, range checks, null checks, and tagged code-path comparison — this tutorial only usedcheckLatency): BTrace Tutorial, Lesson 11 — Runtime Contracts- Two more optional AI/ML extensions ship in this repo and follow the exact same
@Injected+ permission pattern:btrace-rag-quality(RagQualityService— vector-store query latency, result counts, similarity scores) andbtrace-gpu-bridge(GpuBridgeService— ONNX Runtime/DJL/TensorFlow Java inference timing and GPU memory tracking). Both are covered conceptually in Lesson 12; neither has a dedicated hands-on lab yet. - Full permission model,
btracex, and what a denied privileged extension looks like end to end: Tutorial 4 — Extensions and Permissions Without Tears - Everything else you can hook: Quick Reference