事件

只想把上报接起来:看 接入上报,一页讲完代码、会收到什么、看板怎么算。 这一页是完整参考:全部事件与字段、错误来源、扩充属性。指标口径见 指标。

events.tsx
import '@volcengine/chatkit/styles.css';

import { Chatkit, type ChatkitEvent, useChatRuntime } from '@volcengine/chatkit';
import { createMockAdapter } from '@volcengine/chatkit/adapters/mock';
import { useState } from 'react';

const adapter = createMockAdapter({});

// 事件:SDK 自身不上报数据,只调 onEvent,上报到哪里由接入方决定。右侧列出收到的事件,每一轮最后有一条 turn_summary(结论与首字延迟);
// eventAttributes 给每个事件带上自己的维度。
export default function Events() {
  const runtime = useChatRuntime({
    sessionId: 'events',
    adapter,
    eventAttributes: { common: () => ({ page: 'docs-demo' }) },
  });
  const [events, setEvents] = useState<ChatkitEvent[]>([]);
  if (!runtime) return null;
  return (
    <div className="demo-row">
      <div className="demo-main">
        <Chatkit
          runtime={runtime}
          settings={{ recommendations: ['帮我查一下 chatkit 怎么接入'] }}
          onEvent={event => setEvents(current => [event, ...current].slice(0, 40))}
        />
      </div>
      <aside className="demo-side">
        <h4>onEvent 收到的事件</h4>
        {events.length === 0 ? <p className="demo-muted">发一句话,事件会出现在这里</p> : null}
        {events.map((event, index) => (
          <div key={`${event.ts}-${index}`}>
            <strong>{event.name}</strong>
            {event.name === 'run_settled' ? <span> → {event.payload.outcome}</span> : null}
            {event.name === 'turn_summary' ? (
              <span>
                {' '}
                → {event.payload.outcome}
                {event.payload.firstPaintMs === undefined ? '' : ` · 首字 ${Math.round(event.payload.firstPaintMs)} ms`}
              </span>
            ) : null}
          </div>
        ))}
      </aside>
    </div>
  );
}
右侧实时列出 onEvent 收到的事件新窗口打开

出口

使用方式订阅
用界面层(<Chatkit>)onEvent:Core 的全部事件 + 界面层的首次绘制、渲染错误、交互与执行流,一个回调全收到
只用 Core(headless)runtime.events.subscribe(listener):Core 的全部事件;页面隐藏时调 reportPageHidden(),正文首次渲染后调 reportFirstPaint(runId)

useChatRuntime 返回的 runtime 自带 events,交给 <Chatkit runtime={runtime}> 后自动转发到 onEvent。

用会话层(useChatSessions + <Chatkit sessions={sessions}>)时,onEvent 收到的是全部已打开会话的事件,每个事件带着所属会话的 sessionId:切到别的会话之后,原会话在后台跑完的那一轮照样报出来。不经过 <Chatkit> 时订阅 sessions.events.subscribe(listener)。

onEvent 写在 <ChatkitProvider> 上也一样;里面放了 <Chatkit /> 时同一个事件只报一次。

回调抛错不会影响对话:错误交给 onError(来源 event-listener)。

错误出口:onError

onError(error, context) 收非致命错误,给错误监控用。第二个参数说明错误从哪来、属于哪个会话与哪一轮、能不能自愈,可以按 runId 关联到 onEvent 的事件:

