@yagni-app/code 1.0.6 → 1.0.8
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.
- package/README.md +91 -46
- package/dist/claudeCompat.d.ts +59 -0
- package/dist/claudeCompat.js +109 -2
- package/dist/claudePlugins.d.ts +45 -5
- package/dist/claudePlugins.js +129 -21
- package/dist/cli.js +16 -4
- package/dist/doctor.d.ts +21 -8
- package/dist/doctor.js +53 -28
- package/dist/extension/askUserQuestionTool.js +7 -2
- package/dist/extension/condensedTools.d.ts +12 -1
- package/dist/extension/condensedTools.js +17 -9
- package/dist/extension/config.d.ts +6 -0
- package/dist/extension/hooks.d.ts +3 -3
- package/dist/extension/hooks.js +30 -5
- package/dist/extension/index.d.ts +19 -0
- package/dist/extension/index.js +248 -43
- package/dist/extension/mcp/cliConfig.d.ts +1 -1
- package/dist/extension/mcp/cliConfig.js +1 -1
- package/dist/extension/mcp/config.d.ts +24 -2
- package/dist/extension/mcp/config.js +75 -3
- package/dist/extension/mcp/manager.d.ts +3 -1
- package/dist/extension/mcp/manager.js +2 -2
- package/dist/extension/mcp/panel.d.ts +0 -1
- package/dist/extension/mcp/panel.js +13 -3
- package/dist/extension/mcp/startup.js +8 -6
- package/dist/extension/permission/gate.d.ts +101 -2
- package/dist/extension/permission/gate.js +485 -42
- package/dist/extension/permissionRules/bashFileArgs.d.ts +39 -0
- package/dist/extension/permissionRules/bashFileArgs.js +236 -0
- package/dist/extension/permissionRules/engine.d.ts +50 -0
- package/dist/extension/permissionRules/engine.js +238 -0
- package/dist/extension/permissionRules/loadConfig.d.ts +64 -0
- package/dist/extension/permissionRules/loadConfig.js +105 -0
- package/dist/extension/permissionRules/parser.d.ts +38 -0
- package/dist/extension/permissionRules/parser.js +136 -0
- package/dist/extension/permissionRules/pathRules.d.ts +60 -0
- package/dist/extension/permissionRules/pathRules.js +122 -0
- package/dist/extension/permissionRules/shellRules.d.ts +52 -0
- package/dist/extension/permissionRules/shellRules.js +221 -0
- package/dist/extension/pipeline/invocation.d.ts +3 -6
- package/dist/extension/pipeline/invocation.js +3 -6
- package/dist/extension/pipeline/runner.d.ts +0 -1
- package/dist/extension/pipeline/runner.js +6 -14
- package/dist/extension/plugins/inventory.d.ts +88 -0
- package/dist/extension/plugins/inventory.js +144 -0
- package/dist/extension/plugins/panel.d.ts +45 -0
- package/dist/extension/plugins/panel.js +293 -0
- package/dist/extension/sandbox/bash.d.ts +99 -0
- package/dist/extension/sandbox/bash.js +190 -0
- package/dist/extension/sandbox/config.d.ts +115 -0
- package/dist/extension/sandbox/config.js +388 -0
- package/dist/extension/sandbox/manager.d.ts +108 -0
- package/dist/extension/sandbox/manager.js +243 -0
- package/dist/extension/sandbox/panel.d.ts +111 -0
- package/dist/extension/sandbox/panel.js +342 -0
- package/dist/extension/sandbox/session.d.ts +85 -0
- package/dist/extension/sandbox/session.js +829 -0
- package/dist/extension/settingsFiles.d.ts +50 -0
- package/dist/extension/settingsFiles.js +206 -0
- package/dist/extension/telemetry/attrs.d.ts +96 -0
- package/dist/extension/telemetry/attrs.js +149 -0
- package/dist/extension/telemetry/config.d.ts +103 -0
- package/dist/extension/telemetry/config.js +193 -0
- package/dist/extension/telemetry/index.d.ts +7 -0
- package/dist/extension/telemetry/index.js +7 -0
- package/dist/extension/telemetry/probe.d.ts +29 -0
- package/dist/extension/telemetry/probe.js +122 -0
- package/dist/extension/telemetry/register.d.ts +47 -0
- package/dist/extension/telemetry/register.js +207 -0
- package/dist/extension/telemetry/sdk.d.ts +63 -0
- package/dist/extension/telemetry/sdk.js +207 -0
- package/dist/extension/telemetry/tracker.d.ts +131 -0
- package/dist/extension/telemetry/tracker.js +551 -0
- package/dist/extension/vendor/IGNORE-LICENSE-MIT +21 -0
- package/dist/extension/vendor/ignore.d.ts +86 -0
- package/dist/extension/vendor/ignore.js +788 -0
- package/dist/goHeadless.d.ts +1 -1
- package/dist/goHeadless.js +2 -2
- package/dist/launch.d.ts +4 -3
- package/dist/launch.js +7 -4
- package/dist/mcpCommand.d.ts +10 -1
- package/dist/mcpCommand.js +42 -10
- package/dist/otel.d.ts +67 -90
- package/dist/otel.js +152 -195
- package/dist/paths.d.ts +13 -0
- package/dist/paths.js +18 -0
- package/dist/pluginCommand.d.ts +43 -0
- package/dist/pluginCommand.js +499 -0
- package/dist/pluginStore.d.ts +170 -0
- package/dist/pluginStore.js +554 -0
- package/dist/upgrade.js +10 -1
- package/package.json +19 -3
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session telemetry tracker: turns pi's lifecycle into spans, metrics, and
|
|
3
|
+
* log events. Pure with respect to pi — `register.ts` feeds it events, tests
|
|
4
|
+
* feed it directly — and it only ever talks to the OTel API (tracer, meter,
|
|
5
|
+
* logger), never to exporters.
|
|
6
|
+
*
|
|
7
|
+
* Shape (Claude Code parity, prefix `yagni_code`):
|
|
8
|
+
*
|
|
9
|
+
* spans yagni_code.interaction ─┬─ yagni_code.turn ─┬─ yagni_code.llm_request
|
|
10
|
+
* │ └─ yagni_code.tool
|
|
11
|
+
* metrics session.count, token.usage, cost.usage, lines_of_code.count,
|
|
12
|
+
* commit.count, pull_request.count, code_edit_tool.decision,
|
|
13
|
+
* active_time.total
|
|
14
|
+
* events user_prompt, assistant_response, api_request, api_error,
|
|
15
|
+
* tool_result, tool_decision, permission_mode_changed
|
|
16
|
+
*
|
|
17
|
+
* Metadata only. Prompt text, model output, tool arguments, and tool results
|
|
18
|
+
* never become attributes — only their lengths.
|
|
19
|
+
*/
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
import { context as otelContext, SpanStatusCode, trace, } from "@opentelemetry/api";
|
|
22
|
+
import { SeverityNumber } from "@opentelemetry/api-logs";
|
|
23
|
+
import { ATTR_APP_ENTRYPOINT, ATTR_APP_VERSION, ATTR_ERROR_TYPE, ATTR_GEN_AI_AGENT_NAME, ATTR_GEN_AI_CACHE_CREATION_TOKENS_LEGACY, ATTR_GEN_AI_CACHE_READ_TOKENS, ATTR_GEN_AI_CACHE_READ_TOKENS_LEGACY, ATTR_GEN_AI_CACHE_WRITE_TOKENS, ATTR_GEN_AI_CONVERSATION_ID, ATTR_GEN_AI_COST_ESTIMATED_TOTAL, ATTR_GEN_AI_FINISH_REASONS, ATTR_GEN_AI_INPUT_TOKENS, ATTR_GEN_AI_OPERATION_NAME, ATTR_GEN_AI_OUTPUT_TOKENS, ATTR_GEN_AI_TOTAL_TOKENS, ATTR_GEN_AI_PROVIDER_NAME, ATTR_GEN_AI_REQUEST_MODEL, ATTR_GEN_AI_RESPONSE_ID, ATTR_GEN_AI_RESPONSE_MODEL, ATTR_GEN_AI_SYSTEM, ATTR_GEN_AI_TOOL_CALL_ID, ATTR_GEN_AI_TOOL_NAME, ATTR_GEN_AI_TOOL_TYPE, ATTR_HTTP_STATUS_CODE, ATTR_ORGANIZATION_ID, ATTR_SESSION_ID, ATTR_TERMINAL_TYPE, ATTR_USER_EMAIL, EVENT_API_ERROR, EVENT_API_REQUEST, EVENT_ASSISTANT_RESPONSE, EVENT_PERMISSION_MODE_CHANGED, EVENT_TOOL_DECISION, EVENT_TOOL_RESULT, EVENT_USER_PROMPT, GEN_AI_PROVIDER, languageFromPath, METRIC_ACTIVE_TIME, METRIC_CODE_EDIT_DECISION, METRIC_COMMIT_COUNT, METRIC_COST_USAGE, METRIC_LINES_OF_CODE, METRIC_PULL_REQUEST_COUNT, METRIC_SESSION_COUNT, METRIC_TOKEN_USAGE, PREFIX, SPAN_INTERACTION, SPAN_LLM_REQUEST, SPAN_TOOL, SPAN_TURN, } from "./attrs.js";
|
|
24
|
+
/** Idle cutoff for user active time: gaps longer than this are not "active". */
|
|
25
|
+
export const USER_ACTIVE_IDLE_CUTOFF_MS = 5 * 60 * 1000;
|
|
26
|
+
const EDIT_TOOLS = new Set(["edit", "write", "multi_edit", "notebook_edit"]);
|
|
27
|
+
const GIT_COMMIT_RE = /(?:^|[;&|]\s*)git\s+(?:-C\s+\S+\s+)?(?:[a-z-]+=\S+\s+)*commit\b/;
|
|
28
|
+
const GH_PR_CREATE_RE = /(?:^|[;&|]\s*)gh\s+pr\s+create\b/;
|
|
29
|
+
export class SessionTelemetry {
|
|
30
|
+
tracer;
|
|
31
|
+
meter;
|
|
32
|
+
logger;
|
|
33
|
+
config;
|
|
34
|
+
now;
|
|
35
|
+
sessionId;
|
|
36
|
+
sessionCounted = false;
|
|
37
|
+
eventSequence = 0;
|
|
38
|
+
interactionSequence = 0;
|
|
39
|
+
interaction = null;
|
|
40
|
+
turn = null;
|
|
41
|
+
llm = null;
|
|
42
|
+
tools = new Map();
|
|
43
|
+
/** Decision recorded per tool call before it executes (feeds tool_result). */
|
|
44
|
+
decisions = new Map();
|
|
45
|
+
/** When the agent last went idle — the start of the user's active window. */
|
|
46
|
+
idleSince;
|
|
47
|
+
counters = {};
|
|
48
|
+
constructor(deps) {
|
|
49
|
+
this.tracer = deps.tracer;
|
|
50
|
+
this.meter = deps.meter;
|
|
51
|
+
this.logger = deps.logger;
|
|
52
|
+
this.config = deps.config;
|
|
53
|
+
this.now = deps.now ?? (() => Date.now());
|
|
54
|
+
}
|
|
55
|
+
// ── Standard attributes ─────────────────────────────────────────────────
|
|
56
|
+
/** Attributes every metric and event carries (Claude Code's standard set). */
|
|
57
|
+
standardAttributes() {
|
|
58
|
+
const id = this.config.identity;
|
|
59
|
+
const attrs = {};
|
|
60
|
+
if (this.config.includeSessionId && this.sessionId)
|
|
61
|
+
attrs[ATTR_SESSION_ID] = this.sessionId;
|
|
62
|
+
if (this.config.includeVersion && id.appVersion)
|
|
63
|
+
attrs[ATTR_APP_VERSION] = id.appVersion;
|
|
64
|
+
if (this.config.includeEntrypoint)
|
|
65
|
+
attrs[ATTR_APP_ENTRYPOINT] = id.entrypoint;
|
|
66
|
+
if (id.organizationId)
|
|
67
|
+
attrs[ATTR_ORGANIZATION_ID] = id.organizationId;
|
|
68
|
+
if (this.config.includeAccountId && id.userEmail)
|
|
69
|
+
attrs[ATTR_USER_EMAIL] = id.userEmail;
|
|
70
|
+
if (id.terminalType)
|
|
71
|
+
attrs[ATTR_TERMINAL_TYPE] = id.terminalType;
|
|
72
|
+
if (this.config.includeResourceAttributes)
|
|
73
|
+
Object.assign(attrs, this.config.resourceAttributes);
|
|
74
|
+
return attrs;
|
|
75
|
+
}
|
|
76
|
+
/** Attributes every span carries: session identity in both vocabularies. */
|
|
77
|
+
spanAttributes() {
|
|
78
|
+
const attrs = {
|
|
79
|
+
[ATTR_GEN_AI_PROVIDER_NAME]: GEN_AI_PROVIDER,
|
|
80
|
+
[ATTR_GEN_AI_SYSTEM]: GEN_AI_PROVIDER,
|
|
81
|
+
[ATTR_GEN_AI_AGENT_NAME]: this.config.serviceName,
|
|
82
|
+
};
|
|
83
|
+
if (this.sessionId) {
|
|
84
|
+
attrs[ATTR_SESSION_ID] = this.sessionId;
|
|
85
|
+
attrs[ATTR_GEN_AI_CONVERSATION_ID] = this.sessionId;
|
|
86
|
+
}
|
|
87
|
+
const id = this.config.identity;
|
|
88
|
+
if (id.organizationId)
|
|
89
|
+
attrs[ATTR_ORGANIZATION_ID] = id.organizationId;
|
|
90
|
+
// Same gate as the metrics/events attribute set: an admin who turned
|
|
91
|
+
// account identity off gets it off on spans too.
|
|
92
|
+
if (this.config.includeAccountId && id.userEmail)
|
|
93
|
+
attrs[ATTR_USER_EMAIL] = id.userEmail;
|
|
94
|
+
if (id.appVersion)
|
|
95
|
+
attrs[ATTR_APP_VERSION] = id.appVersion;
|
|
96
|
+
return attrs;
|
|
97
|
+
}
|
|
98
|
+
// ── Instruments ─────────────────────────────────────────────────────────
|
|
99
|
+
counter(name, unit, description) {
|
|
100
|
+
let c = this.counters[name];
|
|
101
|
+
if (!c) {
|
|
102
|
+
c = this.meter.createCounter(name, { unit, description });
|
|
103
|
+
this.counters[name] = c;
|
|
104
|
+
}
|
|
105
|
+
return c;
|
|
106
|
+
}
|
|
107
|
+
add(name, unit, description, value, attrs = {}) {
|
|
108
|
+
if (!Number.isFinite(value) || value === 0)
|
|
109
|
+
return;
|
|
110
|
+
try {
|
|
111
|
+
this.counter(name, unit, description).add(value, { ...this.standardAttributes(), ...attrs });
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Metrics are best-effort.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// ── Events ──────────────────────────────────────────────────────────────
|
|
118
|
+
emitEvent(name, attrs) {
|
|
119
|
+
this.eventSequence += 1;
|
|
120
|
+
const record = {
|
|
121
|
+
...this.standardAttributes(),
|
|
122
|
+
"event.name": name,
|
|
123
|
+
"event.timestamp": new Date(this.now()).toISOString(),
|
|
124
|
+
"event.sequence": this.eventSequence,
|
|
125
|
+
...(this.interaction ? { "prompt.id": this.interaction.promptId } : {}),
|
|
126
|
+
...attrs,
|
|
127
|
+
};
|
|
128
|
+
try {
|
|
129
|
+
this.logger.emit({
|
|
130
|
+
eventName: `${PREFIX}.${name}`,
|
|
131
|
+
severityNumber: SeverityNumber.INFO,
|
|
132
|
+
severityText: "INFO",
|
|
133
|
+
body: `${PREFIX}.${name}`,
|
|
134
|
+
attributes: record,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Logs are best-effort.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// ── Session ─────────────────────────────────────────────────────────────
|
|
142
|
+
sessionStart(sessionId) {
|
|
143
|
+
if (sessionId)
|
|
144
|
+
this.sessionId = sessionId;
|
|
145
|
+
if (!this.sessionCounted) {
|
|
146
|
+
this.sessionCounted = true;
|
|
147
|
+
this.add(METRIC_SESSION_COUNT, "1", "Count of CLI sessions started", 1);
|
|
148
|
+
}
|
|
149
|
+
this.idleSince = this.now();
|
|
150
|
+
}
|
|
151
|
+
getSessionId() {
|
|
152
|
+
return this.sessionId;
|
|
153
|
+
}
|
|
154
|
+
// ── Interaction (one user prompt) ───────────────────────────────────────
|
|
155
|
+
interactionStart(prompt) {
|
|
156
|
+
if (this.interaction)
|
|
157
|
+
this.interactionEnd();
|
|
158
|
+
const now = this.now();
|
|
159
|
+
// User active time: the window since the agent last went idle, unless the
|
|
160
|
+
// user walked away (idle cutoff).
|
|
161
|
+
if (this.idleSince !== undefined) {
|
|
162
|
+
const gap = now - this.idleSince;
|
|
163
|
+
if (gap > 0 && gap <= USER_ACTIVE_IDLE_CUTOFF_MS) {
|
|
164
|
+
this.add(METRIC_ACTIVE_TIME, "s", "Total active time in seconds", gap / 1000, { type: "user" });
|
|
165
|
+
}
|
|
166
|
+
this.idleSince = undefined;
|
|
167
|
+
}
|
|
168
|
+
this.interactionSequence += 1;
|
|
169
|
+
const promptId = randomUUID();
|
|
170
|
+
const promptLength = typeof prompt === "string" ? prompt.length : 0;
|
|
171
|
+
// `invoke_agent` is the semconv operation for the root of an agent run.
|
|
172
|
+
// Without it Datadog files the trace as a generic workflow, flags an
|
|
173
|
+
// instrumentation issue on the span, and leaves it out of per-agent views.
|
|
174
|
+
const span = this.tracer.startSpan(SPAN_INTERACTION, {
|
|
175
|
+
attributes: {
|
|
176
|
+
...this.spanAttributes(),
|
|
177
|
+
[ATTR_GEN_AI_OPERATION_NAME]: "invoke_agent",
|
|
178
|
+
user_prompt_length: promptLength,
|
|
179
|
+
"interaction.sequence": this.interactionSequence,
|
|
180
|
+
"prompt.id": promptId,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
const ctx = trace.setSpan(otelContext.active(), span);
|
|
184
|
+
this.interaction = { span, ctx, startedAt: now, promptId };
|
|
185
|
+
this.emitEvent(EVENT_USER_PROMPT, { prompt_length: promptLength });
|
|
186
|
+
}
|
|
187
|
+
interactionEnd() {
|
|
188
|
+
if (!this.interaction)
|
|
189
|
+
return;
|
|
190
|
+
const now = this.now();
|
|
191
|
+
// Close stragglers: pi guarantees agent_end after its children, but an
|
|
192
|
+
// abort can leave a turn or tool open.
|
|
193
|
+
if (this.llm)
|
|
194
|
+
this.llmEnd(undefined);
|
|
195
|
+
for (const [id] of this.tools)
|
|
196
|
+
this.toolEnd(id, { isError: false, resultBytes: 0 });
|
|
197
|
+
if (this.turn)
|
|
198
|
+
this.turnEnd();
|
|
199
|
+
const elapsed = now - this.interaction.startedAt;
|
|
200
|
+
this.interaction.span.setAttribute("interaction.duration_ms", Math.max(0, Math.round(elapsed)));
|
|
201
|
+
this.interaction.span.end();
|
|
202
|
+
this.interaction = null;
|
|
203
|
+
this.decisions.clear();
|
|
204
|
+
this.add(METRIC_ACTIVE_TIME, "s", "Total active time in seconds", Math.max(0, elapsed) / 1000, { type: "cli" });
|
|
205
|
+
this.idleSince = now;
|
|
206
|
+
}
|
|
207
|
+
// ── Turn ────────────────────────────────────────────────────────────────
|
|
208
|
+
turnStart(turnIndex) {
|
|
209
|
+
if (!this.interaction)
|
|
210
|
+
return;
|
|
211
|
+
if (this.turn)
|
|
212
|
+
this.turnEnd();
|
|
213
|
+
const span = this.tracer.startSpan(SPAN_TURN, { attributes: { ...this.spanAttributes(), ...(typeof turnIndex === "number" ? { "turn.index": turnIndex } : {}) } }, this.interaction.ctx);
|
|
214
|
+
this.turn = { span, ctx: trace.setSpan(this.interaction.ctx, span), startedAt: this.now() };
|
|
215
|
+
}
|
|
216
|
+
turnEnd() {
|
|
217
|
+
if (!this.turn)
|
|
218
|
+
return;
|
|
219
|
+
this.turn.span.end();
|
|
220
|
+
this.turn = null;
|
|
221
|
+
}
|
|
222
|
+
// ── LLM request ─────────────────────────────────────────────────────────
|
|
223
|
+
parentCtx() {
|
|
224
|
+
return this.turn?.ctx ?? this.interaction?.ctx ?? otelContext.active();
|
|
225
|
+
}
|
|
226
|
+
llmStart(model) {
|
|
227
|
+
if (this.llm)
|
|
228
|
+
this.llmEnd(undefined);
|
|
229
|
+
const parent = this.parentCtx();
|
|
230
|
+
const clientRequestId = randomUUID();
|
|
231
|
+
const attrs = {
|
|
232
|
+
...this.spanAttributes(),
|
|
233
|
+
[ATTR_GEN_AI_OPERATION_NAME]: "chat",
|
|
234
|
+
client_request_id: clientRequestId,
|
|
235
|
+
"llm_request.context": this.interaction ? "interaction" : "standalone",
|
|
236
|
+
};
|
|
237
|
+
if (model) {
|
|
238
|
+
attrs[ATTR_GEN_AI_REQUEST_MODEL] = model;
|
|
239
|
+
attrs.model = model;
|
|
240
|
+
}
|
|
241
|
+
const span = this.tracer.startSpan(SPAN_LLM_REQUEST, { attributes: attrs }, parent);
|
|
242
|
+
this.llm = {
|
|
243
|
+
span,
|
|
244
|
+
ctx: trace.setSpan(parent, span),
|
|
245
|
+
startedAt: this.now(),
|
|
246
|
+
clientRequestId,
|
|
247
|
+
...(model ? { requestModel: model } : {}),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
llmResponseHeaders(status, headers) {
|
|
251
|
+
if (!this.llm)
|
|
252
|
+
return;
|
|
253
|
+
if (typeof status === "number") {
|
|
254
|
+
this.llm.status = status;
|
|
255
|
+
this.llm.span.setAttribute(ATTR_HTTP_STATUS_CODE, status);
|
|
256
|
+
}
|
|
257
|
+
const h = headers ?? {};
|
|
258
|
+
const requestId = h["x-request-id"] ?? h["request-id"] ?? h["anthropic-request-id"] ?? h["openai-response-id"];
|
|
259
|
+
if (typeof requestId === "string" && requestId) {
|
|
260
|
+
this.llm.requestId = requestId;
|
|
261
|
+
this.llm.span.setAttribute(ATTR_GEN_AI_RESPONSE_ID, requestId);
|
|
262
|
+
this.llm.span.setAttribute("request_id", requestId);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* The assistant message ended: attach usage, emit api_request / api_error
|
|
267
|
+
* plus the token and cost counters, and close the LLM span. Opens a span
|
|
268
|
+
* first when pi delivered the message without a before_provider_request
|
|
269
|
+
* (e.g. a replayed or synthetic message) so nothing is lost.
|
|
270
|
+
*/
|
|
271
|
+
assistantMessageEnd(message) {
|
|
272
|
+
if (!this.llm)
|
|
273
|
+
this.llmStart(message.model);
|
|
274
|
+
const slot = this.llm;
|
|
275
|
+
const now = this.now();
|
|
276
|
+
const durationMs = Math.max(0, Math.round(now - slot.startedAt));
|
|
277
|
+
const model = message.responseModel ?? message.model ?? slot.requestModel ?? "unknown";
|
|
278
|
+
const usage = message.usage ?? {};
|
|
279
|
+
const input = num(usage.input);
|
|
280
|
+
const output = num(usage.output);
|
|
281
|
+
const cacheRead = num(usage.cacheRead);
|
|
282
|
+
const cacheCreation = num(usage.cacheWrite);
|
|
283
|
+
// Claude Code always reports cost_usd (0 when it has nothing), so the flat
|
|
284
|
+
// attribute and the counter keep that shape. The vendor cost attribute is
|
|
285
|
+
// different: a 0 there renders as "$0" in cost views, so it is only set
|
|
286
|
+
// when pi actually priced the request.
|
|
287
|
+
const costKnown = typeof usage.cost === "number" || typeof usage.cost?.total === "number";
|
|
288
|
+
const cost = typeof usage.cost === "number" ? usage.cost : num(usage.cost?.total);
|
|
289
|
+
const stop = message.stopReason ?? "stop";
|
|
290
|
+
const isError = stop === "error";
|
|
291
|
+
const requestId = message.responseId ?? slot.requestId;
|
|
292
|
+
// pi's `input` excludes cached tokens (Anthropic semantics). The semconv
|
|
293
|
+
// `gen_ai.usage.input_tokens` includes them, with the cache counters as
|
|
294
|
+
// subsets; see attrs.ts. The flat Claude Code attributes below stay disjoint.
|
|
295
|
+
const inclusiveInput = input + cacheRead + cacheCreation;
|
|
296
|
+
const span = slot.span;
|
|
297
|
+
if (message.model)
|
|
298
|
+
span.setAttribute(ATTR_GEN_AI_REQUEST_MODEL, message.model);
|
|
299
|
+
span.setAttribute(ATTR_GEN_AI_RESPONSE_MODEL, model);
|
|
300
|
+
span.setAttribute("model", model);
|
|
301
|
+
span.setAttribute(ATTR_GEN_AI_INPUT_TOKENS, inclusiveInput);
|
|
302
|
+
span.setAttribute(ATTR_GEN_AI_OUTPUT_TOKENS, output);
|
|
303
|
+
span.setAttribute(ATTR_GEN_AI_TOTAL_TOKENS, inclusiveInput + output);
|
|
304
|
+
span.setAttribute(ATTR_GEN_AI_CACHE_READ_TOKENS, cacheRead);
|
|
305
|
+
span.setAttribute(ATTR_GEN_AI_CACHE_WRITE_TOKENS, cacheCreation);
|
|
306
|
+
span.setAttribute(ATTR_GEN_AI_CACHE_READ_TOKENS_LEGACY, cacheRead);
|
|
307
|
+
span.setAttribute(ATTR_GEN_AI_CACHE_CREATION_TOKENS_LEGACY, cacheCreation);
|
|
308
|
+
// Datadog ingests this attribute in nanodollars (its `ml_obs.span.llm.total.cost`
|
|
309
|
+
// unit); sending dollars renders a $0.012 call as 1.2e-9 cents. Verified against
|
|
310
|
+
// a live span on 2026-09-02. `cost_usd` below stays in dollars.
|
|
311
|
+
if (costKnown)
|
|
312
|
+
span.setAttribute(ATTR_GEN_AI_COST_ESTIMATED_TOTAL, Math.round(cost * 1_000_000_000));
|
|
313
|
+
span.setAttribute("input_tokens", input);
|
|
314
|
+
span.setAttribute("output_tokens", output);
|
|
315
|
+
span.setAttribute("cache_read_tokens", cacheRead);
|
|
316
|
+
span.setAttribute("cache_creation_tokens", cacheCreation);
|
|
317
|
+
span.setAttribute("cost_usd", cost);
|
|
318
|
+
span.setAttribute("duration_ms", durationMs);
|
|
319
|
+
span.setAttribute("stop_reason", stop);
|
|
320
|
+
span.setAttribute(ATTR_GEN_AI_FINISH_REASONS, [stop]);
|
|
321
|
+
span.setAttribute("success", isError ? "false" : "true");
|
|
322
|
+
span.setAttribute("response.has_tool_call", hasToolCall(message.content) ? "true" : "false");
|
|
323
|
+
if (requestId) {
|
|
324
|
+
span.setAttribute(ATTR_GEN_AI_RESPONSE_ID, requestId);
|
|
325
|
+
span.setAttribute("request_id", requestId);
|
|
326
|
+
}
|
|
327
|
+
const base = {
|
|
328
|
+
model,
|
|
329
|
+
duration_ms: durationMs,
|
|
330
|
+
client_request_id: slot.clientRequestId,
|
|
331
|
+
...(requestId ? { request_id: requestId } : {}),
|
|
332
|
+
};
|
|
333
|
+
if (isError) {
|
|
334
|
+
// A short, single-line excerpt only: a provider error body can echo
|
|
335
|
+
// request content, and the contract is metadata-only. The status code
|
|
336
|
+
// and the leading words are what a dashboard needs.
|
|
337
|
+
const error = errorExcerpt(message.errorMessage);
|
|
338
|
+
span.setAttribute(ATTR_ERROR_TYPE, "api_error");
|
|
339
|
+
span.setAttribute("error", error);
|
|
340
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: error });
|
|
341
|
+
this.emitEvent(EVENT_API_ERROR, {
|
|
342
|
+
...base,
|
|
343
|
+
error,
|
|
344
|
+
attempt: 1,
|
|
345
|
+
...(typeof slot.status === "number" ? { status_code: String(slot.status) } : {}),
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
this.emitEvent(EVENT_API_REQUEST, {
|
|
350
|
+
...base,
|
|
351
|
+
cost_usd: cost,
|
|
352
|
+
cost_usd_micros: Math.round(cost * 1_000_000),
|
|
353
|
+
input_tokens: input,
|
|
354
|
+
output_tokens: output,
|
|
355
|
+
cache_read_tokens: cacheRead,
|
|
356
|
+
cache_creation_tokens: cacheCreation,
|
|
357
|
+
});
|
|
358
|
+
this.emitEvent(EVENT_ASSISTANT_RESPONSE, {
|
|
359
|
+
model,
|
|
360
|
+
response_length: responseLength(message.content),
|
|
361
|
+
...(requestId ? { request_id: requestId } : {}),
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
const tokenAttrs = (type) => ({ type, model });
|
|
365
|
+
this.add(METRIC_TOKEN_USAGE, "{token}", "Number of tokens used", input, tokenAttrs("input"));
|
|
366
|
+
this.add(METRIC_TOKEN_USAGE, "{token}", "Number of tokens used", output, tokenAttrs("output"));
|
|
367
|
+
this.add(METRIC_TOKEN_USAGE, "{token}", "Number of tokens used", cacheRead, tokenAttrs("cacheRead"));
|
|
368
|
+
this.add(METRIC_TOKEN_USAGE, "{token}", "Number of tokens used", cacheCreation, tokenAttrs("cacheCreation"));
|
|
369
|
+
this.add(METRIC_COST_USAGE, "USD", "Cost of the session", cost, { model });
|
|
370
|
+
this.llmEnd(undefined);
|
|
371
|
+
}
|
|
372
|
+
llmEnd(error) {
|
|
373
|
+
if (!this.llm)
|
|
374
|
+
return;
|
|
375
|
+
if (error) {
|
|
376
|
+
this.llm.span.setAttribute(ATTR_ERROR_TYPE, error.name || "Error");
|
|
377
|
+
this.llm.span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
|
|
378
|
+
}
|
|
379
|
+
this.llm.span.end();
|
|
380
|
+
this.llm = null;
|
|
381
|
+
}
|
|
382
|
+
// ── Tools ───────────────────────────────────────────────────────────────
|
|
383
|
+
toolDecision(input) {
|
|
384
|
+
const toolSource = input.toolName.startsWith("mcp__") ? "mcp" : "builtin";
|
|
385
|
+
// Only accepted calls go on to execute (and read this back in toolEnd);
|
|
386
|
+
// a rejected call never reaches tool_execution_end, so storing it would
|
|
387
|
+
// only leak.
|
|
388
|
+
if (input.toolCallId && input.decision === "accept") {
|
|
389
|
+
this.decisions.set(input.toolCallId, { decision: input.decision, source: input.source });
|
|
390
|
+
}
|
|
391
|
+
this.emitEvent(EVENT_TOOL_DECISION, {
|
|
392
|
+
tool_name: input.toolName,
|
|
393
|
+
...(input.toolCallId ? { tool_use_id: input.toolCallId } : {}),
|
|
394
|
+
decision: input.decision,
|
|
395
|
+
source: input.source,
|
|
396
|
+
tool_source: toolSource,
|
|
397
|
+
});
|
|
398
|
+
if (EDIT_TOOLS.has(input.toolName)) {
|
|
399
|
+
const language = languageFromPath(input.filePath);
|
|
400
|
+
this.add(METRIC_CODE_EDIT_DECISION, "1", "Count of code editing tool permission decisions", 1, {
|
|
401
|
+
tool_name: input.toolName,
|
|
402
|
+
decision: input.decision,
|
|
403
|
+
source: input.source,
|
|
404
|
+
...(language ? { language } : {}),
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
toolStart(toolCallId, toolName, args) {
|
|
409
|
+
if (this.tools.has(toolCallId))
|
|
410
|
+
return;
|
|
411
|
+
const parent = this.parentCtx();
|
|
412
|
+
const inputBytes = byteLength(args);
|
|
413
|
+
const attrs = {
|
|
414
|
+
...this.spanAttributes(),
|
|
415
|
+
tool_name: toolName,
|
|
416
|
+
tool_use_id: toolCallId,
|
|
417
|
+
[ATTR_GEN_AI_TOOL_NAME]: toolName,
|
|
418
|
+
[ATTR_GEN_AI_TOOL_CALL_ID]: toolCallId,
|
|
419
|
+
[ATTR_GEN_AI_TOOL_TYPE]: toolName.startsWith("mcp__") ? "extension" : "function",
|
|
420
|
+
[ATTR_GEN_AI_OPERATION_NAME]: "execute_tool",
|
|
421
|
+
tool_input_size_bytes: inputBytes,
|
|
422
|
+
};
|
|
423
|
+
const span = this.tracer.startSpan(SPAN_TOOL, { attributes: attrs }, parent);
|
|
424
|
+
this.tools.set(toolCallId, { span, ctx: trace.setSpan(parent, span), startedAt: this.now(), name: toolName, inputBytes });
|
|
425
|
+
}
|
|
426
|
+
toolEnd(toolCallId, outcome) {
|
|
427
|
+
const slot = this.tools.get(toolCallId);
|
|
428
|
+
if (!slot)
|
|
429
|
+
return;
|
|
430
|
+
const durationMs = Math.max(0, Math.round(this.now() - slot.startedAt));
|
|
431
|
+
slot.span.setAttribute("duration_ms", durationMs);
|
|
432
|
+
slot.span.setAttribute("success", outcome.isError ? "false" : "true");
|
|
433
|
+
slot.span.setAttribute("tool_result_size_bytes", outcome.resultBytes);
|
|
434
|
+
if (outcome.isError) {
|
|
435
|
+
slot.span.setAttribute(ATTR_ERROR_TYPE, outcome.errorType ?? "tool_error");
|
|
436
|
+
slot.span.setStatus({ code: SpanStatusCode.ERROR });
|
|
437
|
+
}
|
|
438
|
+
slot.span.end();
|
|
439
|
+
this.tools.delete(toolCallId);
|
|
440
|
+
const decision = this.decisions.get(toolCallId);
|
|
441
|
+
this.decisions.delete(toolCallId);
|
|
442
|
+
this.emitEvent(EVENT_TOOL_RESULT, {
|
|
443
|
+
tool_name: slot.name,
|
|
444
|
+
tool_use_id: toolCallId,
|
|
445
|
+
success: outcome.isError ? "false" : "true",
|
|
446
|
+
duration_ms: durationMs,
|
|
447
|
+
tool_input_size_bytes: slot.inputBytes,
|
|
448
|
+
tool_result_size_bytes: outcome.resultBytes,
|
|
449
|
+
decision_type: "accept",
|
|
450
|
+
decision_source: decision?.source ?? "config",
|
|
451
|
+
...(outcome.isError ? { error_type: outcome.errorType ?? "tool_error" } : {}),
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
/** Lines added/removed by an edit or write, from pi's tool_result details. */
|
|
455
|
+
linesOfCode(added, removed) {
|
|
456
|
+
this.add(METRIC_LINES_OF_CODE, "1", "Count of lines of code modified", added, { type: "added" });
|
|
457
|
+
this.add(METRIC_LINES_OF_CODE, "1", "Count of lines of code modified", removed, { type: "removed" });
|
|
458
|
+
}
|
|
459
|
+
/** A successful bash command: count commits and PR creations. */
|
|
460
|
+
bashSucceeded(command) {
|
|
461
|
+
if (typeof command !== "string")
|
|
462
|
+
return;
|
|
463
|
+
if (GIT_COMMIT_RE.test(command))
|
|
464
|
+
this.add(METRIC_COMMIT_COUNT, "1", "Count of git commits created", 1);
|
|
465
|
+
if (GH_PR_CREATE_RE.test(command))
|
|
466
|
+
this.add(METRIC_PULL_REQUEST_COUNT, "1", "Count of pull requests created", 1);
|
|
467
|
+
}
|
|
468
|
+
// ── Mode ────────────────────────────────────────────────────────────────
|
|
469
|
+
permissionModeChanged(fromMode, toMode) {
|
|
470
|
+
this.emitEvent(EVENT_PERMISSION_MODE_CHANGED, { from_mode: fromMode, to_mode: toMode });
|
|
471
|
+
}
|
|
472
|
+
// ── Shutdown ────────────────────────────────────────────────────────────
|
|
473
|
+
/** Close everything still open so no span is lost at exit. */
|
|
474
|
+
close() {
|
|
475
|
+
this.interactionEnd();
|
|
476
|
+
if (this.llm)
|
|
477
|
+
this.llmEnd(undefined);
|
|
478
|
+
for (const [id] of this.tools)
|
|
479
|
+
this.toolEnd(id, { isError: false, resultBytes: 0 });
|
|
480
|
+
if (this.turn)
|
|
481
|
+
this.turnEnd();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/** Cap on the exported error excerpt (characters, one line). */
|
|
485
|
+
export const ERROR_EXCERPT_CHARS = 120;
|
|
486
|
+
/** First line of a provider error, capped, never the full body. */
|
|
487
|
+
export function errorExcerpt(message) {
|
|
488
|
+
const firstLine = (message ?? "").split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
489
|
+
if (!firstLine)
|
|
490
|
+
return "LLM request failed";
|
|
491
|
+
return firstLine.length > ERROR_EXCERPT_CHARS ? `${firstLine.slice(0, ERROR_EXCERPT_CHARS)}…` : firstLine;
|
|
492
|
+
}
|
|
493
|
+
function num(v) {
|
|
494
|
+
return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0;
|
|
495
|
+
}
|
|
496
|
+
function hasToolCall(content) {
|
|
497
|
+
if (!Array.isArray(content))
|
|
498
|
+
return false;
|
|
499
|
+
return content.some((p) => p && typeof p === "object" && p.type === "toolCall");
|
|
500
|
+
}
|
|
501
|
+
function responseLength(content) {
|
|
502
|
+
if (typeof content === "string")
|
|
503
|
+
return content.length;
|
|
504
|
+
if (!Array.isArray(content))
|
|
505
|
+
return 0;
|
|
506
|
+
let n = 0;
|
|
507
|
+
for (const p of content) {
|
|
508
|
+
if (p && typeof p === "object" && p.type === "text") {
|
|
509
|
+
const t = p.text;
|
|
510
|
+
if (typeof t === "string")
|
|
511
|
+
n += t.length;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return n;
|
|
515
|
+
}
|
|
516
|
+
export function byteLength(v) {
|
|
517
|
+
if (v === undefined || v === null)
|
|
518
|
+
return 0;
|
|
519
|
+
if (typeof v === "string")
|
|
520
|
+
return Buffer.byteLength(v, "utf8");
|
|
521
|
+
try {
|
|
522
|
+
return Buffer.byteLength(JSON.stringify(v) ?? "", "utf8");
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
return 0;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
/** Count +/- lines in a unified patch, ignoring file headers. */
|
|
529
|
+
export function countPatchLines(patch) {
|
|
530
|
+
let added = 0;
|
|
531
|
+
let removed = 0;
|
|
532
|
+
if (typeof patch !== "string")
|
|
533
|
+
return { added, removed };
|
|
534
|
+
for (const line of patch.split("\n")) {
|
|
535
|
+
if (line.startsWith("+++") || line.startsWith("---"))
|
|
536
|
+
continue;
|
|
537
|
+
if (line.startsWith("+"))
|
|
538
|
+
added += 1;
|
|
539
|
+
else if (line.startsWith("-"))
|
|
540
|
+
removed += 1;
|
|
541
|
+
}
|
|
542
|
+
return { added, removed };
|
|
543
|
+
}
|
|
544
|
+
/** Line count of freshly written content (a write tool call). */
|
|
545
|
+
export function countContentLines(content) {
|
|
546
|
+
if (typeof content !== "string" || content.length === 0)
|
|
547
|
+
return 0;
|
|
548
|
+
const n = content.split("\n").length;
|
|
549
|
+
return content.endsWith("\n") ? n - 1 : n;
|
|
550
|
+
}
|
|
551
|
+
//# sourceMappingURL=tracker.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Copyright (c) 2013 Kael Zhang <i@kael.me>, contributors
|
|
2
|
+
http://kael.me/
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
5
|
+
a copy of this software and associated documentation files (the
|
|
6
|
+
"Software"), to deal in the Software without restriction, including
|
|
7
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
8
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
9
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
10
|
+
the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be
|
|
13
|
+
included in all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
16
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
17
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
18
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
19
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
20
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
21
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// VENDORED TYPES — copy of the npm `ignore` package v7.0.5 index.d.ts
|
|
2
|
+
// (https://github.com/kaelzhang/node-ignore), MIT license. The upstream file
|
|
3
|
+
// ends with `export = ignore` (CJS); this copy ends with an ESM default
|
|
4
|
+
// export matching the ESM-converted ignore.js alongside.
|
|
5
|
+
|
|
6
|
+
type Pathname = string
|
|
7
|
+
|
|
8
|
+
interface IgnoreRule {
|
|
9
|
+
pattern: string
|
|
10
|
+
mark?: string
|
|
11
|
+
negative: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface TestResult {
|
|
15
|
+
ignored: boolean
|
|
16
|
+
unignored: boolean
|
|
17
|
+
rule?: IgnoreRule
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PatternParams {
|
|
21
|
+
pattern: string
|
|
22
|
+
mark?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Ignore {
|
|
26
|
+
/**
|
|
27
|
+
* Adds one or several rules to the current manager.
|
|
28
|
+
* @param {string[]} patterns
|
|
29
|
+
* @returns IgnoreBase
|
|
30
|
+
*/
|
|
31
|
+
add(
|
|
32
|
+
patterns: string | Ignore | readonly (string | Ignore)[] | PatternParams
|
|
33
|
+
): this
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Filters the given array of pathnames, and returns the filtered array.
|
|
37
|
+
* NOTICE that each path here should be a relative path to the root of your repository.
|
|
38
|
+
* @param paths the array of paths to be filtered.
|
|
39
|
+
* @returns The filtered array of paths
|
|
40
|
+
*/
|
|
41
|
+
filter(pathnames: readonly Pathname[]): Pathname[]
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Creates a filter function which could filter
|
|
45
|
+
* an array of paths with Array.prototype.filter.
|
|
46
|
+
*/
|
|
47
|
+
createFilter(): (pathname: Pathname) => boolean
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Returns Boolean whether pathname should be ignored.
|
|
51
|
+
* @param {string} pathname a path to check
|
|
52
|
+
* @returns boolean
|
|
53
|
+
*/
|
|
54
|
+
ignores(pathname: Pathname): boolean
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Returns whether pathname should be ignored or unignored
|
|
58
|
+
* @param {string} pathname a path to check
|
|
59
|
+
* @returns TestResult
|
|
60
|
+
*/
|
|
61
|
+
test(pathname: Pathname): TestResult
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Debugs ignore rules and returns the checking result, which is
|
|
65
|
+
* equivalent to `git check-ignore -v`.
|
|
66
|
+
* @returns TestResult
|
|
67
|
+
*/
|
|
68
|
+
checkIgnore(pathname: Pathname): TestResult
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface Options {
|
|
72
|
+
ignorecase?: boolean
|
|
73
|
+
// For compatibility
|
|
74
|
+
ignoreCase?: boolean
|
|
75
|
+
allowRelativePaths?: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Creates new ignore manager.
|
|
80
|
+
*/
|
|
81
|
+
declare function ignore(options?: Options): Ignore
|
|
82
|
+
|
|
83
|
+
declare const isPathValid: (pathname: string) => boolean
|
|
84
|
+
|
|
85
|
+
export { isPathValid }
|
|
86
|
+
export default ignore
|