oh-langfuse 0.1.60 → 0.1.61

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.
@@ -13,42 +13,42 @@ import re
13
13
  import sys
14
14
  import time
15
15
  import hashlib
16
- from dataclasses import dataclass
16
+ from dataclasses import dataclass, field
17
17
  from datetime import datetime, timezone
18
18
  from pathlib import Path
19
19
  from typing import Any, Dict, List, Optional, Tuple
20
- from urllib.parse import urlparse
21
-
22
-
23
- def codex_langfuse_config_path() -> Path:
24
- codex_dir = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
25
- return codex_dir / "langfuse" / "config.json"
26
-
27
-
28
- def read_langfuse_base_url_from_config() -> Optional[str]:
29
- try:
30
- path = codex_langfuse_config_path()
31
- if not path.exists():
32
- return None
33
- data = json.loads(path.read_text(encoding="utf-8-sig"))
34
- if isinstance(data, dict):
35
- value = data.get("baseUrl") or data.get("host")
36
- if isinstance(value, str) and value.strip():
37
- return value.strip()
38
- except Exception:
39
- return None
40
- return None
41
-
42
-
43
- def configure_langfuse_no_proxy() -> None:
44
- hosts = ["localhost", "127.0.0.1"]
45
- values = [
46
- *(os.environ.get(key) for key in ("LANGFUSE_HOST", "LANGFUSE_BASEURL", "CODEX_LANGFUSE_BASE_URL")),
47
- read_langfuse_base_url_from_config(),
48
- ]
49
- for value in values:
50
- if not value:
51
- continue
20
+ from urllib.parse import urlparse
21
+
22
+
23
+ def codex_langfuse_config_path() -> Path:
24
+ codex_dir = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
25
+ return codex_dir / "langfuse" / "config.json"
26
+
27
+
28
+ def read_langfuse_base_url_from_config() -> Optional[str]:
29
+ try:
30
+ path = codex_langfuse_config_path()
31
+ if not path.exists():
32
+ return None
33
+ data = json.loads(path.read_text(encoding="utf-8-sig"))
34
+ if isinstance(data, dict):
35
+ value = data.get("baseUrl") or data.get("host")
36
+ if isinstance(value, str) and value.strip():
37
+ return value.strip()
38
+ except Exception:
39
+ return None
40
+ return None
41
+
42
+
43
+ def configure_langfuse_no_proxy() -> None:
44
+ hosts = ["localhost", "127.0.0.1"]
45
+ values = [
46
+ *(os.environ.get(key) for key in ("LANGFUSE_HOST", "LANGFUSE_BASEURL", "CODEX_LANGFUSE_BASE_URL")),
47
+ read_langfuse_base_url_from_config(),
48
+ ]
49
+ for value in values:
50
+ if not value:
51
+ continue
52
52
  parsed = urlparse(value if "://" in value else f"http://{value}")
53
53
  if parsed.hostname:
54
54
  hosts.append(parsed.hostname)
@@ -85,8 +85,8 @@ LOG_FILE = STATE_DIR / "codex_langfuse_notify.log"
85
85
  DEBUG = os.environ.get("CODEX_LANGFUSE_DEBUG", "").lower() == "true"
86
86
  MAX_CHARS = int(os.environ.get("CODEX_LANGFUSE_MAX_CHARS", "20000"))
87
87
  METRICS_SCHEMA_VERSION = "1.1"
88
- AGENT_NAME = "codex"
89
- AGENT_TURN_NAME = "Codex Agent Turn"
88
+ AGENT_NAME = "codex"
89
+ AGENT_TURN_NAME = "Codex Agent Turn"
90
90
 
91
91
 
92
92
  def log(level: str, message: str) -> None:
@@ -147,6 +147,53 @@ class SessionState:
147
147
  offset: int = 0
148
148
  buffer: str = ""
149
149
  turn_count: int = 0
