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.
@@ -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"]
@@ -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)