feat: Langfuse session grouping for multi-turn chat traces (#15679)

## Summary

This PR passes `session_id` into Langfuse trace observations so
multi-turn chat messages can be grouped under the same session in
Langfuse.

Changes include:
- Propagate `session_id` from chat/session APIs into
`dialog_service.async_chat`.
- Pass `session_id` into Langfuse `start_observation(...)`.
- Share Langfuse `trace_context` with chat, embedding, rerank, and TTS
model bundles where applicable.
- Add unit coverage to verify Langfuse observations receive
`session_id`.
- Update affected test stubs for the new optional Langfuse context
arguments.

## Related Issue
Closes: #15636 

## Change Type
- [x] Feature
- [x] Bug fix
- [x] Test
- [ ] Refactor
- [ ] Documentation
- [ ] Breaking change

## Real Behavior Proof

Before this change:

- Langfuse observations were created without `session_id`.
- Multi-turn chat traces could not be grouped by session in Langfuse.

After this change:

- Chat/session flows pass `session_id` into `async_chat`.
- Langfuse observations include `session_id`.
- Related model bundles receive shared trace context and session
metadata.

Validation result:

```bash
uv run python -m py_compile \
  api/db/services/tenant_llm_service.py \
  api/db/services/llm_service.py \
  api/db/services/dialog_service.py \
  api/db/services/conversation_service.py \
  api/apps/restful_apis/chat_api.py \
  test/unit_test/api/db/services/test_dialog_service_final_answer.py \
  test/unit_test/api/db/services/test_dialog_service_use_sql_source_columns.py
```
Passed.

```bash
uv run pytest \
  test/unit_test/api/db/services/test_dialog_service_final_answer.py \
  test/unit_test/api/db/services/test_dialog_service_use_sql_source_columns.py -q
```
Result:

```text
11 passed in 16.89s
```

```bash
git diff --check
```
Passed.
## Checklist

- [x] Analyzed the issue requirement.
- [x] Checked existing Langfuse trace integration.
- [x] Implemented only the requested session grouping behavior.
- [x] Added/updated unit tests.
- [x] Ran focused tests successfully.
- [x] Ran Python compile validation.
- [x] Ran whitespace diff validation.
This commit is contained in:
Jonathan Chang
2026-06-12 09:18:06 +07:00
committed by GitHub
parent 0d836afd34
commit de06c9a60b
7 changed files with 153 additions and 42 deletions

View File

@@ -406,7 +406,7 @@ def test_async_chat_final_event_carries_decorated_answer(monkeypatch):
# get_models returns (kbs, embd_mdl, rerank_mdl, chat_mdl, tts_mdl)
monkeypatch.setattr(
dialog_service, "get_models",
lambda _dialog: ([_KB], chat_mdl, None, chat_mdl, None),
lambda _dialog, **_kwargs: ([_KB], chat_mdl, None, chat_mdl, None),
)
monkeypatch.setattr(
dialog_service.KnowledgebaseService, "get_field_map", lambda _kb_ids: {}
@@ -473,7 +473,7 @@ def test_async_chat_langfuse_uses_start_observation(monkeypatch):
monkeypatch.setattr(
dialog_service,
"get_models",
lambda _dialog: ([_KB], chat_mdl, None, chat_mdl, None),
lambda _dialog, **_kwargs: ([_KB], chat_mdl, None, chat_mdl, None),
)
monkeypatch.setattr(
dialog_service.KnowledgebaseService, "get_field_map", lambda _kb_ids: {}
@@ -511,6 +511,105 @@ def test_async_chat_langfuse_uses_start_observation(monkeypatch):
assert langfuse.observation.ended is True
@pytest.mark.p2
def test_async_chat_langfuse_observation_includes_session_id(monkeypatch):
_FakeLangfuseClient.instances = []
monkeypatch.setattr(_FakeLangfuseClient, "fail_start_observation", False)
chat_mdl = _StreamingChatModel("Session traces should be grouped.")
retriever = _StubRetriever()
monkeypatch.setattr(
dialog_service, "get_model_type_by_name",
lambda _tid, _llm_id: ["chat"]
)
monkeypatch.setattr(
dialog_service,
"get_model_config_from_provider_instance",
lambda _tid, _type, _llm_id: _LLM_CONFIG,
)
monkeypatch.setattr(
dialog_service.TenantLangfuseService, "filter_by_tenant",
lambda tenant_id: SimpleNamespace(
public_key="public",
secret_key="secret",
host="http://langfuse.local",
),
)
monkeypatch.setattr(dialog_service, "Langfuse", _FakeLangfuseClient)
monkeypatch.setattr(
dialog_service,
"get_models",
lambda _dialog, **_kwargs: ([_KB], chat_mdl, None, chat_mdl, None),
)
monkeypatch.setattr(
dialog_service.KnowledgebaseService, "get_field_map", lambda _kb_ids: {}
)
monkeypatch.setattr(
dialog_service.KnowledgebaseService, "get_by_ids", lambda _ids: [_KB]
)
monkeypatch.setattr(dialog_service.settings, "retriever", retriever, raising=False)
monkeypatch.setattr(dialog_service, "label_question", lambda _q, _kbs: "")
monkeypatch.setattr(
dialog_service,
"kb_prompt",
lambda _kbinfos, _max_tokens, **_kw: ["RAGFlow is a RAG engine."],
)
dialog = _make_dialog(chat_mdl)
messages = [{"role": "user", "content": "What is RAGFlow?"}]
events = _collect(dialog_service.async_chat(dialog, messages, stream=True, quote=True, session_id="session-1"))
assert any(e.get("final") is True for e in events)
langfuse = _FakeLangfuseClient.instances[0]
assert langfuse.observation_kwargs["trace_context"] == {"trace_id": "trace-id"}
assert langfuse.observation_kwargs["session_id"] == "session-1"
@pytest.mark.p2
def test_get_models_passes_langfuse_trace_context_to_llm_bundles(monkeypatch):
captured = []
class _FakeBundle:
def __init__(self, tenant_id, model_config, **kwargs):
self.tenant_id = tenant_id
self.model_config = model_config
self.trace_context = kwargs.get("trace_context")
self.langfuse_session_id = kwargs.get("langfuse_session_id")
captured.append((tenant_id, model_config["model_type"], kwargs))
monkeypatch.setattr(dialog_service.KnowledgebaseService, "get_by_ids", lambda _ids: [_KB])
monkeypatch.setattr(
dialog_service,
"get_model_config_from_provider_instance",
lambda _tenant_id, model_type, _model_id: {**_LLM_CONFIG, "model_type": model_type},
)
monkeypatch.setattr(
dialog_service,
"get_tenant_default_model_by_type",
lambda _tenant_id, model_type: {**_LLM_CONFIG, "model_type": model_type},
)
monkeypatch.setattr(dialog_service, "LLMBundle", _FakeBundle)
dialog = _make_dialog(None)
dialog.rerank_id = "rerank-1"
dialog.prompt_config["tts"] = True
trace_context = {"trace_id": "trace-id"}
dialog_service.get_models(dialog, trace_context=trace_context, langfuse_session_id="session-1")
assert len(captured) == 4
assert {model_type for _, model_type, _ in captured} == {
dialog_service.LLMType.EMBEDDING,
dialog_service.LLMType.CHAT,
dialog_service.LLMType.RERANK,
dialog_service.LLMType.TTS,
}
for _, _, kwargs in captured:
assert kwargs["trace_context"] is trace_context
assert kwargs["langfuse_session_id"] == "session-1"
@pytest.mark.p2
def test_async_chat_continues_when_langfuse_observation_start_fails(monkeypatch):
"""
@@ -543,7 +642,7 @@ def test_async_chat_continues_when_langfuse_observation_start_fails(monkeypatch)
monkeypatch.setattr(
dialog_service,
"get_models",
lambda _dialog: ([_KB], chat_mdl, None, chat_mdl, None),
lambda _dialog, **_kwargs: ([_KB], chat_mdl, None, chat_mdl, None),
)
monkeypatch.setattr(
dialog_service.KnowledgebaseService, "get_field_map", lambda _kb_ids: {}

View File

@@ -295,7 +295,7 @@ def test_async_chat_uses_all_docs_when_no_doc_ids_selected(monkeypatch):
monkeypatch.setattr(
dialog_service,
"get_models",
lambda _dialog: ([SimpleNamespace(tenant_id="tenant-id")], object(), None, chat_model, None),
lambda _dialog, **_kwargs: ([SimpleNamespace(tenant_id="tenant-id")], object(), None, chat_model, None),
)
monkeypatch.setattr(dialog_service.KnowledgebaseService, "get_field_map", lambda _kb_ids: {})
monkeypatch.setattr(dialog_service, "label_question", lambda _question, _kbs: None)