Deep Dives
Technical companions to the case-study audio guide. Where the guide teaches a pattern as narrative, these references point every claim at a real file — read them when you need to change or debug the surface, not just listen for context.
📦 Case study — these references document the case-study platform, the worked example this guide family grew from. For how this site itself works, read the written guide →
Pointer. The canonical copy of this reference now lives in the infra-spec lane at
specs/infra/deep-dives/observability.md(rootai-appsrepo), which owns changes to the observability code. This copy stays for the audio guide's teaching/render use (mission.md link + roadmap Phase 8.3). Keep them in sync if the implementation changes — the infra-lane copy is the source of truth.
Companion to the audio guide. Chapters 1–6 of
data/audio/case-study.script.mdteach observability as narrative. This doc is the matching technical reference: every claim points at a real file + line number, every header is named, every failure mode is in the catalog. Read this when you need to change the observability surface or debug why a trace is broken — not when you're listening to the guide for context.
Scope: the observability plane as implemented in the case-study monorepo, end to end: outbound instrumentation in the Next.js product app, header propagation over the wire, the Pyodide-Worker-side tracer, LangSmith ingest, the cost report, and the in-app admin dashboard.
Out of scope: anything taught by the constitution as a generic principle
without an implementation here (e.g. RUM-style frontend tracing — not wired).
Per-graph evaluator design is in chapters 7–11 of the audio guide.
1. Why observability is its own plane
Traditional HTTP health checks tell you the API returned 200. They say nothing about whether the model answered the user's question correctly. That gap — between a transport-layer success and a semantic-layer failure — is the problem the observability plane exists to close.
The three planes are non-negotiable in the constitution
(specs/case-study/mission.md:17-27):
| Plane | What it owns | Where it lives |
|---|---|---|
| Control | Graph identity (registry) + routing contract (WORKER_ROUTES) | apps/agentic-sales/src/lib/langgraph-client.ts (TS), agentic_sales.registry (Python) |
| Data | Per-capability worker pools (EMAIL / CLASSIFY / DISCOVERY) | services/case-study-*-worker/ |
| Observability | Distributed run tree that crosses TS↔Python | apps/agentic-sales/src/lib/langgraph-client.ts + services/_shared/tracing/_tracing.py |
The observability plane has two non-negotiables of its own:
- One user action = one debuggable thing across the TS ↔ Python hop. A request that enters the Next.js handler and fans out across worker pools shows up as a single tree, not as N orphaned spans.
- Zero per-request latency overhead in dev / when disabled. All
instrumentation is gated by env vars (
OTEL_EXPORTER_OTLP_ENDPOINT,LANGSMITH_TRACING); unset → strict no-op, zero network, zero PII egress.
2. The wire protocol — exact headers across the boundary
Every cross-process trace hop carries up to four header families. None of them are invented for this codebase; they're standards LangGraph + OTel + LangSmith already understand.
Outbound (TS → Worker)
| Header | Set by | Carries |
|---|---|---|
traceparent | propagation.inject(context.active(), headers) in langgraph-client.ts:217 | W3C trace-context: trace ID + parent span ID + flags |
tracestate | same | W3C trace state (vendor extensions) |
baggage | injectLangSmithHeaders() in langgraph-client.ts:23-33 (when a LangSmith run is active) | LangSmith metadata + tags + project |
langsmith-trace | same | LangSmith's RunTree.to_headers() dotted-order parent — opaque, but lets the Python worker nest under the caller's LangSmith run |
Authorization: Bearer <route.token> | langgraph-client.ts:212-214 | Worker auth (this is the routing seam, not strictly observability, but it lives in the same headers object) |
Inbound parsing (Worker)
The worker's src/_tracing.py:parse_inbound(headers) reads the same two
LangSmith-family headers — langsmith-trace + baggage — to either continue
the caller's run tree or self-root if absent. The dotted-order parse is the
contract that makes nested LangSmith runs work.
Outbound (Worker → TS, on response)
| Header | Set by | Carries |
|---|---|---|
x-langsmith-run-id | response_headers(run) in _tracing.py | UUID of the LangSmith root run the worker just created |
x-langsmith-run-url | same | Direct link to the run in the LangSmith UI |
x-trace-id | same | The worker's peer trace ID (separate from the W3C ID so an operator can pivot across vendor stacks) |
Inbound parsing (TS)
After the fetch returns, langgraph-client.ts:244-249 reads those three
headers and writes them onto the active OTel span:
const runId = res.headers.get("x-langsmith-run-id");
const runUrl = res.headers.get("x-langsmith-run-url");
const peerTrace = res.headers.get("x-trace-id");
if (runId) span.setAttribute("langsmith.run_id", runId);
if (runUrl) span.setAttribute("langsmith.run_url", runUrl);
if (peerTrace) span.setAttribute("peer.trace_id", peerTrace);
The pivot rule: any operator looking at a trace in the company OTel UI
can click langsmith.run_url and land directly in LangSmith on the matching
Python-side run. There is no manual correlation step.
3. TypeScript side — langgraph-client.ts walkthrough
3.1 Bootstrap (env-gated, zero-overhead off)
OpenTelemetry bootstrap is via @vercel/otel's registerOTel. It is
only called when OTEL_EXPORTER_OTLP_ENDPOINT is set. With it unset,
local development and unconfigured deploys pay no cost — the SDK is never
loaded, no spans are created, no fetches are wrapped.
When enabled, registerOTel wires three things:
- OTLP/HTTP trace exporter — sends spans to the configured collector.
- W3C trace-context + baggage propagators — so any outbound fetch
automatically gets
traceparent/tracestate/baggageinjected. - Global fetch auto-instrumentation — every
fetch()call becomes a child span of whatever span is active at the call site. No per-call-site code changes. This catches Neon (@neondatabase/serverless) database queries, LangGraph/runs/waitcalls, DeepSeek LLM calls — all of them.
Sampling is env-driven via OTEL_TRACES_SAMPLER (defaults to always_on
while volume is low; flip to parentbased_traceidratio and set
OTEL_TRACES_SAMPLER_ARG when scaling).
3.2 The boundary call — runGraphWithMeta
langgraph-client.ts:194-271 is the single seam where every outbound graph
call lives. Annotated walkthrough:
return tracer.startActiveSpan(`langgraph.${assistantId}`, async (span) => {
span.setAttribute("langgraph.assistant_id", assistantId);
try {
const route = routeFor(assistantId); // ← data plane
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (route.token) headers.Authorization = `Bearer ${route.token}`;
propagation.inject(context.active(), headers); // ← W3C
injectLangSmithHeaders(headers); // ← LangSmith
// … fetch /runs/wait, classify response, parse headers …
} catch (err) {
if (err instanceof Error) span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end();
}
});
Three contracts this enforces:
- Span name =
langgraph.<assistantId>. Stable across graph versions so cross-time queries don't fragment. Do NOT include version suffixes. langgraph.assistant_idattribute is set on every span. It's the join key for rolling cost / latency up by capability.- Exceptions are recorded onto the span before re-throwing, so a thrown
LangGraphErrordoesn't break the tree shape.
3.3 LangGraphError — typed failures with run-tree metadata
langgraph-client.ts:137-161:
export type LangGraphErrorKind = "auth" | "timeout" | "backend" | "client" | "unknown";
export class LangGraphError extends Error {
readonly kind: LangGraphErrorKind;
readonly status: number;
readonly assistantId: string;
readonly bodyText: string;
// …
}
Status classification (classifyStatus, lines 180-186):
| Status | Kind | Typical cause |
|---|---|---|
| 401, 403 | auth | Missing *_WORKER_SECRET or rotated key |
| 408, 504 | timeout | Worker exceeded AbortSignal.timeout (default 60s) |
| 500-599 | backend | Worker crash / unhandled exception in graph |
| 400-499 | client | Malformed assistant_id / input shape mismatch |
| other | unknown | Network error before HTTP layer |
sanitizeBody (lines 163-178) caps response bodies at 500 chars and unwraps
FastAPI {"detail": "…"} shapes so the error message is readable. Callers
catch LangGraphError to decide retry vs. fallback vs. UI-visible message
(grep: apps/agentic-sales/src/app/api/**/*.ts, src/apollo/resolvers/**).
3.4 injectLangSmithHeaders — best-effort distributed trace
langgraph-client.ts:23-33:
function injectLangSmithHeaders(headers: Record<string, string>): void {
try {
const rt = getCurrentRunTree(true); // permitAbsentRunTree → undefined, no throw
if (!rt) return;
const ls = rt.toHeaders();
if (ls["langsmith-trace"]) headers["langsmith-trace"] = ls["langsmith-trace"];
if (ls.baggage) headers.baggage = ls.baggage;
} catch {
// no run tree / SDK shape change — worker self-roots
}
}
Why best-effort: the Next.js app does NOT wrap every request in a
traceable()/wrapOpenAI() context — observability is OTel-first. The
LangSmith run tree only exists in a request scope when the route handler
explicitly opened one (e.g. inside an ainvoke chain). When absent,
getCurrentRunTree(true) returns undefined and the worker becomes the
root of its own LangSmith run. That's the common case, and it's fine —
it just means the LangSmith UI shows the worker's tree as a standalone run
not nested under a Next.js parent.
3.5 Async ingestion — startGraphRun
For long-running graphs (recall the audio chapter on "Why synchronous
ingestion fails"), there's an async variant: POST /threads then POST /threads/<id>/runs with multitask_strategy: "enqueue". The response
returns three handles — app run ID, LangGraph run ID, thread ID — that the
caller uses to reconstruct the run tree later. Same OTel + LangSmith header
injection applies.
4. Python worker side — _tracing.py walkthrough
4.1 Canonical source
services/_shared/tracing/_tracing.py is the single source of truth.
Every worker's src/_tracing.py is a synced copy (setup_pyodide_deps.sh
in each worker's scripts/ does the rsync). Do NOT edit the copies — edit
the shared file and re-run npm run sync in each worker.
The synced copies live at:
services/case-study-discovery-worker/src/_tracing.pyservices/case-study-classify-worker/src/_tracing.pyservices/research-worker/src/_tracing.py
(There is also services/case-study-intel-worker/src/_tracing.py, kept
as a rollback handle after the 2026-05-28 merge — see
specs/case-study/2026-05-28-merge-intel-into-discovery/.)
4.2 Why hand-rolled, not the langsmith SDK
The official langsmith Python SDK depends on orjson, zstandard,
xxhash, uuid-utils, websockets — all native/compiled extensions with
no Cloudflare Workers Python wheels. So this module rolls a parent/child
run tree by hand and POSTs to the LangSmith ingest REST API over httpx.
The implementation is < 700 lines, talks two endpoints (/runs/batch,
/feedback), and runs in Pyodide unchanged.
4.3 Gating — strict no-op unless enabled
_tracing.py:90-94:
def _enabled(env) -> bool:
return (
(_env(env, "LANGSMITH_TRACING", "") or "").strip().lower() == "true"
and bool((_env(env, "LANGSMITH_API_KEY", "") or "").strip())
)
Both env vars must be set. With either unset:
- Zero network egress.
- Zero PII egress (no run inputs/outputs leave the worker).
trace_runbecomes a transparent context manager that yields a stub.
4.4 Auth — workspace tenancy matters
_tracing.py:97-107:
def _post_headers(cfg: dict) -> dict:
h = {"x-api-key": cfg.get("api_key", ""), "Content-Type": "application/json"}
tenant = (cfg.get("tenant", "") or "").strip()
if tenant:
h["X-Tenant-Id"] = tenant
return h
Gotcha: org-scoped service keys (the ones with prefix lsv2_sk_) 403
on every ingest without X-Tenant-Id. Personal keys (lsv2_pt_) work
without it. The LANGSMITH_WORKSPACE_ID env var (or its alias
LANGSMITH_TENANT_ID) is the workspace UUID. In the email worker config
this is 6095afb1-3101-4972-af8e-d457099fe9f4 — every worker that traces
must have the same value or you get silent 403 storms.
4.5 parse_inbound — continue or self-root
Reads langsmith-trace + baggage from the worker's inbound request
headers. Returns either:
{"trace_id": ..., "parent_run_id": ..., "parent_dotted_order": ...,
"tags": [...], "metadata": {...}}
…or None when no LangSmith headers are present (the worker self-roots).
Called once per /runs/wait in entry.py:
inbound = parse_inbound(request.headers)
async with trace_run(env, assistant_id, graph_input, service=SERVICE,
inbound=inbound, ctx=self.ctx) as run:
result = await graph.ainvoke(graph_input)
run.outputs = result
return _json(..., headers=response_headers(run))
4.6 trace_run — the root context manager
_tracing.py:483-522:
@asynccontextmanager
async def trace_run(env, name, inputs, *, run_type="chain",
service="lead-gen", inbound=None,
post_pending=False, ctx=None):
enabled = _enabled(env)
cfg = _cfg(env, service=service, ctx=ctx)
root = _Run(...)
if post_pending:
await _flush(cfg, post=[root._run_create(pending=True)])
root.posted = True
try:
yield root
except Exception as e:
root.error = f"{type(e).__name__}: {e}"
finally:
if root.posted:
await _flush(cfg, post=root._buf or None, patch=[root._run_patch()])
else:
root._finalize()
await _flush(cfg, post=root._buf)
Three behavior modes:
- Disabled (default in dev). No POSTs. The
yieldedrunis a stub with no-opset_llm_usage,outputs, etc. The graph code never branches on whether tracing is on. - Enabled, fast path. Build the run tree in memory, batch all children
- root into a single
POST /runs/batchat exit. One HTTP call per request.
- root into a single
- Enabled, durable path (
post_pending=True). Used for jobs that might be evicted by Worker CPU limit / lose connectivity mid-run. POSTs the root asstatus=pendingimmediately, then PATCHes it on close. Even if the worker dies mid-graph the run shows up in LangSmith as a pending run that never closed — enough to debug.
4.7 submit_feedback — production quality signals
_tracing.py exposes a feedback POST against the root run:
await submit_feedback(env, root.id, "gate_passed", score=1.0)
await submit_feedback(env, root.id, "no_ai_markers", score=0.0,
comment="markers found in body")
The key naming convention is load-bearing. Production feedback keys must
match the offline evaluator keys in
apps/agentic-sales/backend/scripts/langsmith_run_experiments.py. Same
namespace = the LangSmith pass-rate view aggregates both signals.
5. The three load-bearing span attributes
Chapter 5 of the audio guide ("What goes on a span") names three classes. Every other attribute is decoration that inflates cardinality.
5.1 Request identity
| Attribute | Where set | Why load-bearing |
|---|---|---|
langgraph.assistant_id | TS span, every graph call | Roll-up key for cost/latency by capability |
langsmith.run_id | TS span (from x-langsmith-run-id response header) | Pivot from OTel → LangSmith UI |
langsmith.run_url | TS span (from x-langsmith-run-url) | One-click open in LangSmith |
peer.trace_id | TS span (from x-trace-id) | Pivot from OTel → worker-side trace store |
5.2 Call shape (Python worker side, set_llm_usage)
On any run_type="llm" span the worker records:
model_name(set viaset_llm_usage(model=...)) — e.g.deepseek-chat,@cf/baai/bge-m3. Surfaces asextra.metadata.ls_model_nameso LangSmith's price table can auto-compute $.provider(set viaset_llm_usage(provider="cloudflare"|"deepseek")) —extra.metadata.ls_provider.- Prompt version — recorded by the graph code (not
_tracing.py) asoutputs.metadata.prompt_version. You cannot drop this field. Without it you have a recorded LLM call you can never reproduce because the template might have been edited since.
5.3 Cost (token counts)
run.set_llm_usage(input_tokens=N, output_tokens=N) emits
outputs.usage_metadata in the shape LangSmith's price table consumes. The
worker does NOT record dollar amounts — those would rot as pricing changes.
LangSmith multiplies tokens × model rate at query time.
5.4 What you must NOT record
- Raw model output on the span. PII leak risk — a generated email might
contain a phone number, address, or SSN. Outputs go in
run.outputs(which gets ingested by LangSmith with full PII controls), never on searchable span attributes. - Prompt text as an attribute. Use the prompt version instead.
- Per-prompt-variant attribute keys. Keep the attribute name stable, put the variation into the value. Otherwise cardinality blows up your observability bill.
6. LangSmith configuration — the env-var matrix
Every worker reads the same envelope. Set in wrangler.jsonc vars (for
non-secrets) and wrangler secret put (for the API key).
| Env var | Purpose | Where read |
|---|---|---|
LANGSMITH_TRACING | Master gate: true to emit, anything else = no-op | _tracing.py:_enabled() |
LANGSMITH_API_KEY | Ingest auth (x-api-key header) | _tracing.py:_post_headers() |
LANGSMITH_ENDPOINT | Default https://api.smith.langchain.com | _tracing.py:_cfg() |
LANGSMITH_PROJECT | Project namespace; defaults to "lead-gen" | _tracing.py:_cfg() |
LANGSMITH_WORKSPACE_ID | Org tenancy for lsv2_sk_* service keys | _tracing.py:_post_headers() |
OTEL_EXPORTER_OTLP_ENDPOINT | OTLP/HTTP trace exporter (Next.js side) | instrumentation.ts (gated registerOTel) |
OTEL_TRACES_SAMPLER | always_on (default) / parentbased_traceidratio | @vercel/otel env-driven |
OTEL_TRACES_SAMPLER_ARG | Ratio (e.g. 0.1) for probabilistic head sampling | same |
OTEL_SERVICE_NAME | Defaults to lead-gen-web | instrumentation.ts |
Two distinct trace stores by design:
- LangSmith — owns the run tree of the Python-side graph (one tree per
/runs/waitcall). Holds inputs, outputs, token counts, feedback. This is the "what did the model decide" store. - OTel collector (when wired) — owns the cross-process distributed
trace. Holds the Next.js HTTP span → fetch span → Python span (continued
via
traceparent) → any other downstream calls. This is the "what happened across the system" store.
They cross-link via x-langsmith-run-id (set by worker → read by TS span)
and traceparent (set by TS → continued by Python OTel runtime, if
configured on the worker side).
7. The cost report — langsmith_cost_report.py
apps/agentic-sales/backend/scripts/langsmith_cost_report.py is the
batch-mode summary that turns LangSmith runs into dollars + quality
percentages.
7.1 Projects it consumes
Lines 26-31:
DEFAULT_PROJECTS = [
"lead-gen", # discovery-worker (absorbed intel 2026-05-28) + container
"lead-gen-gateway", # ai-gateway choke point
"research-pipeline",
]
Add a new project here when you spin up a new worker. Otherwise its spend rolls up into nothing.
7.2 Fields read per run
prompt_tokens,completion_tokens,total_tokenstotal_cost(LangSmith's auto-computed$from model + tokens)error(truthy if the run failed)feedback_stats(the production quality signals submitted viasubmit_feedback)extra.metadata.ls_model_name(for per-model breakdown)
7.3 Cost attribution rule
Attributes cost only to run_type == "llm" leaf runs. Parent chain/tool
runs are skipped to avoid double-counting (LangSmith's roll-up already does
that on the server side, but the report re-implements it for batch CSV
output).
7.4 Quality gates parsed
feedback_stats["gate_passed"] # [n, avg]
feedback_stats["no_ai_markers"] # [n, avg]
Pass-rate = sum(avg * n) / sum(n) across runs. Surfaced in the admin
dashboard as colored badges per project.
8. In-app observability surface — admin/langsmith/page.tsx
Lives at apps/agentic-sales/src/app/admin/langsmith/page.tsx. Renders
data from a langsmithAnalytics GraphQL resolver that runs the cost-report
logic on demand.
What's there:
- Cost / token rollup per project.
- Per-model breakdown table (model name, run count, tokens, $).
- Top-failures table — each row links out to the LangSmith trace URL via
the persisted
langsmith_run_id(see §9). - Gate pass-rate + no-AI-marker pass-rate badges per project.
What's NOT there:
- Embedded trace tree visualization. Trace trees are consumed exclusively in the LangSmith UI. The in-app surface is metrics + deep-links.
Why no embedded trace UI: LangSmith's UI is the production-grade tool
for run-tree exploration. Embedding our own would duplicate years of work
and immediately diverge. Deep-linking via x-langsmith-run-url is the
cheap-and-correct path. (specs/case-study/roadmap.md Phase 8.3
proposes a deep-dive page but not an embedded trace viewer.)
9. Persisting LangSmith run IDs to D1 — closing the feedback loop
The email-compose path is the canonical example. Migration:
-- apps/agentic-sales/migrations/0009_email_langsmith_run_id.sql
ALTER TABLE contact_emails ADD COLUMN langsmith_run_id TEXT;
When an email is composed, the langsmith_run_id from the
x-langsmith-run-id response header (set by the worker via
response_headers(run)) gets written to D1 alongside the email. When the
recipient later replies, the inbound-reply handler:
- Looks up the original
contact_emailsrow. - Reads its
langsmith_run_id. - Calls
submit_feedback(env, run_id, "reply_received", score=1.0).
That's the production feedback loop chapter 12 of the audio guide teaches — the recipient's reply becomes a reward signal on the original LLM call, months after the fact, with no manual joining.
Where else this pattern is used: any graph whose outcome can be
measured later (after the synchronous run closes) should persist its
langsmith_run_id on the same D1 row that holds the artifact. The pattern
is shaped, not codified — add new graphs by mirroring the email migration.
10. Sampling — composing three strategies
Audio chapter 6 ("Sampling without going blind"). The strategies layer:
| Strategy | Where | Decision time | Cost | Failure-blindness risk |
|---|---|---|---|---|
| Head | @vercel/otel via OTEL_TRACES_SAMPLER=parentbased_traceidratio | Trace start | Cheap | Drops failures that happen mid-trace |
| Tail | (Configured at the OTel collector, not in this repo) | Trace end | Buffers full trace, then decides | Buffer overflow can evict the very failures it was meant to keep |
| Tagged | extra.metadata on the run (worker side) | Either end | Free | Tag leak to prod traffic → keeps everything → cost spike |
This repo only owns head sampling explicitly (via OTEL_TRACES_SAMPLER_ARG).
Tail sampling and tag-based retention belong in the OTel collector or in
LangSmith's project retention settings. Neither is wired in code; both are
config decisions made out-of-band.
Per-assistant override: the WORKER_ROUTES table is the natural seam
for differential sampling — high-stakes assistants (email_compose,
gh_lead_research) stay on always_on; bulk assistants
(country_classify_bulk) flip to a low ratio. Today this is enforced
manually via env vars per assistant; there is no in-code switch.
11. Failure-mode catalog
Every failure mode mentioned in the audio guide chapters 1-6, mapped to where it actually hits in this codebase.
11.1 Propagation breakages
| Symptom | Cause | Where to look |
|---|---|---|
| Worker run shows as standalone root (not nested under TS span) | getCurrentRunTree(true) returned undefined — no LangSmith run was open in the Next.js scope | Expected default unless route opens a traceable() context |
| Two separate trace IDs for one user action | Load balancer / proxy stripped traceparent | Verify headers arrive on the worker (check parse_inbound parses non-null) |
| OTel span tree collapses to flat orphans | Runtime disabled Node's AsyncLocalStorage | Edge runtime / Workers — make sure the Next.js handler runs on the Node.js runtime, not Edge |
New HTTP client bypasses propagation.inject | A teammate added a fetch-shaped wrapper that didn't go through runGraphWithMeta | Audit apps/agentic-sales/src/ for raw fetch(/runs/wait...) — should be zero |
11.2 Tracer disabled when it shouldn't be
| Symptom | Cause | Fix |
|---|---|---|
| LangSmith shows zero runs for a worker | LANGSMITH_TRACING != "true" | Set as worker vars in wrangler.jsonc |
| LangSmith shows 403 storms in worker logs | lsv2_sk_* service key without LANGSMITH_WORKSPACE_ID | Add the workspace UUID as a var |
| Cost report shows $0 for a project | Project name in wrangler.jsonc doesn't match DEFAULT_PROJECTS in the report script | Sync both |
11.3 Cardinality / cost blowup
| Symptom | Cause | Prevention |
|---|---|---|
| OTel bill spikes 10× overnight | Per-prompt-variant attribute keys (prompt_v17_a, prompt_v17_b) | Keep attribute names stable, put variation into the value (prompt_version=17a) |
| LangSmith retention exceeded mid-month | Experiment tag leaked to prod assistants | Audit extra.metadata.tags — tagged sampler keeps everything tagged |
| Tail-sampler buffer overflow drops failures | Hot path produces more spans than buffer holds | Lower head sampling ratio for that assistant; raise buffer; or move tagged retention to a separate project |
11.4 Reproducibility breakage
| Symptom | Cause | Detection |
|---|---|---|
| Replay of a logged run produces different output | Prompt rewritten in place under the same version number | Bump PROMPT_VERSION on every template edit, even cosmetic; mismatch = caller-side bug, fix the bumping discipline |
| Historical cost numbers don't compare cleanly | Model provider changed token accounting rules | Annotate the discontinuity in the cost report; don't try to "normalize" past data |
Assistant ID taxonomy fragmented (lead-scoring-v2 vs. lead_scoring_v2) | Two teams added the same logical assistant under different IDs | Codify naming in agentic_sales.registry.GRAPHS; reject new IDs that don't match the convention in code review |
11.5 PII / safety
| Symptom | Cause | Mitigation |
|---|---|---|
| Generated email body searchable in OTel UI | Someone added span.setAttribute("output_body", body) | NEVER record outputs as searchable attributes. Outputs go in run.outputs (LangSmith side, behind PII controls), never on OTel spans |
| Customer name appears in LangSmith run search | Treated as searchable metadata instead of input/output | Inputs/outputs are NOT indexed for search by default; check that any added metadata is non-PII (assistant_id, model_name, prompt_version are safe) |
11.6 Async-ingestion gotchas
| Symptom | Cause | Mitigation |
|---|---|---|
| Run silently abandoned | Caller created a thread + run via startGraphRun but never polled | Add a timeout-driven cleanup job; surface "stalled" state explicitly in UI |
| Same completion event delivered twice | Polling client restarted mid-poll | Implement exactly-once dedup on the consumer (last-event-id pattern) |
| Pending / error / stalled states indistinguishable | UI only reads run.status, no inspection of graph checkpoints | Inspect explicit checkpoints in the graph state to distinguish them |
12. What's deferred (not wired)
The audio guide chapters 12–15 teach feedback loops, online evaluators, and PSI drift detection. The current implementation status:
gate_passed+no_ai_markersfeedback: LIVE onemail_composefinalize node. Pattern is portable to other graphs by mirroring thesubmit_feedbackcalls; not yet replicated elsewhere.- Online evaluators (chapter 14): NOT WIRED. The pattern is to run an
evaluator graph in the data plane against recent production runs and
submit scores as feedback. The wiring would go in a new worker
(
services/case-study-evaluator-worker/?) — not yet planned. - PSI drift detection (chapter 15): NOT WIRED. The audio guide teaches
it as a pattern (
computeKS,computePSI); no implementation exists in this repo yet. Roadmap-side phase TBD. - Embedded trace UI in the admin panel: NOT WIRED. See §8 — design decision is to deep-link to LangSmith, not embed.
canary and rampautomation (chapter 19): NOT WIRED. TheWORKER_ROUTEStable supports manual per-assistant ramp by swapping*_WORKER_URLenv vars; no automatic rollback on metric regression yet.
13. References
Audio guide chapters that teach the observability plane
| Chapter | Title | Script line |
|---|---|---|
| 1 | Beyond traditional observability | data/audio/case-study.script.md:31 |
| 2 | The run tree model | line 49 |
| 3 | Run trees build themselves | line 71 |
| 4 | Why synchronous ingestion fails | line 95 |
| 5 | What goes on a span | line 115 |
| 6 | Sampling without going blind | line 131 |
| 18 | Shadow mode rollouts (touches WORKER_ROUTES + propagation) | line 379 |
Code anchors
apps/agentic-sales/src/lib/langgraph-client.ts— TS-side client + spans + headersservices/_shared/tracing/_tracing.py— canonical Python tracerservices/case-study-{discovery,classify,email}-worker/src/_tracing.py— synced copiesservices/case-study-{discovery,classify,email}-worker/src/entry.py—trace_runusage at the worker entryservices/case-study-{discovery,classify,email}-worker/wrangler.jsonc— env-var configurationapps/agentic-sales/backend/scripts/langsmith_cost_report.py— batch cost roll-upapps/agentic-sales/src/app/admin/langsmith/page.tsx— in-app dashboardapps/agentic-sales/migrations/0009_email_langsmith_run_id.sql— D1 persistence example
Constitution / spec anchors
apps/ai-engineer-roadmap/specs/case-study/mission.md:17-27— three-plane definitionapps/ai-engineer-roadmap/specs/case-study/tech-stack.md— stack, commands, forbidden patternsapps/ai-engineer-roadmap/specs/case-study/roadmap.mdPhase 8.3 —/case-study/deep-divepage idea (this doc is its companion content)
External authorities
- W3C Trace Context — https://www.w3.org/TR/trace-context/
- LangSmith ingest REST API — see
_tracing.py:_flush()for the actual endpoints used (/runs/batch,/feedback). - OpenTelemetry semantic conventions for span attributes — used as the
shape for
langgraph.*,langsmith.*,peer.*attributes. - LangSmith dotted-order parent header format — see
RunTree.to_headers()in the LangSmith JS SDK; matches whatparse_inboundreads.
Last verified against code: 2026-05-28. If
apps/agentic-sales/src/lib/langgraph-client.ts or
services/_shared/tracing/_tracing.py has been edited since, line numbers
in §3-4 may have drifted — re-grep for the symbol names rather than chasing
the line.
Companion to the audio guide. The case-study guide is a 26-chapter, ~130-minute narration whose MP3s live on Cloudflare R2. This doc is the matching technical reference for how those bytes reach the player: the same-origin
/api/audioproxy, HTTP Range/206 streaming, the service-worker contract, and the cross-route migration (backlog A41) that brought/langgraph,/langsmithand the per-lesson[slug]pages onto the same path. Read this when you need to change audio delivery or debug a stall — not when you're just listening.
Scope: the audio delivery plane end to end: the URL rewrite in
lib/audio.ts, the proxy route at app/api/audio/[...path]/route.ts, the
service worker in public/sw.js, and the cross-route parity work. Every claim
points at a real file.
Out of scope: TTS generation and R2 upload (the Rust finalize_audio
pipeline), and the audio content itself. This doc is about transport, not
production.
1. Why audio delivery is its own problem
A 5-minute chapter MP3 is ~5 MB. A web <audio> element does not download it
and then play — it streams, seeking to byte offsets on demand as the user
scrubs. The mechanism the browser relies on for that is HTTP Range
requests: the player sends Range: bytes=1048576- and expects a 206 Partial Content response carrying exactly that slice plus a Content-Range header.
Get this wrong and the seek bar either does nothing or stalls the whole tab.
There are two non-negotiables for the delivery plane:
- Progressive streaming, never full-download-then-play. The user must be able to start a chapter and scrub it without waiting for the whole file.
- Same-origin. Cross-origin audio drags in CORS, an extra DNS/TLS handshake, and — critically — a service-worker code path that broke streaming (see §5).
Both are satisfied by routing every chapter MP3 through one same-origin proxy endpoint that faithfully relays Range semantics from R2 to the browser.
2. The three pieces
| Piece | File | Responsibility |
|---|---|---|
| URL rewrite | lib/audio.ts (toSameOriginAudioUrl) | Turn an R2 URL into a /api/audio/… URL at metadata-build time |
| Proxy route | app/api/audio/[...path]/route.ts | Same-origin Worker that streams R2 with Range support |
| Service worker | public/sw.js | Pass /api/audio through untouched; offline-cache opt-in only |
The contract that ties them together is the key path:
knowledge/<slug>/NN.mp3. R2 stores objects under this prefix; the rewrite
produces /api/audio/knowledge/<slug>/NN.mp3; the route allowlist accepts
exactly that shape and nothing else.
3. The URL rewrite (lib/audio.ts)
R2 MP3s are public behind tts.vadim.blog. getAudioMeta does not hand those
absolute URLs to the player — it rewrites them through toSameOriginAudioUrl:
export function toSameOriginAudioUrl(url: string | undefined): string | undefined {
if (!url) return url;
try {
const { pathname } = new URL(url);
if (/^\/knowledge\/[A-Za-z0-9/_-]+\.mp3$/.test(pathname)) {
return `/api/audio${pathname}`;
}
} catch {
// Relative URL or unparseable — leave it as-is.
}
return url;
}
Two properties matter:
- It is conservative. Only paths matching
^/knowledge/…\.mp3$are rewritten; anything else passes through unchanged. A non-R2 URL, or a path that doesn't match the key shape, is never turned into a proxy request. - It runs at metadata-build time, not render time. Both the
stitchedandsingle-fileaudio modes are rewritten asgetAudioMetaassembles theAudioMetaobject (lib/audio.ts), so by the time the player sees a URL it is already same-origin.
Note. The case-study page is a client component, so it cannot
importfrom@/lib/audio(that module pulls in Nodefs/pathand would break the client bundle). It keeps a local copy oftoSameOriginAudioUrlwith a comment to keep it in sync. The duplication is deliberate — a runtime import of the server module is the bug it avoids.
4. The proxy route (app/api/audio/[...path]/route.ts)
The route runs as a Vercel Node function (runtime = "nodejs",
dynamic = "force-dynamic"). It is a streaming reverse proxy for one narrow
class of objects.
4.1 The allowlist (SSRF guard)
const KEY_RE = /^knowledge\/[A-Za-z0-9\/_-]+\.mp3$/;
Every request is checked against KEY_RE and rejected if it contains ...
This is what stops the route from becoming an open proxy / SSRF vector — it can
only ever fetch our own audio objects, never an arbitrary URL. A non-matching
key returns 404 and logs [audio] reject key="…".
4.2 Range forwarding — the heart of seeking
The browser drives seeking with Range/If-Range; R2 answers with 206 and a
Content-Range. The route's job is to relay both directions faithfully:
- Request headers forwarded upstream:
range,if-range,if-none-match,if-modified-since. The first two drive seeking; the rest let R2 answer304on a full-object reload. - Response headers copied back:
content-type,content-length,content-range,accept-ranges,etag,last-modified,expires. The upstream status is preserved —206(partial) or200(full) passes through verbatim.
The status table the route handles explicitly:
| Upstream | Relayed as | Why |
|---|---|---|
200 / 206 | same | Full or partial body streamed straight through |
304 | 304, validators only, no body | Browser keeps its cached copy |
416 | 416 + Content-Range | Player recovers by retrying without a Range |
403 / 404 | 404 | Object missing — collapse to not-found |
other 4xx/5xx | 502 | Upstream failure |
4.3 Header normalization
Two fixups the route always applies, regardless of what R2 returned:
accept-ranges: bytesis always set, even on a200, so the player offers a seek bar.content-typeis normalized: R2 sometimes servesapplication/octet-streamfor objects uploaded without an explicit type, which breaks<audio>in some browsers; the route coerces that (andbinary/text/plain) toaudio/mpeg.vary: Rangeis set so any cache keys on the Range header — a cached full body is never replayed for a partial request, and vice-versa.
4.4 cache: "no-store" is mandatory
const upstream = await fetch(url, { method, headers, cache: "no-store", … });
Next's fetch cache keys on URL only. Without no-store, two different
byte-ranges of the same object would collide on that key and the proxy would
serve the wrong bytes for a seek. CF's edge still caches the immutable upstream
object independently, so no-store costs nothing in practice — it only
disables the per-request fetch dedup that would corrupt Range responses.
4.5 Streaming, retries, timeouts
- No buffering.
GETreturnsnew Response(upstream.body, …)— the body is piped straight through, so a multi-MB MP3 is never fully materialized in Worker memory. - One retry on transient upstream failures (
408/429/5xxor a network error), with a 250ms × attempt backoff. The first body iscancel()ed so the connection can be reused. - 20s per-attempt timeout via
AbortController. A timeout surfaces as504; a connection failure as502. HEADandOPTIONSare supported:HEADreturns headers only;OPTIONSanswers204withAllow: GET, HEAD, OPTIONS+Accept-Rangesfor the occasional player/extension that probes before streaming.
4.6 Observability
One structured log line per request — key, range, upstream status, length,
Content-Range, content-type, elapsed ms — visible via wrangler tail and the
CF dashboard. A range request that R2 answered with a full 200 is tagged
(range-ignored-by-upstream) so a degraded seek is diagnosable from the logs.
5. Why cross-origin stalled (public/sw.js)
Before the proxy, MP3s were served straight from tts.vadim.blog. The service
worker matched cross-origin audio (url.hostname === AUDIO_HOST) and ran a
CacheFirst-with-Range-reconstruction strategy: the Cache API cannot satisfy
a Range request, so the worker had to fetch and cache the whole MP3, then
slice the cached body into a synthetic 206. That eager full-download is
exactly what stalled playback — the user pressed play and waited for the entire
file before the first byte sounded.
The fix is the same-origin path. Because the proxy lives under /api/, the
worker passes it through with a dedicated, deliberately non-buffering branch:
// Same-origin streaming audio (`/api/audio/…mp3`): online playback is a plain
// network passthrough — it does NOT eagerly download the whole file first
// (that was the stall on the cross-origin path).
if (url.pathname.startsWith("/api/audio/") && url.pathname.endsWith(".mp3")) {
event.respondWith(handleSameOriginAudio(request, url));
return;
}
Offline caching becomes opt-in: a full copy is only saved when explicitly
requested for offline use, and online playback never pre-downloads. The
legacy cross-origin AUDIO_HOST branch is retained only for any URL that
still escapes the rewrite. Bumping CACHE_VERSION (now v4) invalidates every
cache on the next activate, which is how an sw.js change ships to clients that
already registered an older worker.
6. The cross-route migration (A41)
Originally only /case-study streamed same-origin; /langgraph,
/langsmith and the per-lesson [slug] pages still pointed at
tts.vadim.blog and inherited the stall. Because the rewrite lives in
getAudioMeta rather than in any one page, routing those routes onto the proxy
was a data-plane change, not a per-page edit: once getAudioMeta emits
/api/audio URLs, every page that reads its metadata inherits the proxy for
free. The proxy allowlist already accepts any knowledge/<slug>/NN.mp3 key, so
no route-specific allowlisting was needed — langgraph, langsmith and lesson
slugs all fit the one key shape.
Verification (Phase 7.4): on each route, DevTools Network must show audio
requests going to /api/audio/knowledge/<slug>/NN.mp3 (not tts.vadim.blog),
returning 206 on seek, with scrubbing that doesn't stall.
7. Failure-mode catalog
| Symptom | Likely cause | Where to look |
|---|---|---|
| Seek bar absent | accept-ranges not reaching the player | §4.3 — route always sets it; check sw passthrough |
| Scrub stalls / re-buffers whole file | request hit the cross-origin sw branch | §5 — confirm URL is /api/audio/…, not tts.vadim.blog |
| Wrong audio bytes on seek | fetch-cache collision | §4.4 — cache: "no-store" missing/stripped |
404 for a real chapter | key fails KEY_RE or R2 403/404 | §4.1 — log line reject key or upstream 403/404 |
<audio> refuses to play | R2 served application/octet-stream | §4.3 — content-type normalization |
504 on play | upstream R2 slow | §4.5 — 20s timeout; check R2/network |
| sw change not taking effect | stale registered worker | §5 — bump CACHE_VERSION |
8. Invariants to preserve when changing this surface
- The key shape is the contract.
knowledge/<slug>/NN.mp3is shared by the rewrite regex, the route allowlist, and R2's object layout. Change one, change all three. cache: "no-store"on the upstream fetch stays. Removing it silently corrupts Range responses (§4.4).- Preserve the upstream status. Never synthesize a
206from a200or vice-versa in the route — relay what R2 sent; the browser handles a200answer to a Range request per RFC 7233. - No eager full-download in the service worker. Online playback must stay a passthrough; offline caching stays opt-in (§5).
- Bump
CACHE_VERSIONon any sw.js change, or clients keep the old worker.
Companion to the fleet. The case-study stack is ~50 LangGraph graphs that discover, classify, research, compose, send, and apply on their own. The single question "may an agent act without a human?" is answered in exactly one place. This doc is the technical reference for that place — read it when you need to change the posture, reason about blast radius, or explain to an auditor why a fully autonomous fleet is defensible.
Scope: the autonomy posture as implemented in
apps/agentic-sales/backend/infra/autonomy.py — the one module every gate
consults, the functions it exposes, the gate matrix it enforces, the runtime
switch that was removed, and how the posture is stamped onto every LangSmith
run so full autonomy stays auditable.
Out of scope: the individual graphs' business logic (what a
discovery/classify/email agent does once it's cleared to act), and
LLM_KILL_SWITCH (a separate lever that halts every LLM path — see §6). The
observability plumbing that carries the autonomy tags is its own deep dive
(observability.md).
1. Why one module owns the posture
A fleet this size has autonomy decisions scattered across every graph: an outreach queue could check its own env var, a CV-application node could read its own flag, a vertical-activation step could hardcode its own default. That is how posture drifts — one graph ships human-in-the-loop, another ships fully autonomous, and nobody can answer "is the fleet approving its own sends?" without auditing fifty files.
autonomy.py exists to make that question answerable in one read. It is the
single chokepoint every interrupt / dry-run / plan-pending gate calls
into. The call sites keep their gate seams — nothing is deleted — but the
answer they get comes from here. That gives two properties:
- Consistency across ~50 graphs. Change the posture once and the whole fleet moves together; there is no per-graph flag to forget.
- A single audit target. Reviewing the fleet's safety posture means reviewing this ~110-line module, not the entire backend.
The history matters for anyone reading the call sites: the fleet was
originally draft-first / human-in-the-loop by design. Every campaign
touch, outreach queue, vertical activation, and CV application paused for an
operator. Autonomy existed only as opt-in flags (auto_approve /
auto_confirm / dry_run=False) that defaulted off. Per the owner
decision dated 2026-06-30, that inverted: the fleet is now fully
autonomous, hardcoded.
2. autonomy_mode() — what "full" means
def autonomy_mode() -> str:
return "full"
def is_autonomous() -> bool:
return autonomy_mode() == "full"
autonomy_mode() returns the string "full" unconditionally.
is_autonomous() is the boolean the gates branch on.
"Full" means end-to-end, no human approval. An agent that discovers a lead, classifies it, researches it, composes an email, and sends it — or one that finds a role and submits a CV application — does the whole chain without an operator in the loop. There is no draft state waiting for a click, no outreach queue pending review, no plan-pending interrupt that blocks.
Two deliberate design choices live in these two functions:
- They are functions, not constants. Tests monkeypatch
autonomy_mode()→"gated"to exercise the human-in-the-loop branches that still live at the call sites. Those branches are dead in production but reachable in tests, which is why the seams were kept rather than ripped out. - The
"full"/"gated"split is the only vocabulary. There is no third mode, no per-capability posture. The fleet is uniform.
3. The gate matrix — what's off, what's on
def gate_enabled(name: str) -> bool:
if not is_autonomous():
return True
return name.strip().lower() == "suppression"
Every fail-closed gate asks gate_enabled("<name>"). Under full autonomy
the answer is off for everything except suppression.
| Gate | State under full autonomy | Rationale |
|---|---|---|
eligibility | OFF | Strict per-lead eligibility no longer blocks a send |
daily_cap | OFF | Per-vertical daily send cap no longer throttles |
cost_ceiling | OFF | Per-run cost ceiling no longer halts a run |
plan_confidence | OFF | Low-confidence plans no longer pause for review |
revops_confirm | OFF | RevOps confirmation step no longer interrupts |
suppression | ON (permanent) | Unsubscribe / do_not_contact honoring — CAN-SPAM |
The if not is_autonomous() seam is retained on purpose: a test that
monkeypatches autonomy_mode() to "gated" gets every gate back
(return True for all names) — full HITL. In production is_autonomous() is
always true, so the function collapses to the single suppression check.
Suppression is not a gate you can flip
Suppression has its own dedicated function so it can never be switched off by the same mechanism as the others:
def keep_suppression() -> bool:
return True
Even under full autonomy, unsubscribes and do_not_contact are always
honored. Sending to a suppressed or unsubscribed recipient can violate
CAN-SPAM, so this is the one safety check that is hardcoded rather than
gate-controlled, with no env var to disable it. It is legal-compliance
plumbing, not an autonomy dial — which is why it lives outside the gate
matrix entirely and is also reachable via gate_enabled("suppression") for
call sites that consult the generic seam.
4. The removed runtime switch
Earlier the posture was a runtime decision. A cluster of env vars governed it:
AGENT_AUTONOMY— the master switch (full/gated).AGENT_AUTONOMY_KEEP_SUPPRESSION— kept suppression on independently.AGENT_AUTONOMY_GATE_<NAME>— per-gate overrides.- a per-run
autonomyoverride — supplied on an individual invocation.
All of these were removed in the 2026-06-30 decision. There is no toggle back to human-in-the-loop in production, no per-gate env override, and no per-run override. The posture is not configuration anymore; it is code.
Why hardcode instead of leaving the switch defaulting to full? A live
switch is a live risk: a stray env var in one worker's wrangler.jsonc, a
copy-paste of an old config, or an operator "just testing gated mode" would
silently re-introduce a HITL pause somewhere in the fleet and desync the
posture the chokepoint exists to keep uniform. Removing the switch makes the
posture a reviewable code change with a git history, not an ambient runtime
state you have to go discover across ~50 deploys.
5. Making full autonomy auditable, not reckless
Full autonomy without a paper trail would be reckless. Two things make it defensible.
5.1 Blast-radius containment
The posture governs whether agents act without approval — it does not remove the guardrails that bound what an action can do. Suppression is permanent (§3). The kill switch still halts the model outright (§6). The fleet still routes through per-capability worker pools with their own auth. The autonomy decision is narrow: it removes the approval step, not the containment.
5.2 Every run is stamped
def autonomy_trace_fields(*, decision=None, mode=None) -> dict[str, Any]:
resolved = (mode or autonomy_mode()).strip().lower()
meta = {"autonomy_mode": resolved}
if decision:
meta["autonomy_decision"] = decision
return {"tags": [f"autonomy:{resolved}"], "metadata": meta}
Every LangSmith run/span folds in this fragment, producing:
{"tags": ["autonomy:full"],
"metadata": {"autonomy_mode": "full", "autonomy_decision": "auto_sent"}}
tags: ["autonomy:full"]lets an operator slice all traces by posture in the LangSmith UI — "show me everything the fleet did fully autonomously" is one filter.metadata.autonomy_moderecords the posture as it was at run time, so a historical trace is self-describing even after the posture changes.metadata.autonomy_decisionrecords what the agent actually did at the gate, drawn from a small fixed enum:
| Constant | Meaning |
|---|---|
DECISION_AUTO_APPROVED | Agent approved without a human |
DECISION_HUMAN_APPROVED | (reachable only under gated/tests) |
DECISION_AUTO_SENT | Outreach sent autonomously |
DECISION_AUTO_APPLIED | CV application submitted autonomously |
DECISION_SKIPPED_GATE | A fail-closed gate was skipped (off) |
DECISION_SUPPRESSED | Recipient suppressed → not contacted |
DECISION_HELD | Action held (reachable only under gated/tests) |
Because every autonomous action carries autonomy:full plus a decision
label, "the agent did X on its own" is always attributable to a specific
run, with inputs and outputs, after the fact. Auditability is what converts
full autonomy from reckless into accountable.
6. What autonomy is not — the kill switch
autonomy.py is independent of LLM_KILL_SWITCH. They answer different
questions:
| Lever | Question it answers | Effect |
|---|---|---|
autonomy_mode() | May an agent act without human approval? | Removes approval steps; agents still run |
LLM_KILL_SWITCH | May the model run at all? | Halts every LLM path fleet-wide |
An operator who wants to stop the fleet does not reach for autonomy — that lever is hardcoded. They reach for the kill switch, which is the emergency stop. Autonomy is about approval, the kill switch is about execution. Conflating them is the most common misreading of this module.
7. References
Code anchors
apps/agentic-sales/backend/infra/autonomy.py— the chokepoint (this doc's subject)infra.langsmith_setup.agent_run_span— consumesdecision=+autonomy_trace_fields()- Call sites: every interrupt / dry-run / plan-pending gate that calls
is_autonomous()/gate_enabled(...)(grep the backend for those symbols)
Function reference
| Function | Returns | Contract |
|---|---|---|
autonomy_mode() | "full" | Posture; monkeypatchable to "gated" in tests |
is_autonomous() | True | autonomy_mode() == "full" |
keep_suppression() | True | CAN-SPAM; hardcoded, no env |
gate_enabled(name) | name == "suppression" | Only suppression on under full autonomy |
autonomy_trace_fields(...) | {tags, metadata} | LangSmith stamp for the autonomy dimension |
Constitution / spec anchors
specs/case-study/mission.md— three-plane definition; autonomy is a control-plane concernspecs/case-study/deep-dives/observability.md— the trace plumbing that carriesautonomy:full
Last verified against code: 2026-06-30 (owner decision that hardcoded
full autonomy). If apps/agentic-sales/backend/infra/autonomy.py has been
edited since, re-read it — the whole module is the source of truth and it is
short enough to read in full rather than trusting quoted excerpts here.
Companion to the LlamaIndex primer.
/how-it-works/llamaindexteaches how the RAG engine works — chunks, embeddings, the vector index, grounding. This doc is the matching serving reference: how that engine actually runs in production, on what hardware, under what memory budget, with which retrieval features on and off, and what to do when it breaks. Every claim points at a real file; every number was measured on the live system (2026-07-14, the day this architecture was rebuilt from scratch after the hosting service and the vector collection were both lost).
Status (2026-07-14): the hosted demo is retired; localhost is the only serving profile. The stack uses exactly one model provider — DeepSeek — called directly, and one embedder — FastEmbed, in-process. No Cloudflare AI, no AI Gateway, no Workers AI. That rules the Render free tier out entirely: FastEmbed needs ~616Mi against its 512Mi cap (measured — the box pegs at 512Mi and never opens a port), the only fit was delegating embeddings to Workers AI, and DeepSeek publishes no embeddings endpoint (404) to fill the gap. The Render service is suspended,
LLAMAINDEX_SERVICE_URLis unset in prod (so the AI features degrade instantly — 2.2s, not a 30s hang), and the keepwarm cron is gone. Everything below about the hosted profile is kept as the record of what was measured and why — read §4 as "the product vs the demo that could not be".
Scope: the serving plane end to end — the Next.js app on Cloudflare Workers, the Python RAG service on Render's free tier, the Qdrant Cloud vector store, the Cloudflare AI Gateway (LLM and embeddings), the keepwarm cron, the daily backup cron, and the recovery runbook.
Out of scope: how the index is built (the primer and /rag-pipeline
own that), retrieval-quality technique design (/llamaindex/patterns), and
observability (its own deep dive).
1. Why serving is its own plane
The engine and the serving substrate obey different constraints. The engine wants the best retrieval money can buy: hybrid dense+BM25 fusion, a cross-encoder reranker, a model-driven strategy router — and on localhost, the machine this project is actually built for, it gets all of them. The demo substrate is a free Render instance with a hard 512Mi memory ceiling — and every one of those engine features, left at its default, kills that process:
- In-process FastEmbed (ONNX runtime + bge-small weights + tokenizers) idles the service at 496Mi of 512Mi — measured via Render's memory metrics. Health checks pass; the first real query is an OOM kill.
- The cross-encoder reranker (
LLAMAINDEX_CE_RERANK, default ON inrag_service/settings.py) lazy-loads a second ONNX model on the first scored query — a ~1.8GB transient spike, measured locally. - The hybrid BM25 leg (
rag_service/techniques/hybrid.py→indexing.py::store_nodes) hydrates every node in the collection (22,210 as of the rebuild) out of Qdrant into local RAM to build its keyword index. - The LOOK strategy router (
LOOK_ROUTER, default on) can select the hybrid strategy per-query, re-triggering that hydration even withLLAMAINDEX_HYBRID=0.
So production serving is a deliberately narrowed profile of the same codebase: dense-only retrieval over a remote vector store, with query embeddings served over HTTP instead of in-process. Dev keeps the full ladder. That split — not a smaller corpus, not a bigger box — is the architecture.
2. The request path, end to end
browser ── select text / ask
│
▼
Next.js on Vercel
│ lib/llm-service.ts — the single TS gateway to LLM completions.
│ Every call carries AbortSignal.timeout(30s): a dead upstream must
│ fail fast into the feature's degrade path (friendly 503), never
│ hang the route.
▼
FastAPI RAG service — Render free tier, https://llamaindex-rag.onrender.com
│ rag_service/main.py imports build the process singletons at startup;
│ /health, /query, /chat, /complete in rag_service/routes.py.
│
├──► Qdrant Cloud (eu-central-1) — collection `ai_engineer_roadmap`,
│ 22,210 nodes (lessons + AST-chunked case-study code), 384-dim
│ cosine. QDRANT_LOAD_ONLY=1: the service only ever READS this
│ collection; a controlled build job owns writes (§5).
│
└──► Cloudflare AI Gateway (one endpoint, two jobs):
• synthesis — DeepSeek chat via the OpenAI-compat route; the model
id MUST be provider-prefixed (`deepseek/deepseek-chat`) or the
gateway answers 400 "Chat completion bad format"
• query embeddings — `workers-ai/@cf/baai/bge-small-en-v1.5` via
the same compat `/embeddings` route (EMBED_REMOTE=1;
rag_service/factories.py::_GatewayEmbedding). Same credentials,
zero extra secrets.
Two auxiliary Cloudflare crons complete the plane:
- Keepwarm —
workers/edge-tasks/pings the Render/healthevery 10 minutes (free instances idle out after ~15). The ping has a bounded 100s timeout (src/entry.py::PING_TIMEOUT_MS): generous enough for Render's ~50s cold wake, bounded because a dead Render service hangs rather than fast-fails (observed: 180s+ with zero bytes), which used to wedge every cron firing. - Backup — daily at 04:00 UTC the main worker's
scheduledhandler (workers/app/index.ts) self-calls/api/cron/backup: a D1.sqldump plus a server-side Qdrant snapshot, both pushed to thedb-backupsR2 bucket (§6).
3. The memory budget, with receipts
The 2026-07-14 rebuild replayed every failure mode in sequence; the numbers below are from Render's events/metrics APIs and local RSS sampling of the identical profile.
| Configuration | Boot RSS | First uncached query | Outcome |
|---|---|---|---|
| fastembed in-process, hybrid on (old blueprint) | >512Mi | — | OOM during boot |
| fastembed in-process, hybrid off | ~496Mi idle | OOM | server_failed: oomKilled {memoryLimit: 512Mi} |
+ MALLOC_ARENA_MAX=2, OMP_NUM_THREADS=1 | ~496Mi | OOM | allocator tuning saved ~15Mi — not enough |
EMBED_REMOTE=1, CE-rerank still default-on | 242Mi | ~2.0GB peak | first scored query loads the cross-encoder ONNX |
| Prod profile (below) | 242Mi | 247Mi peak | grounded 200 in ~9s, instance survives |
The five load-bearing env vars, each with its reason:
| Env var | Prod value | Why it is load-bearing |
|---|---|---|
EMBED_REMOTE | 1 | evicts ONNX runtime + weights from RSS; queries embed via the gateway (~200ms/call) |
LLAMAINDEX_CE_RERANK | 0 | its DEFAULT is ON; first query lazy-loads a cross-encoder → ~1.8GB spike |
LOOK_ROUTER | 0 | the router may pick the hybrid strategy at runtime, hydrating all nodes |
LLAMAINDEX_HYBRID | 0 | BM25 leg hydrates the full collection into RAM at engine build |
LLAMAINDEX_MODEL | deepseek/deepseek-chat | the gateway compat endpoint 400s on the unprefixed default |
All five are encoded in services/llamaindex/render.yaml so a blueprint
recreation reproduces the working service, not the crash-loop.
4. The product (localhost) vs the demo (Render free)
One codebase, two profiles. The flags compose per environment
(rag_service/settings.py reads them all):
| Concern | Localhost — the product (make explain-local) | Render free tier — the public demo |
|---|---|---|
| Query embeddings | FastEmbed in-process (+ shared .embed-cache/embeddings.db) — no embedding API | Workers AI via the AI Gateway (EMBED_REMOTE=1) — the only way to fit 512Mi |
| LLM egress | DeepSeek direct (api.deepseek.com) — no Cloudflare in the path | DeepSeek via the AI Gateway /compat (prefixed model id) |
| Retrieval | hybrid dense+BM25+RRF, HyDE/multi-hop opt-in | dense-only |
| Rerank | cross-encoder (Xenova/ms-marco-MiniLM-L-6-v2) | off |
| Strategy router | LOOK router (model-picks-strategy) | off — static dense path |
| Vector store | embedded Qdrant (data/qdrant, read-only shared store) or the cloud collection | Qdrant Cloud, QDRANT_LOAD_ONLY=1 |
| Strict grounding | on | on (prompt-level; costs no memory) |
| Index building | yes — owns collection writes | never |
run_local.sh enforces the left column rather than trusting the ambient
env: it exports EMBED_REMOTE=0 (an inherited EMBED_REMOTE=1 would otherwise
quietly ship local queries to Cloudflare) and pins the LLM to
api.deepseek.com with an unprefixed model id, reading DEEPSEEK_API_KEY from
$DEEPSEEK_API_KEY, the monorepo-root .env, or the app's .env.local —
whichever has it. LLM_LOCAL=1 goes further and swaps DeepSeek for a local
llama.cpp proxy: embeddings, retrieval, and generation then run with zero
network egress.
Why the demo can't just do what localhost does: in-process FastEmbed costs
~290Mi of ONNX runtime (baseline 19Mi → 308Mi after model load; threads=1
changes nothing), which puts the whole service at 616Mi measured — 104Mi
over the free tier's ceiling, untunable. And DeepSeek publishes no embeddings
endpoint (/v1/embeddings → 404), so "embed with DeepSeek" is not an option
that exists. The demo either borrows Cloudflare's copy of the same bge-small
model or it does not serve uncached queries at all.
The rule that keeps the two profiles compatible: an index is built and queried with vectors from compatible runtimes. FastEmbed and Workers AI serve the same bge-small model, but pooling differences put identical text at cosine ~0.96 across runtimes — measured 22/25 top-5 retrieval overlap on the live corpus, fine for query-time use. Never mix runtimes within one collection build.
5. State: the three caches and the one collection
- Vector collection (
ai_engineer_roadmap, Qdrant Cloud) — the ONLY copy of the production retrieval index. Written exclusively by the build job:services/llamaindex/run_local.shwithQDRANT_URL+QDRANT_COLLECTION=ai_engineer_roadmap+CASE_STUDY_DIRexported boots the service locally, which builds and uploads the code-inclusive corpus (~22k nodes), then serves it for smoke-testing. - Semantic cache (
explain_semantic_cache, same cluster) — near-match answer reuse at 0.95 cosine. Because it lives in Qdrant, it survives Render redeploys and even service deletion (it did — it was the surviving artifact that identified the original cluster during recovery). - Explanation cache (SQLite on the Render disk) — exact-match, ephemeral by design; rebuilt from cold on each deploy.
- Embedding cache (
.embed-cache/embeddings.db, app root) — build-time infrastructure shared with theroadmap-kggrounding lanes. WithEMBED_REMOTE=1it plays no role at serve time.
6. Ops runbook
Deploys. Render autoDeploy is OFF. The only sanctioned trigger is the
deploy hook (RENDER_DEPLOY_HOOK in the credentials vault) — pushes to
main do not deploy the service. The Next app deploys with pnpm run deploy
(scripts/deploy.sh: local build → prebuilt tgz to Vercel → verify).
Backups. /api/cron/backup (app/api/cron/backup/route.ts) runs daily
at 04:00 UTC from the worker's scheduled handler and needs four worker
secrets: CRON_SECRET (Bearer auth the handler sends itself), QDRANT_URL
QDRANT_API_KEY(snapshot source), andCLOUDFLARE_D1(D1 dump token). Keys land in R2db-backupsasknowledge/<date>/d1-<stamp>.sqlandqdrant/<date>/<collection>-<snapshot>, 30-day retention. A missing secret degrades silently ("skipped" / 500 inside a cron nobody watches) — after any worker recreation, runbash scripts/sync-worker-creds.shand checknpx wrangler secret list. This bit us: the cron had never produced a single backup until 2026-07-14.
Restore / rebuild. Cluster lost or collection wiped:
- Fastest: restore the newest
qdrant/<date>/…snapshot from R2 (Qdrant accepts snapshot uploads on collection create). - Otherwise rebuild from source (~30 min): run the build job (§5) against
a fresh cluster, then update
QDRANT_URL/QDRANT_API_KEYon the Render service and in the worker secrets, and redeploy via the hook. - If Render itself is gone: recreate from
services/llamaindex/render.yaml(dashboard → New → Blueprint, orPOST /v1/services), set the dashboard secrets it declaressync: false, and confirm the env profile of §3.
Health. GET /health reports the wired backends —
vectorStore: qdrant:<url>/<collection>, embedBackend: workers-ai-gateway | fastembed, semanticCache — so remote-vs-local
embedding and disk-vs-cloud vectors are observable without dashboard access.
7. Failure catalog
| Symptom | Meaning | Fix |
|---|---|---|
/health hangs 180s+, zero bytes | Render service suspended/dead — the edge holds the socket instead of fast-failing; every upstream burns its full timeout | check service state via Render API; the 30s AbortSignal.timeout in lib/llm-service.ts is the containment |
x-render-routing: no-server, fast 404 | no service behind the hostname — deleted, or mid-first-deploy | recreate/redeploy (§6); hostname is reclaimable if the name is free |
| OOM during deploy boot | in-process embeddings or BM25 hydration in the profile | confirm §3 env vars on the service |
Deploy goes live, then server_failed: oomKilled on first real query | lazy loader in the query path (historically the CE reranker) | LLAMAINDEX_CE_RERANK=0; check Render events API — the OOM kill logs nothing to the service log |
| Gateway 400 "Chat completion bad format" | unprefixed model id on the compat endpoint | LLAMAINDEX_MODEL=deepseek/deepseek-chat |
Qdrant REST answers 404 page not found on /collections | cluster paused/deleted (ingress default backend), not an auth error | check cluster in Qdrant Cloud console; restore per §6 |
Manual curl /api/cron/backup dies ~28s, CF error 1102 | HTTP request-context CPU limit — NOT a cron failure; the scheduled context gets 15 min | verify via R2 objects (knowledge/<date>/, qdrant/<date>/), not manual curls |
| Uncached queries slow (~9s) but cached ~1.5s | expected: remote embed (~200ms) + retrieval + DeepSeek synthesis; semantic cache absorbs repeats | none — this is the free-tier trade |
8. What we deliberately did NOT do
- No paid tier. Standard (2GB) would restore hybrid+rerank in prod with zero code changes; the free-tier profile was chosen deliberately.
- No qdrant-client bump (1.14.3 vs server 1.18.2 warns but works) — pinned deps change on their own test pass, not during an outage.
- No mixed-runtime collection: the corpus is always built by one embedding runtime end-to-end (§4).