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:
Hyu
2026-10-07 09:41:41 +08:00
parent dcc7e17d77
commit 4482e8d876
24 changed files with 1532 additions and 1060 deletions
@@ -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,