interface ChatkitErrorContext {
  source: ChatkitErrorSource;
  sessionId?: string;
  runId?: string;
  messageId?: string;
  recoverable: boolean; // true:已兜住,对话不受影响或可以重试;false:用户能感知到的功能失效
}
source什么时候
render一条消息或一个片段渲染崩了,已显示兜底内容
slot插槽(含会话列表每一项的附加内容)里的一项渲染崩了,这一项不显示
history打开会话时拉历史失败
resume续接或恢复一轮失败
dispatch派发给适配器时出错
projectionCore 快照转换成界面快照时出错
adapter-contract适配器交来不合规的消息(这条消息已被丢掉)
event-listeneronEvent、事件订阅方或上报函数抛错
attributeseventAttributes 的函数抛错(这次不带它的属性)
observerCore 的只读观测者抛错
internalchatkit 内部的归并逻辑出错,这一轮的 turn_summary 可能缺失;是 chatkit 的 bug,请反馈
command界面上的操作失败:停止、重试、重新生成、编辑后重发、onSend
session-list会话列表的操作失败:选择、新建、重命名、删除、加载更多
clipboard复制失败(剪贴板 API 的报错)
upload附件超出大小限制
voice语音输入失败
feature-load按需加载的内置功能加载失败,这个功能暂时不显示

接一处就够:把 onError 写在 <ChatkitProvider>(或 <Chatkit>)上。会话 hook(useChatSessions / useChatRuntime)上没写 onError 时, 它的错误(拉历史、续接、派发、事件订阅方抛错…)也交到这里;里面的 <Chatkit /> 与 SessionList 没写时用 Provider 的。 只有不挂界面(headless)时才需要在 hook 或 ChatCoreRuntime 的 onError 选项上接;都没接时,Core 侧的错误异步抛出,交给宿主的全局错误监控。

后端明确上报的运行失败要看 run_settled / turn_summary 的 outcome 与 errorCode,不会另有一次 onError。

结构

interface EventEnvelope<TName, TPayload> {
  name: TName;
  schemaVersion: number; // 当前 2;字段只增不改,改含义或删字段时升版本
  ts: number; // 墙钟毫秒时间戳,用来对齐与排查;耗时看载荷里的 *Ms,不要拿两个 ts 相减
  sessionId: string;
  runId?: string;
  payload: TPayload;
  attributes?: Record<string, string | number | boolean | null>; // 接入方扩充的属性,见下
  eventId: string; // 事件的唯一标识,数据侧去重用
  seq: number; // 同一次页面访问里所有事件共用的递增序号,数据侧排序、判断「最后一个结论」用
  pageVisitId: string; // 这次页面访问的随机标识:不持久化、刷新即变,不能用来识别用户
}

载荷里只有结构化字段(状态、耗时、错误码、个数),不带用户输入、回答正文、工具参数与结果、原始错误文本,没有例外。 这一条有测试守着:构造带敏感文本的完整对话,断言所有事件里都搜不到。错误的原文走 onError,不进事件。 工具名、错误码、系统提示标识等字符串仍来自后端或业务配置,上报前需要接入方确认;自定义 attributes 也应避免敏感内容。

耗时都用单调时钟(performance.now):不受系统时间调整影响。刷新后重新接上、远端发起的一轮没有本地的受理时点,这类轮次的耗时退回墙钟计算。

扩充属性

上报时总要带上自己的维度:应用版本、场景、Agent、模型、实验分组、入口……chatkit 留了三个入口,结果合并进事件的 attributes:

入口取值时机适合
eventAttributes: { common }每个事件发出时现取应用版本、场景、登录态、实验分组
eventAttributes: { run }发送时按这一轮的输入取一次入口、是否带附件、选的模型
适配器 ingress.annotateRun({ runId, attributes })适配器知道之后,这一轮之后的事件都带实际用的模型、路由到的 Agent、用量档位

eventAttributes 是 useChatSessions 与 useChatRuntime 的参数,两边写法相同;用 createTelemetry 时写在它的 attributes 里,经 sessionOptions 带过去。 它是事件属性的唯一入口:<Chatkit> 与 <ChatkitProvider> 上没有这个参数。

  • 合并顺序:common → run → send(input, { attributes }) → annotateRun,同名后者覆盖前者。
  • 同一轮的事件都带这一轮的属性:send_*、run_*、tool_call_settled、turn_summary,以及界面层按 runId 继承的 first_paint。
  • 只收扁平的基础类型(字符串、有限数字、布尔、null);对象、数组、NaN 在进事件之前丢掉,所以能直接交给任何埋点 SDK。
  • 属性函数抛错:这次不带它的属性,对话照常,错误交给 onError(来源 attributes)。
  • 属性由接入方提供,隐私也由接入方负责:不要把用户输入放进去。