150
+ uploaded_turn_ids: set = field(default_factory=set)
151
+ uploaded_turn_fallback_keys: set = field(default_factory=set)
152
+
153
+
154
+ @dataclass
155
+ class CodexToolCall:
156
+ id: str
157
+ name: str
158
+ input: Any = None
159
+ output: Any = None
160
+ start_time: Optional[str] = None
161
+ end_time: Optional[str] = None
162
+ status: Optional[str] = None
163
+ error: Optional[str] = None
164
+
165
+
166
+ @dataclass
167
+ class CodexStep:
168
+ start_time: Optional[str] = None
169
+ end_time: Optional[str] = None
170
+ assistant_text: str = ""
171
+ reasoning: str = ""
172
+ usage: Dict[str, Any] = field(default_factory=dict)
173
+ tool_calls: List[CodexToolCall] = field(default_factory=list)
174
+
175
+
176
+ @dataclass
177
+ class CodexTurn:
178
+ turn_id: Optional[str] = None
179
+ start_time: Optional[str] = None
180
+ end_time: Optional[str] = None
181
+ completed: bool = False
182
+ aborted: bool = False
183
+ user_text: str = ""
184
+ user_text_fallback: str = ""
185
+ final_output: str = ""
186
+ model: Optional[str] = None
187
+ invocation_params: Dict[str, Any] = field(default_factory=dict)
188
+ steps: List[CodexStep] = field(default_factory=list)
189
+ total_usage: Dict[str, Any] = field(default_factory=dict)
190
+ subagent_thread_ids: List[str] = field(default_factory=list)
191
+
192
+
193
+ @dataclass
194
+ class CodexParsedSession:
195
+ session_meta: Dict[str, Any]
196
+ turns: List[CodexTurn]
150
197
 
151
198
 
152
199
  def read_json_if_exists(path: Path) -> Dict[str, Any]:
@@ -277,6 +324,8 @@ def load_session_state(state: Dict[str, Any], key: str) -> SessionState:
277
324
  offset=int(raw.get("offset", 0) or 0),
278
325
  buffer=str(raw.get("buffer", "") or ""),
279
326
  turn_count=int(raw.get("turn_count", 0) or 0),
327
+ uploaded_turn_ids={str(item) for item in raw.get("uploaded_turn_ids", []) if item},
328
+ uploaded_turn_fallback_keys={str(item) for item in raw.get("uploaded_turn_fallback_keys", []) if item},
280
329
  )
281
330
 
282
331
 
@@ -285,10 +334,31 @@ def save_session_state(state: Dict[str, Any], key: str, ss: SessionState) -> Non
285
334
  "offset": ss.offset,
286
335
  "buffer": ss.buffer,
287
336
  "turn_count": ss.turn_count,
337
+ "uploaded_turn_ids": sorted(ss.uploaded_turn_ids),
338
+ "uploaded_turn_fallback_keys": sorted(ss.uploaded_turn_fallback_keys),
288
339
  "updated": datetime.now(timezone.utc).isoformat(),
289
340
  }
290
341
 
291
342
 
343
+ def read_all_jsonl(path: Path) -> List[Dict[str, Any]]:
344
+ out: List[Dict[str, Any]] = []
345
+ try:
346
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
347
+ for line in f:
348
+ raw = line.strip()
349
+ if not raw:
350
+ continue
351
+ try:
352
+ item = json.loads(raw)
353
+ if isinstance(item, dict):
354
+ out.append(item)
355
+ except Exception as e:
356
+ debug(f"jsonl row parse failed: {e}")
357
+ except Exception as e:
358
+ debug(f"read_all_jsonl failed: {e}")
359
+ return out
360
+
361
+
292
362
  def read_new_jsonl(path: Path, ss: SessionState) -> Tuple[List[Dict[str, Any]], SessionState]:
293
363
  out: List[Dict[str, Any]] = []
294
364
  try:
