mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 16:05:19 +08:00
Core no longer assembles a whole trace locally. Every workflow node is uploaded
the moment it completes as its own `execution_node` record, and each execution
closes with exactly one `execution_chain` record; all records of one execution
carry the same `event_id`, so Space groups them back into a single chain.
- every execution starts at an inbound event: event-less Pipeline lanes and
WebUI/API debug runs synthesize their virtual inbound event at the boundary
and are marked debug=true, so a Runner is never a trace origin
- sampling is decided once per event from a stable hash of the event id (the
previous `int(id[:8], 16)` raised on every `platform:`-prefixed id and so
dropped every successful inbound-event chain) and is reported on each record
as sample{mode,denominator}; the default mode is now `all`
- the local trace buffer, the 120s TTL, the per-process trace cap and the shared
open-node mirror are gone, which also removes the truncated-chain upload and
the re-buffered-closed-trace leak they caused
- a broken chain is always uploaded, and its reason falls back to the first
failed node so the chain explains itself
- flush keeps records until the sender accepts them and counts what it drops;
telemetry health replaces the unused send-task queue
- heartbeat reports bound Agent / plugin-processor / event-binding counts
- bound Pipeline and Agent resources (plugins, MCP servers, knowledge bases,
tools with their Host source, skills, models and the Runner schema selectors
that picked them) are reported on the chain record
- failure text is bounded and redacted instead of passing exception text through
- the dead per-query feature counters and their call sites are removed
Wire contract: schema 2 (`execution_node` / `execution_chain`).
217 lines
7.5 KiB
Python
217 lines
7.5 KiB
Python
"""RuntimeBot ingress tracing: one inbound event owns one v2 execution chain."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
import pytest
|
|
|
|
from langbot.pkg.api.http.context import ExecutionContext
|
|
|
|
TEST_CONTEXT = ExecutionContext(
|
|
instance_uuid='instance-test',
|
|
workspace_uuid='workspace-test',
|
|
placement_generation=1,
|
|
bot_uuid='bot-1',
|
|
)
|
|
|
|
|
|
class FakeTelemetryManager:
|
|
def __init__(self, config=None):
|
|
self.telemetry_config = (
|
|
{'url': 'https://space.example.test', 'execution_trace': 'all'} if config is None else config
|
|
)
|
|
self.sent: list[dict] = []
|
|
|
|
async def send(self, payload: dict) -> bool:
|
|
self.sent.append(payload)
|
|
return True
|
|
|
|
|
|
def make_bot(event_bindings: list[dict], config=None, agent=None):
|
|
from langbot.pkg.platform.botmgr import RuntimeBot
|
|
from langbot.pkg.telemetry.execution import ExecutionCounters
|
|
|
|
manager = FakeTelemetryManager(config)
|
|
counters = ExecutionCounters(manager)
|
|
bot = object.__new__(RuntimeBot)
|
|
bot.bot_entity = SimpleNamespace(
|
|
uuid='bot-1',
|
|
workspace_uuid=TEST_CONTEXT.workspace_uuid,
|
|
event_bindings=event_bindings,
|
|
plugin_processors=[],
|
|
)
|
|
bot.execution_context = TEST_CONTEXT
|
|
bot.workspace_uuid = TEST_CONTEXT.workspace_uuid
|
|
bot.placement_generation = TEST_CONTEXT.placement_generation
|
|
bot.logger = SimpleNamespace(info=AsyncMock(), warning=AsyncMock(), error=AsyncMock(), debug=AsyncMock())
|
|
bot.adapter = FakeAdapter()
|
|
bot.ap = SimpleNamespace(
|
|
telemetry=SimpleNamespace(execution=counters),
|
|
agent_service=SimpleNamespace(get_agent=AsyncMock(return_value=agent)),
|
|
pipeline_service=SimpleNamespace(get_pipeline=AsyncMock(return_value=None)),
|
|
)
|
|
return bot, manager, counters
|
|
|
|
|
|
def message_received_event():
|
|
from langbot_plugin.api.entities.builtin.platform import entities, events, message
|
|
|
|
return events.MessageReceivedEvent(
|
|
message_id='message-1',
|
|
message_chain=message.MessageChain([message.Plain(text='hello')]),
|
|
sender=entities.User(id='user-1', nickname='QA User'),
|
|
chat_type=entities.ChatType.PRIVATE,
|
|
chat_id='user-1',
|
|
)
|
|
|
|
|
|
class FakeAdapter:
|
|
async def get_supported_apis(self):
|
|
return []
|
|
|
|
|
|
async def flushed_records(counters, manager) -> list[dict]:
|
|
"""Flush buffered telemetry and return everything the event emitted.
|
|
|
|
Records are batched and uploaded by ``ExecutionCounters.flush()``; there is
|
|
no per-trace direct send any more.
|
|
"""
|
|
|
|
await counters.flush()
|
|
assert manager.sent, 'expected the flushed telemetry batch'
|
|
return [record for payload in manager.sent for record in payload.get('records', [])]
|
|
|
|
|
|
def execution_nodes(records: list[dict]) -> list[dict]:
|
|
return [record for record in records if record.get('event_type') == 'execution_node']
|
|
|
|
|
|
def execution_chains(records: list[dict]) -> list[dict]:
|
|
return [record for record in records if record.get('event_type') == 'execution_chain']
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_route_miss_emits_one_closed_chain():
|
|
bot, manager, counters = make_bot([])
|
|
|
|
await bot._handle_platform_event(message_received_event(), FakeAdapter())
|
|
|
|
records = await flushed_records(counters, manager)
|
|
assert len(manager.sent) == 1
|
|
chains = execution_chains(records)
|
|
assert len(chains) == 1
|
|
chain = chains[0]
|
|
assert chain['schema'] == 2
|
|
# The execution identity is deterministic: one inbound event, one chain.
|
|
assert chain['event_id'] == 'platform:bot-1:message-1'
|
|
assert chain['instance_id'] == 'instance-test'
|
|
assert chain['workspace_uuid'] == 'workspace-test'
|
|
assert chain['closed_by'] == 'event_done'
|
|
assert chain['stage_count'] == 2
|
|
|
|
rows = execution_nodes(records)
|
|
assert [(row['family'], row['operation'], row['outcome']) for row in rows] == [
|
|
('event_route', 'message.received', 'skipped'),
|
|
('platform_event', 'message.received', 'success'),
|
|
]
|
|
assert {row['seq'] for row in rows} == {0, 1}
|
|
assert all(row['event_id'] == chain['event_id'] for row in rows)
|
|
assert all(row['adapter'] == 'FakeAdapter' for row in rows)
|
|
# The inbound event is the root step; the routing decision nests under it.
|
|
root = next(row for row in rows if row['root'])
|
|
route = next(row for row in rows if row['family'] == 'event_route')
|
|
assert (root['family'], root['parent_node_id']) == ('platform_event', '')
|
|
assert route['parent_node_id'] == root['node_id']
|
|
# The chain is closed and deregistered: nothing dangles after the event.
|
|
assert counters.registry == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unavailable_route_target_carries_route_identity():
|
|
bot, manager, counters = make_bot(
|
|
[
|
|
{
|
|
'id': 'agent-binding',
|
|
'enabled': True,
|
|
'event_pattern': 'message.received',
|
|
'target_type': 'agent',
|
|
'target_uuid': 'agent-1',
|
|
}
|
|
]
|
|
)
|
|
|
|
await bot._handle_platform_event(message_received_event(), FakeAdapter())
|
|
|
|
nodes = execution_nodes(await flushed_records(counters, manager))
|
|
assert [(node['family'], node['outcome'], node['mode']) for node in nodes] == [
|
|
('event_route', 'failed', 'agent'),
|
|
('platform_event', 'success', 'none'),
|
|
]
|
|
assert nodes[0]['route_ref'] == 'agent:agent-1'
|
|
assert nodes[1]['route_ref'] == ''
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discarded_route_is_visible_without_user_values():
|
|
bot, manager, counters = make_bot(
|
|
[
|
|
{
|
|
'id': 'discard-binding',
|
|
'enabled': True,
|
|
'event_pattern': '*',
|
|
'target_type': 'discard',
|
|
}
|
|
]
|
|
)
|
|
|
|
await bot._handle_platform_event(SimpleNamespace(type='platform.member.joined'), FakeAdapter())
|
|
|
|
nodes = execution_nodes(await flushed_records(counters, manager))
|
|
assert [node['outcome'] for node in nodes] == ['skipped', 'success']
|
|
assert nodes[0]['operation'] == 'platform.member.joined'
|
|
assert nodes[0]['mode'] == 'none'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_telemetry_opt_out_emits_nothing():
|
|
bot, manager, counters = make_bot([], config={'url': 'https://space.example.test', 'disable_telemetry': True})
|
|
|
|
await bot._handle_platform_event(message_received_event(), FakeAdapter())
|
|
|
|
assert manager.sent == []
|
|
assert counters.records == []
|
|
assert counters.registry == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_route_trace_scope_is_restored_after_dispatch():
|
|
from langbot.pkg.telemetry import trace as trace_mod
|
|
|
|
bot, _, counters = make_bot([])
|
|
assert trace_mod.current() is None
|
|
|
|
await bot._handle_platform_event(message_received_event(), FakeAdapter())
|
|
|
|
assert trace_mod.current() is None
|
|
buffered = list(counters.records)
|
|
# A later record outside the ingress must not join the closed chain, and an
|
|
# observation without an owning chain is not buffered at all.
|
|
from langbot.pkg.telemetry.execution import record
|
|
|
|
record(bot.ap, TEST_CONTEXT, family='platform_api', operation='send_message', outcome='success')
|
|
assert counters.records == buffered
|
|
assert counters.registry == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_adapter_call_without_ingress_still_counts():
|
|
"""Mock adapter objects used by other tests must not break the ingress path."""
|
|
bot, manager, counters = make_bot([])
|
|
adapter = Mock()
|
|
|
|
await bot._handle_platform_event(message_received_event(), adapter)
|
|
|
|
assert await flushed_records(counters, manager)
|