Skip to content

feat(dsh-langfuse)!: migrate to the Langfuse JS SDK v5 - #5

Merged
Weaxs merged 3 commits into
mainfrom
feat/dsh-langfuse-v5
Aug 27, 2026
Merged

feat(dsh-langfuse)!: migrate to the Langfuse JS SDK v5#5
Weaxs merged 3 commits into
mainfrom
feat/dsh-langfuse-v5

Conversation

@Weaxs

@Weaxs Weaxs commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

What

Migrate dsh-langfuse off the deprecated langfuse v3 package onto the Langfuse JS SDK v5 (all latest): @langfuse/tracing@5.10.1 + @langfuse/otel@5.10.1 + @opentelemetry/sdk-trace-node@2.10.0 as optional peers (the OTEL api/exporter packages @langfuse/otel itself peers on are documented in the README and installed explicitly in CI). Follows the v4→v5 upgrade path.

Key design points

  • Trace = root span (v5 observations-first model): turn traces are root spans created with a forced-fresh traceId (invalid parentSpanContext literal — avoids eagerly loading @opentelemetry/api), so ambient host OTEL spans (e.g. an instrumented HTTP server around dsh) can never adopt turn traces. Trace IO/metadata live on the root observation.
  • Spans only export on end — the migration's biggest behavioral consequence: turn/end ends the root, stale roots end on turn/start replace, abandoned roots end on session/disposed, the fiber-unload sweep ends everything still tracked, and session-less one-off roots are ended by the call itself (a v3→v5 regression caught in review: v3 flushed trace-create eagerly, v5 would have dropped those observations silently).
  • session.id on every observation via a handle-keyed WeakMap — the explicit-tree equivalent of v5's context-scoped propagateAttributes(), which can't wrap this plugin's event-driven lifecycle.
  • Isolated tracer provider (setLangfuseTracerProvider, never provider.register()); the host keeps its own OTEL pipeline. Released on unload (finally).
  • Renames ride otelSpan.updateName() (v5 attributes have no name); modelParameters narrow to string | number (stop list serializes).
  • No-throw seam unchanged: every SDK boundary failure is swallowed with the [dsh-langfuse] prefix.

Integration harness

The secrets-free fake endpoint is now an OTLP/HTTP JSON sink at /api/public/otel/v1/traces (the v5 exporter sends application/json), folding OTLP spans into the same observation shape the evaluators share with the real-mode v1 Observations API. Evaluators parent at the dsh-turn root span (in v5 trace children have a real parentObservationId). The peer install leg lists all five runtime packages.

Verification

  • pnpm typecheck / pnpm build / pnpm test (56/56) / pnpm lint — all green
  • 5 rounds of parallel code-review (Standards + Spec + ponytail) converged to zero findings; the Spec axis verified every API call against the installed 5.10.1/2.10.0 dist sources
  • Real-mode E2E (LANGFUSE_* secrets) runs in CI Stage B; the fake-OTLP leg covers the same evaluators without secrets

