Story-1291 · pramaan-docs ADR-118 · LLM observability

Atlas: Pydantic Logfire (US) for the LLM lane

Branch issue-1291-atlas-logfire · Story: pramaan-io/pramaan-spec#1291 · Provenance: pramaan-docs ADR-118 (Logfire = LLM lane), builds on ADR-117 (Pydantic AI), ADR-116 (provider-switch), ADR-110 §6 (thin brain), ADR-104 (ONE general-tracing system)

🛰️ 1 · Satellite — why this exists

Atlas now runs on Pydantic AI (Story-1288 / ADR-117). The general telemetry lane — CloudWatch + X-Ray via the single ADOT layer (ADR-104) — sees Lambda/AgentCore runtime metrics, but it is blind to the inside of an LLM call: the exact prompt, the model + provider chosen, the token counts, the latency, the structured output. When Atlas fabricates a clause or stalls, that lane gives us a flat span and no answer.

The naïve fix — bolt prompt/response logging into the runtime by hand — detonates on two doctrines: it would re-implement an observability path the platform already standardises (ADR-104 says ONE general-tracing system), and it risks dumping privileged prompt text into the wrong place. The decision (ADR-118): a dedicated LLM lane — Pydantic Logfire — that natively instruments Pydantic AI and ships prompt/model/token/latency spans to a Logfire project, leaving the general lane untouched.

This Story is the agents-side wiring only: one env-gated init hook. No behaviour change, no new truth, no runtime-shape change.

✈️ 2 · Airplane — the shape of the change

cold start (main.py)
      │  configure_observability()          ← the one hook, called once
      ▼
LOGFIRE_TOKEN set?  ──no──►  NO-OP  (no configure, no instrument, no egress)
      │ yes                              local dev + CI are byte-identical here
      ▼
logfire.configure(token, service_name="atlas",
                  advanced=AdvancedOptions(base_url="https://logfire-us.pydantic.dev"),
                  send_to_logfire="if-token-present", console=False)
logfire.instrument_pydantic_ai()           ← instruments ALL five Atlas agents
      ▼
every agent.run / run_stream  →  prompt · model · provider · tokens · latency span
      ▼
OTLP egress → Logfire US datacenter project

One new module (observability.py), one call site (main.py cold start), one dep (logfire). The brain's request/response contracts, events, and truth boundary are frozen.

🔬 3 · Microscope — what was added, and WHY each piece

PieceWhat & WHY
Env gateos.getenv("LOGFIRE_TOKEN") is the only input. Absent → return early, nothing imported, nothing configured. WHY: local dev + CI must run with zero Logfire setup and be indistinguishable from a Logfire-free build (acceptance criterion + ADR-118).
US datacenter pinAdvancedOptions(base_url="https://logfire-us.pydantic.dev"). WHY: Atlas's spans must land in the US project explicitly (ADR-118), not wherever the SDK's account default points.
Global instrumentationlogfire.instrument_pydantic_ai() — one hook covers all five Atlas agents (intake · reflect · explain · reframe · staleness) across both the Bedrock and OpenRouter provider paths (ADR-116). WHY: Pydantic AI is the inference framework (ADR-117), so instrumenting it once captures every model call without per-call code.
Token hygieneRead from env only; never a source literal; never passed to a log call. A dedicated test asserts the token string is absent from captured logs. WHY: a write token is a secret (acceptance criterion).
Never blocks AtlasThe whole body is wrapped: any SDK/setup failure → _log.warning + return False, never a raise. WHY: ADR-110 — the brain never blocks the matter; observability is strictly best-effort.
Idempotency guardModule-level _configured flag; a warm-container second cold-start hook is a no-op. WHY: AgentCore reuses warm containers; configure exactly once per process.
console=FalseNo stdout exporter inside the runtime. WHY: spans go to Logfire; ordinary logs stay on the CloudWatch lane (ADR-104) — the two lanes don't cross-contaminate.
Deps + lockslogfire>=3.0 added to agents/atlas/pyproject.toml + requirements.in; requirements.lock (hashed) and agents/atlas/uv.lock regenerated (resolves logfire 4.37.0).

🔬 4 · The one subtle bit — gate before import

The import logfire lives inside the token branch, not at module top. So with no token the SDK is never even imported — the no-op path costs nothing and a Logfire-free environment never trips over a missing/heavy dependency at cold start. The test injects a fake logfire into sys.modules and asserts it is never touched on the no-token path.

Gate — no token → true no-op

token = os.getenv("LOGFIRE_TOKEN")
if not token:
    _log.debug("Logfire disabled: …")
    return False
# logfire is never imported here

Configured — token present

import logfire
logfire.configure(
  token=token, service_name="atlas",
  advanced=logfire.AdvancedOptions(
    base_url="https://logfire-us.pydantic.dev"),
  send_to_logfire="if-token-present",
  console=False)
logfire.instrument_pydantic_ai()

✅ 5 · Behaviour preserved — the evidence

The point: token absent ⇒ nothing changes; token present ⇒ spans only. A new test module pins every constraint, and the existing Atlas suite stays green.

Invariant (Story-1291 / ADR-118 constraint)Proof
No token → no-op (configure + instrument never called)test_no_token_is_a_noop: a fake logfire SDK is present yet never touched; returns False.
Token → US datacenter + service_name="atlas" + instrumenttest_token_configures_us_datacenter_and_instruments: asserts base_url=https://logfire-us.pydantic.dev, service_name, send_to_logfire="if-token-present", console=False, and instrument_pydantic_ai() called once.
Optional deploy-env tagtest_environment_tag_forwarded_when_set: LOGFIRE_ENVIRONMENT forwarded only when set; omitted otherwise.
Configure once (warm-container safe)test_configure_is_idempotent: second call is a no-op; one configure, one instrument.
Token never loggedtest_token_is_never_logged: the secret string is absent from all captured log output.
Observability never blocks Atlastest_sdk_failure_never_blocks: a throwing configure() returns False, never raises.
Brain doctrine (ADR-110 §6)Logfire is egress-only — no DB/queue/bucket, no boto3 s3·sqs·dynamodb, no run-truth change. Existing event-stream / runtime / reflection tests unchanged and green.
Result  cd agents/atlas && pytest tests -q → 51 passed (45 prior + 6 new). ruff check agents/ → clean. Without LOGFIRE_TOKEN: Atlas behaves byte-identically to before this Story.
Depends on  The pramaan-infra Logfire-secret Story provisions LOGFIRE_TOKEN into the AgentCore runtime env. Until that lands, staging runs the no-op path — correct and safe. This closes the open follow-up flagged in the Story-1288 walkthrough (wiring structured LLM logging into the runtime).