Skip to content

Commit df76f0a

Browse files
Gheat1hamidi-dev
authored andcommitted
fix(copilot): dedup otel records across export files
The trace context, coverage sets, and seen keys were local to _parse_file, but OTEL exporters write spans and logs to different files: one call logged as a chat span in file A and an inference log in file B was counted twice. Collect every file's candidates first, then dedup and emit at _parse scope, preserving the chat span > inference log > agent-turn log > agent-summary span priority. The trace/span/response ids in the dedup keys are globally unique, so widening the scope never merges distinct calls.
1 parent 53a77fe commit df76f0a

3 files changed

Lines changed: 81 additions & 34 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,8 @@ Three logical layers (the class names below live in the files above — `Store`
200200
`--copilot-dir`); with it off the source never appears. Export carries tokens but **no
201201
cost** → token-only backend (**`records_cost=False`**, $0/list-price estimate, same
202202
nudges). OTEL follows the **GenAI semantic conventions** where one call is logged up to
203-
four ways, so `_parse_file` **dedups per file** by trace/response id, keeping the
203+
four ways — spans and logs land in *different* files — so `_parse` **dedups across all
204+
files** by trace/response id, keeping the
204205
highest-fidelity record (chat span > inference log > agent-turn log > agent-summary span;
205206
`_classify`/`_emit`). OpenAI-style tokens (input includes cache read; `cache_write` from
206207
`cache_creation`; reasoning folded into output; a `total_tokens`-only record back-fills).

src/opentab/stores/copilot.py

