ka-telemetry 0.1.0__py3-none-any.whl
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.
- ka_telemetry/__init__.py +7 -0
- ka_telemetry/__main__.py +15 -0
- ka_telemetry/cli.py +185 -0
- ka_telemetry/context_gauge.py +87 -0
- ka_telemetry/flow_usage.py +1305 -0
- ka_telemetry/receiver.py +391 -0
- ka_telemetry/run_usage.py +1303 -0
- ka_telemetry/stream_paths.py +12 -0
- ka_telemetry/stream_usage.py +1737 -0
- ka_telemetry/transcript_notes.py +81 -0
- ka_telemetry/turn_on.py +809 -0
- ka_telemetry-0.1.0.dist-info/METADATA +224 -0
- ka_telemetry-0.1.0.dist-info/RECORD +17 -0
- ka_telemetry-0.1.0.dist-info/WHEEL +5 -0
- ka_telemetry-0.1.0.dist-info/entry_points.txt +2 -0
- ka_telemetry-0.1.0.dist-info/licenses/LICENSE +202 -0
- ka_telemetry-0.1.0.dist-info/top_level.txt +1 -0
ka_telemetry/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""ka_telemetry reads the transcripts Claude Code keeps on this machine and reports the tokens
|
|
2
|
+
they record: per background run, per workflow step, and the current context size of one
|
|
3
|
+
transcript. The readers sit in the modules of this package; this file holds no implementation."""
|
|
4
|
+
|
|
5
|
+
from ka_telemetry import context_gauge, flow_usage, run_usage
|
|
6
|
+
|
|
7
|
+
__all__ = ["context_gauge", "flow_usage", "run_usage"]
|
ka_telemetry/__main__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""`python3 -m ka_telemetry` — runs the same command as the installed `ka-telemetry`.
|
|
2
|
+
|
|
3
|
+
It adds no parser and no dispatch of its own: every argument list goes to `ka_telemetry.cli.main`,
|
|
4
|
+
the target `pyproject.toml` names for the console entry, so the two ways in print the same thing
|
|
5
|
+
and exit with the same status.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from ka_telemetry.cli import main
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
sys.exit(main())
|
ka_telemetry/cli.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""cli — the one `ka-telemetry` command: `runs`, `steps`, `context`, `receive`, `agents`, `check`,
|
|
2
|
+
`turn-on`.
|
|
3
|
+
|
|
4
|
+
`runs`, `steps`, `receive`, `agents`, `check` and `turn-on` hand their arguments to the module's
|
|
5
|
+
own `main`, so each keeps its own parser and nothing is parsed twice. `context` is the one command
|
|
6
|
+
with no `main` of its own: it wires the filesystem port here, calls `context_gauge.read_context`
|
|
7
|
+
with `context_gauge.current_context` as its size reader, and prints the size and what could not be
|
|
8
|
+
read. No counting, grouping, parsing or storing is done in this module.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from ka_telemetry import context_gauge, flow_usage, receiver, run_usage, stream_usage, turn_on
|
|
19
|
+
|
|
20
|
+
_NETWORK_PROMISE = (
|
|
21
|
+
"This tool sends no telemetry and makes no network call except the ones the job you "
|
|
22
|
+
"asked for requires."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _top_parser() -> run_usage.CommandParser:
|
|
27
|
+
"""The parser for `ka-telemetry` itself. It lists the commands and rejects what is not one;
|
|
28
|
+
a command's own arguments belong to the command's parser, built where the command is run."""
|
|
29
|
+
parser = run_usage.CommandParser(
|
|
30
|
+
prog="ka-telemetry",
|
|
31
|
+
next_step="Run `ka-telemetry --help` to see the commands and what each one accepts.",
|
|
32
|
+
description=(
|
|
33
|
+
"Reads the transcripts Claude Code keeps on this machine and reports the tokens "
|
|
34
|
+
"they record: per background run, per workflow step, and the current context size "
|
|
35
|
+
"of one transcript. Those three reports show no cost, because ka-telemetry counts "
|
|
36
|
+
"tokens and does not price them. It also receives the telemetry Claude Code can "
|
|
37
|
+
"send as OpenTelemetry over HTTP in JSON, writes it to files on this machine, and "
|
|
38
|
+
"reads those files for a per-agent report of tokens, cost and duration, and for a "
|
|
39
|
+
"totals check that holds each session's request costs against Claude Code's cost "
|
|
40
|
+
"metric. Every cost in those two comes from what Claude Code reported; "
|
|
41
|
+
"ka-telemetry never computes a cost from tokens."
|
|
42
|
+
),
|
|
43
|
+
epilog=_NETWORK_PROMISE,
|
|
44
|
+
)
|
|
45
|
+
commands = parser.add_subparsers(
|
|
46
|
+
title="commands",
|
|
47
|
+
dest="command",
|
|
48
|
+
required=True,
|
|
49
|
+
metavar="{runs,steps,context,receive,agents,check,turn-on}",
|
|
50
|
+
parser_class=argparse.ArgumentParser,
|
|
51
|
+
)
|
|
52
|
+
commands.add_parser(
|
|
53
|
+
"runs",
|
|
54
|
+
add_help=False,
|
|
55
|
+
help="tokens used by each background run, and by each run you name by session id",
|
|
56
|
+
)
|
|
57
|
+
commands.add_parser(
|
|
58
|
+
"steps", add_help=False, help="tokens used by each step of a workflow run"
|
|
59
|
+
)
|
|
60
|
+
commands.add_parser(
|
|
61
|
+
"context", add_help=False, help="the current context size of one transcript"
|
|
62
|
+
)
|
|
63
|
+
commands.add_parser(
|
|
64
|
+
"receive",
|
|
65
|
+
add_help=False,
|
|
66
|
+
help="receive the telemetry Claude Code sends and write it to files on this machine",
|
|
67
|
+
)
|
|
68
|
+
commands.add_parser(
|
|
69
|
+
"agents",
|
|
70
|
+
add_help=False,
|
|
71
|
+
help="tokens, cost and duration per agent, read from the received telemetry",
|
|
72
|
+
)
|
|
73
|
+
commands.add_parser(
|
|
74
|
+
"check",
|
|
75
|
+
add_help=False,
|
|
76
|
+
help="each session's request costs checked against Claude Code's cost metric",
|
|
77
|
+
)
|
|
78
|
+
commands.add_parser(
|
|
79
|
+
"turn-on",
|
|
80
|
+
add_help=False,
|
|
81
|
+
help="write the keys that make Claude Code send its telemetry to the receiver on this "
|
|
82
|
+
"machine into a Claude Code settings file, remove them, or print them for one run",
|
|
83
|
+
)
|
|
84
|
+
return parser
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _context_parser() -> run_usage.CommandParser:
|
|
88
|
+
parser = run_usage.CommandParser(
|
|
89
|
+
prog="ka-telemetry context",
|
|
90
|
+
next_step="Run `ka-telemetry context --help` to see what it accepts.",
|
|
91
|
+
description=(
|
|
92
|
+
"Reports the current context size of one Claude Code transcript: the input, "
|
|
93
|
+
"cache-read and cache-write tokens of the last response in it, added together. "
|
|
94
|
+
"Output tokens are not part of it."
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
parser.add_argument(
|
|
98
|
+
"transcript",
|
|
99
|
+
metavar="TRANSCRIPT",
|
|
100
|
+
help="path to a transcript file, named <session id>.jsonl",
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
"--json", action="store_true", help="print the result as JSON instead of a line of text"
|
|
104
|
+
)
|
|
105
|
+
return parser
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_files() -> run_usage.Files:
|
|
109
|
+
"""The filesystem port the context command reads through. The one place this module makes it."""
|
|
110
|
+
return run_usage.Files()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def context_size_reader(path: Path, files: run_usage.Files) -> int | None:
|
|
114
|
+
"""The size reader `read_context` is wired with: the scalar `current_context`, so the size
|
|
115
|
+
arithmetic stays in `context_gauge` and is never restated here."""
|
|
116
|
+
return context_gauge.current_context(path, files)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# The words are `change_text.md`'s, each line whole so none is assembled from fragments.
|
|
120
|
+
_CONTEXT_SIZE_KNOWN = "Context size: {size} tokens"
|
|
121
|
+
_CONTEXT_SIZE_UNKNOWN = "Context size: unknown — no assistant usage read from {path}"
|
|
122
|
+
_CONTEXT_LINES_SKIPPED_ONE = " — 1 unreadable line skipped in this transcript"
|
|
123
|
+
_CONTEXT_LINES_SKIPPED_MANY = " — {line_count} unreadable lines skipped in this transcript"
|
|
124
|
+
_CONTEXT_NOT_OPENED = (
|
|
125
|
+
"Context size: unknown — the transcript {path} could not be opened. "
|
|
126
|
+
"Run this command again once it can be read."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _context_text(reading: context_gauge.ContextReading, transcript: str) -> str:
|
|
131
|
+
if reading.unreadable_transcripts:
|
|
132
|
+
return _CONTEXT_NOT_OPENED.format(path=transcript)
|
|
133
|
+
if reading.context_size is None:
|
|
134
|
+
line = _CONTEXT_SIZE_UNKNOWN.format(path=transcript)
|
|
135
|
+
else:
|
|
136
|
+
line = _CONTEXT_SIZE_KNOWN.format(size=reading.context_size)
|
|
137
|
+
if reading.unreadable_lines == 1:
|
|
138
|
+
line += _CONTEXT_LINES_SKIPPED_ONE
|
|
139
|
+
elif reading.unreadable_lines > 1:
|
|
140
|
+
line += _CONTEXT_LINES_SKIPPED_MANY.format(line_count=reading.unreadable_lines)
|
|
141
|
+
return line
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _context(argv: list[str]) -> int:
|
|
145
|
+
args = _context_parser().parse_args(argv)
|
|
146
|
+
reading = context_gauge.read_context(
|
|
147
|
+
Path(args.transcript), build_files(), size_reader=context_size_reader
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if args.json:
|
|
151
|
+
payload = {
|
|
152
|
+
"transcript": args.transcript,
|
|
153
|
+
"context_size": reading.context_size,
|
|
154
|
+
"unreadable_lines": reading.unreadable_lines,
|
|
155
|
+
}
|
|
156
|
+
if reading.unreadable_transcripts:
|
|
157
|
+
payload["unreadable_transcripts"] = reading.unreadable_transcripts
|
|
158
|
+
print(json.dumps(payload, indent=2))
|
|
159
|
+
else:
|
|
160
|
+
print(_context_text(reading, args.transcript))
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main(argv: list[str] | None = None) -> int:
|
|
165
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
166
|
+
command, rest = (args[0], args[1:]) if args else (None, [])
|
|
167
|
+
|
|
168
|
+
if command == "runs":
|
|
169
|
+
return run_usage.main(rest)
|
|
170
|
+
if command == "steps":
|
|
171
|
+
return flow_usage.main(rest)
|
|
172
|
+
if command == "context":
|
|
173
|
+
return _context(rest)
|
|
174
|
+
if command == "receive":
|
|
175
|
+
return receiver.main(rest)
|
|
176
|
+
if command == "agents":
|
|
177
|
+
return stream_usage.main(rest)
|
|
178
|
+
if command == "check":
|
|
179
|
+
return stream_usage.check_main(rest)
|
|
180
|
+
if command == "turn-on":
|
|
181
|
+
return turn_on.main(rest)
|
|
182
|
+
|
|
183
|
+
# Nothing to run: a help request, or a usage error (no command, an unknown one, a stray flag).
|
|
184
|
+
_top_parser().parse_args(args)
|
|
185
|
+
return 0
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""context_gauge — how large a run's context is right now.
|
|
2
|
+
|
|
3
|
+
A caller that wants to know how full a run's context is needs one number: the size of the context
|
|
4
|
+
the run's latest request carried. `run_usage` sums a run's spend per class over the whole
|
|
5
|
+
transcript, which is the wrong figure for this: the cache classes repeat on every request, so a
|
|
6
|
+
sum over responses counts the same context once per turn. The current context is the **last**
|
|
7
|
+
response's `input_tokens + cache_read_input_tokens + cache_creation_input_tokens`, and nothing
|
|
8
|
+
else. `output_tokens` is what the response produced, not what it was given, so it is left out.
|
|
9
|
+
|
|
10
|
+
The de-duplication per `message.id` and the tolerance of a half-written final line are
|
|
11
|
+
`run_usage.usage_records`'s, reused rather than restated: a transcript is read while its own run
|
|
12
|
+
is still writing to it. This module takes no arguments and prints nothing.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from ka_telemetry import run_usage
|
|
22
|
+
|
|
23
|
+
_CONTEXT_CLASSES = ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def current_context(path: Path, files: run_usage.Files) -> int | None:
|
|
27
|
+
"""The three input classes of the newest API response in `path`, summed, or `None` when the
|
|
28
|
+
transcript is unreadable or holds no assistant usage.
|
|
29
|
+
|
|
30
|
+
A class the response does not carry counts as 0, so a response that never wrote the cache
|
|
31
|
+
still gives a number rather than hiding the ones it did carry.
|
|
32
|
+
"""
|
|
33
|
+
latest_usage = None
|
|
34
|
+
for usage, _model, _timestamp in run_usage.usage_records(path, files):
|
|
35
|
+
latest_usage = usage
|
|
36
|
+
if latest_usage is None:
|
|
37
|
+
return None
|
|
38
|
+
return sum(latest_usage.get(name) or 0 for name in _CONTEXT_CLASSES)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ContextReading:
|
|
43
|
+
"""What one transcript came to for `context`: its `context_size` (`None` when no assistant
|
|
44
|
+
usage was read), how many of its lines could not be read, and `1` in `unreadable_transcripts`
|
|
45
|
+
when something is at the path and it could not be opened. A transcript with nothing at its
|
|
46
|
+
path is `(None, 0, 0)`: absent, which is not a file that could not be opened."""
|
|
47
|
+
|
|
48
|
+
context_size: int | None
|
|
49
|
+
unreadable_lines: int = 0
|
|
50
|
+
unreadable_transcripts: int = 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _Snapshot:
|
|
54
|
+
"""The one completed read of a transcript, handed back as the `read_lines` seam
|
|
55
|
+
`run_usage.usage_records` reads through. It reaches no filesystem, so every pass over the
|
|
56
|
+
snapshot reads memory."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, lines: tuple[str, ...]):
|
|
59
|
+
self._lines = lines
|
|
60
|
+
|
|
61
|
+
def read_lines(self, path: Path) -> list[str]:
|
|
62
|
+
return list(self._lines)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def read_context(
|
|
66
|
+
path: Path,
|
|
67
|
+
files: run_usage.Files,
|
|
68
|
+
*,
|
|
69
|
+
size_reader: Callable[[Path, run_usage.Files], int | None],
|
|
70
|
+
) -> ContextReading:
|
|
71
|
+
"""The context size of `path` together with what could not be read, from one read of it.
|
|
72
|
+
|
|
73
|
+
The transcript is opened once, through `files.read_outcome`. A completed read is replayed
|
|
74
|
+
from memory twice: `size_reader` — which owns which response sets the size, and is given the
|
|
75
|
+
snapshot as its `files` — gives the size, and `run_usage.usage_records` with a fresh counter
|
|
76
|
+
gives the number of lines it could not read. `size_reader` is required and has no default, so
|
|
77
|
+
the size arithmetic stays where `current_context` keeps it and is never restated here.
|
|
78
|
+
"""
|
|
79
|
+
outcome = files.read_outcome(path)
|
|
80
|
+
if outcome.lines is None:
|
|
81
|
+
return ContextReading(context_size=None, unreadable_transcripts=0 if outcome.missing else 1)
|
|
82
|
+
snapshot = _Snapshot(outcome.lines)
|
|
83
|
+
context_size = size_reader(path, snapshot)
|
|
84
|
+
counter = run_usage.UnreadableLines()
|
|
85
|
+
for _record in run_usage.usage_records(path, snapshot, unreadable_lines=counter):
|
|
86
|
+
pass
|
|
87
|
+
return ContextReading(context_size=context_size, unreadable_lines=counter.count)
|