stdtel 0.2.3__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.
- eval/__init__.py +0 -0
- eval/fixtures/fastapi-min/app/main.py +14 -0
- eval/power.py +199 -0
- eval/run_eval.py +139 -0
- stdtel/__init__.py +2 -0
- stdtel/doctor.py +211 -0
- stdtel/enrich.py +59 -0
- stdtel/exporter.py +113 -0
- stdtel/hooks/__init__.py +0 -0
- stdtel/hooks/cli.py +318 -0
- stdtel/install.py +173 -0
- stdtel/manifest.py +217 -0
- stdtel/policy_report.py +126 -0
- stdtel/skillmap.py +15 -0
- stdtel/spool.py +124 -0
- stdtel/spool_export.py +86 -0
- stdtel/state.py +118 -0
- stdtel/statusline.py +101 -0
- stdtel/transcript.py +174 -0
- stdtel-0.2.3.dist-info/METADATA +246 -0
- stdtel-0.2.3.dist-info/RECORD +24 -0
- stdtel-0.2.3.dist-info/WHEEL +5 -0
- stdtel-0.2.3.dist-info/entry_points.txt +9 -0
- stdtel-0.2.3.dist-info/top_level.txt +2 -0
stdtel/exporter.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""OTel span emission for std.skill.invocation. Metadata only — never content."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
from opentelemetry import trace
|
|
9
|
+
from opentelemetry.sdk.resources import Resource
|
|
10
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
11
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter, SimpleSpanProcessor
|
|
12
|
+
|
|
13
|
+
SPAN_NAME = "std.skill.invocation"
|
|
14
|
+
SESSION_SPAN_NAME = "std.session.cost"
|
|
15
|
+
DEFAULT_ENDPOINT = "http://localhost:4318"
|
|
16
|
+
DEFAULT_TIMEOUT_S = 2
|
|
17
|
+
# Widened when the hook began seeing every tool, not just Skill: tool_input and
|
|
18
|
+
# tool_response carry file contents, commands and diffs, none of which may leave
|
|
19
|
+
# the machine. Counts only.
|
|
20
|
+
FORBIDDEN_PREFIXES = ("gen_ai.input", "gen_ai.output", "gen_ai.prompt", "gen_ai.completion",
|
|
21
|
+
"tool.input", "tool.output", "tool.arguments", "tool.result",
|
|
22
|
+
"tool_input", "tool_response", "std.tool.input", "std.tool.output")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_provider(resource_attrs: dict, exporter: SpanExporter | None = None) -> TracerProvider:
|
|
26
|
+
base = {"service.name": os.environ.get("OTEL_SERVICE_NAME", "stdtel"),
|
|
27
|
+
"std.harness": resource_attrs.get("std.harness", "claude-code")}
|
|
28
|
+
base.update({k: v for k, v in resource_attrs.items() if v is not None})
|
|
29
|
+
provider = TracerProvider(resource=Resource.create(base))
|
|
30
|
+
if exporter is None:
|
|
31
|
+
provider.add_span_processor(BatchSpanProcessor(_otlp_exporter()))
|
|
32
|
+
else:
|
|
33
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
34
|
+
return provider
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _endpoint() -> str:
|
|
38
|
+
"""Full traces endpoint URL.
|
|
39
|
+
|
|
40
|
+
Claude Code strips `OTEL_*` from every subprocess it spawns, so a hook can
|
|
41
|
+
never see OTEL_EXPORTER_OTLP_ENDPOINT no matter where it is set — it would
|
|
42
|
+
silently fall back to localhost. STDTEL_OTLP_ENDPOINT survives the scrub and
|
|
43
|
+
wins; the OTEL_* names stay as a fallback for direct CLI/CI use, where
|
|
44
|
+
nothing scrubs them.
|
|
45
|
+
"""
|
|
46
|
+
base = (os.environ.get("STDTEL_OTLP_ENDPOINT")
|
|
47
|
+
or os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
|
|
48
|
+
or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
|
|
49
|
+
or DEFAULT_ENDPOINT).rstrip("/")
|
|
50
|
+
return base if base.endswith("/v1/traces") else f"{base}/v1/traces"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _timeout() -> int:
|
|
54
|
+
try:
|
|
55
|
+
return max(1, int(os.environ.get("STDTEL_OTLP_TIMEOUT", DEFAULT_TIMEOUT_S)))
|
|
56
|
+
except ValueError:
|
|
57
|
+
return DEFAULT_TIMEOUT_S
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _otlp_exporter():
|
|
61
|
+
"""OTLP/HTTP exporter that fails fast.
|
|
62
|
+
|
|
63
|
+
The timeout must be set on the *exporter*: force_flush(timeout_millis=...)
|
|
64
|
+
is ignored (open-telemetry/opentelemetry-python#4043), and the env var that
|
|
65
|
+
would otherwise bound it (OTEL_EXPORTER_OTLP_TIMEOUT) is scrubbed before the
|
|
66
|
+
hook ever runs. Unbounded, a dead collector stalls the Stop hook for ~7s of
|
|
67
|
+
retry backoff on every turn.
|
|
68
|
+
"""
|
|
69
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
70
|
+
|
|
71
|
+
return OTLPSpanExporter(endpoint=_endpoint(), timeout=_timeout())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def scrub(attrs: dict) -> dict:
|
|
75
|
+
"""Defence in depth: refuse to emit content attributes even if handed to us."""
|
|
76
|
+
return {k: v for k, v in attrs.items()
|
|
77
|
+
if not k.startswith(FORBIDDEN_PREFIXES) and isinstance(v, (str, int, float, bool))}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def emit_session_cost(provider: TracerProvider, attrs: dict, session_id: str,
|
|
81
|
+
started_at: float, ended_at: float) -> int:
|
|
82
|
+
"""One span per Stop carrying the session's whole token cost.
|
|
83
|
+
|
|
84
|
+
Deliberately overlaps std.skill.invocation: that span attributes a slice of
|
|
85
|
+
these tokens to a skill. The two must never be summed — session cost is the
|
|
86
|
+
total, invocation tail is a share of it.
|
|
87
|
+
"""
|
|
88
|
+
tracer = provider.get_tracer("stdtel", "0.1.0")
|
|
89
|
+
a = scrub(dict(attrs))
|
|
90
|
+
a["session.id"] = session_id
|
|
91
|
+
span = tracer.start_span(SESSION_SPAN_NAME, attributes=a, start_time=int(started_at * 1e9))
|
|
92
|
+
span.end(end_time=int(ended_at * 1e9))
|
|
93
|
+
return 1
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def emit_invocations(provider: TracerProvider, invocations: Iterable[dict], session_id: str) -> int:
|
|
97
|
+
"""Each invocation dict: started_at, ended_at, attributes(dict), error(bool)."""
|
|
98
|
+
tracer = provider.get_tracer("stdtel", "0.1.0")
|
|
99
|
+
n = 0
|
|
100
|
+
for inv in invocations:
|
|
101
|
+
start_ns = int(inv["started_at"] * 1e9)
|
|
102
|
+
end_ns = int((inv.get("ended_at") or time.time()) * 1e9)
|
|
103
|
+
attrs = scrub(inv.get("attributes", {}))
|
|
104
|
+
attrs["session.id"] = session_id
|
|
105
|
+
attrs["gen_ai.operation.name"] = "execute_tool"
|
|
106
|
+
attrs["gen_ai.tool.name"] = "Skill"
|
|
107
|
+
span = tracer.start_span(SPAN_NAME, attributes=attrs, start_time=start_ns)
|
|
108
|
+
if inv.get("error"):
|
|
109
|
+
span.set_status(trace.StatusCode.ERROR)
|
|
110
|
+
span.end(end_time=end_ns)
|
|
111
|
+
n += 1
|
|
112
|
+
provider.force_flush()
|
|
113
|
+
return n
|
stdtel/hooks/__init__.py
ADDED
|
File without changes
|
stdtel/hooks/cli.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Single entrypoint for Claude Code hooks: `stdtel-hook <event>`.
|
|
2
|
+
|
|
3
|
+
Reads the hook JSON payload from stdin. Events: session-start, pre-tool-use,
|
|
4
|
+
post-tool-use, stop. Always exits 0 so a telemetry failure never blocks the
|
|
5
|
+
developer (design §7: memory is best-effort, telemetry likewise).
|
|
6
|
+
|
|
7
|
+
Every stdtel import is deliberately function-local. PreToolUse and PostToolUse
|
|
8
|
+
fire on *every* Skill call and are pure latency in the developer's loop, so they
|
|
9
|
+
must not pay for OpenTelemetry (~19ms) or PyYAML when they never touch them.
|
|
10
|
+
Only `stop` needs the exporter; only the catalogue lookup needs the parser.
|
|
11
|
+
Module scope stays stdlib-only.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _payload() -> dict:
|
|
23
|
+
try:
|
|
24
|
+
return json.load(sys.stdin)
|
|
25
|
+
except Exception:
|
|
26
|
+
return {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def skills_roots() -> list[Path]:
|
|
30
|
+
"""Directories to scan for SKILL.md, in precedence order.
|
|
31
|
+
|
|
32
|
+
STDTEL_SKILLS_ROOT may name several roots separated by os.pathsep; a relative
|
|
33
|
+
one is resolved against CLAUDE_PROJECT_DIR (the project the hook fired in),
|
|
34
|
+
not the process cwd, so `skills` in a project settings file keeps working.
|
|
35
|
+
The user-level catalogue is always searched last, which is what makes hooks
|
|
36
|
+
registered once in ~/.claude/settings.json useful from every project.
|
|
37
|
+
"""
|
|
38
|
+
base = Path(os.environ.get("CLAUDE_PROJECT_DIR") or Path.cwd())
|
|
39
|
+
roots = []
|
|
40
|
+
for raw in os.environ.get("STDTEL_SKILLS_ROOT", "").split(os.pathsep):
|
|
41
|
+
if raw.strip():
|
|
42
|
+
root = Path(raw).expanduser()
|
|
43
|
+
roots.append(root if root.is_absolute() else base / root)
|
|
44
|
+
roots.append(Path.home() / ".claude" / "skills")
|
|
45
|
+
out, seen = [], set()
|
|
46
|
+
for root in roots:
|
|
47
|
+
try:
|
|
48
|
+
resolved = root.resolve()
|
|
49
|
+
except OSError:
|
|
50
|
+
continue
|
|
51
|
+
if resolved not in seen and resolved.is_dir():
|
|
52
|
+
seen.add(resolved)
|
|
53
|
+
out.append(root)
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _catalogue():
|
|
58
|
+
"""Merged catalogue across roots; earlier roots win on name collisions."""
|
|
59
|
+
from stdtel.manifest import load_catalogue # pulls in PyYAML
|
|
60
|
+
|
|
61
|
+
cat: dict = {}
|
|
62
|
+
for root in skills_roots():
|
|
63
|
+
try:
|
|
64
|
+
found = load_catalogue(root, strict=False)
|
|
65
|
+
except Exception:
|
|
66
|
+
continue
|
|
67
|
+
for name, manifest in found.items():
|
|
68
|
+
cat.setdefault(name, manifest)
|
|
69
|
+
return cat
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _skill_from_payload(p: dict) -> tuple[str, str]:
|
|
73
|
+
"""(skill name as invoked, trigger).
|
|
74
|
+
|
|
75
|
+
Field name verified against 120 real Skill invocations across 114 transcripts
|
|
76
|
+
in ~/.claude/projects: the input carries `skill` every time (never `name`,
|
|
77
|
+
never `skill_name` — that is the OTel event surface, a different payload).
|
|
78
|
+
`args` also appears and is deliberately not captured: it can hold content.
|
|
79
|
+
|
|
80
|
+
Trigger is provisional here. The transcript carries the tool_use `caller`
|
|
81
|
+
block, which is ground truth, so Stop upgrades this value; a name is never
|
|
82
|
+
slash-prefixed in the observed data, so nothing sets "explicit" in practice.
|
|
83
|
+
"""
|
|
84
|
+
inp = p.get("tool_input") or p.get("toolArgs") or {}
|
|
85
|
+
name = str(inp.get("skill") or inp.get("name") or "")
|
|
86
|
+
caller = p.get("caller")
|
|
87
|
+
if name.startswith("/"):
|
|
88
|
+
trigger = "explicit"
|
|
89
|
+
elif isinstance(caller, dict) and caller.get("type"):
|
|
90
|
+
trigger = str(caller["type"])
|
|
91
|
+
else:
|
|
92
|
+
trigger = "unknown"
|
|
93
|
+
return name.lstrip("/"), trigger
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _resolve(name: str, cat: dict):
|
|
97
|
+
"""(manifest | None, catalogue name) for a skill as invoked.
|
|
98
|
+
|
|
99
|
+
Plugin-provided skills arrive namespaced — `epic-loop:epic-loop`,
|
|
100
|
+
`anthropic-skills:skill-creator` — which is 71% of real invocations, and is
|
|
101
|
+
what every skill looks like once distributed as a plugin. The catalogue is
|
|
102
|
+
keyed on the bare front-matter `name`, so match the full string first, then
|
|
103
|
+
the segment after the last ":".
|
|
104
|
+
"""
|
|
105
|
+
if name in cat:
|
|
106
|
+
return cat[name], name
|
|
107
|
+
bare = name.rsplit(":", 1)[-1]
|
|
108
|
+
if bare in cat:
|
|
109
|
+
return cat[bare], bare
|
|
110
|
+
return None, bare
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def session_start(p: dict) -> None:
|
|
114
|
+
from stdtel.enrich import resource_attributes
|
|
115
|
+
from stdtel.state import SessionState
|
|
116
|
+
|
|
117
|
+
st = SessionState.load(p.get("session_id", "unknown"))
|
|
118
|
+
st.resource = resource_attributes(Path(p.get("cwd", ".")), payload=p)
|
|
119
|
+
st.started_at = st.started_at or time.time()
|
|
120
|
+
st.save()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def pre_tool_use(p: dict) -> None:
|
|
124
|
+
if p.get("tool_name") != "Skill":
|
|
125
|
+
return
|
|
126
|
+
from stdtel.state import SessionState
|
|
127
|
+
|
|
128
|
+
st = SessionState.load(p.get("session_id", "unknown"))
|
|
129
|
+
name, trigger = _skill_from_payload(p)
|
|
130
|
+
# Version is resolved from the catalogue at Stop, which loads it anyway to
|
|
131
|
+
# attach the rest of the manifest attributes. Reading it here too would put
|
|
132
|
+
# a PyYAML parse of every SKILL.md on the hot path for a value Stop discards.
|
|
133
|
+
st.open_window(name, "unversioned", trigger, p.get("tool_use_id"),
|
|
134
|
+
prompt_id=str(p.get("prompt_id") or ""),
|
|
135
|
+
permission_mode=str(p.get("permission_mode") or ""))
|
|
136
|
+
st.save()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def post_tool_use(p: dict, error: bool = False) -> None:
|
|
140
|
+
"""Fires for every tool, not just Skill.
|
|
141
|
+
|
|
142
|
+
Tool-call failure rate is a first-class metric and a leading indicator of a
|
|
143
|
+
skill instructing the model to do something the environment cannot do. The
|
|
144
|
+
non-Skill path stays at the interpreter floor: state only, no catalogue, no
|
|
145
|
+
exporter, and counts only — tool_input and tool_response never leave here.
|
|
146
|
+
"""
|
|
147
|
+
from stdtel.state import SessionState
|
|
148
|
+
|
|
149
|
+
tool = str(p.get("tool_name") or p.get("toolName") or "")
|
|
150
|
+
st = SessionState.load(p.get("session_id", "unknown"))
|
|
151
|
+
st.record_tool(tool, failed=error)
|
|
152
|
+
if tool != "Skill":
|
|
153
|
+
st.save()
|
|
154
|
+
return
|
|
155
|
+
w = st.close_window(p.get("tool_use_id"), error=error)
|
|
156
|
+
if w is not None and p.get("duration_ms"):
|
|
157
|
+
w.duration_ms = int(p["duration_ms"]) # the harness times the tool call itself
|
|
158
|
+
st.save()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def stop(p: dict, exporter=None) -> int:
|
|
162
|
+
from stdtel.exporter import build_provider, emit_invocations, emit_session_cost
|
|
163
|
+
from stdtel.state import SessionState
|
|
164
|
+
from stdtel.transcript import attribute, read_slice
|
|
165
|
+
|
|
166
|
+
sid = p.get("session_id", "unknown")
|
|
167
|
+
st = SessionState.load(sid)
|
|
168
|
+
transcript = Path(p.get("transcript_path", ""))
|
|
169
|
+
sl = read_slice(transcript, st.transcript_offset)
|
|
170
|
+
st.transcript_offset = sl.new_offset
|
|
171
|
+
attributions = {a.skill: a for a in attribute(sl)}
|
|
172
|
+
loads = {l.skill: l for l in sl.skill_loads}
|
|
173
|
+
cat = _catalogue()
|
|
174
|
+
# any window still open at Stop is closed now (turn ended)
|
|
175
|
+
for w in st.open_windows():
|
|
176
|
+
st.close_window(w.tool_use_id)
|
|
177
|
+
invocations = []
|
|
178
|
+
for w in st.drain_closed():
|
|
179
|
+
manifest, resolved = _resolve(w.skill, cat)
|
|
180
|
+
if manifest is not None and not manifest.telemetry_emit:
|
|
181
|
+
# `telemetry.emit: false` in SKILL.md. Parsed and validated since the
|
|
182
|
+
# first commit, and until now read by nothing — an advertised control
|
|
183
|
+
# that did nothing. The session total still counts these tokens; what
|
|
184
|
+
# is suppressed is attributing them to this skill by name.
|
|
185
|
+
continue
|
|
186
|
+
load = loads.get(w.skill)
|
|
187
|
+
attrs = {
|
|
188
|
+
"std.skill.name": resolved,
|
|
189
|
+
"std.skill.invoked_as": w.skill,
|
|
190
|
+
"std.skill.version": w.version,
|
|
191
|
+
# the transcript's caller block is ground truth; the hook payload may not carry it
|
|
192
|
+
"std.skill.trigger": (load.caller if load and load.caller else w.trigger),
|
|
193
|
+
"std.skill.load_tokens": load.load_tokens if load else 0,
|
|
194
|
+
}
|
|
195
|
+
if ":" in w.skill:
|
|
196
|
+
attrs["std.skill.plugin"] = w.skill.rsplit(":", 1)[0]
|
|
197
|
+
if w.prompt_id:
|
|
198
|
+
# join key to Claude Code's native claude_code.* telemetry
|
|
199
|
+
attrs["std.prompt.id"] = w.prompt_id
|
|
200
|
+
if w.permission_mode:
|
|
201
|
+
attrs["std.harness.permission_mode"] = w.permission_mode
|
|
202
|
+
if w.duration_ms:
|
|
203
|
+
attrs["std.skill.duration_ms"] = w.duration_ms
|
|
204
|
+
if manifest:
|
|
205
|
+
attrs.update(manifest.as_attributes())
|
|
206
|
+
a = attributions.get(w.skill)
|
|
207
|
+
if a:
|
|
208
|
+
attrs["std.skill.tail_tokens"] = a.tail.total
|
|
209
|
+
attrs["std.skill.tail_tokens_first_only"] = a.tail_first_only.total
|
|
210
|
+
attrs["std.skill.llm_requests"] = a.request_count
|
|
211
|
+
attrs["gen_ai.request.model"] = a.models[0] if a.models else "unknown"
|
|
212
|
+
attrs.update(a.tail.as_attributes())
|
|
213
|
+
invocations.append({"started_at": w.started_at, "ended_at": w.ended_at,
|
|
214
|
+
"attributes": attrs, "error": w.error})
|
|
215
|
+
# The session's whole cost, emitted whether or not a skill was ever loaded.
|
|
216
|
+
# Without this a session that used no skill produces no telemetry at all, and
|
|
217
|
+
# cost-per-PR has no denominator (CLAUDE.md: unattributed sessions are kept
|
|
218
|
+
# for cost analysis, excluded from outcome analysis).
|
|
219
|
+
totals = sl.totals()
|
|
220
|
+
tool_calls, tool_failures = st.tool_totals()
|
|
221
|
+
session_attrs = {}
|
|
222
|
+
if sl.requests or tool_calls:
|
|
223
|
+
session_attrs = {
|
|
224
|
+
"std.session.llm_requests": len(sl.requests),
|
|
225
|
+
"gen_ai.request.model": (sl.models() or ["unknown"])[0],
|
|
226
|
+
"std.session.tool_calls": tool_calls,
|
|
227
|
+
"std.session.tool_failures": tool_failures,
|
|
228
|
+
**totals.as_attributes(),
|
|
229
|
+
}
|
|
230
|
+
# per-tool counts as std.session.tool.<name>.{calls,failures}
|
|
231
|
+
for name, (calls, failures) in sorted(st.tool_calls.items()):
|
|
232
|
+
session_attrs[f"std.session.tool.{name}.calls"] = calls
|
|
233
|
+
if failures:
|
|
234
|
+
session_attrs[f"std.session.tool.{name}.failures"] = failures
|
|
235
|
+
st.tool_calls = {} # drained with the windows
|
|
236
|
+
st.save()
|
|
237
|
+
if not invocations and not session_attrs:
|
|
238
|
+
return 0
|
|
239
|
+
# ADR-008: when spooling, the hook writes to disk and opens no socket at all.
|
|
240
|
+
# stdtel-export drains it. A hook that never talks to the network cannot stall
|
|
241
|
+
# on one, which is what "never block the developer" was reaching for.
|
|
242
|
+
if exporter is None and _spooling():
|
|
243
|
+
from stdtel.exporter import SESSION_SPAN_NAME, SPAN_NAME
|
|
244
|
+
from stdtel.spool import append
|
|
245
|
+
rows = [{"name": SPAN_NAME, "session_id": sid, "resource": st.resource,
|
|
246
|
+
"started_at": i["started_at"], "ended_at": i["ended_at"],
|
|
247
|
+
"attributes": i["attributes"], "error": i.get("error", False)}
|
|
248
|
+
for i in invocations]
|
|
249
|
+
if session_attrs:
|
|
250
|
+
rows.append({"name": SESSION_SPAN_NAME, "session_id": sid, "resource": st.resource,
|
|
251
|
+
"started_at": (st.started_at or time.time()), "ended_at": time.time(),
|
|
252
|
+
"attributes": session_attrs})
|
|
253
|
+
st.last_export_ok = True # spooled successfully; export is someone else's job
|
|
254
|
+
st.save()
|
|
255
|
+
return append(rows)
|
|
256
|
+
|
|
257
|
+
provider = build_provider(st.resource, exporter=exporter)
|
|
258
|
+
try:
|
|
259
|
+
emitted = emit_invocations(provider, invocations, sid) if invocations else 0
|
|
260
|
+
st.last_export_ok = True
|
|
261
|
+
except Exception:
|
|
262
|
+
st.last_export_ok = False
|
|
263
|
+
st.save()
|
|
264
|
+
raise
|
|
265
|
+
if session_attrs:
|
|
266
|
+
# Session start comes from state, not the transcript: transcript timestamps
|
|
267
|
+
# can be absent or unparseable, and a start near the epoch turns the span's
|
|
268
|
+
# duration into "seconds since 1970" rather than the session's length.
|
|
269
|
+
now = time.time()
|
|
270
|
+
started = st.started_at or min((i["started_at"] for i in invocations), default=now)
|
|
271
|
+
emitted += emit_session_cost(provider, session_attrs, sid, started, now)
|
|
272
|
+
st.save()
|
|
273
|
+
return emitted
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _spooling() -> bool:
|
|
277
|
+
"""Write spans to disk rather than exporting inline (ADR-008)."""
|
|
278
|
+
return os.environ.get("STDTEL_SPOOL", "").strip().lower() in ("1", "true", "yes", "on")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def disabled() -> bool:
|
|
282
|
+
"""True when the developer has switched telemetry off.
|
|
283
|
+
|
|
284
|
+
Checked before the payload is even read: an opt-out that still parses your
|
|
285
|
+
transcript is not an opt-out.
|
|
286
|
+
"""
|
|
287
|
+
return os.environ.get("STDTEL_DISABLED", "").strip().lower() in ("1", "true", "yes", "on")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def main(argv: list[str] | None = None) -> int:
|
|
291
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
292
|
+
event = argv[0] if argv else ""
|
|
293
|
+
if disabled():
|
|
294
|
+
return 0
|
|
295
|
+
p = _payload()
|
|
296
|
+
try:
|
|
297
|
+
if event == "session-start":
|
|
298
|
+
session_start(p)
|
|
299
|
+
elif event == "pre-tool-use":
|
|
300
|
+
pre_tool_use(p)
|
|
301
|
+
elif event == "post-tool-use":
|
|
302
|
+
post_tool_use(p)
|
|
303
|
+
elif event == "post-tool-use-failure":
|
|
304
|
+
post_tool_use(p, error=True)
|
|
305
|
+
elif event == "stop":
|
|
306
|
+
stop(p)
|
|
307
|
+
else:
|
|
308
|
+
# still exit 0: an unknown event must never block the developer
|
|
309
|
+
print(f"stdtel-hook: unknown event {event!r}; expected one of "
|
|
310
|
+
f"session-start, pre-tool-use, post-tool-use, "
|
|
311
|
+
f"post-tool-use-failure, stop", file=sys.stderr)
|
|
312
|
+
except Exception as e: # never block the developer
|
|
313
|
+
print(f"stdtel: {e}", file=sys.stderr)
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
if __name__ == "__main__":
|
|
318
|
+
raise SystemExit(main())
|
stdtel/install.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Render hook configuration bound to *this* installation's absolute path.
|
|
2
|
+
|
|
3
|
+
Hook processes get a non-login `sh -c` and inherit whatever PATH launched the
|
|
4
|
+
harness, so a bare `stdtel-hook` is not resolvable when a version manager (mise,
|
|
5
|
+
asdf, pyenv) or an activated venv is what put it on PATH. Every comparable tool
|
|
6
|
+
solves this the same way — pre-commit bakes `sys.executable` into the generated
|
|
7
|
+
git hook at install time — so we resolve the absolute path here and write it in.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import shlex
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
HOOK_NAME = "stdtel-hook"
|
|
19
|
+
# What the *shipped plugin* manifests invoke. A distributed manifest cannot know
|
|
20
|
+
# the install path, and a bare name does not resolve in a hook's `sh -c` — that
|
|
21
|
+
# failed with "command not found" on every tool call (issue #13). bin/stdtel-hook
|
|
22
|
+
# resolves the real CLI at run time and exits 0 silently when it is absent.
|
|
23
|
+
PLUGIN_LAUNCHER = "${CLAUDE_PLUGIN_ROOT}/bin/stdtel-hook"
|
|
24
|
+
|
|
25
|
+
# (stdtel event, Claude Code event, tool matcher, Copilot event)
|
|
26
|
+
# PreToolUse stays matched to Skill: it only opens skill windows, and firing it on
|
|
27
|
+
# every tool would be pure latency. PostToolUse is unmatched because tool-call
|
|
28
|
+
# failure rate needs every tool, and the non-Skill path is a counter increment.
|
|
29
|
+
EVENTS = (
|
|
30
|
+
("session-start", "SessionStart", None, "sessionStart"),
|
|
31
|
+
("pre-tool-use", "PreToolUse", "Skill", "preToolUse"),
|
|
32
|
+
("post-tool-use", "PostToolUse", None, "postToolUse"),
|
|
33
|
+
("post-tool-use-failure", "PostToolUseFailure", None, "postToolUseFailure"),
|
|
34
|
+
("stop", "Stop", None, "agentStop"),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class InstallError(RuntimeError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def hook_binary() -> Path:
|
|
43
|
+
"""Absolute path to the stdtel-hook belonging to this installation.
|
|
44
|
+
|
|
45
|
+
Prefer the console script beside the running interpreter — for `uv tool
|
|
46
|
+
install` and pipx that is the tool's own bin directory — and only then fall
|
|
47
|
+
back to PATH, which may resolve to a different install.
|
|
48
|
+
"""
|
|
49
|
+
# NOT .resolve(): in a venv sys.executable is a symlink to the base
|
|
50
|
+
# interpreter, and resolving it lands in the base install's bin/, not the
|
|
51
|
+
# venv's — where the console script we want does not exist.
|
|
52
|
+
sibling = Path(sys.executable).parent / HOOK_NAME
|
|
53
|
+
if sibling.is_file():
|
|
54
|
+
return sibling
|
|
55
|
+
found = shutil.which(HOOK_NAME)
|
|
56
|
+
if found:
|
|
57
|
+
return Path(found).resolve()
|
|
58
|
+
raise InstallError(
|
|
59
|
+
f"{HOOK_NAME} not found beside {sys.executable} or on PATH; "
|
|
60
|
+
f"install with `uv tool install stdtel` (or `pipx install stdtel`) first")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _entry(binary: Path, event: str, exec_form: bool, env: dict | None, timeout: int | None) -> dict:
|
|
64
|
+
"""One hook entry.
|
|
65
|
+
|
|
66
|
+
Shell form (default) is a single quoted absolute path — the shape Claude Code
|
|
67
|
+
has always accepted. Exec form skips `sh -c` and saves ~3ms per call, but is
|
|
68
|
+
opt-in because we have not executed it against a live harness.
|
|
69
|
+
"""
|
|
70
|
+
if exec_form:
|
|
71
|
+
entry: dict = {"type": "command", "command": str(binary), "args": [event]}
|
|
72
|
+
else:
|
|
73
|
+
entry = {"type": "command", "command": f"{shlex.quote(str(binary))} {event}"}
|
|
74
|
+
if timeout is not None:
|
|
75
|
+
entry["timeout"] = timeout
|
|
76
|
+
if env:
|
|
77
|
+
entry["env"] = dict(env)
|
|
78
|
+
return entry
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def claude_hooks(binary: "Path | str", exec_form: bool = False) -> dict:
|
|
82
|
+
hooks: dict = {}
|
|
83
|
+
for event, cc_event, matcher, _ in EVENTS:
|
|
84
|
+
group: dict = {"hooks": [_entry(binary, event, exec_form, None, None)]}
|
|
85
|
+
if matcher:
|
|
86
|
+
group["matcher"] = matcher
|
|
87
|
+
hooks[cc_event] = [group]
|
|
88
|
+
return {"hooks": hooks}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def copilot_hooks(binary: "Path | str", harness: str = "copilot-vscode",
|
|
92
|
+
exec_form: bool = False) -> dict:
|
|
93
|
+
"""Copilot hook config.
|
|
94
|
+
|
|
95
|
+
STDTEL_HARNESS is set per hook on purpose. Copilot also reads
|
|
96
|
+
`~/.claude/settings.json`, and its snake_case dialect is indistinguishable
|
|
97
|
+
from Claude Code's in the payload, so this env block is the only thing that
|
|
98
|
+
keeps Copilot activity from being recorded as claude-code.
|
|
99
|
+
"""
|
|
100
|
+
hooks: dict = {}
|
|
101
|
+
for event, _, matcher, cop_event in EVENTS:
|
|
102
|
+
entry = _entry(binary, event, exec_form, {"STDTEL_HARNESS": harness}, None)
|
|
103
|
+
group: dict = {"hooks": [entry]}
|
|
104
|
+
if matcher:
|
|
105
|
+
group["matcher"] = matcher
|
|
106
|
+
hooks[cop_event] = [group]
|
|
107
|
+
return {"version": 1, "disableAllHooks": False, "hooks": hooks}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def merge_settings(target: Path, block: dict) -> dict:
|
|
111
|
+
"""Merge hooks/env into an existing settings file without clobbering it."""
|
|
112
|
+
current = {}
|
|
113
|
+
if target.is_file():
|
|
114
|
+
try:
|
|
115
|
+
current = json.loads(target.read_text() or "{}")
|
|
116
|
+
except json.JSONDecodeError as e:
|
|
117
|
+
raise InstallError(f"{target} is not valid JSON: {e}") from e
|
|
118
|
+
for key, value in block.items():
|
|
119
|
+
if isinstance(value, dict):
|
|
120
|
+
current.setdefault(key, {}).update(value)
|
|
121
|
+
else:
|
|
122
|
+
current[key] = value
|
|
123
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
target.write_text(json.dumps(current, indent=2) + "\n")
|
|
125
|
+
return current
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _dump(obj: dict) -> str:
|
|
129
|
+
return json.dumps(obj, indent=2)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def main(argv: list[str] | None = None) -> int:
|
|
133
|
+
parser = argparse.ArgumentParser(prog="stdtel-install", description=__doc__.splitlines()[0])
|
|
134
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
135
|
+
|
|
136
|
+
p_hooks = sub.add_parser("hooks", help="print hook config for a harness")
|
|
137
|
+
p_hooks.add_argument("--harness", default="claude-code",
|
|
138
|
+
choices=["claude-code", "copilot-vscode", "copilot-cli"])
|
|
139
|
+
p_hooks.add_argument("--exec-form", action="store_true",
|
|
140
|
+
help="use command+args instead of a shell string (unverified upstream)")
|
|
141
|
+
|
|
142
|
+
p_set = sub.add_parser("settings", help="merge hooks into a Claude Code settings.json")
|
|
143
|
+
p_set.add_argument("--path", type=Path, default=Path.home() / ".claude" / "settings.json")
|
|
144
|
+
p_set.add_argument("--exec-form", action="store_true")
|
|
145
|
+
p_set.add_argument("--dry-run", action="store_true")
|
|
146
|
+
|
|
147
|
+
p_where = sub.add_parser("where", help="print the resolved absolute hook path")
|
|
148
|
+
args = parser.parse_args(sys.argv[1:] if argv is None else argv)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
binary = hook_binary()
|
|
152
|
+
except InstallError as e:
|
|
153
|
+
print(f"stdtel-install: {e}", file=sys.stderr)
|
|
154
|
+
return 1
|
|
155
|
+
|
|
156
|
+
if args.cmd == "where":
|
|
157
|
+
print(binary)
|
|
158
|
+
elif args.cmd == "hooks":
|
|
159
|
+
block = (claude_hooks(binary, args.exec_form) if args.harness == "claude-code"
|
|
160
|
+
else copilot_hooks(binary, args.harness, args.exec_form))
|
|
161
|
+
print(_dump(block))
|
|
162
|
+
elif args.cmd == "settings":
|
|
163
|
+
block = claude_hooks(binary, args.exec_form)
|
|
164
|
+
if args.dry_run:
|
|
165
|
+
print(_dump(block))
|
|
166
|
+
else:
|
|
167
|
+
merge_settings(args.path, block)
|
|
168
|
+
print(f"wrote {len(block['hooks'])} hook events to {args.path} -> {binary}")
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
raise SystemExit(main())
|