Lines changed: 47 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,8 @@ def cache_inputs(self) -> list[str]:
308308
def _files(self) -> list[str]:
309309
files = glob.glob(os.path.join(self.root_dir, "**", "*.jsonl"), recursive=True)
310310
# Add the env-var export file, but only if it isn't already one of the globbed
311-
# ones -- dedup is per file, so reading the same file twice would double-count.
311+
# ones -- the fallback dedup keys are index-based, so reading the same file
312+
# twice would double-count records that lack trace/span ids.
312313
if self._extra_file and os.path.isfile(self._extra_file):
313314
seen = {os.path.realpath(f) for f in files}
314315
if os.path.realpath(self._extra_file) not in seen:
@@ -319,40 +320,37 @@ def _parse(self) -> dict[str, dict]:
319320
if self._sessions is not None:
320321
return self._sessions
321322
sessions: dict[str, dict] = {}
322-
for path, text in read_files_parallel(self._files()):
323-
self._parse_file(path, text.split("\n"), sessions)
324-
for sid, s in sessions.items():
325-
self._finalize(sid, s)
326-
# Drop sessions with no recorded usage (non-LLM-only files): they would only add
327-
# $0 / 0-token rows to a spend browser.
328-
self._sessions = {sid: s for sid, s in sessions.items() if s["model_rows"]}
329-
return self._sessions
330-
331-
def _parse_file(self, path: str, lines: list[str], sessions: dict[str, dict]) -> None:
332-
records = [
333-
o
334-
for line in lines
335-
if '"attributes"' in line
336-
for o in (self._loads(line),)
337-
if isinstance(o, dict) and isinstance(o.get("attributes"), dict)
323+
# OTEL exporters write spans and logs to DIFFERENT files, so trace context, the
324+
# dedup coverage sets, and the seen keys must span all files: one call logged as
325+
# a chat span in file A and an inference log in file B is still one call. Two
326+
# passes -- collect every file's candidates first, then emit.
327+
per_file = [
328+
(path, self._file_records(text.split("\n")))
329+
for path, text in read_files_parallel(self._files())
338330
]
339-
# Per-file trace context: a model / session id seen anywhere on a trace fills in
340-
# for records on that trace that omit it.
331+
# Cross-file trace context: a model / session id seen anywhere on a trace fills
332+
# in for records on that trace that omit it.
341333
trace_ctx: dict[str, dict] = {}
342-
for rec in records:
343-
tid = self._trace_id(rec)
344-
if not tid:
345-
continue
346-
attrs = rec["attributes"]
347-
ctx = trace_ctx.setdefault(tid, {"model": None, "session": None, "prio": -1})
348-
if ctx["model"] is None:
349-
ctx["model"] = self._attr_str(attrs, *self._MODEL_ATTRS)
350-
sid, prio = self._session_attr(attrs)
351-
if sid is not None and prio > ctx["prio"]:
352-
ctx["session"], ctx["prio"] = sid, prio
353-
candidates = [
354-
c for i, rec in enumerate(records) if (c := self._to_candidate(rec, i, trace_ctx, path))
355-
]
334+
for _path, records in per_file:
335+
for rec in records:
336+
tid = self._trace_id(rec)
337+
if not tid:
338+
continue
339+
attrs = rec["attributes"]
340+
ctx = trace_ctx.setdefault(tid, {"model": None, "session": None, "prio": -1})
341+
if ctx["model"] is None:
342+
ctx["model"] = self._attr_str(attrs, *self._MODEL_ATTRS)
343+
sid, prio = self._session_attr(attrs)
344+
if sid is not None and prio > ctx["prio"]:
345+
ctx["session"], ctx["prio"] = sid, prio
346+
candidates: list[dict] = []
347+
idx = 0 # global running index keeps the fallback dedup keys unique across files
348+
for path, records in per_file:
349+
for rec in records:
350+
c = self._to_candidate(rec, idx, trace_ctx, path)
351+
idx += 1
352+
if c is not None:
353+
candidates.append(c)
356354
# Dedup: a chat span is the source of truth; an inference log is dropped when a
357355
# chat covers its trace/response, an agent-turn log when a chat or inference does,
358356
# an agent-summary span when any of the three do.
@@ -371,6 +369,22 @@ def _parse_file(self, path: str, lines: list[str], sessions: dict[str, dict]) ->
371369
continue
372370
seen.add(c["dedup_key"])
373371
self._fold(sessions, c)
372+
for sid, s in sessions.items():
373+
self._finalize(sid, s)
374+
# Drop sessions with no recorded usage (non-LLM-only files): they would only add
375+
# $0 / 0-token rows to a spend browser.
376+
self._sessions = {sid: s for sid, s in sessions.items() if s["model_rows"]}
377+
return self._sessions
378+
379+
@classmethod
380+
def _file_records(cls, lines: list[str]) -> list[dict]:
381+
return [
382+
o
383+
for line in lines
384+
if '"attributes"' in line
385+
for o in (cls._loads(line),)
386+
if isinstance(o, dict) and isinstance(o.get("attributes"), dict)
387+
]
374388

375389
@staticmethod
376390
def _loads(line: str):

test_opentab.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6891,6 +6891,38 @@ def test_copilot_store_dedupes_redundant_records_keeping_chat_span():
68916891
assert rows[0]["tokens_total"] == 70
68926892

68936893

6894+
def test_copilot_store_dedupes_span_and_log_split_across_files():
6895+
with tempfile.TemporaryDirectory() as tmp:
6896+
otel = os.path.join(tmp, ".copilot", "otel")
6897+
# OTEL exporters write spans and logs to DIFFERENT files: the same call appears
6898+
# as a chat span in spans.jsonl and an inference log in logs.jsonl. It must
6899+
# count once (the chat span's 60/10), never twice.
6900+
chat = _otel_chat(
6901+
"conv-x", "gpt-5.4", 60, 10, trace="trace-x", span="chat-1", resp="resp-x"
6902+
)
6903+
inference = {
6904+
"traceId": "trace-x",
6905+
"hrTime": [1775934263, 0],
6906+
"_body": "GenAI inference: gpt-5.4",
6907+
"attributes": {
6908+
"event.name": "gen_ai.client.inference.operation.details",
6909+
"gen_ai.response.model": "gpt-5.4",
6910+
"gen_ai.conversation.id": "conv-x",
6911+
"gen_ai.response.id": "resp-x",
6912+
"gen_ai.usage.input_tokens": 80,
6913+
"gen_ai.usage.output_tokens": 20,
6914+
},
6915+
}
6916+
_write_otel(otel, [chat], name="spans.jsonl")
6917+
_write_otel(otel, [inference], name="logs.jsonl")
6918+
store = ot.CopilotStore(otel, _copilot_args(otel))
6919+
rows = [r for r in store.model_breakdown() if r["root_id"] == "conv-x"]
6920+
assert len(rows) == 1
6921+
assert rows[0]["runs"] == 1 # the log in the other file was suppressed
6922+
assert rows[0]["unpriced_input"] == 60 and rows[0]["unpriced_output"] == 10
6923+
assert rows[0]["tokens_total"] == 70
6924+
6925+
68946926
def test_copilot_store_enriches_cwd_and_title_from_session_store_db():
68956927
with tempfile.TemporaryDirectory() as tmp:
68966928
copilot = os.path.join(tmp, ".copilot")

0 commit comments

Comments
 (0)