feat(#126): introduce Langfuse observability for the memory pipeline - #127
feat(#126): introduce Langfuse observability for the memory pipeline#127alexosugo wants to merge 11 commits into
Conversation
babdb7b to
42726c5
Compare
sugat009
left a comment
There was a problem hiding this comment.
Requesting changes; findings are in the inline comments below.
| "gray-matter": "^4.0.3", | ||
| "js-yaml": "^4.1.1", | ||
| "langfuse": "^3.38.20", | ||
| "langfuse-langchain": "^3.38.20", |
There was a problem hiding this comment.
issue (blocking): langfuse-langchain@3.38.20 peer-requires langchain >=0.0.157 <0.4.0 (i.e. @langchain/core 0.3.x), but main is now on @langchain/core ^1.2.1. No published langfuse-langchain supports core v1, so a fresh npm ci after rebasing onto main will ERESOLVE. CI is green here only because this branch still pins the 0.3.x stack. Needs a resolution: manual langfuse instrumentation instead of the langfuse-langchain handler, pin langchain back (regresses main), or await a v1-compatible release.
| @@ -0,0 +1,8 @@ | |||
| #!/bin/sh | |||
| # roborev post-commit hook v4 - auto-reviews every commit | |||
There was a problem hiding this comment.
issue (blocking, accidental): this and .husky/post-rewrite run local roborev tooling (hardcoded /opt/homebrew/bin/roborev), and .gitignore gains /.roborev/ + .roborev.toml. husky is wired via postinstall: husky install, so these run on every contributor's commit/rewrite. Unrelated to #126 — please drop them from the PR.
| fi | ||
|
|
||
| # agent-memory/_skipped.ndjson is a local runtime log, never real content — keep it empty in git. | ||
| skip_log="agent-memory/_skipped.ndjson" |
There was a problem hiding this comment.
issue (scope): this skip-log emptier, together with the agent-memory/_pending/**/*.md gitignore rule and the emptied _skipped.ndjson, is runtime-log hygiene. Reasonable on its own but outside the #126 observability scope and unmentioned in the description; consider splitting it out.
|
|
||
| const result = await chain.invoke(prompt) as TriageOutput; | ||
| const callbacks = handler ? [handler] : undefined; | ||
| const result = await chain.invoke(prompt, { callbacks }) as TriageOutput; |
There was a problem hiding this comment.
issue: under LLM_PROVIDER=claude-cli, chain is createStructuredCliChain, whose invoke(prompt) ignores this { callbacks } argument, so the Langfuse handler never fires and no token/cost is captured on the claude -p path (only the trace, scrape span, and score). Token/cost — the PR's headline goal — is captured only via the LangChain generation callback, which works on the OpenRouter/Anthropic path the scheduled workflow uses but silently not on the CLI path. docs/observability.md's unconditional "auto-captures model + tokens" overstates the CLI case.
| } | ||
|
|
||
| trace.update({ output }); | ||
| await getLangfuse().flushAsync(); |
There was a problem hiding this comment.
issue (minor): no try/finally around scrape/filter/distill, so if any throws, trace.update()/score()/flushAsync() are skipped and the failed PR leaves an incomplete, unflushed trace with no error recorded. Also this flushAsync() runs per-PR on the shared singleton and runPipeline has no final flush after Promise.all, so a last-in-flight PR that errors before its own flush can lose buffered events. A try/finally that records the error and flushes closes both.
| ``` | ||
| memory-pipeline-pr (input: { prNum, repo, url }; tags: [memory-pipeline, <repo>]) | ||
| ├── span: scrape (no LLM — input: prNum, repo; output: fileCount) | ||
| ├── generation: filter (LangChain callback — model + tokens auto-captured) |
There was a problem hiding this comment.
issue (minor, doc drift): the diagram labels these generation: filter / distill, but the code sets withConfig({ runName: 'triage-classify' }) / 'distill-draft', so the actual Langfuse generation names differ from the docs.
| # DEEPWIKI_MCP_TIMEOUT=30000 | ||
|
|
||
| # Langfuse LLM observability (https://cloud.langfuse.com) | ||
| # Set LANGFUSE_ENABLED=false to disable tracing locally (default: enabled when keys are present) |
There was a problem hiding this comment.
issue (minor): this says tracing is enabled "when keys are present", but the code enables whenever LANGFUSE_ENABLED !== 'false' regardless of keys (the SDK then self-disables if keys are missing). The stated rule is inaccurate.
- 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.
Rebase conflict resolution introduced a bare optional sessionId param after two defaulted params; give it an explicit default instead.
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).
39a0aa5 to
766f008
Compare
|
@sugat009 All issues have been addressed. This is ready for another round of review. |
sugat009
left a comment
There was a problem hiding this comment.
Thanks for the rework. The dependency fix is better than a version pin: langfuse-langchain is gone from package.json, the lockfile and every import, and langfuse@3.38.20 declares no peer dependencies, so the @langchain/core 1.x conflict cannot recur.
Six of my seven July items verify fixed at 16cbd1a: roborev hooks and .gitignore byte-identical to main, 766f008 reverted the _skipped.ndjson and pre-commit scope creep, try/finally wraps the pipeline with trace.update on error, all five docs observation names match the code, and the LANGFUSE_BASE_URL rename has zero stragglers.
Two blocking items. Removing the CallbackHandler removed the token source, so the PR ships no cost data, which is #126's first goal. Roughly six small changes, not a redesign. The second only bites when Langfuse is unreachable.
| const generation = trace?.generation(opts); | ||
| try { | ||
| const output = await invoke(); | ||
| generation?.end({ output }); |
There was a problem hiding this comment.
issue (blocking): observeGeneration ends generations with { output } and never sends usage, so Langfuse shows no tokens or cost on either path. #126 opens with "Cost opacity", so this is the acceptance criterion. Grepping the diff for usage|totalTokens|input_tokens|promptTokens hits only the docs sentence admitting it.
Not blocked, just unwired: langfuse-core's UpdateGenerationBody accepts usage/usageDetails/costDetails on end(), and @langchain/core supports withStructuredOutput(schema, { includeRaw: true }) returning { raw, parsed } with raw.usage_metadata. Four chain builders, two call sites, the test doubles.
| } catch (err) { | ||
| trace.update({ output: { error: errorMessage(err) } }); | ||
| throw err; | ||
| } finally { |
There was a problem hiding this comment.
issue (blocking): the finally awaits flushAsync() per PR at SDK defaults: requestTimeout 5000ms, fetchRetryCount 3, fetchRetryDelay 3000ms, and retryCheck matches HTTP errors too, so a rotated key retries like a timeout. About 29s per PR: 7 minutes lost on a 15-PR nightly, 48 on a --last 100 backfill, plus ~60 SDK error lines.
Separately, flush() drains only flushAt (15) events and flushAsync never awaits pendingIngestionPromises, while reportOutcome calls process.exit. At --concurrency > 1 that kills an in-flight POST, losing events from the failed runs you most want.
Use one await getLangfuse().shutdownAsync() at the end of runPipeline instead of the per-PR flush; it clears the timer, flushes, and awaits pending promises. Add fetchRetryCount: 1 and requestTimeout: 3000.
|
|
||
| const span = trace.span({ name: 'step', input: { x: 1 } }); | ||
| span.end({ output: { y: 2 } }); | ||
| trace.score({ name: 'outcome', value: 1 }); |
There was a problem hiding this comment.
issue (non-blocking): the specs cannot detect an observability regression. The one test touching the real module sets LANGFUSE_ENABLED=false itself, and _client is cached with no reset seam, so an enabled client is unreachable in-process. run-pipeline.spec substitutes a spy-free noop.
Delete { langfuseTrace: trace } at run-pipeline.ts:314 and :337, reintroducing the uninstrumented-CLI bug this PR fixes, and every test passes. Delete the finally flush: still passes. Latent: the noopTrace double lacks generation, so the first workflow following the docs' step 3 throws.
| return _triageChain; | ||
| } | ||
|
|
||
| function getTriageModel(): string { |
There was a problem hiding this comment.
issue (non-blocking): the CLI path labels the model claude-cli and discards the per-call USD cost the adapter already parses. Langfuse cannot price a model name that does not exist, and real cost data is dropped, on the one path where it is available today.
| const output = await invoke(); | ||
| generation?.end({ output }); | ||
| return output; | ||
| } catch (err) { |
There was a problem hiding this comment.
suggestion (non-blocking): failed generations and traces record the error in output but never set level: 'ERROR' or statusMessage, so Langfuse error views and future alerting see zero failures. langfuse-core accepts both on end()/update().
| opts: { force: boolean; tag: string; trace: ReturnType<typeof startTrace>['trace'] } | ||
| ): Promise<void> { | ||
| const { force, tag, trace } = opts; | ||
| const scrapeSpan = trace.span({ name: 'scrape', input: { prNum, repo } }); |
There was a problem hiding this comment.
nitpick: the scrape span is never ended when scrapePR throws, orphaning an observation on every failed PR.
| }); | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| return (llm as any).withStructuredOutput(triageSchema); | ||
| return (llm as any).withStructuredOutput(triageSchema).withConfig({ runName: 'triage-classify' }); |
There was a problem hiding this comment.
nitpick: withConfig({ runName }) is dead now that names come from observeGeneration, and it forced shims into three test doubles. Removing it simplifies the specs.
| }); | ||
| ``` | ||
|
|
||
| 3. Wrap each model call in a generation. This works for both LangChain API calls |
There was a problem hiding this comment.
suggestion (non-blocking): the recipe never mentions observeGeneration, the actual entry point, and its test stub omits generation, contradicting step 3. The token caveat also needs rescoping: the API path has no usage either.
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 singlestartTrace()primitive that returns{ trace, handler }— a trace and the LangChain callback handler rooted on it. Replaces the earlier separatemakeLangfuseHandler/createTrace/flushLangfusehelpers. All calls are no-ops whenLANGFUSE_ENABLED=false.src/types/pipeline.ts—FilterOptionsandDistillOptionsgain an optionallangfuseHandler?: BaseCallbackHandlerfield.src/scripts/filter.ts— threads the handler tochain.invoke({ callbacks })insidellmTriage.src/scripts/distiller.ts— threads the handler tochain.invoke({ callbacks })insidellmDistill.src/scripts/run-pipeline.ts—processSinglePRcreates one Langfuse trace per PR viastartTrace()(with ascrapespan and adistill-outcomescore), setstrace.update({ output }), and flushes viagetLangfuse().flushAsync(). All traces from a singlerunPipelinecall share a session UUID..github/workflows/unit_tests.yml— setsLANGFUSE_ENABLED=falseso CI tests never contact Langfuse..github/workflows/run-pipeline.yml— addsLANGFUSE_PUBLIC_KEY,LANGFUSE_SECRET_KEY, andLANGFUSE_BASE_URLfrom 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)
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/metadataso it stays filterable.Design notes
dotenv.config()runs before the constructor readsprocess.env.LANGFUSE_ENABLED=false:startTrace()'s handler is still constructed but no-ops; callers usehandler ? [handler] : undefinedto skip callbacks entirely.LANGFUSE_HOSTwas renamed toLANGFUSE_BASE_URLacross source, docs, workflow, and.env.examplefor clarity (matches the Langfuse client constructor option name).opts.triageFn/opts.distillFnin tests still bypass the real LLM and the handler, keeping test isolation intact.distill-outcomeis the only score currently emitted, set directly in code (trace.score(...)inrun-pipeline.ts) — not an LLM-judge or human annotation. It only fires for PRs that reach thedistillstage; filtered-out PRs get an unscored trace withdecision: 'skip'in the output.Test plan
npm run build— tsc cleannpm run lint— eslint cleanLANGFUSE_ENABLED=false npm run test:coverage— 416 tests pass, all coverage thresholds met (exit 0)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 thedistill-outcomescore.LANGFUSE_PUBLIC_KEYandLANGFUSE_SECRET_KEYas repository secrets, andLANGFUSE_BASE_URLas a repository variable before the next scheduled pipeline run.