mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-26 18:35:38 +08:00
feat(telemetry)!: upload one record per execution node and one closure per chain
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`).
This commit is contained in:
@@ -16,6 +16,65 @@ KB_SELECTOR_TYPES = {'knowledge-base-multi-selector'}
|
||||
PROMPT_EDITOR_TYPES = {'prompt-editor'}
|
||||
NONE_SENTINELS = {'', '__none__', '__none'}
|
||||
|
||||
# Selector field types that pick a Host resource. The Runner config form is
|
||||
# plugin-declared and open-ended (DynamicFormItemSchema allows extra types), so
|
||||
# this is a registry, not a fixed list: a new selector type is one line here and
|
||||
# is then reported by telemetry without touching the extraction call sites.
|
||||
RESOURCE_SELECTOR_TYPES: dict[str, str] = {
|
||||
'knowledge-base-multi-selector': 'knowledge_bases',
|
||||
'tool-multi-selector': 'tools',
|
||||
'mcp-server-multi-selector': 'mcp_servers',
|
||||
'skill-multi-selector': 'skills',
|
||||
}
|
||||
|
||||
|
||||
def declared_resource_selectors(descriptor: RunnerDescriptor | None) -> dict[str, list[str]]:
|
||||
"""Map each selected resource kind to the schema field names that select it.
|
||||
|
||||
Only the descriptor is read, so this reports what a Runner *can* select even
|
||||
when the current configuration leaves the field empty.
|
||||
"""
|
||||
declared: dict[str, list[str]] = {}
|
||||
if descriptor is None:
|
||||
return declared
|
||||
for item in descriptor.config_schema or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
kind = RESOURCE_SELECTOR_TYPES.get(normalize_schema_item_type(item.get('type')))
|
||||
field_name = item.get('name')
|
||||
if not kind or not isinstance(field_name, str) or not field_name:
|
||||
continue
|
||||
declared.setdefault(kind, [])
|
||||
if field_name not in declared[kind]:
|
||||
declared[kind].append(field_name)
|
||||
return declared
|
||||
|
||||
|
||||
def extract_selected_resources(
|
||||
descriptor: RunnerDescriptor | None,
|
||||
runner_config: dict[str, typing.Any] | None,
|
||||
) -> dict[str, list[str]]:
|
||||
"""Extract the resource ids selected through schema-declared selector fields.
|
||||
|
||||
Field names are never hard-coded: they come from the Runner's own config
|
||||
schema, so a form that renames or adds a selector keeps working.
|
||||
"""
|
||||
selected: dict[str, list[str]] = {}
|
||||
if not isinstance(runner_config, dict):
|
||||
return selected
|
||||
for kind, field_names in declared_resource_selectors(descriptor).items():
|
||||
values: list[str] = []
|
||||
for field_name in field_names:
|
||||
value = runner_config.get(field_name)
|
||||
if isinstance(value, str):
|
||||
if value not in NONE_SENTINELS:
|
||||
values.append(value)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
values.extend(item for item in value if isinstance(item, str) and item not in NONE_SENTINELS)
|
||||
if values:
|
||||
selected[kind] = list(dict.fromkeys(values))
|
||||
return selected
|
||||
|
||||
|
||||
def normalize_schema_item_type(item_type: typing.Any) -> typing.Any:
|
||||
"""Normalize legacy/frontend DynamicForm aliases to protocol field types."""
|
||||
|
||||
@@ -37,6 +37,7 @@ from .run_journal import AgentRunJournal
|
||||
from .session_registry import AgentRunSessionRegistry, get_session_registry
|
||||
from .state_scope import build_state_context
|
||||
from ...provider.tools.loaders import skill as skill_loader
|
||||
from ...telemetry import resources as telemetry_resources
|
||||
from ...telemetry import trace as trace_mod
|
||||
from ...telemetry.execution import bind_trace as bind_execution_trace
|
||||
from ...telemetry.execution import close_trace as close_execution_trace
|
||||
@@ -142,6 +143,22 @@ class AgentRunOrchestrator:
|
||||
binding=binding,
|
||||
descriptor=descriptor,
|
||||
)
|
||||
# Telemetry: report what this run was granted (never what it used). The
|
||||
# materialized resource list is authoritative for tools/skills/models;
|
||||
# the Runner's own config form is authoritative for its selectors.
|
||||
try:
|
||||
from . import config_schema as runner_config_schema
|
||||
|
||||
capabilities = getattr(descriptor, 'capabilities', None)
|
||||
telemetry_resources.note_agent(
|
||||
runner_id=descriptor.id,
|
||||
resources=resources,
|
||||
selectors=runner_config_schema.extract_selected_resources(descriptor, binding.runner_config),
|
||||
capabilities=capabilities.model_dump() if hasattr(capabilities, 'model_dump') else None,
|
||||
tools_all=getattr(binding.resource_policy, 'allow_all_tools', None),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
context = await self.context_builder.build_context_from_event(
|
||||
event=execution_event,
|
||||
|
||||
Reference in New Issue
Block a user