const sessions = useChatSessions({
  adapter,
  eventAttributes: {
    common: () => ({ appVersion: APP_VERSION, scene: currentScene() }),
    run: ({ input }) => ({ withAttachments: (input.attachments?.length ?? 0) > 0 }),
  },
});

一轮的生命周期

时间 →
用户点发送      send_attempt
Core 受理       send_result(stage: accepted)  ◀── 受理时点,所有「从受理算起」的耗时都从这里开始
派发给适配器    run_started(queuedMs)
                send_result(stage: dispatched)
第一段产出      run_first_output                ── firstOutputMs
正文画到屏幕    first_paint                     ── firstPaintMs(首字延迟)
落终态          run_settled(outcome)          ── totalMs
每轮汇总        turn_summary(汇总以上全部)
  • 首字延迟 = 受理到 first_paint,即 turn_summary.firstPaintMs(也是 first_paint.sinceAcceptedMs);
  • 首次响应 = 受理到 run_first_output,即 turn_summary.firstOutputMs;
  • 会话完成耗时 = 受理到终态,即 turn_summary.totalMs。

短回复可能先落终态、界面随后才画出正文:turn_summary 会在终态之后最多等一会儿首次绘制(firstPaintGraceMs,默认 1000 毫秒),等到或到点再发出。 没有正文、或者终态时会话在后台(background: true)时不等。

目录

发送(Core)

事件时机载荷
send_attempt调了 send / regenerate / edit(命令中间件之前)hasActiveRun、via
send_blocked命令中间件没调 next(),或抛错reason: middleware / middleware-error、via
send_result两段:Core 受理(stage: 'accepted'),派发给适配器后的结论(stage: 'dispatched')stage、accepted、reason?、via
  • via 是这一轮从哪个命令来的:send(普通发送)、regenerate(重新生成)、edit(编辑后重发)。三个命令共用这组事件, 统计发送量时按 via 分开看,重新生成不算用户的新提问。
  • 重新生成 / 编辑不受理时 send_result.reason 的取值:run-active(有一轮在跑或在排队)、not-supported(适配器没实现)、 run-not-found(目标轮不存在)、input-unknown(目标轮的输入未知,比如从历史里读出来的轮次)。

运行(Core)

事件时机载荷
run_started派发给适配器(排队中被撤下、从没派发过的 run 不发)origin、queuedMs(排队等待)
run_first_output第一段用户可见的产出(正文、思考、工具调用)latencyMs(从派发到首个产出)
run_phase_changed运行阶段变化(sampling / tool / awaiting-external / finalizing)from、to
run_stalled在跑、却超过阈值(默认 60 秒)没有任何新事实;等待外部输入的阶段不算phase、idleMs、hasOutput
run_aborted用户中止的结论accepted
run_settled落终态status、outcome、totalMs?、durationMs?、firstOutputMs?、errorCode?、retryable?、interruptReason?、stallCount、content、workItems
run_abandoned回复中途被放弃、没有终态:Runtime 释放(切会话、卸载)或页面隐藏 / 关闭outcome: 'interrupted'、reason、phase、totalMs、stallCount、content
  • run_settled 的 outcome 是这一轮的结论,落进且只落进一类:completed / empty / failed / aborted / interrupted / cancelled,判定规则见 指标。cancelled(排队中被撤下、从没派发)不算一轮,没有 turn_summary;turn_summary.outcome 只会是前五类。
  • content 只数有无和个数:{ text, reasoning, toolCalls, media, data };workItems 是 { total, failed }。
  • totalMs 从受理(用户点发送)算起、含排队;durationMs 从派发算起。
  • 卡住的阈值用 useChatRuntime({ stallThresholdMs })(或 ChatCoreRuntime 的同名选项)调,0 关掉。
  • run_abandoned 之后同一 run 如果又落了终态(从后台恢复、重新接上),以最后一个结论为准。