BREAKING CHANGE: consumers replace the langfuse package with the @langfuse/* + @opentelemetry/* peer set — see the package README install section.

Drop the deprecated `langfuse` v3 peer for the v5 OTEL-based stack:
`@langfuse/tracing` + `@langfuse/otel` + `@opentelemetry/sdk-trace-node`
(latest: 5.10.1 / 5.10.1 / 2.10.0; optional peers, lazy dynamic import,
isolated tracer provider — the process-global OTEL provider stays untouched).

v5's observations-first model reshapes the reporter:
- a trace IS its root span, created with a forced-fresh traceId (invalid
  parentSpanContext) so ambient host OTEL spans can't adopt turn traces;
  trace IO/metadata live on the root observation (replaces trace.update)
- spans only export on end: turn/end ends the root, stale roots end on
  turn/start replace, abandoned roots end on session/disposed, the fiber
  unload sweep ends everything still tracked, and session-less one-off
  roots are ended by the call itself
- the correlating session.id attribute is stamped on every observation via
  a handle-keyed WeakMap (the explicit-tree equivalent of
  propagateAttributes, which can't wrap an event-driven lifecycle)
- renames ride otelSpan.updateName (v5 attributes have no name);
  modelParameters narrow to string|number (stop list serializes)

Integration harness: the fake endpoint becomes OTLP/HTTP JSON at
/api/public/otel/v1/traces, evaluators parent at the dsh-turn root span
(v5: no parentObservationId == null for trace children), and the peer
install set lists all five runtime packages.

BREAKING CHANGE: consumers must replace the `langfuse` package with the
@langfuse/* + @opentelemetry/* peer set (see the package README).
@Weaxs
Weaxs deployed to integration August 27, 2026 07:39 — with GitHub Actions Active
@Weaxs
Weaxs deployed to integration August 27, 2026 07:39 — with GitHub Actions Active
@Weaxs
Weaxs deployed to integration August 27, 2026 07:39 — with GitHub Actions Active
@github-actions github-actions Bot added area/ci CI / GitHub Actions changes area/docs Documentation changes area/langfuse dsh-langfuse: Langfuse observability area/package Package, build, or workspace configuration changes size:L Changed lines: L type/feature PR type: feature P2 Priority (triage) labels Aug 27, 2026
…rvers

On a self-hosted Langfuse v3.172.1 the migrated traces rendered as
"Unnamed trace" with empty input/output: the v3 server's OTEL ingestion
derives the trace row from trace-level span attributes
(langfuse.trace.name / langfuse.trace.input / langfuse.trace.output), and
per the v4 migration doc such context must be copied to every span — only
session.id was stamped per-observation, so the trace row kept the session
but lost the name and IO.

- stamp langfuse.trace.name + session.id on EVERY observation (WeakMap
  handle-tree inheritance, replacing the sessionIds map)
- trace roots additionally carry langfuse.trace.input/output via the SDK's
  deprecated-but-official setTraceIO compat path, gated by a roots WeakSet
  so nested spans never get trace IO; redaction paths stay clean
- tests: mock setTraceIO, trace-name inheritance assertions, redaction and
  non-root negative guards
@Weaxs
Weaxs deployed to integration August 27, 2026 09:28 — with GitHub Actions Active
@Weaxs
Weaxs deployed to integration August 27, 2026 09:28 — with GitHub Actions Active
@Weaxs
Weaxs deployed to integration August 27, 2026 09:28 — with GitHub Actions Active
The nobody containment from #4 never actually ran on CI: pull_request_target
evaluates the BASE branch's workflow, so PR #4's own runs used the pre-PR
version and the first real execution was PR #5 — which failed at dsh boot:
EACCES mkdir $DSH_HOME/profiles/node_modules and EACCES reading
pr-head/.env. The runner's ancestor dirs (/home/runner, work/, the checkout
parents) are not world-searchable, so the nobody process cannot traverse
into its own DSH_HOME or the review checkout.

chmod o+x the directory chain: search-without-listing, so the containment
boundary (no secret readable from the model process) is unchanged.
@Weaxs
Weaxs deployed to integration August 27, 2026 09:55 — with GitHub Actions Active
@Weaxs
Weaxs deployed to integration August 27, 2026 09:55 — with GitHub Actions Active
@Weaxs
Weaxs deployed to integration August 27, 2026 09:55 — with GitHub Actions Active
@Weaxs
Weaxs merged commit e6e7b98 into main Aug 27, 2026
10 of 11 checks passed
@Weaxs
Weaxs deleted the feat/dsh-langfuse-v5 branch August 29, 2026 15:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/ci CI / GitHub Actions changes area/docs Documentation changes area/langfuse dsh-langfuse: Langfuse observability area/package Package, build, or workspace configuration changes P2 Priority (triage) size:L Changed lines: L type/feature PR type: feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant