Skip to content

feat(#126): introduce Langfuse observability for the memory pipeline#127

Open
alexosugo wants to merge 10 commits into
mainfrom
feat/126-langfuse-observability
Open

feat(#126): introduce Langfuse observability for the memory pipeline#127
alexosugo wants to merge 10 commits into
mainfrom
feat/126-langfuse-observability

Conversation

@alexosugo

@alexosugo alexosugo commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #126
Closes #103

Introduces end-to-end LLM observability for the memory distillation pipeline using Langfuse.

Note on #103 scope: #103 asked for a self-hosted Langfuse deployment via Docker Compose. This PR ships Langfuse Cloud instead — self-hosting is deferred until the agent platform has matured enough to justify the operational overhead of running our own instance. Trace capture, span structure, and the LangChain integration itself fully satisfy the eval-foundation goal of #103; only the hosting model differs from what was originally scoped.

  • src/observability/index.ts — shared module: lazy singleton Langfuse client (getLangfuse()) plus a single startTrace() primitive that returns { trace, handler } — a trace and the LangChain callback handler rooted on it. Replaces the earlier separate makeLangfuseHandler/createTrace/flushLangfuse helpers. All calls are no-ops when LANGFUSE_ENABLED=false.
  • src/types/pipeline.tsFilterOptions and DistillOptions gain an optional langfuseHandler?: BaseCallbackHandler field.
  • src/scripts/filter.ts — threads the handler to chain.invoke({ callbacks }) inside llmTriage.
  • src/scripts/distiller.ts — threads the handler to chain.invoke({ callbacks }) inside llmDistill.
  • src/scripts/run-pipeline.tsprocessSinglePR creates one Langfuse trace per PR via startTrace() (with a scrape span and a distill-outcome score), sets trace.update({ output }), and flushes via getLangfuse().flushAsync(). All traces from a single runPipeline call share a session UUID.
  • .github/workflows/unit_tests.yml — sets LANGFUSE_ENABLED=false so CI tests never contact Langfuse.
  • .github/workflows/run-pipeline.yml — adds LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, and LANGFUSE_BASE_URL from repository secrets/vars.
  • docs/observability.md — setup guide, trace structure, naming conventions, and a step-by-step recipe for instrumenting new agents/workflows, plus a "Future Work" section (prompt versioning, dashboards, alerting, eval datasets, agent instrumentation).
  • .env.example — documents the four Langfuse variables (LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_BASE_URL, LANGFUSE_ENABLED).

Trace shape (per PR)

memory-pipeline-pr   (input: { prNum, repo, url }; tags: [memory-pipeline, <repo>]; session: <run UUID>)
├── span: scrape          input: { prNum, repo }  output: { fileCount }
├── generation: filter    ← LangChain callback auto-captures model + tokens + latency
└── generation: distill   ← LangChain callback auto-captures model + tokens + latency
    score: distill-outcome           1 = written, 0 = flag-for-human

The trace id is generated by Langfuse per run (not derived from the PR number), so reprocessing the same PR produces a distinct trace rather than mutating an earlier run's session. PR identity lives in input/tags/metadata so it stays filterable.

Design notes

  • Lazy singleton: the Langfuse client is created on first call (not at module load time) so dotenv.config() runs before the constructor reads process.env.
  • LANGFUSE_ENABLED=false: startTrace()'s handler is still constructed but no-ops; callers use handler ? [handler] : undefined to skip callbacks entirely.
  • Env var naming: LANGFUSE_HOST was renamed to LANGFUSE_BASE_URL across source, docs, workflow, and .env.example for clarity (matches the Langfuse client constructor option name).
  • Injection point preserved: opts.triageFn / opts.distillFn in tests still bypass the real LLM and the handler, keeping test isolation intact.
  • Scoring today: distill-outcome is the only score currently emitted, set directly in code (trace.score(...) in run-pipeline.ts) — not an LLM-judge or human annotation. It only fires for PRs that reach the distill stage; filtered-out PRs get an unscored trace with decision: 'skip' in the output.

Test plan

  • npm run build — tsc clean
  • npm run lint — eslint clean
  • LANGFUSE_ENABLED=false npm run test:coverage — 416 tests pass, all coverage thresholds met (exit 0)
  • Ran npm run run-pipeline -- --pr <cht-core-pr> locally with real Langfuse keys and confirmed a trace appears in the Langfuse Cloud dashboard with scrape span, LLM generations, and the distill-outcome score.
  • Add LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY as repository secrets, and LANGFUSE_BASE_URL as a repository variable before the next scheduled pipeline run.

alexosugo added 9 commits July 1, 2026 18:11
- Add src/observability/index.ts — lazy singleton Langfuse client,
  makeLangfuseHandler factory, createTrace and flushLangfuse helpers
- Thread optional langfuseHandler through FilterOptions and DistillOptions
- filter.ts / distiller.ts pass the handler to chain.invoke callbacks
- run-pipeline.ts creates one trace per PR with scrape span and
  distill-outcome score; groups all PR traces under a shared session UUID
- Disable tracing in CI (LANGFUSE_ENABLED=false in unit_tests.yml)
- Add Langfuse secrets to run-pipeline.yml production env
- Add docs/observability.md covering setup, naming conventions, and
  how to instrument new agents or workflows
- Update .env.example with Langfuse variables
- makeLangfuseHandler: drop LANGFUSE_ENABLED guard (SDK no-ops when
  enabled=false); return type narrows from CallbackHandler|undefined
  to CallbackHandler
- export getLangfuse(); delete flushLangfuse wrapper; callers call
  getLangfuse().flushAsync() directly
- delete resetLangfuseClient (unreachable from production code; tests
  mock the whole module)
- remove ?? traceId fallback in processSinglePR; pass sessionId directly
  to both makeLangfuseHandler and createTrace
…ce()

Collapse makeLangfuseHandler()/createTrace() into a single startTrace()
returning { trace, handler }, so callers no longer juggle two related
objects built from the same trace id. Let the Langfuse SDK generate trace
ids instead of handcrafting them from PR number — the old scheme could
silently corrupt session groupings when overlapping --since windows
reprocessed the same PR. Update run-pipeline.ts wiring, docs, and add a
direct unit test for the disabled no-op path.
This is a runtime audit log appended to by local pipeline/test runs, not
a file with content meant to be checked in. Truncate the stale entries
that had accumulated in the repo.
Auto-empties and re-stages the file if it's non-empty at commit time, so
local pipeline/test runs appending to it never leak into git history.
Match the variable name actually used in .env across the module, docs,
and CI workflow.
_pending/*.md files are local/test-run output, promoted to
agent-memory/domains/ via a reviewed PR — not meant to be committed
directly. Only the .gitkeep placeholders stay tracked.
Installs post-commit and post-rewrite husky hooks so every commit gets
automatically reviewed by roborev; ignores its local snapshot state.
Rebase conflict resolution introduced a bare optional sessionId param
after two defaulted params; give it an explicit default instead.
@alexosugo alexosugo force-pushed the feat/126-langfuse-observability branch from babdb7b to 42726c5 Compare July 1, 2026 15:32
@alexosugo alexosugo requested review from Hareet and sugat009 July 1, 2026 15:35
Override withStructuredOutput() chain names via withConfig({ runName })
so triage/distill spans show as triage-classify/distill-draft instead
of the generic RunnableSequence in Langfuse traces.

Also collapse processSinglePR's 5 positional params into an options
object to satisfy SonarQube S107 (max 4 params).
@sugat009 sugat009 moved this from In Progress to In Review in CHT Multi-Agent System (cht-agent) Jul 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

Introduce Langfuse observability and LLMOps for the agent platform Eval foundation: Langfuse trace capture for LangGraph agents

2 participants