每轮汇总(Core)

事件时机载荷
turn_summary一轮有了结论:落终态(等首次绘制,见上)或被放弃时;被放弃后又落终态的再发一条见下表
字段说明
revision同一轮第几次汇总。数据侧按 sessionId + runId 取最大的一条
outcome、effective结论;effective 为 true 表示计入有效轮次(completed / empty / failed / interrupted)
via、origin从哪个命令来(远端发起的一轮没有 via)、本地发送还是远端发起
queuedMs、firstOutputMs、firstPaintMs排队等待;受理到首次执行产出;受理到正文首次渲染(首字延迟)
totalMs、durationMs受理到终态(含排队);派发到终态
background终态时会话不在前台(不是当前会话,或页面不可见)。运行时不知道前后台时不出现
errorCode、retryable、interruptReason失败与中断的原因
abandonReason被放弃的原因:runtime-disposed / page-hidden
stallCount、content、toolCalls卡住次数;产出概要;工具调用计数 { total, failed, cancelled }

turn_summary 是由原始事件归并出来的派生事件,原始事件照常发出。两套口径做看板时二选一,不要相加。 排队中被撤下(cancelled)的不算一轮,不发 turn_summary,所以它的 outcome 不含 cancelled。

工具与历史(Core)

事件时机载荷
tool_call_settled一次工具调用结束;run 落终态时还在跑的算 cancelledtoolName(协议给的原始名)、status、durationMs?、errorCode?
history_loaded拉会话历史结束:打开会话(refresh)、向前翻页(loadMore)kind、ok、durationMs、messageCount

tool_call_settled.durationMs 从 Core 第一次看到这次调用算起;第一次出现就已经有结果的(比如协议一帧里同时给了调用和结果)没有耗时。 history_loaded 只在后端有历史接口(适配器实现了 history)时发。

性能与错误(界面层)

事件时机载荷
first_paint某一轮的助手正文第一次画到屏幕上(每个 run 一次)messageId、sinceAcceptedMs?(受理到此刻,即首字延迟)
render_error一条消息、一个片段或插槽里的一项渲染崩了,被错误边界兜住(4 秒内同内容一次)scope、errorName、messageId?、role?、partType?、slot?

render_error 只带错误的类名;完整的错误(含堆栈和渲染上下文)仍然走 onError,给错误监控用。 scope 为 slot 时是插槽里的一项出错:这一项不显示,同一位置的其他项与对话不受影响,slot 是出错的位置名。

交互(界面层)

事件时机载荷
assistant_message_hover鼠标移入一条回答的操作栏messageId
conversation_navigation_click点了左侧轮次导航的某一轮roundId、messageId、roundIndex
conversation_event_action点了系统提示(提示条)上的操作按钮kind、actionId、messageId

conversation_event_action 的 kind 是提示的类型(如 chatkit.notice),actionId 是后端在提示里给这个按钮的标识; 提示的标题与说明属于对话内容,不进事件。要在点击后做事(打开摘要、跳转),在 onEvent 里按这两个字段分发。

执行流(界面层)

折叠组、工具行、展开区的曝光与交互。曝光要求元素 50% 进入视口并驻留 300ms,同一身份在同一页面、同一会话里只报一次:切走再切回、按会话重挂载 Chatkit 都不重报。 载荷类型是 ChatkitFlowEventPayloads。

事件时机载荷
agent_run_show运行状态行曝光runPhase、displayState、interactionState、hasExecution、finalAnswerStarted、durationMs?
agent_run_action展开 / 收起执行过程operation、runPhase、displayStateBefore、durationMs?
process_group_show多步折叠组曝光(两步及以上)summaryMode、stepCountBucket、hasReasoning、hasSubagent、runPhase
process_group_action展开 / 收起折叠组operation、summaryMode、stepCountBucket、runPhase、hasReasoning
tool_summary_show一个工具行曝光工具字段 + clusterKey、toolCategory、rendererSource、isExpandable
tool_detail_action展开 / 收起工具或思考详情工具字段 + operation、detailSurface、runPhase
tool_preview_show工具展开区曝光工具字段 + openSource?、contentStatus?、runPhase
tool_preview_action展开区里的操作工具字段 + actionType、targetIndex?、activeTab?