@@ -717,6 +787,27 @@ def summarize_skill_usages(skill_usages: List[Dict[str, str]]) -> List[Dict[str,
717
787
  return list(summary.values())
718
788
 
719
789
 
790
+ def is_codex_wrapper_text(text: str) -> bool:
791
+ stripped = (text or "").strip()
792
+ return stripped.startswith("<environment_context") or stripped.startswith("<user_instructions")
793
+
794
+
795
+ def append_text(existing: str, text: str) -> str:
796
+ text = text or ""
797
+ if not text:
798
+ return existing or ""
799
+ return f"{existing}\n{text}" if existing else text
800
+
801
+
802
+ def parse_tool_input(value: Any) -> Any:
803
+ if isinstance(value, str):
804
+ try:
805
+ return json.loads(value)
806
+ except Exception:
807
+ return value
808
+ return value
809
+
810
+
720
811
  def get_payload(row: Dict[str, Any]) -> Dict[str, Any]:
721
812
  payload = row.get("payload")
722
813
  return payload if isinstance(payload, dict) else {}
@@ -748,6 +839,183 @@ def extract_usage(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
748
839
  return usage
749
840
 
750
841
 
842
+ def parse_codex_session(rows: List[Dict[str, Any]]) -> CodexParsedSession:
843
+ session_meta: Dict[str, Any] = {"session_path": ""}
844
+ turns: List[CodexTurn] = []
845
+ turn: Optional[CodexTurn] = None
846
+ step: Optional[CodexStep] = None
847
+ tool_calls_by_id: Dict[str, CodexToolCall] = {}
848
+ last_ts: Optional[str] = None
849
+
850
+ def ensure_turn(ts: Optional[str]) -> CodexTurn:
851
+ nonlocal turn
852
+ if turn is None:
853
+ turn = CodexTurn(start_time=ts, end_time=ts)
854
+ return turn
855
+
856
+ def ensure_step(ts: Optional[str]) -> CodexStep:
857
+ nonlocal step
858
+ if step is None:
859
+ step = CodexStep(start_time=ts, end_time=ts)
860
+ return step
861
+
862
+ def close_step(ts: Optional[str], usage: Optional[Dict[str, Any]] = None) -> None:
863
+ nonlocal step
864
+ if step is None or turn is None:
865
+ return
866
+ step.end_time = ts or step.end_time
867
+ if isinstance(usage, dict):
868
+ step.usage = usage
869
+ turn.steps.append(step)
870
+ step = None
871
+
872
+ def finish_turn(ts: Optional[str], completed: bool, aborted: bool) -> None:
873
+ nonlocal turn, step, tool_calls_by_id
874
+ if turn is None:
875
+ return
876
+ close_step(ts)
877
+ turn.end_time = ts or turn.end_time
878
+ turn.completed = completed
879
+ turn.aborted = aborted
880
+ if not turn.user_text:
881
+ turn.user_text = turn.user_text_fallback
882
+ if not turn.final_output:
883
+ for candidate in reversed(turn.steps):
884
+ if candidate.assistant_text:
885
+ turn.final_output = candidate.assistant_text
886
+ break
887
+ turns.append(turn)
888
+ turn = None
889
+ step = None
890
+ tool_calls_by_id = {}
891
+
892
+ for row in rows:
893
+ ts = str(row.get("timestamp") or last_ts or "")
894
+ last_ts = ts
895
+ row_type = row.get("type")
896
+ payload = get_payload(row)
897
+
898
+ if row_type == "session_meta":
899
+ session_meta.update(payload)
900
+ continue
901
+
902
+ if row_type == "turn_context":
903
+ t = ensure_turn(ts)
904
+ model = payload.get("model")
905
+ if isinstance(model, str) and model.strip():
906
+ t.model = model.strip()
907
+ if isinstance(payload, dict):
908
+ t.invocation_params = dict(payload)
909
+ continue
910
+
911
+ if row_type == "response_item":
912
+ item_type = payload.get("type")
913
+ t = ensure_turn(ts)
914
+ if item_type == "message":
915
+ role = payload.get("role")
916
+ text = extract_text(payload.get("content"))
917
+ if role == "assistant" and text:
918
+ s = ensure_step(ts)
919
+ s.assistant_text = append_text(s.assistant_text, text)
920
+ elif role == "user" and text and not is_codex_wrapper_text(text) and not t.user_text_fallback:
921
+ t.user_text_fallback = text
922
+ elif item_type == "reasoning":
923
+ text = extract_text(payload.get("content") or payload.get("summary"))
924
+ if text:
925
+ s = ensure_step(ts)
926
+ s.reasoning = append_text(s.reasoning, text)
927
+ elif item_type in ("function_call", "custom_tool_call"):
928
+ call_id = first_string(str(payload.get("call_id") or ""), str(payload.get("id") or "")) or ""
929
+ call = CodexToolCall(
930
+ id=call_id,
931
+ name=first_string(str(payload.get("name") or "")) or "tool",
932
+ input=parse_tool_input(payload.get("arguments") if item_type == "function_call" else payload.get("input")),
933
+ start_time=ts,
934
+ )
935
+ ensure_step(ts).tool_calls.append(call)
936
+ if call_id:
937
+ tool_calls_by_id[call_id] = call
938
+ elif item_type in ("function_call_output", "custom_tool_call_output"):
939
+ call_id = str(payload.get("call_id") or "")
940
+ call = tool_calls_by_id.get(call_id)
941
+ if call:
942
+ call.output = payload.get("output")
943
+ call.end_time = ts
944
+ continue
945
+
946
+ if row_type != "event_msg":
947
+ continue
948
+
949
+ event_type = payload.get("type")
950
+ if event_type == "task_started":
951
+ if turn is not None:
952
+ finish_turn(ts, completed=False, aborted=False)
953
+ turn = CodexTurn(
954
+ turn_id=first_string(str(payload.get("turn_id") or "")),
955
+ start_time=ts,
956
+ end_time=ts,
957
+ )
958
+ step = None
959
+ tool_calls_by_id = {}
960
+ continue
961
+
962
+ t = ensure_turn(ts)
963
+ if event_type == "user_message" and isinstance(payload.get("message"), str):
964
+ if not t.user_text:
965
+ t.user_text = payload["message"]
966
+ elif event_type == "agent_message" and isinstance(payload.get("message"), str):
967
+ t.final_output = payload["message"]
968
+ elif event_type == "token_count":
969
+ info = payload.get("info")
970
+ if isinstance(info, dict):
971
+ total = info.get("total_token_usage")
972
+ if isinstance(total, dict):
973
+ t.total_usage = total
974
+ last = info.get("last_token_usage")
975
+ close_step(ts, last if isinstance(last, dict) else None)
976
+ elif event_type == "task_complete":
977
+ finish_turn(ts, completed=True, aborted=False)
978
+ elif event_type == "turn_aborted":
979
+ finish_turn(ts, completed=True, aborted=True)
980
+ else:
981
+ if event_type == "collab_agent_spawn_end" and isinstance(payload.get("new_thread_id"), str):
982
+ t.subagent_thread_ids.append(payload["new_thread_id"])
983
+ call_id = payload.get("call_id")
984
+ if isinstance(call_id, str) and isinstance(event_type, str) and event_type.endswith("_end"):
985
+ call = tool_calls_by_id.get(call_id)
986
+ if call:
987
+ call.end_time = ts
988
+ if isinstance(payload.get("status"), str):
989
+ call.status = payload["status"]
990
+ if payload.get("status") in ("failed", "declined"):
991
+ call.error = first_string(
992
+ str(payload.get("stderr") or ""),
993
+ str(payload.get("error") or ""),
994
+ str(payload.get("message") or ""),
995
+ )
996
+ if call.output is None:
997
+ call.output = {
998
+ "stdout": payload.get("stdout"),
999
+ "stderr": payload.get("stderr"),
1000
+ "success": payload.get("success"),
1001
+ "status": payload.get("status"),
1002
+ "aggregated_output": payload.get("aggregated_output"),
1003
+ }
1004
+ elif isinstance(call.output, str) and payload.get("stdout") is not None:
1005
+ call.output = {
1006
+ "stdout": payload.get("stdout"),
1007
+ "stderr": payload.get("stderr"),
1008
+ "success": payload.get("success"),
1009
+ "status": payload.get("status"),
1010
+ "aggregated_output": payload.get("aggregated_output"),
1011
+ }
1012
+
1013
+ if turn is not None:
1014
+ finish_turn(last_ts, completed=False, aborted=False)
1015
+
1016
+ return CodexParsedSession(session_meta=session_meta, turns=turns)
1017
+
1018
+
751
1019
  def usage_details_from_codex(usage: Dict[str, Any]) -> Dict[str, int]:
752
1020
  raw = usage.get("last_token_usage")
753
1021
  if not isinstance(raw, dict):
@@ -825,6 +1093,77 @@ def collect_turn_material(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
825
1093
  }
826
1094
 
827
1095
 
1096
+ def codex_turn_usage_details(turn: CodexTurn) -> Dict[str, int]:
1097
+ for step in reversed(turn.steps):
1098
+ details = usage_details_from_codex({"last_token_usage": step.usage})
1099
+ if details:
1100
+ return details
1101
+ return {}
1102
+
1103
+
1104
+ def codex_turn_material(turn: CodexTurn) -> Dict[str, Any]:
1105
+ tool_calls: List[Dict[str, Any]] = []
1106
+ tool_results: List[Dict[str, Any]] = []
1107
+ skill_sources: List[Any] = [turn.invocation_params]
1108
+ for step in turn.steps:
1109
+ skill_sources.append(step.reasoning)
1110
+ for call in step.tool_calls:
1111
+ tool_calls.append({"id": call.id, "name": call.name, "input": call.input})
1112
+ tool_results.append({
1113
+ "id": call.id,
1114
+ "name": call.name,
1115
+ "output": call.output,
1116
+ "status": call.status,
1117
+ "error": call.error,
1118
+ "start_time": call.start_time,
1119
+ "end_time": call.end_time,
1120
+ })
1121
+ skill_sources.append({"name": call.name, "input": call.input, "output": call.output})
1122
+ return {
1123
+ "user_text": turn.user_text,
1124
+ "assistant_text": turn.final_output or "\n\n".join(step.assistant_text for step in turn.steps if step.assistant_text),
1125
+ "tool_calls": tool_calls,
1126
+ "tool_results": tool_results,
1127
+ "skill_detection_sources": skill_sources,
1128
+ }
1129
+
1130
+
1131
+ def codex_turn_upload_key(session_path: Path, turn: CodexTurn, index: int) -> Tuple[str, str]:
1132
+ if turn.turn_id:
1133
+ return "turn_id", str(turn.turn_id)
1134
+ seed = {
1135
+ "session_path": str(session_path),
1136
+ "index": index,
1137
+ "start_time": turn.start_time,
1138
+ "end_time": turn.end_time,
1139
+ "user_text": turn.user_text,
1140
+ "final_output": turn.final_output,
1141
+ "aborted": turn.aborted,
1142
+ }
1143
+ digest = hashlib.sha256(json.dumps(seed, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
1144
+ return "fallback", digest
1145
+
1146
+
1147
+ def should_upload_codex_turn(ss: SessionState, session_path: Path, turn: CodexTurn, index: int) -> bool:
1148
+ if not (turn.completed or turn.aborted):
1149
+ return False
1150
+ material = codex_turn_material(turn)
1151
+ if not material.get("assistant_text") and not material.get("tool_calls") and not material.get("tool_results"):
1152
+ return False
1153
+ kind, value = codex_turn_upload_key(session_path, turn, index)
1154
+ if kind == "turn_id":
1155
+ return value not in ss.uploaded_turn_ids
1156
+ return value not in ss.uploaded_turn_fallback_keys
1157
+
1158
+
1159
+ def mark_codex_turn_uploaded(ss: SessionState, session_path: Path, turn: CodexTurn, index: int) -> None:
1160
+ kind, value = codex_turn_upload_key(session_path, turn, index)
1161
+ if kind == "turn_id":
1162
+ ss.uploaded_turn_ids.add(value)
1163
+ else:
1164
+ ss.uploaded_turn_fallback_keys.add(value)
1165
+
1166
+
828
1167
  def emit_codex_turn(
829
1168
  langfuse: Langfuse,
830
1169
  session_id: str,
@@ -832,13 +1171,13 @@ def emit_codex_turn(
832
1171
  turn_num: int,
833
1172
  session_path: Path,
834
1173
  meta: Dict[str, Any],
835
- material: Dict[str, Any],
836
- usage: Dict[str, Any],
1174
+ turn: CodexTurn,
837
1175
  ) -> None:
1176
+ material = codex_turn_material(turn)
838
1177
  user_text, user_meta = truncate(material.get("user_text") or "")
839
1178
  assistant_text, assistant_meta = truncate(material.get("assistant_text") or "")
840
- usage_details = usage_details_from_codex(usage)
841
- model = first_string(meta.get("model"), meta.get("model_provider")) or "codex"
1179
+ usage_details = codex_turn_usage_details(turn)
1180
+ model = first_string(turn.model, meta.get("model"), meta.get("model_provider")) or "codex"
842
1181
  tool_calls = material.get("tool_calls") or []
843
1182
  tool_results = material.get("tool_results") or []
844
1183
  skill_usages = detect_turn_skill_usages(material, discover_known_skills())
@@ -863,11 +1202,11 @@ def emit_codex_turn(
863
1202
  with propagate_attributes(
864
1203
  user_id=user_id,
865
1204
  session_id=session_id,
866
- trace_name=AGENT_TURN_NAME,
867
- tags=[AGENT_NAME],
868
- ):
869
- with langfuse.start_as_current_observation(
870
- name=AGENT_TURN_NAME,
1205
+ trace_name=AGENT_TURN_NAME,
1206
+ tags=[AGENT_NAME],
1207
+ ):
1208
+ with langfuse.start_as_current_observation(
1209
+ name=AGENT_TURN_NAME,
871
1210
  input={"role": "user", "content": user_text},
872
1211
  output={"role": "assistant", "content": assistant_text},
873
1212
  metadata={
@@ -881,8 +1220,13 @@ def emit_codex_turn(
881
1220
  "originator": meta.get("originator"),
882
1221
  "cli_version": meta.get("cli_version"),
883
1222
  "user_text": user_meta,
884
- "usage": usage,
1223
+ "usage": {"last_token_usage": usage_details},
885
1224
  "skills": skill_summary,
1225
+ "codex_turn_id": turn.turn_id or "",
1226
+ "completed": turn.completed,
1227
+ "aborted": turn.aborted,
1228
+ "subagent_thread_ids": turn.subagent_thread_ids,
1229
+ "step_count": len(turn.steps),
886
1230
  },
887
1231
  ) as trace_span:
888
1232
  with langfuse.start_as_current_observation(
@@ -940,6 +1284,10 @@ def emit_codex_turn(
940
1284
  "tool_name": result.get("name"),
941
1285
  "turn_number": turn_num,
942
1286
  "output_meta": output_meta,
1287
+ "status": result.get("status"),
1288
+ "error": result.get("error"),
1289
+ "start_time": result.get("start_time"),
1290
+ "end_time": result.get("end_time"),
943
1291
  "metrics_schema_version": METRICS_SCHEMA_VERSION,
944
1292
  },
945
1293
  ) as tool_obs:
@@ -1000,28 +1348,46 @@ def main() -> int:
1000
1348
  state = read_json_if_exists(STATE_FILE)
1001
1349
  key = session_key(session_path)
1002
1350
  ss = load_session_state(state, key)
1003
- rows, ss = read_new_jsonl(session_path, ss)
1351
+ rows = read_all_jsonl(session_path)
1004
1352
  if not rows:
1353
+ try:
1354
+ ss.offset = session_path.stat().st_size
1355
+ ss.buffer = ""
1356
+ except Exception:
1357
+ pass
1005
1358
  save_session_state(state, key, ss)
1006
1359
  write_json_atomic(STATE_FILE, state)
1007
1360
  return 0
1008
1361
 
1009
- material = collect_turn_material(rows)
1010
- if not material.get("assistant_text") and not material.get("tool_calls") and not material.get("tool_results"):
1362
+ try:
1363
+ ss.offset = session_path.stat().st_size
1364
+ ss.buffer = ""
1365
+ except Exception:
1366
+ pass
1367
+
1368
+ parsed = parse_codex_session(rows)
1369
+ if not parsed.turns:
1011
1370
  save_session_state(state, key, ss)
1012
1371
  write_json_atomic(STATE_FILE, state)
1013
1372
  return 0
1014
1373
 
1015
1374
  meta = get_session_meta(rows, session_path)
1375
+ meta.update({k: v for k, v in parsed.session_meta.items() if v not in (None, "")})
1376
+ meta.setdefault("session_path", str(session_path))
1016
1377
  session_id = first_string(str(meta.get("id")) if meta.get("id") else "", session_path.stem) or session_path.stem
1017
- usage = extract_usage(rows)
1018
- turn_num = ss.turn_count + 1
1378
+ uploaded = 0
1019
1379
 
1020
- try:
1021
- emit_codex_turn(langfuse, session_id, user_id, turn_num, session_path, meta, material, usage)
1022
- ss.turn_count = turn_num
1023
- except Exception as e:
1024
- debug(f"emit_codex_turn failed: {e}")
1380
+ for index, turn in enumerate(parsed.turns, start=1):
1381
+ if not should_upload_codex_turn(ss, session_path, turn, index):
1382
+ continue
1383
+ turn_num = ss.turn_count + 1
1384
+ try:
1385
+ emit_codex_turn(langfuse, session_id, user_id, turn_num, session_path, meta, turn)
1386
+ mark_codex_turn_uploaded(ss, session_path, turn, index)
1387
+ ss.turn_count = turn_num
1388
+ uploaded += 1
1389
+ except Exception as e:
1390
+ debug(f"emit_codex_turn failed: {e}")
1025
1391
 
1026
1392
  save_session_state(state, key, ss)
1027
1393
  write_json_atomic(STATE_FILE, state)
@@ -1030,7 +1396,7 @@ def main() -> int:
1030
1396
  langfuse.flush()
1031
1397
  except Exception:
1032
1398
  pass
1033
- log("INFO", f"Processed Codex turn {ss.turn_count} for {session_path}")
1399
+ log("INFO", f"Processed {uploaded} Codex turn(s), total {ss.turn_count} for {session_path}")
1034
1400
  return 0
1035
1401
  except Exception as e:
1036
1402
  debug(f"unexpected failure: {e}")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-langfuse",
3
- "version": "0.1.60",
3
+ "version": "0.1.61",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Use npm scripts to configure Claude Code / OpenCode / Codex with Langfuse tracing.",
@@ -27,11 +27,11 @@
27
27
  "scripts/log-filter-utils.mjs",
28
28
  "scripts/metrics-utils.mjs",
29
29
  "scripts/runtime-state-utils.mjs",
30
- "scripts/update-langfuse-runtime.mjs",
31
- "scripts/update-utils.mjs",
32
- "scripts/verify-update-runtime.mjs",
33
- "scripts/verify-update-utils.mjs",
34
- "langfuse_hook.py",
30
+ "scripts/update-langfuse-runtime.mjs",
31
+ "scripts/update-utils.mjs",
32
+ "scripts/verify-update-runtime.mjs",
33
+ "scripts/verify-update-utils.mjs",
34
+ "langfuse_hook.py",
35
35
  "codex_langfuse_notify.py",
36
36
  "README.md",
37
37
  "SELF_VERIFY.md",