coze_lab 0.1.24 → 0.1.26

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/README.md CHANGED
@@ -74,6 +74,8 @@ In cloud mode, trace verification and hook uploads prefer
74
74
  `COZELOOP_API_TOKEN` and fall back to `COZE_API_TOKEN`. The selfcheck result is
75
75
  authoritative: if the token does not have trace ingest permission, onboard still
76
76
  writes the hook configuration but reports `verify=fail` with `token_source`.
77
+ For Python SDK uploads, `OTEL_ENDPOINT` is not used as the SDK base URL; set
78
+ `COZELOOP_API_BASE_URL` only when the SDK ingest endpoint should be overridden.
77
79
 
78
80
  **At hook execution time** (Claude Code / Codex), the Python hook script automatically:
79
81
  1. Reads `~/.cozeloop/credentials.json`
package/index.js CHANGED
@@ -4706,6 +4706,10 @@ function normalizeTraceAgentIds(ids) {
4706
4706
 
4707
4707
  function getCloudCozeloopApiBaseUrl() {
4708
4708
  const raw = process.env.COZELOOP_API_BASE_URL || process.env.OTEL_ENDPOINT || '';
4709
+ return normalizeCozeloopApiBaseUrl(raw);
4710
+ }
4711
+
4712
+ function normalizeCozeloopApiBaseUrl(raw) {
4709
4713
  const base = raw.trim().replace(/\/+$/, '');
4710
4714
  if (!base) return '';
4711
4715
  if (base.endsWith('/v1/loop/opentelemetry/v1/traces')) {
@@ -4730,6 +4734,14 @@ function getCozeloopApiBaseUrl(cloud) {
4730
4734
  return cloud ? (getCloudCozeloopApiBaseUrl() || COZE_API) : COZE_API;
4731
4735
  }
4732
4736
 
4737
+ function getCloudCozeloopSdkApiBaseUrl() {
4738
+ return normalizeCozeloopApiBaseUrl(process.env.COZELOOP_API_BASE_URL || '');
4739
+ }
4740
+
4741
+ function getCozeloopSdkApiBaseUrl(cloud) {
4742
+ return cloud ? (getCloudCozeloopSdkApiBaseUrl() || COZE_API) : COZE_API;
4743
+ }
4744
+
4733
4745
  function getOtelEndpointBase(cloud) {
4734
4746
  return `${getCozeloopApiBaseUrl(cloud).replace(/\/+$/, '')}/v1/loop/opentelemetry`;
4735
4747
  }
@@ -4935,11 +4947,13 @@ function runCommand(input) {
4935
4947
 
4936
4948
  async function verifyTraceReportViaSdk(token, workspaceId, pairCode, pythonCmd, tokenSource) {
4937
4949
  const pair = pairCode || crypto.randomBytes(6).toString('hex');
4938
- const apiBase = getCozeloopApiBaseUrl(true);
4950
+ const apiBase = getCozeloopSdkApiBaseUrl(true);
4939
4951
  const script = `
4940
4952
  import json
4941
4953
  import os
4942
4954
  import sys
4955
+ import urllib.error
4956
+ import urllib.request
4943
4957
 
4944
4958
  events = []
4945
4959
 
@@ -4947,6 +4961,51 @@ def finish_event(info):
4947
4961
  if getattr(info, "is_event_fail", False):
4948
4962
  events.append(getattr(info, "detail_msg", "") or "trace export failed")
4949
4963
 
4964
+ def extract_logid(text):
4965
+ if not text:
4966
+ return ""
4967
+ marker = "logid="
4968
+ idx = text.find(marker)
4969
+ if idx >= 0:
4970
+ out = []
4971
+ for ch in text[idx + len(marker):]:
4972
+ if ch.isalnum():
4973
+ out.append(ch)
4974
+ else:
4975
+ break
4976
+ return "".join(out)
4977
+ for marker in ('"logid":"', '"log_id":"', '"Logid":"'):
4978
+ idx = text.find(marker)
4979
+ if idx >= 0:
4980
+ rest = text[idx + len(marker):]
4981
+ return rest.split('"', 1)[0]
4982
+ return ""
4983
+
4984
+ def http_diag():
4985
+ base = os.environ.get("COZELOOP_API_BASE_URL", "").strip().rstrip("/") or "https://api.coze.cn"
4986
+ url = base + "/v1/loop/traces/ingest"
4987
+ body = json.dumps({"spans": []}).encode()
4988
+ req = urllib.request.Request(
4989
+ url,
4990
+ data=body,
4991
+ headers={
4992
+ "Content-Type": "application/json",
4993
+ "Authorization": "Bearer " + os.environ.get("COZELOOP_API_TOKEN", ""),
4994
+ "x-tt-env": os.environ.get("x_tt_env", ""),
4995
+ "x-use-ppe": os.environ.get("x_use_ppe", ""),
4996
+ },
4997
+ method="POST",
4998
+ )
4999
+ try:
5000
+ with urllib.request.urlopen(req, timeout=10) as resp:
5001
+ text = resp.read().decode("utf-8", "replace")
5002
+ return {"url": url, "status": resp.status, "logid": resp.headers.get("x-tt-logid", "") or extract_logid(text), "body": text[:300]}
5003
+ except urllib.error.HTTPError as e:
5004
+ text = e.read().decode("utf-8", "replace")
5005
+ return {"url": url, "status": e.code, "logid": e.headers.get("x-tt-logid", "") or extract_logid(text), "body": text[:300]}
5006
+ except Exception as e:
5007
+ return {"url": url, "status": 0, "logid": "", "body": type(e).__name__ + ": " + str(e)}
5008
+
4950
5009
  try:
4951
5010
  import cozeloop
4952
5011
  kwargs = {
@@ -4966,11 +5025,15 @@ try:
4966
5025
  client.flush()
4967
5026
  client.close()
4968
5027
  if events:
4969
- print(json.dumps({"success": False, "body": "\\n".join(events), "api_base_url": os.environ.get("COZELOOP_API_BASE_URL", ""), "token_source": os.environ.get("COZELOOP_TOKEN_SOURCE", "")}, ensure_ascii=False))
5028
+ diag = http_diag()
5029
+ body = "\\n".join(events) + "\\nhttp_diag=" + json.dumps(diag, ensure_ascii=False)
5030
+ print(json.dumps({"success": False, "body": body, "logid": diag.get("logid", ""), "api_base_url": os.environ.get("COZELOOP_API_BASE_URL", ""), "token_source": os.environ.get("COZELOOP_TOKEN_SOURCE", "")}, ensure_ascii=False))
4970
5031
  sys.exit(1)
4971
- print(json.dumps({"success": True, "body": "", "api_base_url": os.environ.get("COZELOOP_API_BASE_URL", ""), "token_source": os.environ.get("COZELOOP_TOKEN_SOURCE", "")}, ensure_ascii=False))
5032
+ print(json.dumps({"success": True, "body": "", "logid": "", "api_base_url": os.environ.get("COZELOOP_API_BASE_URL", ""), "token_source": os.environ.get("COZELOOP_TOKEN_SOURCE", "")}, ensure_ascii=False))
4972
5033
  except Exception as e:
4973
- print(json.dumps({"success": False, "body": str(e), "api_base_url": os.environ.get("COZELOOP_API_BASE_URL", ""), "token_source": os.environ.get("COZELOOP_TOKEN_SOURCE", "")}, ensure_ascii=False))
5034
+ diag = http_diag()
5035
+ body = str(e) + "\\nhttp_diag=" + json.dumps(diag, ensure_ascii=False)
5036
+ print(json.dumps({"success": False, "body": body, "logid": diag.get("logid", ""), "api_base_url": os.environ.get("COZELOOP_API_BASE_URL", ""), "token_source": os.environ.get("COZELOOP_TOKEN_SOURCE", "")}, ensure_ascii=False))
4974
5037
  sys.exit(1)
4975
5038
  `;
4976
5039
  const env = {
@@ -5005,7 +5068,7 @@ except Exception as e:
5005
5068
  const snippet = String(body || '').slice(0, 300);
5006
5069
  if (snippet) console.log(snippet);
5007
5070
  }
5008
- return { success, status: result.code || 0, body, traceId: '', pairCode: pair, apiBaseUrl: apiBase, tokenSource };
5071
+ return { success, status: result.code || 0, body, traceId: '', pairCode: pair, apiBaseUrl: apiBase, tokenSource, logid: parsed?.logid || '' };
5009
5072
  }
5010
5073
 
5011
5074
  // 真实发一条最小 OTLP trace 到 CozeLoop,验证上报链路是否打通。
@@ -5538,7 +5601,7 @@ async function main() {
5538
5601
  } else if (CLOUD_MODE) {
5539
5602
  // 云端:注入已成功,验证失败不阻断(放行),记录结果供后台弹 warning。
5540
5603
  cloudResult.verify = 'fail';
5541
- cloudResult.logid = extractLogid(verifyResult.body) || cloudResult.logid;
5604
+ cloudResult.logid = verifyResult.logid || extractLogid(verifyResult.body) || cloudResult.logid;
5542
5605
  cloudResult.message = `trace 上报自检失败 HTTP ${verifyResult.status}: ${(verifyResult.body || '').slice(0, 200)}`;
5543
5606
  warn('trace 上报自检失败,但 hook 配置已写入(云端放行)。');
5544
5607
  console.log('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coze_lab",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "Configure local AI agents (Claude Code, Codex, OpenClaw) to report traces to CozeLoop",
5
5
  "keywords": [
6
6
  "cozeloop",
@@ -264,7 +264,7 @@ def _normalize_api_base_url(url: str) -> str:
264
264
 
265
265
  def get_api_base_url() -> str:
266
266
  return _normalize_api_base_url(
267
- os.environ.get("COZELOOP_API_BASE_URL", "") or os.environ.get("OTEL_ENDPOINT", "")
267
+ os.environ.get("COZELOOP_API_BASE_URL", "")
268
268
  )
269
269
 
270
270
  def get_fresh_token() -> Optional[str]:
@@ -225,7 +225,7 @@ def _normalize_api_base_url(url: str) -> str:
225
225
 
226
226
  def get_api_base_url() -> str:
227
227
  return _normalize_api_base_url(
228
- os.environ.get("COZELOOP_API_BASE_URL", "") or os.environ.get("OTEL_ENDPOINT", "")
228
+ os.environ.get("COZELOOP_API_BASE_URL", "")
229
229
  )
230
230
 
231
231
 
@@ -640,6 +640,54 @@ def _ts_ms(dt):
640
640
  return int(dt.timestamp() * 1000) if dt is not None else None
641
641
 
642
642
 
643
+ def _max_dt(*values):
644
+ result = None
645
+ for value in values:
646
+ if value is not None and (result is None or value > result):
647
+ result = value
648
+ return result
649
+
650
+
651
+ def _parse_ts_value(value):
652
+ return _parse_ts({"_ts": value}) if value else None
653
+
654
+
655
+ def _turn_timestamps(turn):
656
+ values = []
657
+ for item in [turn.get("user_message"), *turn.get("assistant_messages", [])]:
658
+ dt = _parse_ts(item)
659
+ if dt:
660
+ values.append(dt)
661
+ for item in [*turn.get("tool_calls", []), *turn.get("tool_results", [])]:
662
+ dt = _parse_ts(item)
663
+ if dt:
664
+ values.append(dt)
665
+ for sc in turn.get("subagent_calls", []):
666
+ for key in ("_start_ts", "_end_ts"):
667
+ dt = _parse_ts_value(sc.get(key))
668
+ if dt:
669
+ values.append(dt)
670
+ return values
671
+
672
+
673
+ def _turn_bounds(turn):
674
+ values = _turn_timestamps(turn)
675
+ if not values:
676
+ return None, None
677
+ return min(values), max(values)
678
+
679
+
680
+ def _assistant_bounds(turn):
681
+ values = []
682
+ for item in turn.get("assistant_messages", []):
683
+ dt = _parse_ts(item)
684
+ if dt:
685
+ values.append(dt)
686
+ if not values:
687
+ return None, None
688
+ return min(values), max(values)
689
+
690
+
643
691
  def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
644
692
  """Group raw JSONL entries into conversation turns.
645
693
 
@@ -749,6 +797,8 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
749
797
  "message": args.get("message", ""),
750
798
  "model": args.get("model"),
751
799
  "result": None,
800
+ "_start_ts": payload.get("_ts"),
801
+ "_end_ts": None,
752
802
  }
753
803
  current_turn["subagent_calls"].append(subagent_call)
754
804
  pending_calls[call_id] = {"kind": "spawn", "subagent_call": subagent_call}
@@ -756,14 +806,16 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
756
806
  pending_calls[call_id] = {
757
807
  "kind": "wait",
758
808
  "ids": args.get("ids", []),
809
+ "_start_ts": payload.get("_ts"),
759
810
  }
760
811
  else:
761
812
  current_turn["tool_calls"].append({
762
813
  "call_id": call_id,
763
814
  "name": name,
764
815
  "input": args,
816
+ "_ts": payload.get("_ts"),
765
817
  })
766
- pending_calls[call_id] = {"kind": "tool"}
818
+ pending_calls[call_id] = {"kind": "tool", "_start_ts": payload.get("_ts")}
767
819
 
768
820
  elif item_type == "function_call_output":
769
821
  if current_turn is None:
@@ -783,6 +835,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
783
835
  subagent_call["nickname"] = out.get("nickname")
784
836
  except (json.JSONDecodeError, TypeError, AttributeError):
785
837
  pass
838
+ subagent_call["_end_ts"] = payload.get("_ts")
786
839
 
787
840
  elif kind == "wait":
788
841
  try:
@@ -795,6 +848,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
795
848
  for sc in current_turn["subagent_calls"]:
796
849
  if sc.get("agent_id") == agent_id and sc.get("result") is None:
797
850
  sc["result"] = result_text
851
+ sc["_end_ts"] = payload.get("_ts")
798
852
  break
799
853
  except (json.JSONDecodeError, TypeError, AttributeError):
800
854
  pass
@@ -803,6 +857,7 @@ def group_messages_into_turns(entries: List[Dict[str, Any]]) -> List[Dict[str, A
803
857
  current_turn["tool_results"].append({
804
858
  "call_id": call_id,
805
859
  "output": raw_output,
860
+ "_ts": payload.get("_ts"),
806
861
  })
807
862
 
808
863
  if current_turn is not None:
@@ -888,13 +943,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
888
943
  # 整体时间范围:所有 user/assistant payload 的真实 timestamp 极值
889
944
  _all_ts = []
890
945
  for _t in turns:
891
- _u = _parse_ts(_t.get("user_message"))
892
- if _u:
893
- _all_ts.append(_u)
894
- for _a in _t.get("assistant_messages", []):
895
- _ad = _parse_ts(_a)
896
- if _ad:
897
- _all_ts.append(_ad)
946
+ _all_ts.extend(_turn_timestamps(_t))
898
947
  root_start_dt = min(_all_ts) if _all_ts else None
899
948
  root_end_dt = max(_all_ts) if _all_ts else None
900
949
 
@@ -906,6 +955,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
906
955
  "source": "codex_cli",
907
956
  }
908
957
  if root_start_dt is not None and root_end_dt is not None:
958
+ root_span.set_finish_time(root_end_dt)
909
959
  root_tags["real_start_ms"] = _ts_ms(root_start_dt)
910
960
  root_tags["real_end_ms"] = _ts_ms(root_end_dt)
911
961
  root_tags["latency_ms"] = _ts_ms(root_end_dt) - _ts_ms(root_start_dt)
@@ -947,14 +997,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
947
997
  for i, turn in enumerate(turns):
948
998
  try:
949
999
  # turn 真实时间:start=user payload 时间;end=最后一条 assistant payload 时间
950
- turn_start_dt = _parse_ts(turn.get("user_message"))
951
- turn_end_dt = None
952
- for _a in turn.get("assistant_messages", []):
953
- _ad = _parse_ts(_a)
954
- if _ad:
955
- turn_end_dt = _ad
956
- if turn_start_dt is None:
957
- turn_start_dt = turn_end_dt
1000
+ turn_start_dt, turn_end_dt = _turn_bounds(turn)
958
1001
 
959
1002
  with client.start_span(name=f"turn_{i}", span_type="main", start_time=turn_start_dt) as turn_span:
960
1003
  turn_span.set_runtime(Runtime(library="codex-cli"))
@@ -965,6 +1008,7 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
965
1008
  "source": "codex_cli",
966
1009
  }
967
1010
  if turn_start_dt is not None and turn_end_dt is not None:
1011
+ turn_span.set_finish_time(turn_end_dt)
968
1012
  _turn_tags["real_start_ms"] = _ts_ms(turn_start_dt)
969
1013
  _turn_tags["real_end_ms"] = _ts_ms(turn_end_dt)
970
1014
  _turn_tags["latency_ms"] = _ts_ms(turn_end_dt) - _ts_ms(turn_start_dt)
@@ -973,21 +1017,20 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
973
1017
  # --- Model span for assistant response ---
974
1018
  if turn.get("assistant_messages"):
975
1019
  # model span start:第一条 assistant payload 时间,回退到 turn 起点
976
- _model_start_dt = None
977
- for _a in turn.get("assistant_messages", []):
978
- _model_start_dt = _parse_ts(_a)
979
- if _model_start_dt:
980
- break
1020
+ _model_start_dt, _model_end_dt = _assistant_bounds(turn)
981
1021
  if _model_start_dt is None:
982
1022
  _model_start_dt = turn_start_dt
1023
+ if _model_end_dt is None:
1024
+ _model_end_dt = turn_end_dt
983
1025
  with client.start_span(name="assistant_response", span_type="model", start_time=_model_start_dt) as model_span:
984
1026
  model_span.set_runtime(Runtime(library="codex-cli"))
985
1027
  model_span.set_model_name(model_name)
986
- if _model_start_dt is not None and turn_end_dt is not None:
1028
+ if _model_start_dt is not None and _model_end_dt is not None:
1029
+ model_span.set_finish_time(_model_end_dt)
987
1030
  model_span.set_tags({
988
1031
  "real_start_ms": _ts_ms(_model_start_dt),
989
- "real_end_ms": _ts_ms(turn_end_dt),
990
- "latency_ms": _ts_ms(turn_end_dt) - _ts_ms(_model_start_dt),
1032
+ "real_end_ms": _ts_ms(_model_end_dt),
1033
+ "latency_ms": _ts_ms(_model_end_dt) - _ts_ms(_model_start_dt),
991
1034
  })
992
1035
 
993
1036
  # Build input messages: history + current turn input
@@ -1056,17 +1099,30 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1056
1099
  # --- Tool call spans ---
1057
1100
  for tool_call in turn.get("tool_calls", []):
1058
1101
  tool_name = tool_call.get("name", "unknown")
1059
- with client.start_span(name=f"tool_{tool_name}", span_type="tool", start_time=turn_start_dt) as tool_span:
1102
+ tool_start_dt = _parse_ts(tool_call) or turn_start_dt
1103
+ tool_end_dt = None
1104
+ call_id = tool_call.get("call_id")
1105
+ for result in turn.get("tool_results", []):
1106
+ if result.get("call_id") == call_id:
1107
+ tool_end_dt = _parse_ts(result)
1108
+ break
1109
+ tool_finish_dt = _max_dt(tool_end_dt, tool_start_dt)
1110
+ with client.start_span(name=f"tool_{tool_name}", span_type="tool", start_time=tool_start_dt) as tool_span:
1060
1111
  tool_span.set_runtime(Runtime(library="codex-cli"))
1061
- tool_span.set_tags({
1112
+ tool_tags = {
1062
1113
  "tool_name": tool_name,
1063
- "call_id": tool_call.get("call_id"),
1064
- })
1114
+ "call_id": call_id,
1115
+ }
1116
+ if tool_start_dt is not None and tool_finish_dt is not None:
1117
+ tool_span.set_finish_time(tool_finish_dt)
1118
+ tool_tags["real_start_ms"] = _ts_ms(tool_start_dt)
1119
+ tool_tags["real_end_ms"] = _ts_ms(tool_finish_dt)
1120
+ tool_tags["latency_ms"] = _ts_ms(tool_finish_dt) - _ts_ms(tool_start_dt)
1121
+ tool_span.set_tags(tool_tags)
1065
1122
  tool_span.set_input(
1066
1123
  json.dumps(tool_call.get("input", {}), ensure_ascii=False)[:2000]
1067
1124
  )
1068
1125
  # Find matching tool result
1069
- call_id = tool_call.get("call_id")
1070
1126
  for result in turn.get("tool_results", []):
1071
1127
  if result.get("call_id") == call_id:
1072
1128
  output = result.get("output", "")
@@ -1079,15 +1135,24 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1079
1135
  for sc in turn.get("subagent_calls", []):
1080
1136
  agent_id = sc.get("agent_id") or "unknown"
1081
1137
  nickname = sc.get("nickname") or agent_id
1138
+ subagent_start_dt = _parse_ts_value(sc.get("_start_ts")) or turn_start_dt
1139
+ subagent_end_dt = _parse_ts_value(sc.get("_end_ts")) or turn_end_dt
1140
+ subagent_finish_dt = _max_dt(subagent_end_dt, subagent_start_dt)
1082
1141
 
1083
- with client.start_span(name=f"subagent_{nickname}", span_type="agent", start_time=turn_start_dt) as subagent_span:
1142
+ with client.start_span(name=f"subagent_{nickname}", span_type="agent", start_time=subagent_start_dt) as subagent_span:
1084
1143
  subagent_span.set_runtime(Runtime(library="codex-cli"))
1085
- subagent_span.set_tags({
1144
+ subagent_tags = {
1086
1145
  "agent_id": agent_id,
1087
1146
  "agent_nickname": nickname,
1088
1147
  "agent_role": sc.get("role") or "",
1089
1148
  "agent_model": sc.get("model") or "",
1090
- })
1149
+ }
1150
+ if subagent_start_dt is not None and subagent_finish_dt is not None:
1151
+ subagent_span.set_finish_time(subagent_finish_dt)
1152
+ subagent_tags["real_start_ms"] = _ts_ms(subagent_start_dt)
1153
+ subagent_tags["real_end_ms"] = _ts_ms(subagent_finish_dt)
1154
+ subagent_tags["latency_ms"] = _ts_ms(subagent_finish_dt) - _ts_ms(subagent_start_dt)
1155
+ subagent_span.set_tags(subagent_tags)
1091
1156
  subagent_span.set_input(sc.get("message", "")[:2000])
1092
1157
 
1093
1158
  # Load and include saved subagent turn data
@@ -1097,20 +1162,45 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1097
1162
  sa_model = sa_data.get("model_name", "codex")
1098
1163
 
1099
1164
  for si, sa_turn in enumerate(sa_turns):
1100
- with client.start_span(name=f"turn_{si}", span_type="main") as sa_turn_span:
1165
+ sa_turn_start_dt, sa_turn_end_dt = _turn_bounds(sa_turn)
1166
+ if sa_turn_start_dt is None:
1167
+ sa_turn_start_dt = subagent_start_dt
1168
+ if sa_turn_end_dt is None:
1169
+ sa_turn_end_dt = sa_turn_start_dt
1170
+ sa_turn_finish_dt = _max_dt(sa_turn_end_dt, sa_turn_start_dt)
1171
+
1172
+ with client.start_span(name=f"turn_{si}", span_type="main", start_time=sa_turn_start_dt) as sa_turn_span:
1101
1173
  sa_turn_span.set_runtime(Runtime(library="codex-cli"))
1102
- sa_turn_span.set_tags({
1174
+ sa_turn_tags = {
1103
1175
  "turn_index": si,
1104
1176
  "turn_id": sa_turn.get("turn_id", ""),
1105
1177
  "agent_name": nickname,
1106
- })
1178
+ }
1179
+ if sa_turn_start_dt is not None and sa_turn_finish_dt is not None:
1180
+ sa_turn_span.set_finish_time(sa_turn_finish_dt)
1181
+ sa_turn_tags["real_start_ms"] = _ts_ms(sa_turn_start_dt)
1182
+ sa_turn_tags["real_end_ms"] = _ts_ms(sa_turn_finish_dt)
1183
+ sa_turn_tags["latency_ms"] = _ts_ms(sa_turn_finish_dt) - _ts_ms(sa_turn_start_dt)
1184
+ sa_turn_span.set_tags(sa_turn_tags)
1107
1185
 
1108
1186
  # Subagent model span
1109
1187
  if sa_turn.get("assistant_messages"):
1110
- with client.start_span(name="assistant_response", span_type="model") as sa_model_span:
1188
+ sa_model_start_dt, sa_model_end_dt = _assistant_bounds(sa_turn)
1189
+ if sa_model_start_dt is None:
1190
+ sa_model_start_dt = sa_turn_start_dt
1191
+ if sa_model_end_dt is None:
1192
+ sa_model_end_dt = sa_turn_end_dt
1193
+ sa_model_finish_dt = _max_dt(sa_model_end_dt, sa_model_start_dt)
1194
+ with client.start_span(name="assistant_response", span_type="model", start_time=sa_model_start_dt) as sa_model_span:
1111
1195
  sa_model_span.set_runtime(Runtime(library="codex-cli"))
1112
1196
  sa_model_span.set_model_name(sa_model)
1113
- sa_model_span.set_tags({"agent_name": nickname})
1197
+ sa_model_tags = {"agent_name": nickname}
1198
+ if sa_model_start_dt is not None and sa_model_finish_dt is not None:
1199
+ sa_model_span.set_finish_time(sa_model_finish_dt)
1200
+ sa_model_tags["real_start_ms"] = _ts_ms(sa_model_start_dt)
1201
+ sa_model_tags["real_end_ms"] = _ts_ms(sa_model_finish_dt)
1202
+ sa_model_tags["latency_ms"] = _ts_ms(sa_model_finish_dt) - _ts_ms(sa_model_start_dt)
1203
+ sa_model_span.set_tags(sa_model_tags)
1114
1204
 
1115
1205
  sa_input = sa_turn.get("input_messages", [])
1116
1206
  if not sa_input:
@@ -1154,17 +1244,30 @@ def send_turns_to_cozeloop(turns: List[Dict[str, Any]], session_id: str, model_n
1154
1244
  # Subagent tool spans
1155
1245
  for sa_tc in sa_turn.get("tool_calls", []):
1156
1246
  sa_tool_name = sa_tc.get("name", "unknown")
1157
- with client.start_span(name=f"tool_{sa_tool_name}", span_type="tool") as sa_tool_span:
1247
+ sa_tool_start_dt = _parse_ts(sa_tc) or sa_turn_start_dt
1248
+ sa_tool_end_dt = None
1249
+ sa_cid = sa_tc.get("call_id")
1250
+ for sa_r in sa_turn.get("tool_results", []):
1251
+ if sa_r.get("call_id") == sa_cid:
1252
+ sa_tool_end_dt = _parse_ts(sa_r)
1253
+ break
1254
+ sa_tool_finish_dt = _max_dt(sa_tool_end_dt, sa_tool_start_dt)
1255
+ with client.start_span(name=f"tool_{sa_tool_name}", span_type="tool", start_time=sa_tool_start_dt) as sa_tool_span:
1158
1256
  sa_tool_span.set_runtime(Runtime(library="codex-cli"))
1159
- sa_tool_span.set_tags({
1257
+ sa_tool_tags = {
1160
1258
  "tool_name": sa_tool_name,
1161
- "call_id": sa_tc.get("call_id"),
1259
+ "call_id": sa_cid,
1162
1260
  "agent_name": nickname,
1163
- })
1261
+ }
1262
+ if sa_tool_start_dt is not None and sa_tool_finish_dt is not None:
1263
+ sa_tool_span.set_finish_time(sa_tool_finish_dt)
1264
+ sa_tool_tags["real_start_ms"] = _ts_ms(sa_tool_start_dt)
1265
+ sa_tool_tags["real_end_ms"] = _ts_ms(sa_tool_finish_dt)
1266
+ sa_tool_tags["latency_ms"] = _ts_ms(sa_tool_finish_dt) - _ts_ms(sa_tool_start_dt)
1267
+ sa_tool_span.set_tags(sa_tool_tags)
1164
1268
  sa_tool_span.set_input(
1165
1269
  json.dumps(sa_tc.get("input", {}), ensure_ascii=False)[:2000]
1166
1270
  )
1167
- sa_cid = sa_tc.get("call_id")
1168
1271
  for sa_r in sa_turn.get("tool_results", []):
1169
1272
  if sa_r.get("call_id") == sa_cid:
1170
1273
  sa_out = sa_r.get("output", "")