工具字段(FlowToolTrackingFields):toolName(协议给的原始名)、renderer、toolState、renderPipeline。 renderPipeline 的取值:registered_renderer、generic_tool_card、fallback_tool_card、static_summary、summary_only; summaryMode:active_tool(运行中)/ action_set(已结束);stepCountBucket:1、2、3、4_5、6_10、gt_10; actionType 如 switch_tab、open_web_result、copy_input。复制失败是诊断信息,走 onError(来源 clipboard),不进事件。

类型与工具

  • ChatkitEvent:onEvent 收到的全部事件的联合类型;ChatkitEventName 是全部事件名,ChatkitEventOf<'turn_summary'> 按名字取类型。
  • CHATKIT_EVENT_NAMES:全部事件名的只读数组。
  • isChatkitEvent(event, 'turn_summary'):按名字收窄,收窄后载荷有完整类型。
if (isChatkitEvent(event, 'turn_summary') && event.payload.effective) track('chatTurn', event.payload.outcome);

createTelemetry 参考

选项:

选项说明
track必填。事件名(经 rename 映射)与拍平后的一层属性
beacon离开页面通道。页面隐藏、关闭时的事件走它;不给时也走 track
reportError错误监控,见下面的「错误出口」。track / beacon 抛的错也交给它
eventssummary(默认)、core-metrics(METRICS.md 用到的原始事件,不含 turn_summary)、all,或事件名数组
rename事件名映射,如 { turn_summary: 'chatTurnEnd' }
attributes扩充属性,即会话 hook 的 eventAttributes(见下)

summary 预设报 turn_summary、render_error、history_loaded,以及发送失败的 send_result(口径见 METRICS.md 的「发送失败」)。

拍平规则:载荷字段原样展开,嵌套对象的字段名用下划线连接(content.text 展开成 content 加下划线加 text);信封字段 eventId、seq、pageVisitId、schemaVersion、ts、sessionId、runId 一并带上;接入方属性放在最底下,与载荷或信封同名时加前缀 attr 和下划线保留。 flattenChatkitEvent(event) 单独导出,自己写上报层时也能用。

同一个 eventId 只报一次。开发模式下,没给 reportError、属性值会被丢掉或疑似用户输入、页面隐藏时 track 返回 Promise 又没有 beacon,都会在控制台提示一次。

自己写上报层

不用 createTelemetry 时,在 onEvent 里映射成自己的事件名。页面关闭时浏览器不会等异步请求, run_abandoned(page-hidden)与同时发出的 turn_summary 要用 navigator.sendBeacon 或埋点 SDK 的离开页面通道发出。

<Chatkit
  sessions={sessions}
  onEvent={event => {
    if (!isChatkitEvent(event, 'turn_summary')) return;
    const props = flattenChatkitEvent(event);
    if (event.payload.abandonReason === 'page-hidden') beacon('chatTurnEnd', props);
    else track('chatTurnEnd', props);
  }}
  onError={(error, context) => reportError(error, context)}
/>

开发调试

@volcengine/chatkit/devtools 是开发调试用的子入口,不进根入口的体积:

  • createTurnMetrics():在页面里按 METRICS.md 的口径实时归并指标,把它的 record 接在 onEvent 上;
  • <ChatkitTelemetryInspector metrics={metrics} />:浮层,显示核心指标与最近的事件,生产构建不渲染。

验收接入是否完整:Mock 适配器的 telemetry-acceptance 场景按发送次数轮流跑出每一种结论, 跑完后把上报函数收到的调用交给 @volcengine/chatkit/testing 的 verifyTelemetry,它检查每种结论都有汇总、属性齐全、没有对话内容、没有重复上报。