coze_lab 0.1.41 → 0.1.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coze_lab",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
4
4
  "description": "Configure local AI agents (Claude Code, Codex, OpenClaw) to report traces to CozeLoop",
5
5
  "keywords": [
6
6
  "cozeloop",
@@ -127,9 +127,9 @@ _DEFAULT_WORKSPACE_ID = "7649231955045072915" # hardcoded spaceID fallback
127
127
  _COZE_CTX_OPEN = "<coze-context>"
128
128
  _COZE_CTX_CLOSE = "</coze-context>"
129
129
 
130
- # 中途事件(PostToolUse)增量上报的最小间隔(秒)。密集工具调用下用它节流,避免每秒多次 flush。
131
- # 终态事件(Stop)不受此限制。
132
- INCREMENTAL_UPLOAD_MIN_INTERVAL = float(os.environ.get("COZELOOP_INCREMENTAL_MIN_INTERVAL", "10"))
130
+ # 实时 step 上报的批量间隔(秒):距上次上报不足此值则跳过本次,把已结束的 span 攒到下次一起发,
131
+ # 最多每 REALTIME_BATCH_INTERVAL 秒一批,压低请求数。终态(Stop)不受限,保证收尾即时完整。
132
+ REALTIME_BATCH_INTERVAL = float(os.environ.get("COZELOOP_REALTIME_BATCH_INTERVAL", "5"))
133
133
 
134
134
 
135
135
  def _content_to_text(content: Any) -> str:
@@ -550,6 +550,19 @@ def read_new_messages(file_path: str, start_line: int = 0) -> List[Dict[str, Any
550
550
 
551
551
  # --- Content Helpers ---
552
552
 
553
+ def _usage_int(usage: Any, key: str) -> int:
554
+ """Read a token count from a usage dict, treating missing/None/非数字 一律为 0。
555
+
556
+ Claude Code transcript 里 usage 的 cache_* 等字段常【存在但值为 null】,dict.get(key, 0)
557
+ 对显式 null 返回 None 而非 0,后续 None + int / None > 0 会抛 TypeError,导致整个实时
558
+ 上报失败、trace 查不到。这里统一兜底。
559
+ """
560
+ if not isinstance(usage, dict):
561
+ return 0
562
+ v = usage.get(key)
563
+ return v if isinstance(v, int) else 0
564
+
565
+
553
566
  def is_empty_content(content: Any) -> bool:
554
567
  """Return True if content carries no meaningful data."""
555
568
  if content is None:
@@ -676,7 +689,7 @@ def _group_subagent_steps(progress_msgs: List[Dict[str, Any]]) -> List[Dict[str,
676
689
  existing.extend(content)
677
690
  last_step["tool_calls"].extend(tool_calls)
678
691
  usage = pmsg.get("usage", {})
679
- if usage.get("input_tokens", 0) > 0 or usage.get("output_tokens", 0) > 0:
692
+ if _usage_int(usage, "input_tokens") > 0 or _usage_int(usage, "output_tokens") > 0:
680
693
  last_step["assistant_message"]["message"]["usage"] = usage
681
694
  else:
682
695
  steps.append({
@@ -849,7 +862,7 @@ def group_messages_into_turns(messages: List[Dict[str, Any]]) -> List[Dict[str,
849
862
  last_step["tool_calls"].extend(tool_calls)
850
863
  # Carry over usage from the later line (earlier line typically has zeros)
851
864
  usage = message.get("usage", {})
852
- if usage.get("input_tokens", 0) > 0 or usage.get("output_tokens", 0) > 0:
865
+ if _usage_int(usage, "input_tokens") > 0 or _usage_int(usage, "output_tokens") > 0:
853
866
  last_step["assistant_message"]["message"]["usage"] = usage
854
867
  else:
855
868
  # New API response — create a new step
@@ -1329,10 +1342,10 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
1329
1342
 
1330
1343
  # Set token usage for this specific model call
1331
1344
  usage = assistant_message_obj.get("usage", {})
1332
- input_tokens = usage.get("input_tokens", 0)
1333
- output_tokens = usage.get("output_tokens", 0)
1334
- cache_creation = usage.get("cache_creation_input_tokens", 0)
1335
- cache_read = usage.get("cache_read_input_tokens", 0)
1345
+ input_tokens = _usage_int(usage, "input_tokens")
1346
+ output_tokens = _usage_int(usage, "output_tokens")
1347
+ cache_creation = _usage_int(usage, "cache_creation_input_tokens")
1348
+ cache_read = _usage_int(usage, "cache_read_input_tokens")
1336
1349
  if input_tokens > 0 or cache_creation > 0 or cache_read > 0:
1337
1350
  model_span.set_input_tokens(input_tokens + cache_creation + cache_read)
1338
1351
  if output_tokens > 0:
@@ -1402,10 +1415,10 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
1402
1415
 
1403
1416
  # Distribute total usage evenly across sub-agent model steps.
1404
1417
  total_usage = tool_call.get("_total_usage", {})
1405
- total_in = (total_usage.get("input_tokens", 0)
1406
- + total_usage.get("cache_creation_input_tokens", 0)
1407
- + total_usage.get("cache_read_input_tokens", 0))
1408
- total_out = total_usage.get("output_tokens", 0)
1418
+ total_in = (_usage_int(total_usage, "input_tokens")
1419
+ + _usage_int(total_usage, "cache_creation_input_tokens")
1420
+ + _usage_int(total_usage, "cache_read_input_tokens"))
1421
+ total_out = _usage_int(total_usage, "output_tokens")
1409
1422
  n_model_steps = len(sub_steps)
1410
1423
  per_step_in = total_in // n_model_steps if n_model_steps > 0 else 0
1411
1424
  per_step_out = total_out // n_model_steps if n_model_steps > 0 else 0
@@ -1615,6 +1628,249 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, history
1615
1628
  return True
1616
1629
 
1617
1630
 
1631
+ # --- 实时 step 级上报(方案 B:每个 step span 结束即可见)---------------------
1632
+ #
1633
+ # 设计:一个会话(thread)= 一条稳定 trace。首次触发时建 root span(claude_code_request)
1634
+ # 并把它的 to_header()(含 trace_id + root span_id + baggage)持久化到 state["rt_root_header"]。
1635
+ # 之后每次 hook 触发,从 header 重建 root context,把【自上次以来新完成的 step】作为
1636
+ # model_span(+其下 tool_span) 挂到固定 root 下、当场 flush —— 于是每个 step 结束即可查。
1637
+ # step 级水位线用 state["rt_last_global_step"] 跟踪(按 (turn_index, step_index) 线性展开)。
1638
+ #
1639
+ # 与整树 send_turns_to_cozeloop 的关系:实时路径独立成 trace,不依赖也不复用整树逻辑。
1640
+ # 终态(Stop)时调用 finalize_realtime_root 收尾(补 root 的 output、finish)。
1641
+
1642
+ def _rt_state_key_header():
1643
+ return "rt_root_header"
1644
+
1645
+ def _flatten_completed_steps(turns: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
1646
+ """把 turns 线性展开成 step 列表;只取【已完成】的 step。
1647
+
1648
+ 一个 step(assistant 一次模型调用 + 其触发的 tool_results)视为已完成的判定:
1649
+ 它后面还有别的 step/turn(说明模型已经继续往下走),或它带有 tool_results。
1650
+ 最后一个 step 若没有 tool_results 且无后继,视为仍可能在进行中 —— 留到下次。
1651
+ 返回的每项含: turn, step, turn_index, step_index, is_last_known。
1652
+ """
1653
+ flat = []
1654
+ for ti, turn in enumerate(turns):
1655
+ steps = turn.get("steps", [])
1656
+ for si, step in enumerate(steps):
1657
+ flat.append({"turn": turn, "step": step, "turn_index": ti, "step_index": si})
1658
+ # 标记每个 step 是否“已完成”:非全局最后一个 step 一定已完成;
1659
+ # 全局最后一个 step 仅当它有 tool_results(工具已回结果)才算完成。
1660
+ completed = []
1661
+ for idx, item in enumerate(flat):
1662
+ is_global_last = (idx == len(flat) - 1)
1663
+ has_results = bool(item["step"].get("tool_results"))
1664
+ if (not is_global_last) or has_results:
1665
+ completed.append(item)
1666
+ return completed, len(flat)
1667
+
1668
+
1669
+ def send_steps_realtime(turns, session_id, history_turns, state, coze_tags_override=None, is_terminal=False):
1670
+ """实时上报:把新完成的 step 作为 span 挂到稳定 root,结束即 flush。
1671
+
1672
+ 返回 True 表示本次有推进(已发或已收尾),None 表示失败(state 不前进)。
1673
+ """
1674
+ token = get_fresh_token()
1675
+ if token:
1676
+ os.environ["COZELOOP_API_TOKEN"] = token
1677
+ workspace_id = os.environ.get("COZELOOP_WORKSPACE_ID", "") or _DEFAULT_WORKSPACE_ID
1678
+ client_kwargs = {"ultra_large_report": True, "upload_timeout": 120}
1679
+ if workspace_id:
1680
+ client_kwargs["workspace_id"] = workspace_id
1681
+ if token:
1682
+ client_kwargs["api_token"] = token
1683
+ api_base_url = get_api_base_url()
1684
+ if api_base_url:
1685
+ client_kwargs["api_base_url"] = api_base_url
1686
+
1687
+ try:
1688
+ client = cozeloop.new_client(**client_kwargs)
1689
+ except Exception as e:
1690
+ debug_log(f"[rt] new_client failed: {e}")
1691
+ return None
1692
+
1693
+ try:
1694
+ # ---- coze_tags(root 用,保证 root span 可按 message_id 查到)----
1695
+ coze_tags = {}
1696
+ for turn in list(turns) + list(history_turns or []):
1697
+ um = turn.get("user_message", {}).get("message", {})
1698
+ t = coze_context_tags(um.get("content") if um else None)
1699
+ if t:
1700
+ coze_tags = t
1701
+ break
1702
+ if not coze_tags and coze_tags_override:
1703
+ coze_tags = dict(coze_tags_override)
1704
+ coze_tags = {k: v for k, v in coze_tags.items() if isinstance(v, str) and v.strip()}
1705
+
1706
+ # ---- 取/建稳定 root ----
1707
+ root_header = state.get(_rt_state_key_header())
1708
+ root_ctx = None
1709
+ if root_header:
1710
+ try:
1711
+ root_ctx = client.get_span_from_header(root_header)
1712
+ except Exception as e:
1713
+ debug_log(f"[rt] rebuild root from header failed: {e}")
1714
+ root_ctx = None
1715
+ if root_ctx is None or not getattr(root_ctx, "trace_id", ""):
1716
+ # 首次:建 root span,立即发出(input=首条 user 文本),存 header。root 不在此处 finish,
1717
+ # 用 start_new_trace 确保独立一条 trace。
1718
+ first_user_text = ""
1719
+ for turn in turns:
1720
+ um = turn.get("user_message", {}).get("message", {})
1721
+ uc = um.get("content") if um else None
1722
+ if not is_empty_content(uc):
1723
+ first_user_text = format_content(uc)
1724
+ break
1725
+ root_start_dt = None
1726
+ if turns:
1727
+ root_start_dt = _parse_ts(turns[0].get("user_message"))
1728
+ root_span = client.start_span(name="claude_code_request", span_type="main",
1729
+ start_time=root_start_dt, start_new_trace=True)
1730
+ root_span.set_runtime(Runtime(library="claude-code"))
1731
+ rtags = {"thread_id": session_id, "source": "claude_code", "realtime": True}
1732
+ rtags.update(coze_tags)
1733
+ root_span.set_tags(rtags)
1734
+ rbag = {"thread_id": session_id}
1735
+ rbag.update(coze_tags)
1736
+ root_span.set_baggage(rbag)
1737
+ if first_user_text:
1738
+ root_span.set_input(first_user_text)
1739
+ state[_rt_state_key_header()] = root_span.to_header()
1740
+ root_ctx = client.get_span_from_header(state[_rt_state_key_header()])
1741
+ # root 立即 finish 落库(后端 ResolveTraceIDByMessageID 按 root_span 查,root 必须可查)。
1742
+ # 实测:root finish 后,子 span 仍可用其 header 挂到同一 trace_id 下,不影响后续增量。
1743
+ root_span.finish()
1744
+ client.flush()
1745
+ debug_log(f"[rt] root created+finished trace_id={getattr(root_ctx,'trace_id','?')}")
1746
+
1747
+ # ---- 发新完成的 step ----
1748
+ completed, total_steps = _flatten_completed_steps(turns)
1749
+ last_global = state.get("rt_last_global_step", 0)
1750
+ new_items = completed[last_global:]
1751
+ # 批量节流:非终态时,距上次上报不足 REALTIME_BATCH_INTERVAL 秒则本次不发,
1752
+ # 把已结束的 step 攒到下次触发一起发(最多每 5s 一批)。终态(Stop)不节流,立即收尾。
1753
+ if new_items and not is_terminal:
1754
+ since = time.time() - state.get("rt_last_upload_ts", 0)
1755
+ if since < REALTIME_BATCH_INTERVAL:
1756
+ debug_log(f"[rt] batch throttle: {since:.1f}s<{REALTIME_BATCH_INTERVAL}s, defer {len(new_items)} step(s)")
1757
+ new_items = []
1758
+ sent = 0
1759
+ for item in new_items:
1760
+ step = item["step"]
1761
+ assistant_msg = step.get("assistant_message", {})
1762
+ amo = assistant_msg.get("message", {})
1763
+ model_name = amo.get("model", "claude-code")
1764
+ gidx = last_global + sent # 全局 step 序号,用于命名
1765
+ step_start_dt = _parse_ts(assistant_msg)
1766
+ step_end_dt = None
1767
+ for r in step.get("tool_results", []):
1768
+ te = _parse_ts_str(r.get("_result_ts"))
1769
+ if te:
1770
+ step_end_dt = te
1771
+
1772
+ # model_span 挂 root
1773
+ mspan = client.start_span(name=f"model_call_{gidx}", span_type="model",
1774
+ start_time=step_start_dt, child_of=root_ctx)
1775
+ mspan.set_runtime(Runtime(library="claude-code"))
1776
+ try:
1777
+ mspan.set_model_name(model_name)
1778
+ except Exception:
1779
+ pass
1780
+ if step_start_dt is not None and step_end_dt is not None:
1781
+ _set_finish_time_safe(mspan, step_end_dt)
1782
+ raw_content = amo.get("content", [])
1783
+ text_parts = []
1784
+ if isinstance(raw_content, list):
1785
+ for it in raw_content:
1786
+ if isinstance(it, dict) and it.get("type") == "text" and it.get("text"):
1787
+ text_parts.append(it["text"])
1788
+ elif isinstance(raw_content, str):
1789
+ text_parts.append(raw_content)
1790
+ mspan.set_input(format_content((assistant_msg.get("message", {}) or {}).get("content")))
1791
+ if text_parts:
1792
+ mspan.set_output("\n".join(text_parts))
1793
+ usage = amo.get("usage", {})
1794
+ it_tok = _usage_int(usage, "input_tokens") + _usage_int(usage, "cache_creation_input_tokens") + _usage_int(usage, "cache_read_input_tokens")
1795
+ if it_tok > 0:
1796
+ try: mspan.set_input_tokens(it_tok)
1797
+ except Exception: pass
1798
+ if _usage_int(usage, "output_tokens") > 0:
1799
+ try: mspan.set_output_tokens(_usage_int(usage, "output_tokens"))
1800
+ except Exception: pass
1801
+ mspan_ctx = client.get_span_from_header(mspan.to_header())
1802
+ mspan.finish()
1803
+
1804
+ # tool_span 挂 model_span
1805
+ for tc in step.get("tool_calls", []):
1806
+ tname = tc.get("name", "unknown")
1807
+ tid = tc.get("id")
1808
+ tool_end_dt = None
1809
+ for r in step.get("tool_results", []):
1810
+ if r.get("tool_use_id") == tid:
1811
+ tool_end_dt = _parse_ts_str(r.get("_result_ts"))
1812
+ break
1813
+ tspan = client.start_span(name=f"tool_{tname}", span_type="tool",
1814
+ start_time=step_start_dt, child_of=mspan_ctx)
1815
+ tspan.set_runtime(Runtime(library="claude-code"))
1816
+ if tool_end_dt is not None:
1817
+ _set_finish_time_safe(tspan, tool_end_dt)
1818
+ tspan.set_tags({"tool_name": tname, "tool_call_id": tid, "step_index": gidx})
1819
+ tspan.set_input(json.dumps(tc.get("input", {}), ensure_ascii=False)[:2000])
1820
+ for r in step.get("tool_results", []):
1821
+ if r.get("tool_use_id") == tid:
1822
+ tspan.set_output(_format_tool_output(r.get("content", "")))
1823
+ break
1824
+ tspan.finish()
1825
+ sent += 1
1826
+ # 每个 step 立即 flush —— 这是“结束即可见”的关键。
1827
+ client.flush()
1828
+
1829
+ new_last = last_global + sent
1830
+ state["rt_last_global_step"] = new_last
1831
+ if sent > 0:
1832
+ state["rt_last_upload_ts"] = time.time()
1833
+
1834
+ # ---- 收尾:终态时补 root output 并 finish ----
1835
+ if is_terminal:
1836
+ last_output = None
1837
+ for turn in reversed(turns):
1838
+ for step in reversed(turn.get("steps", [])):
1839
+ amo = step.get("assistant_message", {}).get("message", {})
1840
+ c = amo.get("content", [])
1841
+ if isinstance(c, list):
1842
+ tp = [x.get("text", "") for x in c if isinstance(x, dict) and x.get("type") == "text" and x.get("text")]
1843
+ if tp:
1844
+ last_output = "\n".join(tp); break
1845
+ elif isinstance(c, str) and c.strip():
1846
+ last_output = c; break
1847
+ if last_output:
1848
+ break
1849
+ if root_ctx is not None and last_output:
1850
+ # 重发一个同 trace 的收尾 span 承载最终输出(root 对象已不可用,用子 span 兜底)。
1851
+ fin = client.start_span(name="final_response", span_type="main", child_of=root_ctx)
1852
+ fin.set_runtime(Runtime(library="claude-code"))
1853
+ fin.set_output(last_output)
1854
+ fin.finish()
1855
+ client.flush()
1856
+ debug_log(f"[rt] finalized, total sent steps={new_last}")
1857
+
1858
+ debug_log(f"[rt] sent {sent} new step(s), last_global={new_last}/{total_steps}, terminal={is_terminal}")
1859
+ return True
1860
+ except Exception as e:
1861
+ debug_log(f"[rt] send_steps_realtime error: {e}")
1862
+ return None
1863
+ finally:
1864
+ try:
1865
+ client.flush()
1866
+ except Exception:
1867
+ pass
1868
+ try:
1869
+ client.close()
1870
+ except Exception:
1871
+ pass
1872
+
1873
+
1618
1874
  # --- Hook Input ---
1619
1875
 
1620
1876
  def read_hook_stdin() -> Dict[str, Any]:
@@ -1674,28 +1930,19 @@ def main():
1674
1930
  debug_log(f"Using conversation file: {conversation_file}")
1675
1931
  print(f"[CozeLoop] 读取会话文件: {conversation_file}", file=sys.stderr)
1676
1932
 
1677
- # Load state to know where to start reading
1933
+ # Load state
1678
1934
  state_file = get_state_file_path(conversation_file)
1679
1935
  state = load_state(state_file)
1680
- last_processed_line = state.get("last_processed_line", 0)
1681
-
1682
- # 节流:PostToolUse 在密集工具调用下会高频触发。距上次上报不足 INCREMENTAL_UPLOAD_MIN_INTERVAL
1683
- # 秒则跳过本次增量上报,避免每秒多次 flush 抬高上报量/成本。终态事件(Stop)永不被节流,
1684
- # 保证任务结束时一定收尾上报最后一批。
1685
- if not is_terminal_event:
1686
- now_ts = time.time()
1687
- last_upload_ts = state.get("last_upload_ts", 0)
1688
- if now_ts - last_upload_ts < INCREMENTAL_UPLOAD_MIN_INTERVAL:
1689
- debug_log(f"throttled: event={hook_event} since_last={now_ts - last_upload_ts:.1f}s < {INCREMENTAL_UPLOAD_MIN_INTERVAL}s, skip")
1690
- return
1691
-
1692
- # Read new messages from the file
1693
- new_messages = read_new_messages(conversation_file, last_processed_line)
1694
-
1695
- # Determine session ID: prefer stdin, then messages, then state, then generate
1936
+
1937
+ # 方案 B(实时 step 级上报):读【全量】消息重建完整 turn/step 序列。实时路径用全局 step
1938
+ # 水位线 state["rt_last_global_step"] 增量,不再用 last_processed_line(那是整树增量的水位线)。
1939
+ # 每次 hook 触发(PostToolUse=每次工具调用后)都把新完成的 step 发出,结束即可见,不节流。
1940
+ all_messages = read_new_messages(conversation_file, 0)
1941
+
1942
+ # Determine session ID
1696
1943
  session_id = hook_input.get("session_id")
1697
1944
  if not session_id:
1698
- for msg in new_messages:
1945
+ for msg in all_messages:
1699
1946
  if msg.get("sessionId"):
1700
1947
  session_id = msg.get("sessionId")
1701
1948
  break
@@ -1704,83 +1951,48 @@ def main():
1704
1951
  session_id = state["session_id"]
1705
1952
  else:
1706
1953
  session_id = f"claude-code-{datetime.now().strftime('%Y%m%d-%H%M%S')}-{os.getpid()}"
1707
- debug_log(f"Generated new session ID: {session_id}")
1708
-
1709
1954
  state["session_id"] = session_id
1710
- debug_log(f"Session ID: {session_id}")
1955
+ debug_log(f"Session ID: {session_id}, event={hook_event}")
1711
1956
 
1712
- if not new_messages:
1713
- debug_log("No new messages to process.")
1957
+ if not all_messages:
1958
+ debug_log("No messages to process.")
1714
1959
  return
1715
1960
 
1716
- debug_log(f"Found {len(new_messages)} new messages.")
1717
-
1718
- # Read historical messages to build context for model input
1719
- history_turns = []
1720
- if last_processed_line > 0:
1721
- historical_messages = read_new_messages(conversation_file, 0)
1722
- historical_messages = [m for m in historical_messages if m.get("_line_number", 0) < last_processed_line]
1723
- history_turns = group_messages_into_turns(historical_messages)
1724
- debug_log(f"Loaded {len(history_turns)} historical turn(s) for context.")
1725
-
1726
- # Group messages into turns and send to CozeLoop — only if coze-context present.
1727
- turns = group_messages_into_turns(new_messages)
1728
- if turns:
1729
- # coze-context 只出现在首条用户消息里(cozelab 注入的 agent 才有)。增量 hook 触发时,
1730
- # 新消息往往全是工具调用/结果(不带 coze-context),故必须连同历史 turns 一起判断——
1731
- # 只要整个会话出现过 coze-context,就说明是被注入的 agent,后续增量都应上报。
1732
- def _turn_has_ctx(turn):
1733
- return bool(coze_context_tags(
1734
- (turn.get("user_message", {}).get("message", {}) or {}).get("content")
1735
- ))
1736
- has_coze_ctx = any(_turn_has_ctx(t) for t in turns) or any(_turn_has_ctx(t) for t in history_turns)
1737
- if not has_coze_ctx:
1738
- debug_log("No coze-context found in any turn (incl. history), skipping upload.")
1739
- return
1740
-
1741
- # 持久化 coze_tags 到 state:<coze-context> 只在首 turn,后续增量批次的 turns 不含它,
1742
- # 其 root span 会缺 coze_message_id 而无法按 message_id 查到。首次解析到就存 state,
1743
- # 后续批次作为 override 传给 send_turns,保证每个 turn 的 root span 都带 coze_* tag。
1744
- for _t in list(turns) + list(history_turns):
1961
+ # 全量分组成 turns/steps
1962
+ turns = group_messages_into_turns(all_messages)
1963
+ if not turns:
1964
+ debug_log("No turns.")
1965
+ return
1966
+
1967
+ # coze-context 判定:整个会话出现过即可(首 turn 注入)。
1968
+ def _turn_has_ctx(turn):
1969
+ return bool(coze_context_tags(
1970
+ (turn.get("user_message", {}).get("message", {}) or {}).get("content")
1971
+ ))
1972
+ if not any(_turn_has_ctx(t) for t in turns):
1973
+ debug_log("No coze-context in any turn, skipping upload.")
1974
+ return
1975
+
1976
+ # 持久化 coze_tags 到 state,供 root span 注入(保证可按 message_id 查到)。
1977
+ if not state.get("coze_tags"):
1978
+ for _t in turns:
1745
1979
  _um = (_t.get("user_message", {}).get("message", {}) or {})
1746
1980
  _tags = {k: v for k, v in coze_context_tags(_um.get("content")).items() if isinstance(v, str) and v.strip()}
1747
1981
  if _tags:
1748
1982
  state["coze_tags"] = _tags
1749
1983
  break
1750
- coze_tags_override = state.get("coze_tags") or None
1751
-
1752
- # turn 边界控制:中途事件(PostToolUse)触发时,最后一个 turn 往往仍在进行中
1753
- # (后续还会追加 step)。若此刻就上报并推进其行号,同一逻辑 turn 会在下次触发时
1754
- # 因缺了起始 user 消息而被拆成新的 root span。故中途事件只上报“已完成”的 turn
1755
- # (= 除最后一个之外的所有 turn),把最后一个留到下次/收尾。终态事件(Stop)上报全部。
1756
- if is_terminal_event:
1757
- turns_to_send = turns
1758
- else:
1759
- turns_to_send = turns[:-1]
1760
- if not turns_to_send:
1761
- debug_log(f"event={hook_event}: no completed turn to send yet (turns={len(turns)}), defer")
1762
- return
1763
-
1764
- print(f"[CozeLoop] 开始上报: session={session_id}, event={hook_event}, turns={len(turns_to_send)}/{len(turns)}", file=sys.stderr)
1765
- uploaded = send_turns_to_cozeloop(turns_to_send, session_id, history_turns, coze_tags_override=coze_tags_override)
1766
- if uploaded is None:
1767
- debug_log("Send failed, state not advanced.")
1768
- return
1769
-
1770
- # 推进 last_processed_line:只推进到已上报 turn 覆盖的最后一行。中途事件保留了最后一个
1771
- # 未完成 turn,故推进到“倒数第二个 turn 的末行”,让未完成 turn 的所有行下次重新读取。
1772
- if is_terminal_event:
1773
- last_line_in_batch = max(msg.get("_line_number", 0) for msg in new_messages)
1774
- state["last_processed_line"] = last_line_in_batch + 1
1775
- else:
1776
- # turns_to_send 是 turns[:-1],下一个未发送 turn 的起始行即新的水位线。
1777
- next_turn_start = turns[-1].get("start_line", 0)
1778
- state["last_processed_line"] = next_turn_start
1779
- state["last_upload_ts"] = time.time()
1780
- save_state(state_file, state)
1781
- print(f"[CozeLoop] 上报完成 ✓ session={session_id}, turns={len(turns_to_send)}", file=sys.stderr)
1782
- debug_log(f"State updated. event={hook_event} last_processed_line={state['last_processed_line']}")
1783
-
1984
+ coze_tags_override = state.get("coze_tags") or None
1985
+
1986
+ print(f"[CozeLoop] 实时上报: session={session_id}, event={hook_event}", file=sys.stderr)
1987
+ ok = send_steps_realtime(turns, session_id, [], state,
1988
+ coze_tags_override=coze_tags_override,
1989
+ is_terminal=is_terminal_event)
1990
+ if ok is None:
1991
+ debug_log("Realtime send failed, state not advanced.")
1992
+ return
1993
+ save_state(state_file, state)
1994
+ print(f"[CozeLoop] 实时上报完成 ✓ session={session_id}, last_step={state.get('rt_last_global_step')}", file=sys.stderr)
1995
+ debug_log(f"State saved. rt_last_global_step={state.get('rt_last_global_step')}")
1784
1996
  debug_log("Hook finished.")
1785
1997
 
1786
1998
  if __name__ == "__main__":
@@ -50,9 +50,9 @@ _REFRESH_LOCK_STALE = 30
50
50
  _DEFAULT_WORKSPACE_ID = "7649231955045072915" # hardcoded spaceID fallback
51
51
  _OTEL_SUFFIX = "/v1/loop/opentelemetry"
52
52
 
53
- # 中途事件(PostToolUse)增量上报的最小间隔(秒)。密集工具调用下用它节流,避免每次工具调用都 flush。
54
- # 终态事件(Stop/SubagentStop)不受此限制。
55
- INCREMENTAL_UPLOAD_MIN_INTERVAL = float(os.environ.get("COZELOOP_INCREMENTAL_MIN_INTERVAL", "10"))
53
+ # 实时 tool span 上报的批量间隔(秒):距上次上报不足此值则本次不发,把已结束的 tool span
54
+ # 攒到下次一起发(最多每 5s 一批)。终态(Stop/SubagentStop)不受限,立即收尾。
55
+ REALTIME_BATCH_INTERVAL = float(os.environ.get("COZELOOP_REALTIME_BATCH_INTERVAL", "5"))
56
56
 
57
57
 
58
58
  # --- coze-context parsing -------------------------------------------------
@@ -787,6 +787,18 @@ def truncate_text(text: str, limit: int = 12000) -> str:
787
787
 
788
788
  # --- Message Grouping ---
789
789
 
790
+ def _usage_int(usage, key):
791
+ """从 token_usage dict 读 token 数,missing/None/非数字 一律按 0。
792
+
793
+ transcript 里 token_usage 字段可能【存在但值为 null】,dict.get(key, 0) 对显式 null
794
+ 返回 None 而非 0,后续 None > 0 / None + int 会抛 TypeError 中断上报。这里统一兜底。
795
+ """
796
+ if not isinstance(usage, dict):
797
+ return 0
798
+ v = usage.get(key)
799
+ return v if isinstance(v, int) else 0
800
+
801
+
790
802
  def _parse_ts(obj):
791
803
  """从 codex entry/payload 的 timestamp 解析 datetime(带时区)。失败返回 None。
792
804
 
@@ -1299,8 +1311,8 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1299
1311
 
1300
1312
  # Set token usage
1301
1313
  token_usage = turn.get("token_usage", {})
1302
- input_tokens = token_usage.get("input_tokens", 0)
1303
- output_tokens = token_usage.get("output_tokens", 0)
1314
+ input_tokens = _usage_int(token_usage, "input_tokens")
1315
+ output_tokens = _usage_int(token_usage, "output_tokens")
1304
1316
  if input_tokens > 0:
1305
1317
  model_span.set_input_tokens(input_tokens)
1306
1318
  if output_tokens > 0:
@@ -1446,10 +1458,12 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1446
1458
  sa_model_span.set_output(ModelOutput(choices=sa_choices))
1447
1459
 
1448
1460
  sa_token = sa_turn.get("token_usage", {})
1449
- if sa_token.get("input_tokens", 0) > 0:
1450
- sa_model_span.set_input_tokens(sa_token["input_tokens"])
1451
- if sa_token.get("output_tokens", 0) > 0:
1452
- sa_model_span.set_output_tokens(sa_token["output_tokens"])
1461
+ sa_in = _usage_int(sa_token, "input_tokens")
1462
+ sa_out = _usage_int(sa_token, "output_tokens")
1463
+ if sa_in > 0:
1464
+ sa_model_span.set_input_tokens(sa_in)
1465
+ if sa_out > 0:
1466
+ sa_model_span.set_output_tokens(sa_out)
1453
1467
 
1454
1468
  # Subagent tool spans
1455
1469
  for sa_tc in sa_turn.get("tool_calls", []):
@@ -1552,6 +1566,165 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1552
1566
  return ctx
1553
1567
 
1554
1568
 
1569
+ # --- 实时 step 级上报(方案 B:每个 tool_call 结束即可见)---------------------
1570
+ #
1571
+ # 与 claude-code 同架构:一个会话 = 一条稳定 trace。首次建 root(codex_request)立即 finish 并存
1572
+ # to_header() 到 state["rt_root_header"];之后每次触发把【新完成的 tool_call】(有 result 的)作为
1573
+ # tool span 挂到固定 root、当场 flush。codex 的实时单元是 tool_call(turn 内平铺,按 call_id 关联
1574
+ # result)。全局水位线 state["rt_last_tool"] 跟踪已发的 tool_call 数。
1575
+
1576
+ def send_steps_realtime(turns, session_id, state, model_name="codex", coze_tags_override=None, is_terminal=False):
1577
+ import cozeloop
1578
+ from cozeloop.spec.tracespec import Runtime
1579
+ token = get_fresh_token()
1580
+ if token:
1581
+ os.environ["COZELOOP_API_TOKEN"] = token
1582
+ workspace_id = os.environ.get("COZELOOP_WORKSPACE_ID", "") or _DEFAULT_WORKSPACE_ID
1583
+ client_kwargs = {"ultra_large_report": True, "upload_timeout": 120}
1584
+ if workspace_id:
1585
+ client_kwargs["workspace_id"] = workspace_id
1586
+ if token:
1587
+ client_kwargs["api_token"] = token
1588
+ api_base_url = get_api_base_url()
1589
+ if api_base_url:
1590
+ client_kwargs["api_base_url"] = api_base_url
1591
+ try:
1592
+ client = cozeloop.new_client(**client_kwargs)
1593
+ except Exception as e:
1594
+ hook_log(f"[rt] new_client failed: {e}")
1595
+ return None
1596
+
1597
+ try:
1598
+ # coze_tags
1599
+ coze_tags = {}
1600
+ for turn in turns:
1601
+ t = {f"coze_{k}": v for k, v in turn_coze_context(turn).items()}
1602
+ if t:
1603
+ coze_tags = t
1604
+ break
1605
+ if not coze_tags and coze_tags_override:
1606
+ coze_tags = dict(coze_tags_override)
1607
+ coze_tags = {k: v for k, v in coze_tags.items() if isinstance(v, str) and v.strip()}
1608
+
1609
+ # 取/建稳定 root
1610
+ root_header = state.get("rt_root_header")
1611
+ root_ctx = None
1612
+ if root_header:
1613
+ try:
1614
+ root_ctx = client.get_span_from_header(root_header)
1615
+ except Exception:
1616
+ root_ctx = None
1617
+ if root_ctx is None or not getattr(root_ctx, "trace_id", ""):
1618
+ first_user_text = ""
1619
+ for turn in turns:
1620
+ if turn.get("user_message_text"):
1621
+ first_user_text = turn["user_message_text"]
1622
+ break
1623
+ root_start_dt = _parse_ts_value(turns[0].get("tool_calls", [{}])[0].get("_ts")) if turns and turns[0].get("tool_calls") else None
1624
+ root_span = client.start_span(name="codex_request", span_type="main",
1625
+ start_time=root_start_dt, start_new_trace=True)
1626
+ root_span.set_runtime(Runtime(library="codex-cli"))
1627
+ rtags = {"thread_id": session_id, "source": "codex_cli", "realtime": True}
1628
+ rtags.update(coze_tags)
1629
+ root_span.set_tags(rtags)
1630
+ rbag = {"thread_id": session_id}
1631
+ rbag.update(coze_tags)
1632
+ root_span.set_baggage(rbag)
1633
+ if first_user_text:
1634
+ root_span.set_input(truncate_text(first_user_text))
1635
+ state["rt_root_header"] = root_span.to_header()
1636
+ root_ctx = client.get_span_from_header(state["rt_root_header"])
1637
+ root_span.finish() # 立即 finish 落库(后端按 root_span 查),子 span 仍可挂同 trace
1638
+ client.flush()
1639
+ hook_log(f"[rt] root created trace_id={getattr(root_ctx,'trace_id','?')}")
1640
+
1641
+ # 线性展开所有 tool_call(带 result 的才算完成),按全局序增量发
1642
+ flat = []
1643
+ for ti, turn in enumerate(turns):
1644
+ results = {r.get("call_id"): r for r in turn.get("tool_results", [])}
1645
+ for tc in turn.get("tool_calls", []):
1646
+ cid = tc.get("call_id")
1647
+ flat.append({"tc": tc, "result": results.get(cid), "turn_index": ti})
1648
+ # 只发已完成(有 result)的,且全局最后一个若无 result 则留到下次
1649
+ completed = []
1650
+ for idx, item in enumerate(flat):
1651
+ is_last = (idx == len(flat) - 1)
1652
+ if item["result"] is not None or (not is_last):
1653
+ completed.append(item)
1654
+ last_tool = state.get("rt_last_tool", 0)
1655
+ new_items = completed[last_tool:]
1656
+ # 批量节流:非终态时距上次上报<5s 则本次不发,攒到下次一起发。终态不节流。
1657
+ if new_items and not is_terminal:
1658
+ since = time.time() - state.get("rt_last_upload_ts", 0)
1659
+ if since < REALTIME_BATCH_INTERVAL:
1660
+ hook_log(f"[rt] batch throttle {since:.1f}s<{REALTIME_BATCH_INTERVAL}s defer {len(new_items)} tool(s)")
1661
+ new_items = []
1662
+ sent = 0
1663
+ for item in new_items:
1664
+ tc = item["tc"]; result = item["result"]
1665
+ gidx = last_tool + sent
1666
+ tname = tc.get("name", "unknown")
1667
+ start_dt = _parse_ts_value(tc.get("_ts"))
1668
+ end_dt = _parse_ts_value(result.get("_ts")) if result else None
1669
+ # 每个 tool_call 用一个 model_call_+tool 子树(与 claude-code 视觉一致)
1670
+ mspan = client.start_span(name=f"model_call_{gidx}", span_type="model",
1671
+ start_time=start_dt, child_of=root_ctx)
1672
+ mspan.set_runtime(Runtime(library="codex-cli"))
1673
+ try: mspan.set_model_name(model_name)
1674
+ except Exception: pass
1675
+ if start_dt and end_dt:
1676
+ _set_finish_time_safe(mspan, end_dt)
1677
+ mspan.set_input(truncate_text(json.dumps(tc.get("input", {}), ensure_ascii=False)))
1678
+ mspan_ctx = client.get_span_from_header(mspan.to_header())
1679
+ mspan.finish()
1680
+
1681
+ tspan = client.start_span(name=f"tool_{tname}", span_type="tool",
1682
+ start_time=start_dt, child_of=mspan_ctx)
1683
+ tspan.set_runtime(Runtime(library="codex-cli"))
1684
+ if end_dt:
1685
+ _set_finish_time_safe(tspan, end_dt)
1686
+ tspan.set_tags({"tool_name": tname, "tool_call_id": tc.get("call_id"), "step_index": gidx})
1687
+ tspan.set_input(truncate_text(json.dumps(tc.get("input", {}), ensure_ascii=False)))
1688
+ if result is not None:
1689
+ tspan.set_output(truncate_text(str(result.get("output", ""))))
1690
+ tspan.finish()
1691
+ sent += 1
1692
+ client.flush() # 每个 tool_call 立即 flush → 结束即可见
1693
+
1694
+ state["rt_last_tool"] = last_tool + sent
1695
+ if sent > 0:
1696
+ state["rt_last_upload_ts"] = time.time()
1697
+
1698
+ if is_terminal:
1699
+ # 收尾:补最终 assistant 输出
1700
+ last_out = ""
1701
+ for turn in reversed(turns):
1702
+ for am in reversed(turn.get("assistant_messages", [])):
1703
+ txt = extract_assistant_text(am)
1704
+ if txt:
1705
+ last_out = txt; break
1706
+ if last_out:
1707
+ break
1708
+ if last_out:
1709
+ fin = client.start_span(name="final_response", span_type="main", child_of=root_ctx)
1710
+ fin.set_runtime(Runtime(library="codex-cli"))
1711
+ fin.set_output(truncate_text(last_out))
1712
+ fin.finish()
1713
+ client.flush()
1714
+ hook_log(f"[rt] finalized total_tools={state['rt_last_tool']}")
1715
+
1716
+ hook_log(f"[rt] sent {sent} tool span(s), last_tool={state['rt_last_tool']}, terminal={is_terminal}")
1717
+ return True
1718
+ except Exception as e:
1719
+ hook_log(f"[rt] error: {e}")
1720
+ return None
1721
+ finally:
1722
+ try: client.flush()
1723
+ except Exception: pass
1724
+ try: client.close()
1725
+ except Exception: pass
1726
+
1727
+
1555
1728
  # --- Main Execution ---
1556
1729
 
1557
1730
  def main():
@@ -1615,19 +1788,19 @@ def main():
1615
1788
  state_file = get_state_file_path(transcript_path)
1616
1789
  state = load_state(state_file)
1617
1790
 
1618
- # Read new entries
1619
- entries = read_rollout_messages(transcript_path, state["last_processed_line"])
1791
+ # 方案 B(实时):读【全量】entries 重建完整 turn/tool 序列。实时路径用全局 tool 水位线
1792
+ # state["rt_last_tool"] 增量,每次触发把新完成的 tool_call 发出,结束即可见,不节流。
1793
+ entries = read_rollout_messages(transcript_path, 0)
1620
1794
 
1621
1795
  if not entries:
1622
- hook_log(f"skip no new entries transcript={transcript_path}")
1623
- debug_log("No new entries to process")
1796
+ hook_log(f"skip no entries transcript={transcript_path}")
1797
+ debug_log("No entries to process")
1624
1798
  return
1625
1799
 
1626
- hook_log(f"read entries={len(entries)} from_line={state['last_processed_line']} transcript={transcript_path}")
1627
- debug_log(f"Read {len(entries)} new entries from line {state['last_processed_line']}")
1800
+ hook_log(f"read entries={len(entries)} (full) transcript={transcript_path}")
1628
1801
 
1629
1802
  # Parse session identity
1630
- all_entries_for_meta = read_rollout_messages(transcript_path, 0)
1803
+ all_entries_for_meta = entries
1631
1804
  session_info = parse_session_meta(all_entries_for_meta)
1632
1805
 
1633
1806
  session_id = session_info["session_id"] or hook_input.get("session_id", "")
@@ -1679,25 +1852,14 @@ def main():
1679
1852
 
1680
1853
  # Send turns to CozeLoop — only if at least one turn carries coze-context.
1681
1854
  if turns:
1682
- # coze-context 只出现在首个 turn 的 user 消息里。增量上报推进后,后续批次的 turns
1683
- # 已不含首 turn,直接判断会误判为"无 coze-context"而跳过。故用 state 持久化"本会话
1684
- # 曾见过 coze-context"标记:一旦见过,后续增量批次都视为应上报(与 claude-code 用
1685
- # history_turns 判断等效)。
1686
- seen_ctx = bool(state.get("seen_coze_context"))
1687
- has_coze_ctx = seen_ctx or any(
1688
- turn_coze_context(t)
1689
- for t in turns
1690
- )
1855
+ # coze-context 只出现在首个 turn。会话出现过即视为应上报。
1856
+ has_coze_ctx = any(turn_coze_context(t) for t in turns)
1691
1857
  if not has_coze_ctx:
1692
1858
  hook_log(f"skip no coze-context turns={len(turns)} session_id={session_id}")
1693
1859
  debug_log("No coze-context found in any turn, skipping upload.")
1694
1860
  return
1695
- if not seen_ctx:
1696
- state["seen_coze_context"] = True
1697
1861
 
1698
- # 持久化 coze_tags 到 state:<coze-context> 只在首 turn,后续增量批次不含它,其 root span
1699
- # 会缺 coze_message_id 无法按 message_id 查到。首次解析到就存 state,后续批次作为 override
1700
- # 传给 send_turns,保证每个 turn 的 root span 都带 coze_* tag。
1862
+ # 持久化 coze_tags 到 state,供 root span 注入。
1701
1863
  if not state.get("coze_tags"):
1702
1864
  for _t in turns:
1703
1865
  _tags = {f"coze_{k}": v for k, v in turn_coze_context(_t).items() if isinstance(v, str) and v.strip()}
@@ -1706,50 +1868,17 @@ def main():
1706
1868
  break
1707
1869
  coze_tags_override = state.get("coze_tags") or None
1708
1870
 
1709
- # 节流:PostToolUse 在密集工具调用下高频触发。距上次上报不足间隔则跳过本次增量上报。
1710
- # 终态事件(Stop/SubagentStop)永不被节流,保证 turn 结束时一定收尾。
1711
- if not is_terminal_event:
1712
- now_ts = time.time()
1713
- last_upload_ts = state.get("last_upload_ts", 0)
1714
- if now_ts - last_upload_ts < INCREMENTAL_UPLOAD_MIN_INTERVAL:
1715
- hook_log(f"throttled event={hook_event} since_last={now_ts - last_upload_ts:.1f}s session_id={session_id}")
1716
- debug_log(f"throttled: event={hook_event}, skip incremental upload")
1717
- return
1718
-
1719
- # turn 边界控制:中途事件(PostToolUse)触发时,最后一个 turn 往往仍在进行中
1720
- # (task_complete 尚未到达,后续还会追加内容)。只上报已完成 turn(turns[:-1]),
1721
- # 把最后一个留到下次/收尾,避免同一 turn 被拆成多个 trace。终态事件上报全部。
1722
- if is_terminal_event:
1723
- turns_to_send = turns
1724
- else:
1725
- turns_to_send = turns[:-1]
1726
- if not turns_to_send:
1727
- hook_log(f"defer no completed turn event={hook_event} turns={len(turns)} session_id={session_id}")
1728
- debug_log(f"event={hook_event}: no completed turn to send yet, defer")
1871
+ # 方案 B 实时:把新完成的 tool_call 作为 span 挂稳定 root,结束即可见。不节流。
1872
+ ok = send_steps_realtime(turns, session_id, state, model_name=model_name,
1873
+ coze_tags_override=coze_tags_override,
1874
+ is_terminal=is_terminal_event)
1875
+ if ok is None:
1876
+ hook_log(f"realtime send failed session_id={session_id}")
1877
+ debug_log("Realtime send failed, state not advanced")
1729
1878
  return
1730
-
1731
- history_context = state.get("conversation_history", [])
1732
- updated_history = send_turns_to_cozeloop(
1733
- turns_to_send, session_id, model_name,
1734
- history_context=history_context,
1735
- coze_tags_override=coze_tags_override,
1736
- )
1737
- if updated_history is not None:
1738
- # 推进 last_processed_line:终态推进到所有 entry 末行;中途保留最后一个未完成 turn,
1739
- # 推进到该 turn 的起始行,让它的所有 entry 下次重新读取。
1740
- if is_terminal_event:
1741
- last_line = max(e.get("_line_number", 0) for e in entries) + 1
1742
- else:
1743
- last_line = turns[-1].get("start_line", max(e.get("_line_number", 0) for e in entries) + 1)
1744
- state["last_processed_line"] = last_line
1745
- state["conversation_history"] = updated_history
1746
- state["last_upload_ts"] = time.time()
1747
- save_state(state_file, state)
1748
- hook_log(f"state advanced event={hook_event} last_line={last_line} sent={len(turns_to_send)}/{len(turns)} session_id={session_id}")
1749
- debug_log(f"State updated, last processed line: {last_line}")
1750
- else:
1751
- hook_log(f"send failed state not advanced session_id={session_id}")
1752
- debug_log("Send failed, state not advanced")
1879
+ save_state(state_file, state)
1880
+ hook_log(f"rt state saved event={hook_event} last_tool={state.get('rt_last_tool')} session_id={session_id}")
1881
+ debug_log(f"State saved. rt_last_tool={state.get('rt_last_tool')}")
1753
1882
  else:
1754
1883
  hook_log(f"skip no turns session_id={session_id}")
1755
1884
  debug_log("No turns to send")
@@ -633,8 +633,9 @@ export class CozeloopExporter {
633
633
  this.provider = new BasicTracerProvider({ resource });
634
634
  this.provider.addSpanProcessor(new BatchSpanProcessor(exporter, {
635
635
  maxQueueSize: 100,
636
- maxExportBatchSize: this.config.batchSize || 10,
637
- scheduledDelayMillis: this.config.batchInterval || 5000,
636
+ // 实时上报:每个 span 结束即发。maxExportBatchSize=1 + 短延迟,不攒批。
637
+ maxExportBatchSize: this.config.batchSize || 1,
638
+ scheduledDelayMillis: this.config.batchInterval || 500,
638
639
  }));
639
640
  // Do NOT call this.provider.register() — it sets the global TracerProvider
640
641
  // singleton, so if the plugin is activated more than once (e.g. gateway +
@@ -534,8 +534,10 @@ const cozeloopTracePlugin = {
534
534
  workspaceId,
535
535
  serviceName: pluginConfig.serviceName || "openclaw-agent",
536
536
  debug: pluginConfig.debug || false,
537
- batchSize: pluginConfig.batchSize || 10,
538
- batchInterval: pluginConfig.batchInterval || 5000,
537
+ // 实时上报:每个 span 结束即发,不攒批。batchSize=1 + 短延迟让 span 秒级可见。
538
+ // 如需降上报量可在 plugin config 显式调大 batchSize/batchInterval。
539
+ batchSize: pluginConfig.batchSize || 1,
540
+ batchInterval: pluginConfig.batchInterval || 500,
539
541
  enabledHooks: pluginConfig.enabledHooks,
540
542
  disableLocalCredentials: pluginConfig.disableLocalCredentials === true,
541
543
  logFile: pluginConfig.logFile,
@@ -35,13 +35,13 @@
35
35
  },
36
36
  "batchSize": {
37
37
  "type": "number",
38
- "default": 10,
39
- "description": "Number of spans to buffer before sending"
38
+ "default": 1,
39
+ "description": "Spans per export batch. Default 1 = 每个 span 结束即发(实时)。调大可降上报量但牺牲实时性。"
40
40
  },
41
41
  "batchInterval": {
42
42
  "type": "number",
43
- "default": 5000,
44
- "description": "Maximum time (ms) to wait before sending buffered spans"
43
+ "default": 500,
44
+ "description": "Max ms to wait before flushing. Default 500ms 配合 batchSize=1 实现秒级可见。"
45
45
  },
46
46
  "enabledHooks": {
47
47
  "type": "array",